@henryqw/pi-pr 4.0.7 → 4.0.10
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 +13 -5
- package/extensions/pr-ci.ts +1 -3
- package/extensions/pr-command.ts +36 -7
- package/extensions/pr-comment-sweep.ts +6 -23
- package/extensions/pr-create.ts +16 -8
- package/extensions/pr-execution.ts +17 -194
- package/extensions/pr-feedback.ts +1 -2
- package/extensions/pr-github.ts +31 -4
- package/extensions/pr-merge.ts +1 -1
- package/extensions/pr-routing.ts +55 -2
- package/extensions/pr-update-branch.ts +1 -3
- package/extensions/pr.ts +3 -2
- package/package.json +2 -1
- package/skills/pi-pr-comment-sweep/scripts/pr-feedback.mjs +1 -1
- package/skills/pi-pr-create/SKILL.md +1 -1
package/README.md
CHANGED
|
@@ -36,6 +36,8 @@ GitHub, use the network, or write files.
|
|
|
36
36
|
|
|
37
37
|
**Requires.** [`@henryqw/pi-herdr`](https://pi.henry.wang/extensions/pi-herdr) is the shared Herdr CLI client. It installs with this package.
|
|
38
38
|
|
|
39
|
+
**Uses.** [`@henryqw/pi-process`](https://pi.henry.wang/packages/pi-process) runs bounded child processes. It installs with this package.
|
|
40
|
+
|
|
39
41
|
**Improves.** [`@henryqw/pi-footer`](https://pi.henry.wang/extensions/pi-footer) shows current-branch pull-request status in the footer.
|
|
40
42
|
|
|
41
43
|
## Use
|
|
@@ -44,9 +46,12 @@ Run `/pr` in a GitHub checkout. It reads fresh pull request and local state, the
|
|
|
44
46
|
|
|
45
47
|
For creation, put an optional base branch first. For example, run `/pr --base release/2026 Keep the title concise.` The base is a branch name, not a host or repository. Only creation accepts the base and remaining guidance. Other routes reject them instead of ignoring them.
|
|
46
48
|
|
|
49
|
+
Run `/pr --feedback` to explicitly start or resume a feedback sweep. Use it for actionable conversation comments that do not select the sweep automatically. It cannot be combined with a base, other options, or instructions.
|
|
50
|
+
|
|
47
51
|
| Surface | Type | Purpose |
|
|
48
52
|
| --- | --- | --- |
|
|
49
53
|
| `/pr [--base BRANCH] [creation instructions]` | command | Run the current pull request's next safe route. |
|
|
54
|
+
| `/pr --feedback` | command | Explicitly start or resume the guarded feedback sweep. |
|
|
50
55
|
| Footer | ui | Show a linked `PR #number` and one plain-language status. |
|
|
51
56
|
| Widget | ui | Show one action hint or transient routing status. |
|
|
52
57
|
|
|
@@ -64,17 +69,18 @@ Each footer entry is one linked `PR #number` plus one plain-language status: `N
|
|
|
64
69
|
|
|
65
70
|
| Current condition | `/pr` route |
|
|
66
71
|
| --- | --- |
|
|
67
|
-
| No current-branch pull request, no published matching ref, safe Git push configuration, and a commit
|
|
72
|
+
| No current-branch pull request, no published matching ref, safe Git push configuration, and a commit or ordinary pending work | Start pull-request creation. |
|
|
68
73
|
| One open pull request inferred from a published matching ref | Confirm the exact `remote/ref`, then link the local branch. |
|
|
69
74
|
| Ambiguous or unsafe discovery | Show the blocked reason and do not mutate Git or GitHub. |
|
|
70
75
|
| Base update required or merge conflict | Update from the base branch's current target when the tree is clean and local HEAD equals the PR head. |
|
|
71
76
|
| GitHub Actions job failed | Run the CI fix workflow when the same local prerequisite holds. |
|
|
72
77
|
| External check or commit status failed | Show `CI failed` as a no-action blocker. |
|
|
73
78
|
| Changes requested or unresolved review threads | Start or resume the package comment sweep when the same local prerequisite holds. |
|
|
79
|
+
| Explicit `/pr --feedback` on an open, configured pull request | Start or resume the sweep when the tree is clean and local HEAD equals the PR head. |
|
|
74
80
|
| No-action state | Report the state without taking action. |
|
|
75
81
|
| Merge-ready pull request | Ask for final confirmation, recheck fresh state, and squash-merge if confirmed. |
|
|
76
82
|
|
|
77
|
-
`pi-pr-create` selects its base in this order: the leading `/pr --base BRANCH`, one `branch.<branch>.gh-merge-base` value, then the default branch of validated `origin`. It captures the selected base OID and merge-base. Creation requires
|
|
83
|
+
`pi-pr-create` selects its base in this order: the leading `/pr --base BRANCH`, one `branch.<branch>.gh-merge-base` value, then the default branch of validated `origin`. It captures the selected base OID and merge-base. Creation requires a commit ahead or ordinary pending work, including untracked files. A Git operation in progress does not count as pending work. If the current branch is the selected base, pi-pr stays silent because GitHub cannot create a pull request from a ref to itself.
|
|
78
84
|
|
|
79
85
|
The base always comes from validated `origin`. The head may use that repository or a fork with the same GitHub source. Base and head must use the same GitHub host. Other fork relationships stop before mutation.
|
|
80
86
|
|
|
@@ -92,6 +98,8 @@ Each helper workflow receives a random run ID and its first action. The run stay
|
|
|
92
98
|
|
|
93
99
|
For comment sweeps, `/pr` checks the package recovery file without changing it. It selects `start` when recovery is absent. It selects `resume` only when valid recovery matches the fresh route authority. Invalid recovery stays unchanged and blocks dispatch with its path and reason.
|
|
94
100
|
|
|
101
|
+
`/pr --feedback` uses the same discovery, reservation, recovery, and guard checks. It can select the sweep even when CI failure or merge readiness would otherwise select another route. Without the flag, route priority stays unchanged.
|
|
102
|
+
|
|
95
103
|
Direct skill or `pi_pr_*` tool calls cannot create route authority. Run `/pr` to reserve a fresh route.
|
|
96
104
|
|
|
97
105
|
Only one helper run can exist at a time. Most runs expire when the agent settles. A create or branch-update conflict stays available for one user-guided continuation, then expires after that continuation settles. Session replacement and shutdown forget the run without aborting or cleaning a pending merge.
|
|
@@ -121,7 +129,7 @@ A missing pull request uses creation. For an existing pull request, the first ma
|
|
|
121
129
|
5. Waiting or local safety block: no action.
|
|
122
130
|
6. Merge-ready: allow clean local HEAD equal to or behind the PR head. Confirm, then merge directly.
|
|
123
131
|
|
|
124
|
-
Ordinary conversation comments do not trigger a route or block a merge. Changes requested and unresolved review threads can select the package comment sweep.
|
|
132
|
+
Ordinary conversation comments do not trigger a route or block a merge. Changes requested and unresolved review threads can select the package comment sweep. Use `/pr --feedback` when a conversation comment needs action.
|
|
125
133
|
|
|
126
134
|
The comment sweep resolves its bundled helper and references from the installed package skill path. It does not require an external `jq` executable.
|
|
127
135
|
|
|
@@ -137,7 +145,7 @@ The footer and widget load once at session start. A directory outside a Git work
|
|
|
137
145
|
|
|
138
146
|
They refresh after local commits, PR creation, pushes, and each dispatched workflow settles. During creation, intermediate refreshes wait until the workflow settles. They also refresh after any successful delegated task settles. There is no periodic presentation refresh, so external changes may leave the footer and widget stale indefinitely. `/pr` reads fresh state before routing or acting and remains authoritative.
|
|
139
147
|
|
|
140
|
-
The create widget stays hidden
|
|
148
|
+
The create widget stays hidden on a clean branch with no commit ahead. It appears for a commit ahead or ordinary pending work. It stays hidden during a Git operation and when the current branch is the selected base. `/pr` replaces any hint with routing feedback while it selects a route. The feedback clears before route interaction. A dispatched workflow keeps the widget hidden until the agent settles. Direct and no-action routes refresh it after completion. A failed command restores the prior hint and schedules a refresh, except when fresh lookup hits the GitHub API quota: it shows the sanitized message `GitHub API rate limit exhausted; retry after GitHub resets it` and does not immediately retry.
|
|
141
149
|
|
|
142
150
|
Presentation uses route priority, so draft appears before running CI. `/pr` reads fresh state before routing or merging. The command is authoritative for actions.
|
|
143
151
|
|
|
@@ -151,7 +159,7 @@ The GitHub response must match the observed URL, host, repository, head ref, hea
|
|
|
151
159
|
|
|
152
160
|
## Limits and recovery
|
|
153
161
|
|
|
154
|
-
- `/pr` accepts creation syntax
|
|
162
|
+
- `/pr` accepts either standalone `--feedback` or creation syntax with leading `--base BRANCH` and optional guidance. It rejects unknown or conflicting options. It does not open a browser.
|
|
155
163
|
- It does not run `/done` or `/sweep`.
|
|
156
164
|
- Presentation refreshes do not auto-triage comments or start a workflow. The package comment sweep starts or resumes only when an explicit `/pr` selects it.
|
|
157
165
|
- It does not enable auto-merge or add a merge queue.
|
package/extensions/pr-ci.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { spawnBounded, type Exec, type ExecOptions } from "@henryqw/pi-process";
|
|
3
4
|
import {
|
|
4
5
|
loadCurrentPullRequest,
|
|
5
6
|
readValidatedRemoteAuthority,
|
|
@@ -15,10 +16,7 @@ import {
|
|
|
15
16
|
requiredOid,
|
|
16
17
|
requiredText,
|
|
17
18
|
runChecked,
|
|
18
|
-
spawnBounded,
|
|
19
19
|
withWorktreeLock,
|
|
20
|
-
type Exec,
|
|
21
|
-
type ExecOptions,
|
|
22
20
|
} from "./pr-execution.ts";
|
|
23
21
|
|
|
24
22
|
const PAGE_BYTES = 512 * 1024;
|
package/extensions/pr-command.ts
CHANGED
|
@@ -11,8 +11,11 @@ import {
|
|
|
11
11
|
} from "./pr-github.ts";
|
|
12
12
|
import {
|
|
13
13
|
deriveNextStep,
|
|
14
|
+
deriveRouteDecision,
|
|
15
|
+
type FeedbackRouteBlocker,
|
|
14
16
|
type NextStep,
|
|
15
17
|
type PullRequestTarget,
|
|
18
|
+
type RouteIntent,
|
|
16
19
|
} from "./pr-routing.ts";
|
|
17
20
|
|
|
18
21
|
export type WorkflowNextStep = Extract<NextStep, "create" | "update-branch" | "sweep" | "fix-ci">;
|
|
@@ -61,18 +64,42 @@ export type PrCommandDependencies = {
|
|
|
61
64
|
|
|
62
65
|
type ParsedPrArguments = {
|
|
63
66
|
base?: string;
|
|
67
|
+
intent: RouteIntent;
|
|
64
68
|
instructions: string;
|
|
65
69
|
};
|
|
66
70
|
|
|
67
71
|
function parsePrArguments(args: string): ParsedPrArguments {
|
|
68
72
|
const leading = args.trimStart();
|
|
73
|
+
const trimmed = leading.trim();
|
|
74
|
+
if (leading.startsWith("--feedback=")) throw new Error("/pr feedback syntax is --feedback");
|
|
75
|
+
if (/^--feedback(?:\s|$)/.test(leading)) {
|
|
76
|
+
if (trimmed !== "--feedback") throw new Error("/pr --feedback cannot be combined with other options or instructions");
|
|
77
|
+
return { intent: "feedback", instructions: "" };
|
|
78
|
+
}
|
|
79
|
+
if (/(?:^|\s)--feedback(?=\s|=|$)/.test(leading)) {
|
|
80
|
+
throw new Error("/pr --feedback cannot be combined with other options or instructions");
|
|
81
|
+
}
|
|
69
82
|
if (leading.startsWith("--base=")) throw new Error("/pr base syntax is --base <branch>");
|
|
70
|
-
if (
|
|
71
|
-
|
|
83
|
+
if (/^--base(?:\s|$)/.test(leading)) {
|
|
84
|
+
const value = /^--base\s+(\S+)/.exec(leading);
|
|
85
|
+
if (!value) throw new Error("/pr --base requires a branch");
|
|
86
|
+
const instructions = leading.slice(value[0].length).trim();
|
|
87
|
+
if (instructions.startsWith("--")) throw new Error(`Unknown /pr option: ${instructions.split(/\s+/, 1)[0]}`);
|
|
88
|
+
return { base: value[1]!, intent: "automatic", instructions };
|
|
89
|
+
}
|
|
90
|
+
if (leading.startsWith("--")) throw new Error(`Unknown /pr option: ${leading.split(/\s+/, 1)[0]}`);
|
|
91
|
+
return { intent: "automatic", instructions: args.trim() };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function feedbackBlockerMessage(blocker: FeedbackRouteBlocker): string {
|
|
95
|
+
switch (blocker.kind) {
|
|
96
|
+
case "discovery-blocked": return "/pr --feedback is blocked because pull request discovery is unsafe";
|
|
97
|
+
case "pull-request-unavailable": return "/pr --feedback requires a current pull request";
|
|
98
|
+
case "pull-request-not-open": return "/pr --feedback requires an open pull request";
|
|
99
|
+
case "target-not-configured": return "/pr --feedback requires a configured pull request target; run /pr to link the branch first";
|
|
100
|
+
case "worktree-dirty": return "/pr --feedback is blocked by a dirty worktree";
|
|
101
|
+
case "head-not-equal": return `/pr --feedback is blocked by local HEAD ${blocker.relation}`;
|
|
72
102
|
}
|
|
73
|
-
const value = /^--base\s+(\S+)/.exec(leading);
|
|
74
|
-
if (!value) throw new Error("/pr --base requires a branch");
|
|
75
|
-
return { base: value[1]!, instructions: leading.slice(value[0].length).trim() };
|
|
76
103
|
}
|
|
77
104
|
|
|
78
105
|
function workflowReservation(
|
|
@@ -257,11 +284,13 @@ export function createPrCommandHandler(
|
|
|
257
284
|
const commandInvocation = onRouteResolved && "assertCurrent" in onRouteResolved
|
|
258
285
|
? onRouteResolved as PrCommandInvocation
|
|
259
286
|
: undefined;
|
|
260
|
-
const { base, instructions } = parsePrArguments(args);
|
|
287
|
+
const { base, intent, instructions } = parsePrArguments(args);
|
|
261
288
|
const discovery = await load(pi, ctx, undefined, undefined, base);
|
|
262
289
|
commandInvocation?.assertCurrent();
|
|
263
|
-
const
|
|
290
|
+
const decision = deriveRouteDecision(discovery, intent);
|
|
291
|
+
const nextStep = decision.nextStep;
|
|
264
292
|
onRouteResolved?.(nextStep);
|
|
293
|
+
if (decision.kind === "feedback-blocked") throw new Error(feedbackBlockerMessage(decision.blocker));
|
|
265
294
|
if (base !== undefined && nextStep !== "create") {
|
|
266
295
|
throw new Error("/pr --base is accepted only for pull request creation");
|
|
267
296
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import {
|
|
3
|
-
import { join
|
|
2
|
+
import { realpath, rm } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
4
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { spawnBounded, type Exec, type ExecOptions } from "@henryqw/pi-process";
|
|
5
6
|
import {
|
|
6
7
|
extensionConfigDir,
|
|
7
8
|
readTextFileBounded,
|
|
@@ -27,6 +28,7 @@ import {
|
|
|
27
28
|
type PullRequestLoadContext,
|
|
28
29
|
} from "./pr-github.ts";
|
|
29
30
|
import {
|
|
31
|
+
inspectGitOperation,
|
|
30
32
|
isAncestor,
|
|
31
33
|
parseNulPaths,
|
|
32
34
|
parseStatusSnapshot,
|
|
@@ -35,18 +37,14 @@ import {
|
|
|
35
37
|
requiredOid,
|
|
36
38
|
requiredText,
|
|
37
39
|
runChecked,
|
|
38
|
-
spawnBounded,
|
|
39
40
|
withWorktreeLock,
|
|
40
41
|
type AttemptState,
|
|
41
|
-
type Exec,
|
|
42
|
-
type ExecOptions,
|
|
43
42
|
} from "./pr-execution.ts";
|
|
44
43
|
|
|
45
44
|
export const SWEEP_RECOVERY_MAX_BYTES = 1024 * 1024;
|
|
46
45
|
|
|
47
46
|
const STATE_VERSION = 1;
|
|
48
47
|
const STATE_FILE = "state.json";
|
|
49
|
-
const GIT_OPERATION_STATES = ["MERGE_HEAD", "rebase-merge", "rebase-apply", "CHERRY_PICK_HEAD", "REVERT_HEAD", "sequencer"];
|
|
50
48
|
const LEDGER_NOTE_MAX_BYTES = 2 * 1024;
|
|
51
49
|
const CHECK_MAX_COUNT = 32;
|
|
52
50
|
const CHECK_ARGUMENTS_MAX_BYTES = 32 * 1024;
|
|
@@ -640,23 +638,8 @@ export class PullRequestCommentSweep {
|
|
|
640
638
|
}
|
|
641
639
|
|
|
642
640
|
private async requireNoGitOperation(): Promise<void> {
|
|
643
|
-
const
|
|
644
|
-
|
|
645
|
-
], this.options());
|
|
646
|
-
const normalized = paths.stdout.replace(/\r\n/g, "\n");
|
|
647
|
-
const values = (normalized.endsWith("\n") ? normalized.slice(0, -1) : normalized).split("\n");
|
|
648
|
-
if (values.length !== GIT_OPERATION_STATES.length || values.some((path) => !path)) {
|
|
649
|
-
throw new Error("Git operation state path resolution returned invalid output");
|
|
650
|
-
}
|
|
651
|
-
for (const [index, path] of values.entries()) {
|
|
652
|
-
try {
|
|
653
|
-
await lstat(resolve(this.cwd, path));
|
|
654
|
-
throw new Error(`${GIT_OPERATION_STATES[index]} is in progress`);
|
|
655
|
-
} catch (error) {
|
|
656
|
-
if (error && typeof error === "object" && (error as NodeJS.ErrnoException).code === "ENOENT") continue;
|
|
657
|
-
throw error;
|
|
658
|
-
}
|
|
659
|
-
}
|
|
641
|
+
const operation = await inspectGitOperation(this.exec, this.options());
|
|
642
|
+
if (operation !== null) throw new Error(`${operation} is in progress`);
|
|
660
643
|
}
|
|
661
644
|
|
|
662
645
|
private async localPaths(): Promise<string[]> {
|
package/extensions/pr-create.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { spawnBounded, type Exec, type ExecOptions } from "@henryqw/pi-process";
|
|
2
3
|
import {
|
|
3
4
|
branchTrackingRef,
|
|
4
5
|
fetchBranchTrackingRef,
|
|
@@ -15,7 +16,10 @@ import {
|
|
|
15
16
|
type PullRequestLoadContext,
|
|
16
17
|
type PullRequestPublication,
|
|
17
18
|
} from "./pr-github.ts";
|
|
18
|
-
import
|
|
19
|
+
import {
|
|
20
|
+
isPullRequestCreationEligible,
|
|
21
|
+
type PullRequestTarget,
|
|
22
|
+
} from "./pr-routing.ts";
|
|
19
23
|
import {
|
|
20
24
|
assertOnlyDeclaredStatusChanged,
|
|
21
25
|
inspectWorktree,
|
|
@@ -26,10 +30,7 @@ import {
|
|
|
26
30
|
requiredOid,
|
|
27
31
|
requiredText,
|
|
28
32
|
runChecked,
|
|
29
|
-
spawnBounded,
|
|
30
33
|
withWorktreeLock,
|
|
31
|
-
type Exec,
|
|
32
|
-
type ExecOptions,
|
|
33
34
|
} from "./pr-execution.ts";
|
|
34
35
|
|
|
35
36
|
const MAX_TITLE_BYTES = 256;
|
|
@@ -221,13 +222,14 @@ export class PullRequestCreator {
|
|
|
221
222
|
return { cwd: this.cwd, signal: this.signal ?? new AbortController().signal };
|
|
222
223
|
}
|
|
223
224
|
|
|
224
|
-
private async freshNone()
|
|
225
|
+
private async freshNone() {
|
|
225
226
|
const discovery = await this.load(this.pi(), this.context(), undefined, undefined, this.explicitBase);
|
|
226
227
|
if (discovery.kind !== "none" || !sameTarget(this.target, discovery.creationTarget)) {
|
|
227
228
|
throw new Error("PR creation cancelled: fresh complete discovery is no longer none");
|
|
228
229
|
}
|
|
229
230
|
const branch = line((await runChecked(this.exec, "git", ["branch", "--show-current"], this.options())).stdout, "current branch");
|
|
230
231
|
if (branch !== this.target.branch) throw new Error("PR creation cancelled: current branch changed");
|
|
232
|
+
return discovery;
|
|
231
233
|
}
|
|
232
234
|
|
|
233
235
|
private async liveBase(): Promise<string> {
|
|
@@ -270,8 +272,11 @@ export class PullRequestCreator {
|
|
|
270
272
|
this.target,
|
|
271
273
|
this.explicitBase,
|
|
272
274
|
);
|
|
273
|
-
if (preflight.
|
|
274
|
-
throw new Error("PR creation
|
|
275
|
+
if (preflight.worktree === "operation") {
|
|
276
|
+
throw new Error("PR creation cannot prepare while a Git operation is in progress");
|
|
277
|
+
}
|
|
278
|
+
if (!isPullRequestCreationEligible({ ...preflight, relation: "distinct-ref" })) {
|
|
279
|
+
throw new Error("PR creation requires at least one commit ahead of the selected base or pending work");
|
|
275
280
|
}
|
|
276
281
|
const { base } = preflight;
|
|
277
282
|
this.state.base = {
|
|
@@ -286,11 +291,14 @@ export class PullRequestCreator {
|
|
|
286
291
|
"fetch", "--no-write-fetch-head", "--no-tags", "--no-recurse-submodules", base.fetchSource, base.oid,
|
|
287
292
|
], this.options());
|
|
288
293
|
await runChecked(this.exec, "git", ["cat-file", "-e", `${base.oid}^{commit}`], this.options());
|
|
289
|
-
await this.freshNone();
|
|
294
|
+
const fresh = await this.freshNone();
|
|
290
295
|
if (await readHead(this.exec, this.options()) !== preflight.head) {
|
|
291
296
|
throw new Error("PR creation cancelled: local HEAD changed during prepare");
|
|
292
297
|
}
|
|
293
298
|
if (await this.liveBase() !== base.oid) throw new Error("PR creation cancelled: base ref moved during prepare");
|
|
299
|
+
if (!isPullRequestCreationEligible(fresh.branch)) {
|
|
300
|
+
throw new Error("PR creation cancelled: branch no longer has a committed change or pending work");
|
|
301
|
+
}
|
|
294
302
|
this.state.phase = "prepared";
|
|
295
303
|
return { kind: "prepared", base: { ...this.state.base }, mergeBase: base.mergeBase };
|
|
296
304
|
}, { agentDir: this.agentDir, signal: this.signal });
|
|
@@ -1,47 +1,20 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { lstat, mkdir, realpath } from "node:fs/promises";
|
|
3
3
|
import { join, resolve } from "node:path";
|
|
4
|
-
import { spawn } from "node:child_process";
|
|
5
4
|
import { extensionConfigDir } from "@henryqw/pi-config-store";
|
|
5
|
+
import { spawnBounded, type Exec, type ExecOptions, type ExecResult } from "@henryqw/pi-process";
|
|
6
6
|
import { lock } from "proper-lockfile";
|
|
7
7
|
|
|
8
|
-
export const DEFAULT_EXEC_TIMEOUT_MS = 30_000;
|
|
9
|
-
export const DEFAULT_OUTPUT_LIMIT_BYTES = 64 * 1024;
|
|
10
8
|
export const MAX_CONFLICT_PATHS = 128;
|
|
11
9
|
export const MAX_CONFLICT_PATH_BYTES = 1_024;
|
|
12
10
|
export const MAX_CONFLICT_PATHS_BYTES = 32 * 1024;
|
|
13
11
|
|
|
14
12
|
const OID = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
15
13
|
const GIT_OPERATION_STATES = ["MERGE_HEAD", "rebase-merge", "rebase-apply", "CHERRY_PICK_HEAD", "REVERT_HEAD", "sequencer"];
|
|
16
|
-
const PROCESS_TERMINATION_GRACE_MS = 250;
|
|
17
14
|
const WORKTREE_LOCK_OPTIONS = { realpath: false, stale: 30_000, update: 5_000, retries: 0 } as const;
|
|
18
15
|
|
|
19
|
-
export type ExecResult = {
|
|
20
|
-
stdout: string;
|
|
21
|
-
stderr: string;
|
|
22
|
-
code: number;
|
|
23
|
-
killed: boolean;
|
|
24
|
-
stdoutTruncated?: boolean;
|
|
25
|
-
};
|
|
26
|
-
|
|
27
|
-
export type ExecOptions = {
|
|
28
|
-
cwd: string;
|
|
29
|
-
signal?: AbortSignal;
|
|
30
|
-
timeoutMs?: number;
|
|
31
|
-
stdoutLimitBytes?: number;
|
|
32
|
-
stdoutTailBytes?: number;
|
|
33
|
-
stderrLimitBytes?: number;
|
|
34
|
-
stdin?: string;
|
|
35
|
-
};
|
|
36
|
-
|
|
37
|
-
export type Exec = (command: string, args: string[], options: ExecOptions) => Promise<ExecResult>;
|
|
38
|
-
|
|
39
16
|
export type AttemptState = "none" | "attempting" | "applied" | "blocked" | "unknown";
|
|
40
|
-
|
|
41
|
-
function positiveInteger(value: number, label: string): number {
|
|
42
|
-
if (!Number.isSafeInteger(value) || value <= 0) throw new TypeError(`${label} must be a positive safe integer`);
|
|
43
|
-
return value;
|
|
44
|
-
}
|
|
17
|
+
export type GitWorktreeState = "clean" | "dirty" | "operation";
|
|
45
18
|
|
|
46
19
|
export function requiredText(value: unknown, label: string): string {
|
|
47
20
|
if (typeof value !== "string" || !value || value.trim() !== value || /[\u0000-\u001f\u007f]/.test(value)) {
|
|
@@ -56,167 +29,6 @@ export function requiredOid(value: unknown, label: string): string {
|
|
|
56
29
|
return parsed;
|
|
57
30
|
}
|
|
58
31
|
|
|
59
|
-
function validatedArguments(args: string[]): string[] {
|
|
60
|
-
if (!Array.isArray(args) || args.some((arg) => typeof arg !== "string" || arg.includes("\0"))) {
|
|
61
|
-
throw new TypeError("command arguments must be an array of strings without NUL bytes");
|
|
62
|
-
}
|
|
63
|
-
return args;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
function decode(chunks: Buffer[], bytes: number, label: string): string {
|
|
67
|
-
try {
|
|
68
|
-
return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(Buffer.concat(chunks, bytes));
|
|
69
|
-
} catch {
|
|
70
|
-
throw new Error(`${label} was not valid UTF-8`);
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/** Run one argv-only child process with bounded streaming output, cancellation, and a deadline. */
|
|
75
|
-
export const spawnBounded: Exec = async (command, args, options) => {
|
|
76
|
-
requiredText(command, "command");
|
|
77
|
-
validatedArguments(args);
|
|
78
|
-
requiredText(options.cwd, "cwd");
|
|
79
|
-
options.signal?.throwIfAborted();
|
|
80
|
-
const timeoutMs = positiveInteger(options.timeoutMs ?? DEFAULT_EXEC_TIMEOUT_MS, "timeoutMs");
|
|
81
|
-
const stdoutLimit = positiveInteger(options.stdoutLimitBytes ?? DEFAULT_OUTPUT_LIMIT_BYTES, "stdoutLimitBytes");
|
|
82
|
-
const stdoutTailLimit = options.stdoutTailBytes === undefined
|
|
83
|
-
? undefined
|
|
84
|
-
: positiveInteger(options.stdoutTailBytes, "stdoutTailBytes");
|
|
85
|
-
const stderrLimit = positiveInteger(options.stderrLimitBytes ?? DEFAULT_OUTPUT_LIMIT_BYTES, "stderrLimitBytes");
|
|
86
|
-
if (options.stdin !== undefined && typeof options.stdin !== "string") throw new TypeError("stdin must be a string");
|
|
87
|
-
|
|
88
|
-
return await new Promise<ExecResult>((resolve, reject) => {
|
|
89
|
-
const child = spawn(command, args, {
|
|
90
|
-
cwd: options.cwd,
|
|
91
|
-
detached: process.platform !== "win32",
|
|
92
|
-
env: process.env,
|
|
93
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
94
|
-
});
|
|
95
|
-
const childPid = child.pid;
|
|
96
|
-
const processGroup = process.platform !== "win32" && typeof childPid === "number" &&
|
|
97
|
-
Number.isSafeInteger(childPid) && childPid > 0 ? childPid : null;
|
|
98
|
-
const stdout: Buffer[] = [];
|
|
99
|
-
const stderr: Buffer[] = [];
|
|
100
|
-
const stdoutDecoder = stdoutTailLimit === undefined
|
|
101
|
-
? undefined
|
|
102
|
-
: new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
|
103
|
-
let stdoutBytes = 0;
|
|
104
|
-
let stdoutTruncated = false;
|
|
105
|
-
let stderrBytes = 0;
|
|
106
|
-
let failure: Error | undefined;
|
|
107
|
-
let killed = false;
|
|
108
|
-
let settled = false;
|
|
109
|
-
let killTimer: ReturnType<typeof setTimeout> | undefined;
|
|
110
|
-
|
|
111
|
-
const signalChild = (signal: NodeJS.Signals): void => {
|
|
112
|
-
if (processGroup !== null) {
|
|
113
|
-
try {
|
|
114
|
-
process.kill(-processGroup, signal);
|
|
115
|
-
killed = true;
|
|
116
|
-
return;
|
|
117
|
-
} catch (error) {
|
|
118
|
-
if (error && typeof error === "object" && (error as NodeJS.ErrnoException).code === "ESRCH") return;
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
killed = child.kill(signal) || killed;
|
|
122
|
-
};
|
|
123
|
-
const stop = (error: Error): void => {
|
|
124
|
-
if (failure) return;
|
|
125
|
-
failure = error;
|
|
126
|
-
signalChild("SIGTERM");
|
|
127
|
-
killTimer = setTimeout(() => signalChild("SIGKILL"), PROCESS_TERMINATION_GRACE_MS);
|
|
128
|
-
};
|
|
129
|
-
const appendTail = (text: string): void => {
|
|
130
|
-
if (!text || stdoutTailLimit === undefined) return;
|
|
131
|
-
const chunk = Buffer.from(text, "utf8");
|
|
132
|
-
stdout.push(chunk);
|
|
133
|
-
stdoutBytes += chunk.length;
|
|
134
|
-
if (stdoutBytes <= stdoutTailLimit) return;
|
|
135
|
-
stdoutTruncated = true;
|
|
136
|
-
let remove = stdoutBytes - stdoutTailLimit;
|
|
137
|
-
while (remove > 0) {
|
|
138
|
-
const first = stdout[0]!;
|
|
139
|
-
if (first.length <= remove) {
|
|
140
|
-
stdout.shift();
|
|
141
|
-
stdoutBytes -= first.length;
|
|
142
|
-
remove -= first.length;
|
|
143
|
-
continue;
|
|
144
|
-
}
|
|
145
|
-
let start = remove;
|
|
146
|
-
while (start < first.length && (first[start]! & 0xc0) === 0x80) start += 1;
|
|
147
|
-
stdout[0] = Buffer.from(first.subarray(start));
|
|
148
|
-
stdoutBytes -= start;
|
|
149
|
-
remove = 0;
|
|
150
|
-
}
|
|
151
|
-
};
|
|
152
|
-
const decodeTail = (chunk?: Buffer): void => {
|
|
153
|
-
if (!stdoutDecoder) return;
|
|
154
|
-
try {
|
|
155
|
-
appendTail(chunk === undefined ? stdoutDecoder.decode() : stdoutDecoder.decode(chunk, { stream: true }));
|
|
156
|
-
} catch {
|
|
157
|
-
stop(new Error(`${command} stdout was not valid UTF-8`));
|
|
158
|
-
}
|
|
159
|
-
};
|
|
160
|
-
const timer = setTimeout(() => stop(new Error(`${command} timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
161
|
-
const abort = (): void => stop(options.signal?.reason instanceof Error
|
|
162
|
-
? options.signal.reason
|
|
163
|
-
: new DOMException("The operation was aborted", "AbortError"));
|
|
164
|
-
options.signal?.addEventListener("abort", abort, { once: true });
|
|
165
|
-
if (options.signal?.aborted) abort();
|
|
166
|
-
|
|
167
|
-
child.stdout.on("data", (chunk: Buffer) => {
|
|
168
|
-
if (failure) return;
|
|
169
|
-
if (stdoutDecoder) {
|
|
170
|
-
decodeTail(chunk);
|
|
171
|
-
return;
|
|
172
|
-
}
|
|
173
|
-
stdoutBytes += chunk.length;
|
|
174
|
-
if (stdoutBytes > stdoutLimit) {
|
|
175
|
-
stop(new Error(`${command} stdout exceeded ${stdoutLimit} bytes`));
|
|
176
|
-
return;
|
|
177
|
-
}
|
|
178
|
-
stdout.push(chunk);
|
|
179
|
-
});
|
|
180
|
-
child.stderr.on("data", (chunk: Buffer) => {
|
|
181
|
-
if (failure) return;
|
|
182
|
-
stderrBytes += chunk.length;
|
|
183
|
-
if (stderrBytes > stderrLimit) {
|
|
184
|
-
stop(new Error(`${command} stderr exceeded ${stderrLimit} bytes`));
|
|
185
|
-
return;
|
|
186
|
-
}
|
|
187
|
-
stderr.push(chunk);
|
|
188
|
-
});
|
|
189
|
-
child.once("error", stop);
|
|
190
|
-
child.once("close", (code, signal) => {
|
|
191
|
-
if (settled) return;
|
|
192
|
-
settled = true;
|
|
193
|
-
clearTimeout(timer);
|
|
194
|
-
if (killTimer !== undefined && processGroup === null) clearTimeout(killTimer);
|
|
195
|
-
options.signal?.removeEventListener("abort", abort);
|
|
196
|
-
if (!failure && stdoutDecoder) decodeTail();
|
|
197
|
-
if (failure) {
|
|
198
|
-
reject(failure);
|
|
199
|
-
return;
|
|
200
|
-
}
|
|
201
|
-
try {
|
|
202
|
-
resolve({
|
|
203
|
-
stdout: stdoutDecoder
|
|
204
|
-
? Buffer.concat(stdout, stdoutBytes).toString("utf8")
|
|
205
|
-
: decode(stdout, stdoutBytes, `${command} stdout`),
|
|
206
|
-
stderr: decode(stderr, stderrBytes, `${command} stderr`),
|
|
207
|
-
code: code ?? 1,
|
|
208
|
-
killed: killed || signal !== null,
|
|
209
|
-
...(stdoutDecoder ? { stdoutTruncated } : {}),
|
|
210
|
-
});
|
|
211
|
-
} catch (error) {
|
|
212
|
-
reject(error);
|
|
213
|
-
}
|
|
214
|
-
});
|
|
215
|
-
if (options.stdin === undefined) child.stdin.end();
|
|
216
|
-
else child.stdin.end(options.stdin, "utf8");
|
|
217
|
-
});
|
|
218
|
-
};
|
|
219
|
-
|
|
220
32
|
export function commandText(command: string, args: readonly string[]): string {
|
|
221
33
|
return [command, ...args].join(" ");
|
|
222
34
|
}
|
|
@@ -236,9 +48,8 @@ export async function runChecked(
|
|
|
236
48
|
return result;
|
|
237
49
|
}
|
|
238
50
|
|
|
239
|
-
/**
|
|
240
|
-
export async function
|
|
241
|
-
const status = await runChecked(exec, "git", ["status", "--porcelain=v1", "--untracked-files=all"], options);
|
|
51
|
+
/** Return the active Git operation marker without mutating the repository. */
|
|
52
|
+
export async function inspectGitOperation(exec: Exec, options: ExecOptions): Promise<string | null> {
|
|
242
53
|
const stateOutput = await runChecked(exec, "git", [
|
|
243
54
|
"rev-parse",
|
|
244
55
|
...GIT_OPERATION_STATES.flatMap((state) => ["--git-path", state]),
|
|
@@ -251,7 +62,7 @@ export async function inspectWorktree(exec: Exec, options: ExecOptions): Promise
|
|
|
251
62
|
for (const [index, path] of statePaths.entries()) {
|
|
252
63
|
try {
|
|
253
64
|
await lstat(resolve(options.cwd, path));
|
|
254
|
-
return
|
|
65
|
+
return GIT_OPERATION_STATES[index]!;
|
|
255
66
|
} catch (error) {
|
|
256
67
|
if (error && typeof error === "object" && (error as NodeJS.ErrnoException).code === "ENOENT") continue;
|
|
257
68
|
const code = error && typeof error === "object" && typeof (error as NodeJS.ErrnoException).code === "string"
|
|
@@ -260,9 +71,21 @@ export async function inspectWorktree(exec: Exec, options: ExecOptions): Promise
|
|
|
260
71
|
throw new Error(`Git operation state inspection failed for ${GIT_OPERATION_STATES[index]}: ${code}`);
|
|
261
72
|
}
|
|
262
73
|
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Distinguish ordinary pending work from an in-progress Git operation. */
|
|
78
|
+
export async function inspectWorktreeState(exec: Exec, options: ExecOptions): Promise<GitWorktreeState> {
|
|
79
|
+
const status = await runChecked(exec, "git", ["status", "--porcelain=v1", "--untracked-files=all"], options);
|
|
80
|
+
if (await inspectGitOperation(exec, options) !== null) return "operation";
|
|
263
81
|
return status.stdout === "" ? "clean" : "dirty";
|
|
264
82
|
}
|
|
265
83
|
|
|
84
|
+
/** Inspect both porcelain state and Git operation markers without mutating the repository. */
|
|
85
|
+
export async function inspectWorktree(exec: Exec, options: ExecOptions): Promise<"clean" | "dirty"> {
|
|
86
|
+
return await inspectWorktreeState(exec, options) === "clean" ? "clean" : "dirty";
|
|
87
|
+
}
|
|
88
|
+
|
|
266
89
|
/** Exclude concurrent PR mutations for one canonical worktree and pi-pr namespace. */
|
|
267
90
|
export async function withWorktreeLock<T>(
|
|
268
91
|
cwd: string,
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
+
import type { Exec, ExecOptions } from "@henryqw/pi-process";
|
|
2
3
|
import {
|
|
3
4
|
runChecked,
|
|
4
5
|
requiredOid,
|
|
5
6
|
requiredText,
|
|
6
|
-
type Exec,
|
|
7
|
-
type ExecOptions,
|
|
8
7
|
} from "./pr-execution.ts";
|
|
9
8
|
import type { CurrentPullRequest } from "./pr-github.ts";
|
|
10
9
|
|
package/extensions/pr-github.ts
CHANGED
|
@@ -5,7 +5,11 @@ import type {
|
|
|
5
5
|
import { lstatSync } from "node:fs";
|
|
6
6
|
import { dirname, join, resolve } from "node:path";
|
|
7
7
|
import { inspectLocalMergeSafety } from "./pr-merge.ts";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
inspectWorktreeState,
|
|
10
|
+
withWorktreeLock,
|
|
11
|
+
type GitWorktreeState,
|
|
12
|
+
} from "./pr-execution.ts";
|
|
9
13
|
import type {
|
|
10
14
|
CiStatus,
|
|
11
15
|
LocalMergeSafety,
|
|
@@ -112,10 +116,11 @@ export type PullRequestCreationPreflight = {
|
|
|
112
116
|
mergeBase: string;
|
|
113
117
|
};
|
|
114
118
|
ahead: number;
|
|
119
|
+
worktree: GitWorktreeState;
|
|
115
120
|
};
|
|
116
121
|
|
|
117
122
|
type CreationPreflightResult =
|
|
118
|
-
| { kind: "same-ref" }
|
|
123
|
+
| { kind: "same-ref"; worktree: GitWorktreeState }
|
|
119
124
|
| { kind: "distinct-ref"; preflight: PullRequestCreationPreflight };
|
|
120
125
|
|
|
121
126
|
type CreationIdentity = {
|
|
@@ -1158,6 +1163,24 @@ function parseCreationAhead(output: string): number {
|
|
|
1158
1163
|
return ahead;
|
|
1159
1164
|
}
|
|
1160
1165
|
|
|
1166
|
+
async function inspectCreationWorktree(
|
|
1167
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
1168
|
+
context: PullRequestLoadContext,
|
|
1169
|
+
): Promise<GitWorktreeState> {
|
|
1170
|
+
try {
|
|
1171
|
+
return await inspectWorktreeState(
|
|
1172
|
+
async (command, args) => await invoke(pi, context, "Inspect creation worktree", command, args),
|
|
1173
|
+
{ cwd: context.cwd, signal: context.signal },
|
|
1174
|
+
);
|
|
1175
|
+
} catch (error) {
|
|
1176
|
+
if (error instanceof PullRequestLoadError) throw error;
|
|
1177
|
+
return fail(
|
|
1178
|
+
"Inspect creation worktree",
|
|
1179
|
+
error instanceof Error ? error.message : "inspection failed",
|
|
1180
|
+
);
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1161
1184
|
async function preflightCreation(
|
|
1162
1185
|
pi: Pick<ExtensionAPI, "exec">,
|
|
1163
1186
|
context: PullRequestLoadContext,
|
|
@@ -1176,7 +1199,8 @@ async function preflightCreation(
|
|
|
1176
1199
|
: await validateCreationRef(pi, context, explicitBaseRef);
|
|
1177
1200
|
const baseRef = configuredBaseRef ?? await readDefaultCreationBaseRef(pi, context, origin);
|
|
1178
1201
|
const relation = await inspectCreationRepositoryRelation(pi, context, identity.target, origin, baseRef);
|
|
1179
|
-
|
|
1202
|
+
const worktree = await inspectCreationWorktree(pi, context);
|
|
1203
|
+
if (relation === "same-ref") return { kind: relation, worktree };
|
|
1180
1204
|
const trackingRef = `refs/remotes/origin/${baseRef}`;
|
|
1181
1205
|
await execute(pi, context, "Fetch creation base", "git", [
|
|
1182
1206
|
"fetch", "--no-write-fetch-head", "--no-tags", "--no-recurse-submodules", "--",
|
|
@@ -1208,6 +1232,7 @@ async function preflightCreation(
|
|
|
1208
1232
|
mergeBase,
|
|
1209
1233
|
},
|
|
1210
1234
|
ahead,
|
|
1235
|
+
worktree,
|
|
1211
1236
|
},
|
|
1212
1237
|
};
|
|
1213
1238
|
}
|
|
@@ -1236,7 +1261,9 @@ async function creationDiscovery(
|
|
|
1236
1261
|
return {
|
|
1237
1262
|
kind: "none",
|
|
1238
1263
|
creationTarget: target,
|
|
1239
|
-
branch:
|
|
1264
|
+
branch: result.kind === "same-ref"
|
|
1265
|
+
? { ahead: 0, worktree: result.worktree, relation: result.kind }
|
|
1266
|
+
: { ahead: result.preflight.ahead, worktree: result.preflight.worktree, relation: result.kind },
|
|
1240
1267
|
};
|
|
1241
1268
|
}
|
|
1242
1269
|
|
package/extensions/pr-merge.ts
CHANGED
package/extensions/pr-routing.ts
CHANGED
|
@@ -40,6 +40,8 @@ export type PullRequestTarget = {
|
|
|
40
40
|
|
|
41
41
|
export type BranchCreationState = {
|
|
42
42
|
ahead: number;
|
|
43
|
+
worktree: "clean" | "dirty" | "operation";
|
|
44
|
+
relation: "same-ref" | "distinct-ref";
|
|
43
45
|
};
|
|
44
46
|
|
|
45
47
|
export type DiscoveryIssue =
|
|
@@ -59,6 +61,17 @@ export type PullRequestDiscovery<T extends PullRequest = PullRequest> =
|
|
|
59
61
|
| { kind: "inactive" };
|
|
60
62
|
|
|
61
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
|
+
export type RouteDecision =
|
|
73
|
+
| { kind: "selected"; nextStep: NextStep }
|
|
74
|
+
| { kind: "feedback-blocked"; nextStep: "blocked"; blocker: FeedbackRouteBlocker };
|
|
62
75
|
|
|
63
76
|
function localMutationSafe(local: LocalMergeSafety): boolean {
|
|
64
77
|
return local.worktree === "clean" && local.head === "equal";
|
|
@@ -88,12 +101,52 @@ export function derivePullRequestNextStep(pullRequest: PullRequest): Exclude<Nex
|
|
|
88
101
|
return "merge";
|
|
89
102
|
}
|
|
90
103
|
|
|
91
|
-
export function
|
|
104
|
+
export function isPullRequestCreationEligible(branch: BranchCreationState): boolean {
|
|
105
|
+
return branch.relation === "distinct-ref" && branch.worktree !== "operation" &&
|
|
106
|
+
(branch.ahead > 0 || branch.worktree === "dirty");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function deriveAutomaticNextStep(discovery: PullRequestDiscovery<PullRequest & { target: PullRequestTarget }>): NextStep {
|
|
92
110
|
if (discovery.kind === "inactive") return "none";
|
|
93
111
|
if (discovery.kind === "blocked") return "blocked";
|
|
94
|
-
if (discovery.kind === "none") return discovery.branch
|
|
112
|
+
if (discovery.kind === "none") return isPullRequestCreationEligible(discovery.branch) ? "create" : "none";
|
|
95
113
|
if (discovery.pullRequest.target.provenance === "inferred") {
|
|
96
114
|
return discovery.pullRequest.lifecycle === "open" ? "link-branch" : "none";
|
|
97
115
|
}
|
|
98
116
|
return derivePullRequestNextStep(discovery.pullRequest);
|
|
99
117
|
}
|
|
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", nextStep: "blocked", blocker: { kind: "discovery-blocked" } };
|
|
126
|
+
}
|
|
127
|
+
if (discovery.kind !== "current") {
|
|
128
|
+
return { kind: "feedback-blocked", nextStep: "blocked", blocker: { kind: "pull-request-unavailable" } };
|
|
129
|
+
}
|
|
130
|
+
const pullRequest = discovery.pullRequest;
|
|
131
|
+
if (pullRequest.lifecycle !== "open") {
|
|
132
|
+
return { kind: "feedback-blocked", nextStep: "blocked", blocker: { kind: "pull-request-not-open" } };
|
|
133
|
+
}
|
|
134
|
+
if (pullRequest.target.provenance !== "configured") {
|
|
135
|
+
return { kind: "feedback-blocked", nextStep: "blocked", blocker: { kind: "target-not-configured" } };
|
|
136
|
+
}
|
|
137
|
+
if (pullRequest.local.worktree !== "clean") {
|
|
138
|
+
return { kind: "feedback-blocked", nextStep: "blocked", blocker: { kind: "worktree-dirty" } };
|
|
139
|
+
}
|
|
140
|
+
if (pullRequest.local.head !== "equal") {
|
|
141
|
+
return {
|
|
142
|
+
kind: "feedback-blocked",
|
|
143
|
+
nextStep: "blocked",
|
|
144
|
+
blocker: { kind: "head-not-equal", relation: pullRequest.local.head },
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
return { kind: "selected", nextStep: "sweep" };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function deriveNextStep(discovery: PullRequestDiscovery<PullRequest & { target: PullRequestTarget }>): NextStep {
|
|
151
|
+
return deriveRouteDecision(discovery).nextStep;
|
|
152
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { spawnBounded, type Exec, type ExecOptions } from "@henryqw/pi-process";
|
|
2
3
|
import {
|
|
3
4
|
loadCurrentPullRequest,
|
|
4
5
|
samePullRequestSnapshot,
|
|
@@ -15,10 +16,7 @@ import {
|
|
|
15
16
|
requiredOid,
|
|
16
17
|
resolveRepositoryFetchSource,
|
|
17
18
|
runChecked,
|
|
18
|
-
spawnBounded,
|
|
19
19
|
withWorktreeLock,
|
|
20
|
-
type Exec,
|
|
21
|
-
type ExecOptions,
|
|
22
20
|
} from "./pr-execution.ts";
|
|
23
21
|
|
|
24
22
|
export type UpdateBranchPhase = "ready" | "conflict-awaiting-user" | "verified" | "published" | "blocked";
|
package/extensions/pr.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { spawnBounded } from "@henryqw/pi-process";
|
|
2
3
|
import { realpath } from "node:fs/promises";
|
|
3
4
|
import {
|
|
4
5
|
isBashToolResult,
|
|
@@ -26,7 +27,7 @@ import {
|
|
|
26
27
|
samePullRequestObservation,
|
|
27
28
|
type PullRequestObservation,
|
|
28
29
|
} from "./pr-github.ts";
|
|
29
|
-
import { runChecked
|
|
30
|
+
import { runChecked } from "./pr-execution.ts";
|
|
30
31
|
import {
|
|
31
32
|
discoveryIssueKey,
|
|
32
33
|
discoveryIssueMessage,
|
|
@@ -783,7 +784,7 @@ export default function pullRequestExtension(
|
|
|
783
784
|
releaseWorkflow,
|
|
784
785
|
});
|
|
785
786
|
pi.registerCommand("pr", {
|
|
786
|
-
description: "[--base <branch>
|
|
787
|
+
description: "[--feedback | --base <branch> [instructions]] — Run the current branch pull request next step",
|
|
787
788
|
handler: async (args, ctx) => {
|
|
788
789
|
if (!ctx.hasUI || !context) return;
|
|
789
790
|
const generation = sessionGeneration;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@henryqw/pi-pr",
|
|
3
|
-
"version": "4.0.
|
|
3
|
+
"version": "4.0.10",
|
|
4
4
|
"description": "Run /pr to safely discover or link the current pull request, then create, update, address feedback, fix CI, or merge when ready.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
"dependencies": {
|
|
35
35
|
"@henryqw/pi-config-store": "^1.1.0",
|
|
36
36
|
"@henryqw/pi-herdr": "^0.4.5",
|
|
37
|
+
"@henryqw/pi-process": "^0.1.0",
|
|
37
38
|
"proper-lockfile": "^4.1.2"
|
|
38
39
|
},
|
|
39
40
|
"devDependencies": {
|
|
@@ -17,10 +17,10 @@ import {
|
|
|
17
17
|
loadCurrentPullRequest,
|
|
18
18
|
samePullRequestSnapshot,
|
|
19
19
|
} from "../../../extensions/pr-github.ts";
|
|
20
|
+
import { spawnBounded } from "@henryqw/pi-process";
|
|
20
21
|
import {
|
|
21
22
|
inspectWorktree,
|
|
22
23
|
readHead,
|
|
23
|
-
spawnBounded,
|
|
24
24
|
} from "../../../extensions/pr-execution.ts";
|
|
25
25
|
|
|
26
26
|
class UsageError extends Error {}
|
|
@@ -7,7 +7,7 @@ description: Prepare and publish the current branch pull request with determinis
|
|
|
7
7
|
|
|
8
8
|
Use only `pi_pr_create` for base selection, merge, push, upstream, and GitHub work. Do not repeat its Git or GitHub checks with shell tools.
|
|
9
9
|
|
|
10
|
-
Start with the prompted `prepare` action. It uses the saved branch base: explicit `/pr --base BRANCH`, one `branch.<branch>.gh-merge-base` value, then validated `origin` default. It requires a committed change ahead
|
|
10
|
+
Start with the prompted `prepare` action. It uses the saved branch base: explicit `/pr --base BRANCH`, one `branch.<branch>.gh-merge-base` value, then validated `origin` default. It requires a committed change ahead or ordinary pending work, including untracked files. It rejects an in-progress Git operation and a head ref equal to the selected base ref. The base is always `origin`; fork heads must share its GitHub source and host. Apply trailing prompt guidance only to the PR content.
|
|
11
11
|
|
|
12
12
|
After `prepare`, inspect the returned base and merge-base. Separate and commit coherent pending work. Preserve coherent staging. Exclude `.context/` and unrelated changes. Stop when separation is unsafe.
|
|
13
13
|
|