@henryqw/pi-pr 6.0.8 → 6.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +151 -247
- package/docs/pr-routing.svg +13 -13
- package/extensions/pr-command.ts +71 -100
- package/extensions/pr-comment-sweep.ts +245 -53
- package/extensions/pr-create.ts +50 -80
- package/extensions/pr-feedback-attention.ts +69 -0
- package/extensions/pr-feedback.ts +23 -1
- package/extensions/pr-publish-work.ts +143 -0
- package/extensions/pr-routing.ts +5 -49
- package/extensions/pr-ui.ts +5 -5
- package/extensions/pr-update-branch.ts +187 -63
- package/extensions/pr.ts +154 -50
- package/package.json +1 -1
- package/skills/pi-pr-comment-sweep/SKILL.md +9 -46
- package/skills/pi-pr-comment-sweep/references/recovery.md +5 -24
- package/skills/pi-pr-comment-sweep/references/thread-triage.md +3 -5
- package/skills/pi-pr-create/SKILL.md +4 -6
- package/skills/pi-pr-publish-work/SKILL.md +12 -0
- package/skills/pi-pr-update-branch/SKILL.md +5 -7
package/extensions/pr-create.ts
CHANGED
|
@@ -20,19 +20,20 @@ import {
|
|
|
20
20
|
type PullRequestTarget,
|
|
21
21
|
} from "./pr-routing.ts";
|
|
22
22
|
import {
|
|
23
|
-
assertOnlyDeclaredStatusChanged,
|
|
24
23
|
extensionExecApi,
|
|
25
24
|
inspectWorktree,
|
|
25
|
+
inspectWorktreeState,
|
|
26
|
+
parseNulPaths,
|
|
27
|
+
parseStatusSnapshot,
|
|
28
|
+
validateResolvedConflictPaths,
|
|
26
29
|
isAncestor,
|
|
27
30
|
isRecord,
|
|
28
|
-
parseNulPaths,
|
|
29
31
|
parseSingleOutputLine,
|
|
30
32
|
readHead,
|
|
31
33
|
readRemoteOid,
|
|
32
34
|
requiredOid,
|
|
33
35
|
requiredText,
|
|
34
36
|
runChecked,
|
|
35
|
-
validateResolvedConflictPaths,
|
|
36
37
|
withWorktreeLock,
|
|
37
38
|
} from "./pr-execution.ts";
|
|
38
39
|
|
|
@@ -51,7 +52,7 @@ type CreateBaseAuthority = {
|
|
|
51
52
|
fetchSource: string;
|
|
52
53
|
};
|
|
53
54
|
|
|
54
|
-
type CreatePhase = "unprepared" | "prepared" | "
|
|
55
|
+
type CreatePhase = "unprepared" | "prepared" | "verified" | "pushed" | "published" | "blocked";
|
|
55
56
|
|
|
56
57
|
type CrossRepositoryCreateAuthority = {
|
|
57
58
|
baseRepositoryId: string;
|
|
@@ -63,14 +64,14 @@ type CreatePullRequestState = {
|
|
|
63
64
|
phase: CreatePhase;
|
|
64
65
|
base?: CreateBaseAuthority;
|
|
65
66
|
createAuthority?: CrossRepositoryCreateAuthority;
|
|
67
|
+
pending?: { head: string; status: string };
|
|
66
68
|
publicationHead?: string;
|
|
67
|
-
|
|
69
|
+
verifiedHead?: string;
|
|
68
70
|
};
|
|
69
71
|
|
|
70
72
|
type CreatePullRequestResult =
|
|
71
73
|
| { kind: "prepared"; base: CreateBaseAuthority; mergeBase: string }
|
|
72
74
|
| { kind: "verified"; head: string; fastForward: boolean }
|
|
73
|
-
| { kind: "conflict"; paths: string[] }
|
|
74
75
|
| { kind: "pushed"; head: string }
|
|
75
76
|
| { kind: "published"; url: string };
|
|
76
77
|
|
|
@@ -170,7 +171,6 @@ export class PullRequestCreator {
|
|
|
170
171
|
private readonly agentDir?: string;
|
|
171
172
|
private readonly exec: Exec;
|
|
172
173
|
private readonly load: Load;
|
|
173
|
-
private explicitBase?: string;
|
|
174
174
|
|
|
175
175
|
constructor(options: CreatePullRequestOptions) {
|
|
176
176
|
if (options.target.remoteOid !== null) requiredOid(options.target.remoteOid, "remote OID");
|
|
@@ -196,7 +196,7 @@ export class PullRequestCreator {
|
|
|
196
196
|
}
|
|
197
197
|
|
|
198
198
|
private async freshNone() {
|
|
199
|
-
const discovery = await this.load(this.pi(), this.context()
|
|
199
|
+
const discovery = await this.load(this.pi(), this.context());
|
|
200
200
|
if (discovery.kind !== "none" || !sameTarget(this.target, discovery.creationTarget)) {
|
|
201
201
|
throw new Error("PR creation cancelled: fresh complete discovery is no longer none");
|
|
202
202
|
}
|
|
@@ -232,18 +232,16 @@ export class PullRequestCreator {
|
|
|
232
232
|
return await readHead(this.exec, this.options());
|
|
233
233
|
}
|
|
234
234
|
|
|
235
|
-
async prepare(
|
|
235
|
+
async prepare(): Promise<CreatePullRequestResult> {
|
|
236
236
|
if (this.state.phase !== "unprepared") {
|
|
237
237
|
throw new Error("PR creation prepare action was already consumed");
|
|
238
238
|
}
|
|
239
239
|
return await withWorktreeLock(this.cwd, async () => {
|
|
240
|
-
this.explicitBase = explicitBase;
|
|
241
240
|
await this.freshNone();
|
|
242
241
|
const preflight = await preflightPullRequestCreation(
|
|
243
242
|
this.pi(),
|
|
244
243
|
this.context(),
|
|
245
244
|
this.target,
|
|
246
|
-
this.explicitBase,
|
|
247
245
|
);
|
|
248
246
|
if (preflight.worktree === "operation") {
|
|
249
247
|
throw new Error("PR creation cannot prepare while a Git operation is in progress");
|
|
@@ -277,83 +275,57 @@ export class PullRequestCreator {
|
|
|
277
275
|
}, { agentDir: this.agentDir, signal: this.signal });
|
|
278
276
|
}
|
|
279
277
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
this.state.phase = "verified";
|
|
293
|
-
delete this.state.conflict;
|
|
294
|
-
return { head, fastForward };
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
private async captureConflict(originalHead: string): Promise<string[]> {
|
|
298
|
-
const base = this.state.base!;
|
|
299
|
-
const mergeHead = requiredOid(parseSingleOutputLine((await runChecked(this.exec, "git", ["rev-parse", "--verify", "MERGE_HEAD^{commit}"], this.options())).stdout, "MERGE_HEAD"), "MERGE_HEAD");
|
|
300
|
-
if (mergeHead !== base.oid) throw new Error("Failed merge did not retain the frozen base");
|
|
301
|
-
const paths = parseNulPaths((await runChecked(this.exec, "git", ["diff", "--name-only", "-z", "--diff-filter=U"], this.options())).stdout, "Unmerged paths");
|
|
302
|
-
if (!paths.length) throw new Error("git merge failed without bounded unmerged paths");
|
|
303
|
-
const status = await runChecked(this.exec, "git", ["status", "--porcelain=v2", "-z", "--untracked-files=all"], this.options());
|
|
304
|
-
this.state.phase = "conflict-awaiting-user";
|
|
305
|
-
this.state.conflict = { paths, statusBaseline: status.stdout, originalHead };
|
|
306
|
-
return paths;
|
|
278
|
+
async inspect(): Promise<{ paths: string[]; head: string }> {
|
|
279
|
+
if (this.state.phase !== "prepared") throw new Error("PR creation must be prepared before inspecting pending work");
|
|
280
|
+
return await withWorktreeLock(this.cwd, async () => {
|
|
281
|
+
await this.freshNone();
|
|
282
|
+
if (await inspectWorktreeState(this.exec, this.options()) === "operation") throw new Error("Git operation in progress");
|
|
283
|
+
const head = await readHead(this.exec, this.options());
|
|
284
|
+
const status = (await runChecked(this.exec, "git", ["status", "--porcelain=v2", "-z", "--untracked-files=all"], this.options())).stdout;
|
|
285
|
+
const paths = [...parseStatusSnapshot(status).keys()];
|
|
286
|
+
parseNulPaths(paths.map((path) => `${path}\0`).join(""), "Pending paths");
|
|
287
|
+
this.state.pending = { head, status };
|
|
288
|
+
return { paths, head };
|
|
289
|
+
}, { agentDir: this.agentDir, signal: this.signal });
|
|
307
290
|
}
|
|
308
291
|
|
|
309
|
-
async
|
|
310
|
-
if (this.state.phase !== "prepared" || !this.state.
|
|
311
|
-
|
|
292
|
+
async commit(pathsInput: string[], message: string): Promise<{ head: string }> {
|
|
293
|
+
if (this.state.phase !== "prepared" || !this.state.pending) throw new Error("PR creation has no inspected pending work");
|
|
294
|
+
const paths = validateResolvedConflictPaths(pathsInput, []);
|
|
295
|
+
if (!paths.length || paths.some((path) => !parseStatusSnapshot(this.state.pending!.status).has(path))) {
|
|
296
|
+
throw new Error("Commit paths must be reviewed pending paths");
|
|
312
297
|
}
|
|
298
|
+
requiredText(message, "commit message");
|
|
313
299
|
return await withWorktreeLock(this.cwd, async () => {
|
|
314
300
|
await this.freshNone();
|
|
315
|
-
if (await this.
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
return { kind: "verified", head: originalHead, fastForward: false };
|
|
320
|
-
}
|
|
321
|
-
this.state.phase = "blocked";
|
|
322
|
-
const result = await this.exec("git", ["merge", "--no-edit", this.state.base!.oid], this.options());
|
|
323
|
-
if (result.killed) throw new Error("git merge was killed; its outcome is unknown");
|
|
324
|
-
if (result.code === 0) {
|
|
325
|
-
const verified = await this.verifyMerge(originalHead);
|
|
326
|
-
return { kind: "verified", ...verified };
|
|
327
|
-
}
|
|
328
|
-
try {
|
|
329
|
-
const paths = await this.captureConflict(originalHead);
|
|
330
|
-
return { kind: "conflict", paths };
|
|
331
|
-
} catch (error) {
|
|
332
|
-
throw new Error(`git merge failed: ${result.stderr.trim() || result.stdout.trim() || `exit code ${result.code}`}; ${error instanceof Error ? error.message : String(error)}`);
|
|
301
|
+
if (await readHead(this.exec, this.options()) !== this.state.pending!.head ||
|
|
302
|
+
await inspectWorktreeState(this.exec, this.options()) === "operation" ||
|
|
303
|
+
(await runChecked(this.exec, "git", ["status", "--porcelain=v2", "-z", "--untracked-files=all"], this.options())).stdout !== this.state.pending!.status) {
|
|
304
|
+
throw new Error("Pending work changed after inspection");
|
|
333
305
|
}
|
|
306
|
+
const staged = parseNulPaths((await runChecked(this.exec, "git", ["diff", "--cached", "--no-renames", "--name-only", "-z"], this.options())).stdout, "Staged paths");
|
|
307
|
+
if (staged.some((path) => !paths.includes(path))) throw new Error("Unrelated staged changes require an ownership decision");
|
|
308
|
+
this.state.pending = undefined; // Commit outcome may be uncertain; never replay it.
|
|
309
|
+
await runChecked(this.exec, "git", ["--literal-pathspecs", "add", "-A", "--", ...paths], this.options());
|
|
310
|
+
await runChecked(this.exec, "git", ["commit", "-m", message], this.options());
|
|
311
|
+
return { head: await readHead(this.exec, this.options()) };
|
|
334
312
|
}, { agentDir: this.agentDir, signal: this.signal });
|
|
335
313
|
}
|
|
336
314
|
|
|
337
|
-
async
|
|
338
|
-
if (this.state.phase !== "
|
|
339
|
-
throw new Error("PR creation
|
|
315
|
+
async verify(): Promise<CreatePullRequestResult> {
|
|
316
|
+
if (this.state.phase !== "prepared" || !this.state.base) {
|
|
317
|
+
throw new Error("PR creation is not prepared for verification");
|
|
340
318
|
}
|
|
341
|
-
const paths = validateResolvedConflictPaths(pathsInput, this.state.conflict.paths);
|
|
342
319
|
return await withWorktreeLock(this.cwd, async () => {
|
|
343
320
|
await this.freshNone();
|
|
344
321
|
if (await this.liveBase() !== this.state.base!.oid) throw new Error("PR creation cancelled: frozen base moved");
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
this.state.phase = "
|
|
351
|
-
|
|
352
|
-
const unmerged = parseNulPaths((await runChecked(this.exec, "git", ["diff", "--name-only", "-z", "--diff-filter=U"], this.options())).stdout, "Unmerged paths");
|
|
353
|
-
if (unmerged.length) throw new Error(`Conflict paths remain unresolved: ${unmerged.join(", ")}`);
|
|
354
|
-
await runChecked(this.exec, "git", ["-c", "core.editor=true", "merge", "--continue"], this.options());
|
|
355
|
-
const verified = await this.verifyMerge(this.state.conflict!.originalHead);
|
|
356
|
-
return { kind: "verified", ...verified };
|
|
322
|
+
const head = await this.requireCleanHead();
|
|
323
|
+
if (!isPullRequestCreationEligible((await this.freshNone()).branch) || await readHead(this.exec, this.options()) !== head) {
|
|
324
|
+
throw new Error("PR creation changed during verification or no longer has a committed change");
|
|
325
|
+
}
|
|
326
|
+
this.state.verifiedHead = head;
|
|
327
|
+
this.state.phase = "verified";
|
|
328
|
+
return { kind: "verified", head, fastForward: false };
|
|
357
329
|
}, { agentDir: this.agentDir, signal: this.signal });
|
|
358
330
|
}
|
|
359
331
|
|
|
@@ -374,16 +346,14 @@ export class PullRequestCreator {
|
|
|
374
346
|
}
|
|
375
347
|
|
|
376
348
|
async push(): Promise<CreatePullRequestResult> {
|
|
377
|
-
if (this.state.phase !== "verified" || !this.state.base) {
|
|
349
|
+
if (this.state.phase !== "verified" || !this.state.base || !this.state.verifiedHead) {
|
|
378
350
|
throw new Error("PR creation is not ready to push");
|
|
379
351
|
}
|
|
380
352
|
return await withWorktreeLock(this.cwd, async () => {
|
|
381
353
|
await this.freshNone();
|
|
382
354
|
if (await this.liveBase() !== this.state.base!.oid) throw new Error("PR creation cancelled: frozen base moved");
|
|
383
355
|
const head = await this.requireCleanHead();
|
|
384
|
-
if (
|
|
385
|
-
throw new Error("PR creation HEAD does not contain the frozen base");
|
|
386
|
-
}
|
|
356
|
+
if (head !== this.state.verifiedHead) throw new Error("PR creation verified HEAD changed");
|
|
387
357
|
const original = this.target.remoteOid;
|
|
388
358
|
if (original !== null && !(await isAncestor(this.exec, this.options(), original, head))) {
|
|
389
359
|
throw new Error("PR creation push would not fast-forward the frozen remote OID");
|
|
@@ -423,7 +393,7 @@ export class PullRequestCreator {
|
|
|
423
393
|
}
|
|
424
394
|
return;
|
|
425
395
|
}
|
|
426
|
-
const discovery = await this.load(this.pi(), this.context()
|
|
396
|
+
const discovery = await this.load(this.pi(), this.context());
|
|
427
397
|
if (discovery.kind === "none") {
|
|
428
398
|
if (!sameTarget(this.target, discovery.creationTarget, head)) throw new Error("Published target authority changed");
|
|
429
399
|
return;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { realpath } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { extensionConfigDir, readTextFileBounded, writePrivateTextFileAtomically } from "@henryqw/pi-config-store";
|
|
5
|
+
import { spawnBounded, type Exec } from "@henryqw/pi-process";
|
|
6
|
+
import { collectPullRequestFeedback, feedbackAuthorityFromCurrent, feedbackEntries, type FeedbackSnapshot } from "./pr-feedback.ts";
|
|
7
|
+
import { loadCurrentPullRequest, samePullRequestSnapshot, type CurrentPullRequest } from "./pr-github.ts";
|
|
8
|
+
import { extensionExecApi, isRecord, parseSingleOutputLine, runChecked } from "./pr-execution.ts";
|
|
9
|
+
|
|
10
|
+
function fingerprint(snapshot: FeedbackSnapshot, blockedIds: ReadonlySet<string> = new Set()): string {
|
|
11
|
+
const entries = feedbackEntries(snapshot).filter(({ id }) => !blockedIds.has(id)).map(({ id, kind, node }) => ({
|
|
12
|
+
id, kind, body: "body" in node ? node.body : null,
|
|
13
|
+
})).sort((a, b) => a.id.localeCompare(b.id));
|
|
14
|
+
return createHash("sha256").update(JSON.stringify(entries)).digest("hex");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function markerPath(cwd: string, agentDir?: string, signal?: AbortSignal, exec: Exec = spawnBounded): Promise<string> {
|
|
18
|
+
const root = parseSingleOutputLine((await runChecked(exec, "git", ["rev-parse", "--show-toplevel"], { cwd, signal })).stdout, "worktree root");
|
|
19
|
+
const identity = createHash("sha256").update(await realpath(root)).digest("hex");
|
|
20
|
+
return join(extensionConfigDir("pi-pr", agentDir), "feedback", `${identity}.json`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function readMarker(path: string, signal?: AbortSignal): Promise<{ url: string; fingerprint: string } | null> {
|
|
24
|
+
let raw: string;
|
|
25
|
+
try {
|
|
26
|
+
raw = await readTextFileBounded(path, 2_048, { signal });
|
|
27
|
+
} catch (error) {
|
|
28
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
let marker: unknown;
|
|
32
|
+
try { marker = JSON.parse(raw); } catch { throw new Error(`Invalid feedback attention marker is preserved at ${path}`); }
|
|
33
|
+
if (!isRecord(marker) || Object.keys(marker).sort().join(",") !== "fingerprint,url,version" ||
|
|
34
|
+
marker.version !== 1 || typeof marker.url !== "string" || typeof marker.fingerprint !== "string" ||
|
|
35
|
+
!/^[0-9a-f]{64}$/.test(marker.fingerprint)) {
|
|
36
|
+
throw new Error(`Invalid feedback attention marker is preserved at ${path}`);
|
|
37
|
+
}
|
|
38
|
+
return { url: marker.url, fingerprint: marker.fingerprint };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function needsFeedbackAttention(
|
|
42
|
+
current: CurrentPullRequest,
|
|
43
|
+
options: { cwd: string; agentDir?: string; signal?: AbortSignal; exec?: Exec; load?: typeof loadCurrentPullRequest },
|
|
44
|
+
): Promise<boolean> {
|
|
45
|
+
const { cwd, agentDir, signal } = options;
|
|
46
|
+
const exec = options.exec ?? spawnBounded;
|
|
47
|
+
const snapshot = await collectPullRequestFeedback(feedbackAuthorityFromCurrent(current), { exec, cwd, signal });
|
|
48
|
+
const discovery = await (options.load ?? loadCurrentPullRequest)(extensionExecApi(exec, cwd, signal),
|
|
49
|
+
{ cwd, signal: signal ?? new AbortController().signal });
|
|
50
|
+
if (discovery.kind !== "current" || !samePullRequestSnapshot(current, discovery.pullRequest) ||
|
|
51
|
+
current.base.oid !== discovery.pullRequest.base.oid) {
|
|
52
|
+
throw new Error("Feedback discovery cancelled: pull request authority changed");
|
|
53
|
+
}
|
|
54
|
+
const marker = await readMarker(await markerPath(cwd, agentDir, signal, exec), signal);
|
|
55
|
+
const entries = feedbackEntries(snapshot);
|
|
56
|
+
if (!entries.length) return false;
|
|
57
|
+
return !marker || marker.url !== current.url.href || marker.fingerprint !== fingerprint(snapshot);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function markFeedbackHandled(
|
|
61
|
+
snapshot: FeedbackSnapshot,
|
|
62
|
+
options: { cwd: string; agentDir?: string; signal?: AbortSignal; exec?: Exec; blockedIds?: readonly string[] },
|
|
63
|
+
): Promise<void> {
|
|
64
|
+
const path = await markerPath(options.cwd, options.agentDir, options.signal, options.exec);
|
|
65
|
+
await readMarker(path, options.signal);
|
|
66
|
+
await writePrivateTextFileAtomically(path, `${JSON.stringify({ version: 1, url: snapshot.pullRequest.url, fingerprint: fingerprint(snapshot, new Set(options.blockedIds)) })}\n`, {
|
|
67
|
+
signal: options.signal,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
@@ -32,6 +32,8 @@ query ThreadReplies($threadId:ID!,$cursor:String){node(id:$threadId){... on Pull
|
|
|
32
32
|
}}}`;
|
|
33
33
|
const RESOLVE_THREAD_MUTATION = `
|
|
34
34
|
mutation ResolveThread($threadId:ID!){resolveReviewThread(input:{threadId:$threadId}){thread{id isResolved}}}`;
|
|
35
|
+
const REPLY_THREAD_MUTATION = `
|
|
36
|
+
mutation ReplyThread($threadId:ID!,$body:String!){addPullRequestReviewThreadReply(input:{pullRequestReviewThreadId:$threadId,body:$body}){comment{id body}}}`;
|
|
35
37
|
|
|
36
38
|
type FeedbackAuthor = { login: string } | null;
|
|
37
39
|
type FeedbackComment = {
|
|
@@ -377,7 +379,9 @@ class FeedbackClient {
|
|
|
377
379
|
|
|
378
380
|
private async request(query: string, variables: Record<string, string | number | null>, host: string, readOnly: boolean): Promise<Record<string, unknown>> {
|
|
379
381
|
const args = ["api", "graphql", "--hostname", requiredText(host, "GitHub host"), "-F", "query=@-"];
|
|
380
|
-
for (const [name, value] of Object.entries(variables))
|
|
382
|
+
for (const [name, value] of Object.entries(variables)) {
|
|
383
|
+
if (value !== null) args.push(typeof value === "number" ? "-F" : "-f", `${name}=${value}`);
|
|
384
|
+
}
|
|
381
385
|
for (let attempt = 0; ; attempt += 1) {
|
|
382
386
|
try {
|
|
383
387
|
this.pages += 1;
|
|
@@ -499,6 +503,24 @@ export async function collectPullRequestFeedback(
|
|
|
499
503
|
});
|
|
500
504
|
}
|
|
501
505
|
|
|
506
|
+
export async function replyToPullRequestThread(
|
|
507
|
+
authorityInput: FeedbackAuthority,
|
|
508
|
+
threadIdInput: string,
|
|
509
|
+
bodyInput: string,
|
|
510
|
+
options: FeedbackClientOptions,
|
|
511
|
+
): Promise<string> {
|
|
512
|
+
const authority = parseAuthority(authorityInput);
|
|
513
|
+
const threadId = requiredText(threadIdInput, "review thread ID");
|
|
514
|
+
const body = requiredText(bodyInput, "review thread reply");
|
|
515
|
+
const data = await new FeedbackClient(options).mutate(REPLY_THREAD_MUTATION, { threadId, body }, authority.host);
|
|
516
|
+
if (!isRecord(data.addPullRequestReviewThreadReply) || !isRecord(data.addPullRequestReviewThreadReply.comment) ||
|
|
517
|
+
data.addPullRequestReviewThreadReply.comment.body !== body ||
|
|
518
|
+
typeof data.addPullRequestReviewThreadReply.comment.id !== "string") {
|
|
519
|
+
throw new Error(`GitHub did not confirm reply to ${threadId}`);
|
|
520
|
+
}
|
|
521
|
+
return requiredText(data.addPullRequestReviewThreadReply.comment.id, "review reply ID");
|
|
522
|
+
}
|
|
523
|
+
|
|
502
524
|
export async function resolvePullRequestThread(
|
|
503
525
|
authorityInput: FeedbackAuthority,
|
|
504
526
|
threadIdInput: string,
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { spawnBounded, type Exec } from "@henryqw/pi-process";
|
|
2
|
+
import { cloneCurrentPullRequest, loadCurrentPullRequest, readValidatedRemoteAuthority, samePullRequestSnapshot, type CurrentPullRequest } from "./pr-github.ts";
|
|
3
|
+
import { extensionExecApi, inspectWorktree, inspectWorktreeState, isAncestor, parseNulPaths, parseStatusSnapshot, readHead, readRemoteOid, requiredText, runChecked, validateResolvedConflictPaths, withWorktreeLock } from "./pr-execution.ts";
|
|
4
|
+
|
|
5
|
+
type Check = { command: string; args: string[] };
|
|
6
|
+
type Options = { cwd: string; authority: CurrentPullRequest; signal?: AbortSignal; agentDir?: string; exec?: Exec; loadCurrentPullRequest?: typeof loadCurrentPullRequest };
|
|
7
|
+
|
|
8
|
+
/** One local publication attempt. An uncertain push consumes the run, even when a later read recovers. */
|
|
9
|
+
export class PullRequestWorkPublisher {
|
|
10
|
+
private readonly authority: CurrentPullRequest;
|
|
11
|
+
private readonly exec: Exec;
|
|
12
|
+
private readonly load: typeof loadCurrentPullRequest;
|
|
13
|
+
private readonly options: Options;
|
|
14
|
+
private initialHead?: string;
|
|
15
|
+
private status?: string;
|
|
16
|
+
private validatedHead?: string;
|
|
17
|
+
private consumed = false;
|
|
18
|
+
|
|
19
|
+
constructor(options: Options) {
|
|
20
|
+
if (options.authority.lifecycle !== "open" || options.authority.target.provenance !== "configured") {
|
|
21
|
+
throw new Error("Local publication requires a configured open pull request");
|
|
22
|
+
}
|
|
23
|
+
if (options.authority.local.head === "behind" || options.authority.local.head === "diverged") {
|
|
24
|
+
throw new Error("Local publication requires a branch descended from the published PR head");
|
|
25
|
+
}
|
|
26
|
+
this.options = options;
|
|
27
|
+
this.authority = cloneCurrentPullRequest(options.authority);
|
|
28
|
+
this.exec = options.exec ?? spawnBounded;
|
|
29
|
+
this.load = options.loadCurrentPullRequest ?? loadCurrentPullRequest;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
private execOptions() { return { cwd: this.options.cwd, signal: this.options.signal }; }
|
|
33
|
+
|
|
34
|
+
private async authorityCheck(): Promise<void> {
|
|
35
|
+
const { cwd, signal } = this.options;
|
|
36
|
+
const discovery = await this.load(extensionExecApi(this.exec, cwd, signal), { cwd, signal: signal ?? new AbortController().signal });
|
|
37
|
+
if (discovery.kind !== "current" || !samePullRequestSnapshot(this.authority, discovery.pullRequest)) {
|
|
38
|
+
throw new Error("Local publication cancelled: PR identity or remote head changed");
|
|
39
|
+
}
|
|
40
|
+
const remote = await readValidatedRemoteAuthority(extensionExecApi(this.exec, cwd, signal),
|
|
41
|
+
{ cwd, signal: signal ?? new AbortController().signal }, this.authority.target.remote);
|
|
42
|
+
if (remote.fetchSource !== this.authority.target.fetchSource || remote.host !== this.authority.target.host ||
|
|
43
|
+
remote.repository.toLowerCase() !== this.authority.target.repository.toLowerCase()) {
|
|
44
|
+
throw new Error("Local publication cancelled: push destination changed");
|
|
45
|
+
}
|
|
46
|
+
if (await readRemoteOid(this.exec, this.execOptions(), this.authority.target.fetchSource, this.authority.target.ref) !== this.authority.head.oid) {
|
|
47
|
+
throw new Error("Local publication cancelled: remote lease changed");
|
|
48
|
+
}
|
|
49
|
+
const branch = requiredText((await runChecked(this.exec, "git", ["branch", "--show-current"], this.execOptions())).stdout.trim(), "current branch");
|
|
50
|
+
if (branch !== this.authority.target.branch) throw new Error("Local publication cancelled: branch changed");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async inspect(): Promise<{ paths: string[]; head: string }> {
|
|
54
|
+
if (this.initialHead) throw new Error("Pending changes already inspected");
|
|
55
|
+
return await withWorktreeLock(this.options.cwd, async () => {
|
|
56
|
+
await this.authorityCheck();
|
|
57
|
+
if (await inspectWorktreeState(this.exec, this.execOptions()) === "operation") throw new Error("Git operation in progress");
|
|
58
|
+
const head = await readHead(this.exec, this.execOptions());
|
|
59
|
+
if (!(await isAncestor(this.exec, this.execOptions(), this.authority.head.oid, head))) {
|
|
60
|
+
throw new Error("Local HEAD is not a descendant of the published PR head");
|
|
61
|
+
}
|
|
62
|
+
const status = (await runChecked(this.exec, "git", ["status", "--porcelain=v2", "-z", "--untracked-files=all"], this.execOptions())).stdout;
|
|
63
|
+
const paths = [...parseStatusSnapshot(status).keys()];
|
|
64
|
+
parseNulPaths(paths.map((path) => `${path}\0`).join(""), "Pending paths");
|
|
65
|
+
this.initialHead = head;
|
|
66
|
+
this.status = status;
|
|
67
|
+
return { paths, head };
|
|
68
|
+
}, { agentDir: this.options.agentDir, signal: this.options.signal });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async commit(pathsInput: string[], message: string): Promise<{ head: string }> {
|
|
72
|
+
if (!this.initialHead || this.status === undefined || this.consumed) throw new Error("Pending changes must be inspected first");
|
|
73
|
+
const paths = validateResolvedConflictPaths(pathsInput, []);
|
|
74
|
+
if (!paths.length || paths.some((path) => !parseStatusSnapshot(this.status!).has(path))) {
|
|
75
|
+
throw new Error("Commit paths must be reviewed pending paths");
|
|
76
|
+
}
|
|
77
|
+
requiredText(message, "commit message");
|
|
78
|
+
return await withWorktreeLock(this.options.cwd, async () => {
|
|
79
|
+
await this.authorityCheck();
|
|
80
|
+
if (await readHead(this.exec, this.execOptions()) !== this.initialHead ||
|
|
81
|
+
await inspectWorktreeState(this.exec, this.execOptions()) === "operation" ||
|
|
82
|
+
(await runChecked(this.exec, "git", ["status", "--porcelain=v2", "-z", "--untracked-files=all"], this.execOptions())).stdout !== this.status) {
|
|
83
|
+
throw new Error("Pending changes moved after inspection; inspect again in a new /pr");
|
|
84
|
+
}
|
|
85
|
+
// Never let an already-staged unrelated path enter this commit.
|
|
86
|
+
const staged = parseNulPaths((await runChecked(this.exec, "git", ["diff", "--cached", "--no-renames", "--name-only", "-z"], this.execOptions())).stdout, "Staged paths");
|
|
87
|
+
if (staged.some((path) => !paths.includes(path))) throw new Error("Unrelated staged changes require an ownership decision");
|
|
88
|
+
this.consumed = true;
|
|
89
|
+
await runChecked(this.exec, "git", ["--literal-pathspecs", "add", "-A", "--", ...paths], this.execOptions());
|
|
90
|
+
await runChecked(this.exec, "git", ["commit", "-m", message], this.execOptions());
|
|
91
|
+
return { head: await readHead(this.exec, this.execOptions()) };
|
|
92
|
+
}, { agentDir: this.options.agentDir, signal: this.options.signal });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async validate(checks: Check[]): Promise<{ head: string; checks: number }> {
|
|
96
|
+
if (!this.initialHead || this.validatedHead) throw new Error("Local publication validation is unavailable");
|
|
97
|
+
if (!Array.isArray(checks) || checks.length > 32 || checks.some(({ command, args }) =>
|
|
98
|
+
typeof command !== "string" || !command || !Array.isArray(args) || args.some((arg) => typeof arg !== "string"))) {
|
|
99
|
+
throw new Error("Invalid validation commands");
|
|
100
|
+
}
|
|
101
|
+
return await withWorktreeLock(this.options.cwd, async () => {
|
|
102
|
+
await this.authorityCheck();
|
|
103
|
+
if (await inspectWorktree(this.exec, this.execOptions()) !== "clean") throw new Error("Unrelated pending changes remain; ask about ownership before publishing");
|
|
104
|
+
const head = await readHead(this.exec, this.execOptions());
|
|
105
|
+
if (!(await isAncestor(this.exec, this.execOptions(), this.authority.head.oid, head))) {
|
|
106
|
+
throw new Error("Local HEAD is not a descendant of the published PR head");
|
|
107
|
+
}
|
|
108
|
+
await runChecked(this.exec, "git", ["diff", "--check", this.authority.head.oid, head], this.execOptions());
|
|
109
|
+
for (const check of checks) await runChecked(this.exec, check.command, check.args, this.execOptions());
|
|
110
|
+
if (await readHead(this.exec, this.execOptions()) !== head || await inspectWorktree(this.exec, this.execOptions()) !== "clean") {
|
|
111
|
+
throw new Error("Validation changed local HEAD or worktree");
|
|
112
|
+
}
|
|
113
|
+
this.validatedHead = head;
|
|
114
|
+
return { head, checks: checks.length + 1 };
|
|
115
|
+
}, { agentDir: this.options.agentDir, signal: this.options.signal });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async publish(): Promise<{ kind: "published"; head: string }> {
|
|
119
|
+
if (!this.validatedHead) throw new Error("Local work is not validated");
|
|
120
|
+
const head = this.validatedHead;
|
|
121
|
+
this.validatedHead = undefined; // A response loss must never allow a second push.
|
|
122
|
+
return await withWorktreeLock(this.options.cwd, async () => {
|
|
123
|
+
await this.authorityCheck();
|
|
124
|
+
if (await inspectWorktree(this.exec, this.execOptions()) !== "clean" || await readHead(this.exec, this.execOptions()) !== head) {
|
|
125
|
+
throw new Error("Validated local HEAD or worktree changed");
|
|
126
|
+
}
|
|
127
|
+
const original = this.authority.head.oid;
|
|
128
|
+
if (head === original) return { kind: "published", head };
|
|
129
|
+
let error: unknown;
|
|
130
|
+
try {
|
|
131
|
+
await runChecked(this.exec, "git", ["push", "--porcelain", `--force-with-lease=refs/heads/${this.authority.target.ref}:${original}`,
|
|
132
|
+
"--recurse-submodules=no", "--", this.authority.target.fetchSource,
|
|
133
|
+
`${head}:refs/heads/${this.authority.target.ref}`], this.execOptions());
|
|
134
|
+
} catch (failure) { error = failure; }
|
|
135
|
+
let remote: string | null;
|
|
136
|
+
try { remote = await readRemoteOid(this.exec, this.execOptions(), this.authority.target.fetchSource, this.authority.target.ref); }
|
|
137
|
+
catch { throw new Error("Local publication outcome unknown; do not retry"); }
|
|
138
|
+
if (remote === head) return { kind: "published", head };
|
|
139
|
+
if (remote === original) throw new Error(`Local publication not applied${error ? `: ${String(error)}` : ""}`);
|
|
140
|
+
throw new Error("Local publication outcome unknown; do not retry");
|
|
141
|
+
}, { agentDir: this.options.agentDir, signal: this.options.signal });
|
|
142
|
+
}
|
|
143
|
+
}
|
package/extensions/pr-routing.ts
CHANGED
|
@@ -60,19 +60,7 @@ export type PullRequestDiscovery<T extends PullRequest = PullRequest> =
|
|
|
60
60
|
| { kind: "blocked"; issue: DiscoveryIssue }
|
|
61
61
|
| { kind: "inactive" };
|
|
62
62
|
|
|
63
|
-
export type NextStep = "create" | "link-branch" | "blocked" | "none" | "update-branch" | "sweep" | "fix-ci" | "merge";
|
|
64
|
-
export type RouteIntent = "automatic" | "feedback";
|
|
65
|
-
export type FeedbackRouteBlocker =
|
|
66
|
-
| { kind: "discovery-blocked" }
|
|
67
|
-
| { kind: "pull-request-unavailable" }
|
|
68
|
-
| { kind: "pull-request-not-open" }
|
|
69
|
-
| { kind: "target-not-configured" }
|
|
70
|
-
| { kind: "worktree-dirty" }
|
|
71
|
-
| { kind: "head-not-equal"; relation: Exclude<LocalHeadRelation, "equal"> };
|
|
72
|
-
type RouteDecision =
|
|
73
|
-
| { kind: "selected"; nextStep: NextStep }
|
|
74
|
-
| { kind: "feedback-blocked"; blocker: FeedbackRouteBlocker };
|
|
75
|
-
|
|
63
|
+
export type NextStep = "create" | "link-branch" | "blocked" | "none" | "publish-work" | "update-branch" | "sweep" | "fix-ci" | "merge";
|
|
76
64
|
function localMutationSafe(local: LocalMergeSafety): boolean {
|
|
77
65
|
return local.worktree === "clean" && local.head === "equal";
|
|
78
66
|
}
|
|
@@ -84,7 +72,9 @@ function localMergeSafe(local: LocalMergeSafety): boolean {
|
|
|
84
72
|
export function derivePullRequestNextStep(pullRequest: PullRequest): Exclude<NextStep, "create" | "link-branch" | "blocked"> {
|
|
85
73
|
const { lifecycle, conditions, local } = pullRequest;
|
|
86
74
|
if (lifecycle !== "open" || conditions.draft) return "none";
|
|
87
|
-
if (
|
|
75
|
+
if (local.worktree === "dirty" && (local.head === "behind" || local.head === "diverged")) return "none";
|
|
76
|
+
if (local.worktree === "dirty" || local.head === "ahead") return "publish-work";
|
|
77
|
+
if (conditions.conflict) {
|
|
88
78
|
return localMutationSafe(local) ? "update-branch" : "none";
|
|
89
79
|
}
|
|
90
80
|
if (conditions.ci === "failure") return localMutationSafe(local) ? "fix-ci" : "none";
|
|
@@ -106,7 +96,7 @@ export function isPullRequestCreationEligible(branch: BranchCreationState): bool
|
|
|
106
96
|
(branch.ahead > 0 || branch.worktree === "dirty");
|
|
107
97
|
}
|
|
108
98
|
|
|
109
|
-
function
|
|
99
|
+
export function deriveNextStep(discovery: PullRequestDiscovery<PullRequest & { target: PullRequestTarget }>): NextStep {
|
|
110
100
|
if (discovery.kind === "inactive") return "none";
|
|
111
101
|
if (discovery.kind === "blocked") return "blocked";
|
|
112
102
|
if (discovery.kind === "none") return isPullRequestCreationEligible(discovery.branch) ? "create" : "none";
|
|
@@ -115,37 +105,3 @@ function deriveAutomaticNextStep(discovery: PullRequestDiscovery<PullRequest & {
|
|
|
115
105
|
}
|
|
116
106
|
return derivePullRequestNextStep(discovery.pullRequest);
|
|
117
107
|
}
|
|
118
|
-
|
|
119
|
-
export function deriveRouteDecision(
|
|
120
|
-
discovery: PullRequestDiscovery<PullRequest & { target: PullRequestTarget }>,
|
|
121
|
-
intent: RouteIntent = "automatic",
|
|
122
|
-
): RouteDecision {
|
|
123
|
-
if (intent === "automatic") return { kind: "selected", nextStep: deriveAutomaticNextStep(discovery) };
|
|
124
|
-
if (discovery.kind === "blocked") {
|
|
125
|
-
return { kind: "feedback-blocked", blocker: { kind: "discovery-blocked" } };
|
|
126
|
-
}
|
|
127
|
-
if (discovery.kind !== "current") {
|
|
128
|
-
return { kind: "feedback-blocked", blocker: { kind: "pull-request-unavailable" } };
|
|
129
|
-
}
|
|
130
|
-
const pullRequest = discovery.pullRequest;
|
|
131
|
-
if (pullRequest.lifecycle !== "open") {
|
|
132
|
-
return { kind: "feedback-blocked", blocker: { kind: "pull-request-not-open" } };
|
|
133
|
-
}
|
|
134
|
-
if (pullRequest.target.provenance !== "configured") {
|
|
135
|
-
return { kind: "feedback-blocked", blocker: { kind: "target-not-configured" } };
|
|
136
|
-
}
|
|
137
|
-
if (pullRequest.local.worktree !== "clean") {
|
|
138
|
-
return { kind: "feedback-blocked", blocker: { kind: "worktree-dirty" } };
|
|
139
|
-
}
|
|
140
|
-
if (pullRequest.local.head !== "equal") {
|
|
141
|
-
return {
|
|
142
|
-
kind: "feedback-blocked",
|
|
143
|
-
blocker: { kind: "head-not-equal", relation: pullRequest.local.head },
|
|
144
|
-
};
|
|
145
|
-
}
|
|
146
|
-
return { kind: "selected", nextStep: "sweep" };
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
export function deriveNextStep(discovery: PullRequestDiscovery<PullRequest & { target: PullRequestTarget }>): NextStep {
|
|
150
|
-
return deriveAutomaticNextStep(discovery);
|
|
151
|
-
}
|
package/extensions/pr-ui.ts
CHANGED
|
@@ -91,12 +91,12 @@ function footerStatus(input: PrDisplayInput): Pick<PrFooter, "text" | "color"> {
|
|
|
91
91
|
return { text: "open", color: "accent" };
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
-
function widgetText(
|
|
94
|
+
function widgetText(nextStep: NextStep): string | undefined {
|
|
95
95
|
switch (nextStep) {
|
|
96
|
+
case "publish-work":
|
|
97
|
+
return "Run /pr to publish local work";
|
|
96
98
|
case "update-branch":
|
|
97
|
-
return
|
|
98
|
-
? "Run /pr to resolve merge conflict"
|
|
99
|
-
: "Run /pr to update branch";
|
|
99
|
+
return "Run /pr to resolve merge conflict";
|
|
100
100
|
case "sweep":
|
|
101
101
|
return "Run /pr to address review feedback";
|
|
102
102
|
case "fix-ci":
|
|
@@ -143,7 +143,7 @@ export function projectPrDisplay(
|
|
|
143
143
|
url: input.url.href,
|
|
144
144
|
...footerStatus(input),
|
|
145
145
|
},
|
|
146
|
-
widget: widgetText(
|
|
146
|
+
widget: widgetText(nextStep),
|
|
147
147
|
};
|
|
148
148
|
}
|
|
149
149
|
|