pr-shepherd 0.37.0 → 0.37.2
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/.claude-plugin/plugin.json +1 -1
- package/README.md +11 -9
- package/bin/cli/help-command-pages.d.mts +1 -1
- package/bin/cli/help-iterate-poll-pages.d.mts +1 -1
- package/bin/cli/help-iterate-poll-pages.mjs +5 -5
- package/bin/cli/help.d.mts +1 -1
- package/bin/cli/iterate-instructions.mjs +6 -6
- package/bin/commands/commit-suggestion-instruction.d.mts +1 -1
- package/bin/commands/commit-suggestion-instruction.mjs +11 -3
- package/bin/commands/commit-suggestion.mjs +13 -0
- package/bin/commands/iterate/check-instructions.d.mts +1 -0
- package/bin/commands/iterate/check-instructions.mjs +21 -19
- package/bin/commands/iterate/render.mjs +48 -49
- package/bin/commands/shepherd-journal.d.mts +4 -4
- package/bin/commands/shepherd-journal.mjs +5 -6
- package/bin/suggestions/lines.d.mts +12 -0
- package/bin/suggestions/lines.mjs +56 -0
- package/bin/suggestions/patch.mjs +2 -45
- package/bin/suggestions/range-adjacent.d.mts +7 -0
- package/bin/suggestions/range-adjacent.mjs +100 -0
- package/bin/suggestions/range-anchor.d.mts +1 -0
- package/bin/suggestions/range-anchor.mjs +40 -0
- package/bin/suggestions/range-exact-overlap.d.mts +2 -0
- package/bin/suggestions/range-exact-overlap.mjs +28 -0
- package/bin/suggestions/range-similarity.d.mts +2 -0
- package/bin/suggestions/range-similarity.mjs +135 -0
- package/bin/suggestions/range-substantive.d.mts +4 -0
- package/bin/suggestions/range-substantive.mjs +12 -0
- package/bin/suggestions/range-work.d.mts +2 -0
- package/bin/suggestions/range-work.mjs +12 -0
- package/bin/suggestions/range.d.mts +14 -0
- package/bin/suggestions/range.mjs +56 -0
- package/package.json +2 -2
- package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
- package/plugins/pr-shepherd/.codex.mcp.json +1 -1
- package/plugins/pr-shepherd/.mcp.json +1 -1
- package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +3 -1
package/README.md
CHANGED
|
@@ -29,11 +29,11 @@ The MCP server exposes three tools: `iterate`, `apply`, and `build_suggestion_pa
|
|
|
29
29
|
|
|
30
30
|
Each tick returns exactly one action:
|
|
31
31
|
|
|
32
|
-
- `WAIT` — no immediate action;
|
|
33
|
-
- `MARK_READY` — the CLI
|
|
34
|
-
- `FIX_CODE` —
|
|
35
|
-
- `CANCEL` —
|
|
36
|
-
- `ESCALATE` —
|
|
32
|
+
- `WAIT` — no immediate action; continue with the next poll.
|
|
33
|
+
- `MARK_READY` — the CLI converted an eligible draft PR to ready; continue polling.
|
|
34
|
+
- `FIX_CODE` — agent work is required; complete it, then continue polling.
|
|
35
|
+
- `CANCEL` — stop polling because the PR merged, closed, or completed its ready-delay.
|
|
36
|
+
- `ESCALATE` — stop polling until a human provides direction.
|
|
37
37
|
|
|
38
38
|
Example shape:
|
|
39
39
|
|
|
@@ -65,10 +65,12 @@ Conversations Resolved: No [Not Required]
|
|
|
65
65
|
|
|
66
66
|
## Instructions
|
|
67
67
|
|
|
68
|
-
1.
|
|
69
|
-
2.
|
|
70
|
-
3.
|
|
71
|
-
4.
|
|
68
|
+
1. Review each item under `## Review threads` and `## Failing checks` and decide whether it needs a code change.
|
|
69
|
+
2. Apply every warranted review fix in each file referenced above.
|
|
70
|
+
3. Read the included CI log excerpt; fetch the full log if needed, then rerun transient failures or fix real failures.
|
|
71
|
+
4. If you changed code, commit any remaining changes and push before review mutations. Otherwise, do not commit or push.
|
|
72
|
+
5. Replace `$HEAD_SHA` and `$DISMISS_MESSAGE`, then run the `apply review:` command shown above.
|
|
73
|
+
6. `[FIX_CODE]` is non-terminal. Continue with the next poll using the same CLI mode and flags, or call MCP `iterate` again.
|
|
72
74
|
```
|
|
73
75
|
|
|
74
76
|
See [docs/actions.md](docs/actions.md) for the complete output contract. Iterate/poll PR outcomes use exit codes `0` and `10`–`14`; command and GitHub failures use `sysexits.h` codes — [docs/exit-codes.md](docs/exit-codes.md).
|
|
@@ -176,7 +176,7 @@ Flags:
|
|
|
176
176
|
|
|
177
177
|
PR may be a number or GitHub pull request URL. When omitted, the current branch PR is inferred.
|
|
178
178
|
Exit code: 0 on success; nonzero on failure (sysexits.h — see docs/exit-codes.md).`;
|
|
179
|
-
readonly iterate: "pr-shepherd iterate\n\nRun one iterate tick for a pull request. The no-subcommand form polls; use this subcommand for a single tick.\nThe output contains one action and an action-specific ## Instructions section.\n\nUsage:\n pr-shepherd iterate [PR] [iterate-flags]\n\nIterate flags:\n --ready-delay <duration> Settle window before a clean PR cancels. Bare number = minutes. Example: 15m.\n --stall-timeout <duration> Escalate repeated unchanged failures after this duration. Bare number = minutes. 0 disables.\n --no-auto-mark-ready Do not convert draft PRs to ready for review.\n --no-auto-cancel-actionable Do not cancel in-progress runs before actionable fixes.\n --format text|json Output Markdown text or JSON. Default: text.\n --verbose Include verbose iterate fields.\n --help, -h Print this help and exit before GitHub, git, config, or log I/O.\n\nDurations accept s/m/h suffixes: 30s, 4.5m, 1h. A bare number is minutes; decimals are allowed only with an explicit unit (4.5m).\n\nActions:\n WAIT No immediate
|
|
179
|
+
readonly iterate: "pr-shepherd iterate\n\nRun one iterate tick for a pull request. The no-subcommand form polls; use this subcommand for a single tick.\nThe output contains one action and an action-specific ## Instructions section.\n\nUsage:\n pr-shepherd iterate [PR] [iterate-flags]\n\nIterate flags:\n --ready-delay <duration> Settle window before a clean PR cancels. Bare number = minutes. Example: 15m.\n --stall-timeout <duration> Escalate repeated unchanged failures after this duration. Bare number = minutes. 0 disables.\n --no-auto-mark-ready Do not convert draft PRs to ready for review.\n --no-auto-cancel-actionable Do not cancel in-progress runs before actionable fixes.\n --format text|json Output Markdown text or JSON. Default: text.\n --verbose Include verbose iterate fields.\n --help, -h Print this help and exit before GitHub, git, config, or log I/O.\n\nDurations accept s/m/h suffixes: 30s, 4.5m, 1h. A bare number is minutes; decimals are allowed only with an explicit unit (4.5m).\n\nActions:\n WAIT No immediate action; continue with the next poll.\n MARK_READY Draft PR was marked ready; continue with the next poll.\n FIX_CODE Agent action is required; follow the instructions, then continue polling.\n CANCEL Stop polling: merged/closed or ready-delay elapsed.\n ESCALATE Stop polling until a human provides direction.\n\nExit codes:\n 0 CANCEL (merged or ready-delay elapsed)\n 10 WAIT\n 11 MARK_READY\n 12 FIX_CODE\n 13 ESCALATE\n 14 CANCEL (closed without merging)\n A command/validation/GitHub failure exits with a sysexits.h code instead (see docs/exit-codes.md).";
|
|
180
180
|
readonly poll: "pr-shepherd poll\n\nRun iterate repeatedly for WAIT ticks and during the FIX_CODE debounce window. Print only the\nfinal tick to stdout.\nPoll exits as soon as iterate returns MARK_READY, CANCEL, or ESCALATE, or when timeout\nreturns the last WAIT result. FIX_CODE starts a --debounce settle window (default 1m): poll keeps\niterating at --interval, then runs one more tick after the window and returns that result.\nWith --until-terminal, poll also continues through MARK_READY.\n\nUsage:\n pr-shepherd poll [PR] [poll-flags] [iterate-flags]\n\nPoll flags:\n --interval <duration> Sleep between WAIT ticks. Bare number = seconds. Default: 60s.\n --timeout <duration> Maximum wall-clock wait for WAIT ticks. Bare number = seconds. Default: 4.5m.\n --debounce <duration> Settle window after first FIX_CODE before returning. Bare number = seconds. Default: 60s. 0 disables.\n --quiet-status During WAIT polling, print only changed status snapshots.\n --until-terminal Continue through WAIT/MARK_READY until FIX_CODE/CANCEL/ESCALATE.\n\nForwarded iterate flags:\n --ready-delay <duration> Settle window before a clean PR cancels. Bare number = minutes. Example: 15m.\n --stall-timeout <duration> Escalate repeated unchanged failures after this duration. Bare number = minutes. 0 disables.\n --no-auto-mark-ready Do not convert draft PRs to ready for review.\n --no-auto-cancel-actionable Do not cancel in-progress runs before actionable fixes.\n --format text|json Output Markdown text or JSON. Default: text.\n --verbose Include verbose iterate fields and detailed per-tick lines.\n --help, -h Print this help and exit before GitHub, git, config, or log I/O.\n\nDurations accept s/m/h suffixes: 30s, 4.5m, 1h. A bare number uses each flag's default unit (seconds\nfor --interval/--timeout/--debounce, minutes for --ready-delay/--stall-timeout); decimals are allowed only with\nan explicit unit (4.5m).\nEach WAIT tick writes a single dot to stderr by default; --quiet-status prints only changed WAIT snapshots, and --verbose emits detailed per-tick lines.\nFIX_CODE debounce writes a remaining-seconds line to stderr. --timeout does not cut an in-flight debounce short.\nWith --until-terminal, --timeout is ignored for WAIT ticks and polling continues until FIX_CODE, CANCEL, or ESCALATE.\n\nExit codes: same as iterate (the final tick's action/reason decides the code).\n 0 CANCEL (merged or ready-delay elapsed)\n 10 WAIT (including a WAIT returned by --timeout)\n 11 MARK_READY\n 12 FIX_CODE\n 13 ESCALATE\n 14 CANCEL (closed without merging)\n A command/validation/GitHub failure exits with a sysexits.h code instead (see docs/exit-codes.md).";
|
|
181
181
|
readonly clean: `pr-shepherd clean
|
|
182
182
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export declare const ITERATE_USAGE = "pr-shepherd iterate\n\nRun one iterate tick for a pull request. The no-subcommand form polls; use this subcommand for a single tick.\nThe output contains one action and an action-specific ## Instructions section.\n\nUsage:\n pr-shepherd iterate [PR] [iterate-flags]\n\nIterate flags:\n --ready-delay <duration> Settle window before a clean PR cancels. Bare number = minutes. Example: 15m.\n --stall-timeout <duration> Escalate repeated unchanged failures after this duration. Bare number = minutes. 0 disables.\n --no-auto-mark-ready Do not convert draft PRs to ready for review.\n --no-auto-cancel-actionable Do not cancel in-progress runs before actionable fixes.\n --format text|json Output Markdown text or JSON. Default: text.\n --verbose Include verbose iterate fields.\n --help, -h Print this help and exit before GitHub, git, config, or log I/O.\n\nDurations accept s/m/h suffixes: 30s, 4.5m, 1h. A bare number is minutes; decimals are allowed only with an explicit unit (4.5m).\n\nActions:\n WAIT No immediate
|
|
1
|
+
export declare const ITERATE_USAGE = "pr-shepherd iterate\n\nRun one iterate tick for a pull request. The no-subcommand form polls; use this subcommand for a single tick.\nThe output contains one action and an action-specific ## Instructions section.\n\nUsage:\n pr-shepherd iterate [PR] [iterate-flags]\n\nIterate flags:\n --ready-delay <duration> Settle window before a clean PR cancels. Bare number = minutes. Example: 15m.\n --stall-timeout <duration> Escalate repeated unchanged failures after this duration. Bare number = minutes. 0 disables.\n --no-auto-mark-ready Do not convert draft PRs to ready for review.\n --no-auto-cancel-actionable Do not cancel in-progress runs before actionable fixes.\n --format text|json Output Markdown text or JSON. Default: text.\n --verbose Include verbose iterate fields.\n --help, -h Print this help and exit before GitHub, git, config, or log I/O.\n\nDurations accept s/m/h suffixes: 30s, 4.5m, 1h. A bare number is minutes; decimals are allowed only with an explicit unit (4.5m).\n\nActions:\n WAIT No immediate action; continue with the next poll.\n MARK_READY Draft PR was marked ready; continue with the next poll.\n FIX_CODE Agent action is required; follow the instructions, then continue polling.\n CANCEL Stop polling: merged/closed or ready-delay elapsed.\n ESCALATE Stop polling until a human provides direction.\n\nExit codes:\n 0 CANCEL (merged or ready-delay elapsed)\n 10 WAIT\n 11 MARK_READY\n 12 FIX_CODE\n 13 ESCALATE\n 14 CANCEL (closed without merging)\n A command/validation/GitHub failure exits with a sysexits.h code instead (see docs/exit-codes.md).";
|
|
2
2
|
export declare const POLL_USAGE = "pr-shepherd poll\n\nRun iterate repeatedly for WAIT ticks and during the FIX_CODE debounce window. Print only the\nfinal tick to stdout.\nPoll exits as soon as iterate returns MARK_READY, CANCEL, or ESCALATE, or when timeout\nreturns the last WAIT result. FIX_CODE starts a --debounce settle window (default 1m): poll keeps\niterating at --interval, then runs one more tick after the window and returns that result.\nWith --until-terminal, poll also continues through MARK_READY.\n\nUsage:\n pr-shepherd poll [PR] [poll-flags] [iterate-flags]\n\nPoll flags:\n --interval <duration> Sleep between WAIT ticks. Bare number = seconds. Default: 60s.\n --timeout <duration> Maximum wall-clock wait for WAIT ticks. Bare number = seconds. Default: 4.5m.\n --debounce <duration> Settle window after first FIX_CODE before returning. Bare number = seconds. Default: 60s. 0 disables.\n --quiet-status During WAIT polling, print only changed status snapshots.\n --until-terminal Continue through WAIT/MARK_READY until FIX_CODE/CANCEL/ESCALATE.\n\nForwarded iterate flags:\n --ready-delay <duration> Settle window before a clean PR cancels. Bare number = minutes. Example: 15m.\n --stall-timeout <duration> Escalate repeated unchanged failures after this duration. Bare number = minutes. 0 disables.\n --no-auto-mark-ready Do not convert draft PRs to ready for review.\n --no-auto-cancel-actionable Do not cancel in-progress runs before actionable fixes.\n --format text|json Output Markdown text or JSON. Default: text.\n --verbose Include verbose iterate fields and detailed per-tick lines.\n --help, -h Print this help and exit before GitHub, git, config, or log I/O.\n\nDurations accept s/m/h suffixes: 30s, 4.5m, 1h. A bare number uses each flag's default unit (seconds\nfor --interval/--timeout/--debounce, minutes for --ready-delay/--stall-timeout); decimals are allowed only with\nan explicit unit (4.5m).\nEach WAIT tick writes a single dot to stderr by default; --quiet-status prints only changed WAIT snapshots, and --verbose emits detailed per-tick lines.\nFIX_CODE debounce writes a remaining-seconds line to stderr. --timeout does not cut an in-flight debounce short.\nWith --until-terminal, --timeout is ignored for WAIT ticks and polling continues until FIX_CODE, CANCEL, or ESCALATE.\n\nExit codes: same as iterate (the final tick's action/reason decides the code).\n 0 CANCEL (merged or ready-delay elapsed)\n 10 WAIT (including a WAIT returned by --timeout)\n 11 MARK_READY\n 12 FIX_CODE\n 13 ESCALATE\n 14 CANCEL (closed without merging)\n A command/validation/GitHub failure exits with a sysexits.h code instead (see docs/exit-codes.md).";
|
|
3
3
|
/** Public help page for the default PR polling invocation. */
|
|
4
4
|
export declare const DEFAULT_USAGE: string;
|
|
@@ -18,11 +18,11 @@ Iterate flags:
|
|
|
18
18
|
Durations accept s/m/h suffixes: 30s, 4.5m, 1h. A bare number is minutes; decimals are allowed only with an explicit unit (4.5m).
|
|
19
19
|
|
|
20
20
|
Actions:
|
|
21
|
-
WAIT No immediate
|
|
22
|
-
MARK_READY Draft PR was marked ready
|
|
23
|
-
FIX_CODE
|
|
24
|
-
CANCEL
|
|
25
|
-
ESCALATE
|
|
21
|
+
WAIT No immediate action; continue with the next poll.
|
|
22
|
+
MARK_READY Draft PR was marked ready; continue with the next poll.
|
|
23
|
+
FIX_CODE Agent action is required; follow the instructions, then continue polling.
|
|
24
|
+
CANCEL Stop polling: merged/closed or ready-delay elapsed.
|
|
25
|
+
ESCALATE Stop polling until a human provides direction.
|
|
26
26
|
|
|
27
27
|
Exit codes:
|
|
28
28
|
0 CANCEL (merged or ready-delay elapsed)
|
package/bin/cli/help.d.mts
CHANGED
|
@@ -176,7 +176,7 @@ Flags:
|
|
|
176
176
|
|
|
177
177
|
PR may be a number or GitHub pull request URL. When omitted, the current branch PR is inferred.
|
|
178
178
|
Exit code: 0 on success; nonzero on failure (sysexits.h — see docs/exit-codes.md).`;
|
|
179
|
-
readonly iterate: "pr-shepherd iterate\n\nRun one iterate tick for a pull request. The no-subcommand form polls; use this subcommand for a single tick.\nThe output contains one action and an action-specific ## Instructions section.\n\nUsage:\n pr-shepherd iterate [PR] [iterate-flags]\n\nIterate flags:\n --ready-delay <duration> Settle window before a clean PR cancels. Bare number = minutes. Example: 15m.\n --stall-timeout <duration> Escalate repeated unchanged failures after this duration. Bare number = minutes. 0 disables.\n --no-auto-mark-ready Do not convert draft PRs to ready for review.\n --no-auto-cancel-actionable Do not cancel in-progress runs before actionable fixes.\n --format text|json Output Markdown text or JSON. Default: text.\n --verbose Include verbose iterate fields.\n --help, -h Print this help and exit before GitHub, git, config, or log I/O.\n\nDurations accept s/m/h suffixes: 30s, 4.5m, 1h. A bare number is minutes; decimals are allowed only with an explicit unit (4.5m).\n\nActions:\n WAIT No immediate
|
|
179
|
+
readonly iterate: "pr-shepherd iterate\n\nRun one iterate tick for a pull request. The no-subcommand form polls; use this subcommand for a single tick.\nThe output contains one action and an action-specific ## Instructions section.\n\nUsage:\n pr-shepherd iterate [PR] [iterate-flags]\n\nIterate flags:\n --ready-delay <duration> Settle window before a clean PR cancels. Bare number = minutes. Example: 15m.\n --stall-timeout <duration> Escalate repeated unchanged failures after this duration. Bare number = minutes. 0 disables.\n --no-auto-mark-ready Do not convert draft PRs to ready for review.\n --no-auto-cancel-actionable Do not cancel in-progress runs before actionable fixes.\n --format text|json Output Markdown text or JSON. Default: text.\n --verbose Include verbose iterate fields.\n --help, -h Print this help and exit before GitHub, git, config, or log I/O.\n\nDurations accept s/m/h suffixes: 30s, 4.5m, 1h. A bare number is minutes; decimals are allowed only with an explicit unit (4.5m).\n\nActions:\n WAIT No immediate action; continue with the next poll.\n MARK_READY Draft PR was marked ready; continue with the next poll.\n FIX_CODE Agent action is required; follow the instructions, then continue polling.\n CANCEL Stop polling: merged/closed or ready-delay elapsed.\n ESCALATE Stop polling until a human provides direction.\n\nExit codes:\n 0 CANCEL (merged or ready-delay elapsed)\n 10 WAIT\n 11 MARK_READY\n 12 FIX_CODE\n 13 ESCALATE\n 14 CANCEL (closed without merging)\n A command/validation/GitHub failure exits with a sysexits.h code instead (see docs/exit-codes.md).";
|
|
180
180
|
readonly poll: "pr-shepherd poll\n\nRun iterate repeatedly for WAIT ticks and during the FIX_CODE debounce window. Print only the\nfinal tick to stdout.\nPoll exits as soon as iterate returns MARK_READY, CANCEL, or ESCALATE, or when timeout\nreturns the last WAIT result. FIX_CODE starts a --debounce settle window (default 1m): poll keeps\niterating at --interval, then runs one more tick after the window and returns that result.\nWith --until-terminal, poll also continues through MARK_READY.\n\nUsage:\n pr-shepherd poll [PR] [poll-flags] [iterate-flags]\n\nPoll flags:\n --interval <duration> Sleep between WAIT ticks. Bare number = seconds. Default: 60s.\n --timeout <duration> Maximum wall-clock wait for WAIT ticks. Bare number = seconds. Default: 4.5m.\n --debounce <duration> Settle window after first FIX_CODE before returning. Bare number = seconds. Default: 60s. 0 disables.\n --quiet-status During WAIT polling, print only changed status snapshots.\n --until-terminal Continue through WAIT/MARK_READY until FIX_CODE/CANCEL/ESCALATE.\n\nForwarded iterate flags:\n --ready-delay <duration> Settle window before a clean PR cancels. Bare number = minutes. Example: 15m.\n --stall-timeout <duration> Escalate repeated unchanged failures after this duration. Bare number = minutes. 0 disables.\n --no-auto-mark-ready Do not convert draft PRs to ready for review.\n --no-auto-cancel-actionable Do not cancel in-progress runs before actionable fixes.\n --format text|json Output Markdown text or JSON. Default: text.\n --verbose Include verbose iterate fields and detailed per-tick lines.\n --help, -h Print this help and exit before GitHub, git, config, or log I/O.\n\nDurations accept s/m/h suffixes: 30s, 4.5m, 1h. A bare number uses each flag's default unit (seconds\nfor --interval/--timeout/--debounce, minutes for --ready-delay/--stall-timeout); decimals are allowed only with\nan explicit unit (4.5m).\nEach WAIT tick writes a single dot to stderr by default; --quiet-status prints only changed WAIT snapshots, and --verbose emits detailed per-tick lines.\nFIX_CODE debounce writes a remaining-seconds line to stderr. --timeout does not cut an in-flight debounce short.\nWith --until-terminal, --timeout is ignored for WAIT ticks and polling continues until FIX_CODE, CANCEL, or ESCALATE.\n\nExit codes: same as iterate (the final tick's action/reason decides the code).\n 0 CANCEL (merged or ready-delay elapsed)\n 10 WAIT (including a WAIT returned by --timeout)\n 11 MARK_READY\n 12 FIX_CODE\n 13 ESCALATE\n 14 CANCEL (closed without merging)\n A command/validation/GitHub failure exits with a sysexits.h code instead (see docs/exit-codes.md).";
|
|
181
181
|
readonly clean: `pr-shepherd clean
|
|
182
182
|
|
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
export function buildSimpleIterateInstructions(result) {
|
|
2
2
|
switch (result.action) {
|
|
3
3
|
case "wait":
|
|
4
|
-
return [
|
|
4
|
+
return [
|
|
5
|
+
"No action is needed this tick. Continue with the next poll using the same interface and mode: rerun the current `pr-shepherd` CLI invocation with its flags, or call MCP `iterate` again.",
|
|
6
|
+
];
|
|
5
7
|
case "mark_ready":
|
|
6
8
|
return [
|
|
7
|
-
"The CLI
|
|
9
|
+
"The CLI marked the PR ready for review. Continue with the next poll using the same interface and mode: rerun the current `pr-shepherd` CLI invocation with its flags, or call MCP `iterate` again.",
|
|
8
10
|
];
|
|
9
11
|
case "cancel":
|
|
10
|
-
return ["Stop — the
|
|
12
|
+
return ["Stop — the PR loop is complete. No further polling is needed."];
|
|
11
13
|
case "escalate":
|
|
12
|
-
return [
|
|
13
|
-
"Stop — the PR needs human direction before iterating can resume. This is a manual handoff; do not continue automated fix attempts.",
|
|
14
|
-
];
|
|
14
|
+
return ["Stop — human direction is required before automated polling can resume."];
|
|
15
15
|
}
|
|
16
16
|
}
|
|
17
17
|
export function adaptIterateLog(log) {
|
|
@@ -5,4 +5,4 @@
|
|
|
5
5
|
* e.g. `"## Review threads"`.
|
|
6
6
|
* @param includeDriftHint - Whether to add the trailing note about drift on failed apply.
|
|
7
7
|
*/
|
|
8
|
-
export declare function buildCommitSuggestionInstruction(prNumber: number, sectionName: string, includeDriftHint: boolean): string;
|
|
8
|
+
export declare function buildCommitSuggestionInstruction(prNumber: number, sectionName: string, includeDriftHint: boolean): string[];
|
|
@@ -17,7 +17,15 @@ export function buildCommitSuggestionInstruction(prNumber, sectionName, includeD
|
|
|
17
17
|
"--format=json",
|
|
18
18
|
]).text;
|
|
19
19
|
const driftHint = includeDriftHint
|
|
20
|
-
? "
|
|
21
|
-
: "
|
|
22
|
-
|
|
20
|
+
? "If the patch does not apply because the suggestion drifted, use the manual-fix step below. Do not retry the command."
|
|
21
|
+
: "If the patch does not apply, use the manual-edit step below. Do not retry the command.";
|
|
22
|
+
const manualStep = includeDriftHint ? "manual-fix step" : "manual-edit step";
|
|
23
|
+
return [
|
|
24
|
+
`For each thread marked \`[suggestion]\` under \`${sectionName}\`, run \`${command}\` to retrieve its patch and suggested commit.`,
|
|
25
|
+
"The CLI only builds the patch. Apply it, stage the listed file, and follow the returned commit instructions.",
|
|
26
|
+
`If the command refuses because the suggestion is unsafe (an unsafe anchored range or nested/unbalanced suggestion fences), skip patch application and use the ${manualStep} below. Do not retry the command.`,
|
|
27
|
+
"For any other refusal, follow the CLI error's stated recovery action; do not manually edit the suggestion.",
|
|
28
|
+
driftHint,
|
|
29
|
+
"Keep human-authored thread IDs in `apply review:` so Shepherd replies instead of resolving them.",
|
|
30
|
+
];
|
|
23
31
|
}
|
|
@@ -6,6 +6,7 @@ import { getRepoInfo, getCurrentPrNumber, getCurrentBranch } from "../github/cli
|
|
|
6
6
|
import { fetchSuggestionThread } from "../github/suggestion-thread.mjs";
|
|
7
7
|
import { parseSuggestion, isCommittableSuggestion } from "../suggestions/parse.mjs";
|
|
8
8
|
import { buildUnifiedDiff } from "../suggestions/patch.mjs";
|
|
9
|
+
import { getUnsafeSuggestionRangeReason } from "../suggestions/range.mjs";
|
|
9
10
|
import { EXIT, ShepherdError } from "../exit-codes.mjs";
|
|
10
11
|
import { buildPrShepherdCommand } from "../cli/runner.mjs";
|
|
11
12
|
import { getEffectiveCwd, getExecutionCwd } from "../execution-context.mjs";
|
|
@@ -80,6 +81,18 @@ export async function runCommitSuggestion(opts) {
|
|
|
80
81
|
throw new ShepherdError(`Thread ${opts.threadId} path escapes the working tree.`, EXIT.UNAVAILABLE);
|
|
81
82
|
}
|
|
82
83
|
const originalContent = await readFile(resolvedPath, "utf8");
|
|
84
|
+
const unsafeRangeReason = getUnsafeSuggestionRangeReason({
|
|
85
|
+
originalContent,
|
|
86
|
+
startLine,
|
|
87
|
+
endLine,
|
|
88
|
+
replacementLines: parsed.lines,
|
|
89
|
+
});
|
|
90
|
+
if (unsafeRangeReason) {
|
|
91
|
+
const range = startLine === endLine ? `${startLine}` : `${startLine}-${endLine}`;
|
|
92
|
+
throw new ShepherdError(`Thread ${opts.threadId}'s suggestion does not safely fit GitHub's anchored range ` +
|
|
93
|
+
`${filePath}:${range}: ${unsafeRangeReason} Refusing to build a patch; inspect the surrounding ` +
|
|
94
|
+
`source and reviewer intent, then apply the change manually.`, EXIT.UNAVAILABLE);
|
|
95
|
+
}
|
|
83
96
|
const patch = buildUnifiedDiff({
|
|
84
97
|
path: filePath,
|
|
85
98
|
originalContent,
|
|
@@ -14,3 +14,4 @@ export declare function buildBehindBaseHintInstruction(baseBranch: string, hint:
|
|
|
14
14
|
/** Build the `Run the apply review: command` instruction, including its optional substitution hint. */
|
|
15
15
|
export declare function buildResolveCommandInstruction(resolveCommand: ResolveCommand): string[];
|
|
16
16
|
export declare function buildFailingCheckInstructions(checks: AgentCheck[]): string[];
|
|
17
|
+
export declare function buildFixCompletionInstruction(checks: AgentCheck[]): string;
|
|
@@ -1,12 +1,9 @@
|
|
|
1
1
|
/** Build the stale-CR clause appended to the `## Changes-requested reviews` instruction. */
|
|
2
2
|
export function buildCrStaleClause(reviews) {
|
|
3
|
-
const bot = reviews.some((r) => r.staleBotCr)
|
|
4
|
-
? " `[pending dismissal — already surfaced]` bullets are bot CRs from a prior tick."
|
|
5
|
-
: "";
|
|
6
3
|
const human = reviews.some((r) => r.staleReview && !r.staleBotCr)
|
|
7
|
-
? " `[stale]` bullets are human CRs on an old commit
|
|
4
|
+
? " `[stale]` bullets are human CRs on an old commit. Ask the reviewer to re-review."
|
|
8
5
|
: "";
|
|
9
|
-
return
|
|
6
|
+
return human;
|
|
10
7
|
}
|
|
11
8
|
/**
|
|
12
9
|
* Build the optional behind-base push hint. Empty unless the branch is actually behind its base
|
|
@@ -21,7 +18,7 @@ export function buildBehindBaseHintInstruction(baseBranch, hint, isBehind) {
|
|
|
21
18
|
const trimmedHint = typeof hint === "string" ? hint.trim() : "";
|
|
22
19
|
if (!isBehind || trimmedHint === "")
|
|
23
20
|
return [];
|
|
24
|
-
return [`The branch is behind \`origin/${baseBranch}
|
|
21
|
+
return [`The branch is behind \`origin/${baseBranch}\`. ${trimmedHint} before pushing.`];
|
|
25
22
|
}
|
|
26
23
|
/** Build the `Run the apply review: command` instruction, including its optional substitution hint. */
|
|
27
24
|
export function buildResolveCommandInstruction(resolveCommand) {
|
|
@@ -29,17 +26,15 @@ export function buildResolveCommandInstruction(resolveCommand) {
|
|
|
29
26
|
return [];
|
|
30
27
|
const instructions = [];
|
|
31
28
|
if ((resolveCommand.replyThreadIds?.length ?? 0) > 0) {
|
|
32
|
-
instructions.push(
|
|
29
|
+
instructions.push("Before `apply review:`, remove any `--reply-thread-ids` entry whose latest visible comment is your own Shepherd reply. Do not reply to yourself.");
|
|
33
30
|
}
|
|
34
|
-
const substituteParts = [];
|
|
35
31
|
if (resolveCommand.requiresHeadSha) {
|
|
36
|
-
|
|
32
|
+
instructions.push("Replace `$HEAD_SHA` with the pushed commit SHA, or `$(git rev-parse HEAD)` if you did not push.");
|
|
37
33
|
}
|
|
38
34
|
if (resolveCommand.requiresDismissMessage) {
|
|
39
|
-
|
|
35
|
+
instructions.push("Replace `$DISMISS_MESSAGE` with one sentence describing what changed.");
|
|
40
36
|
}
|
|
41
|
-
|
|
42
|
-
instructions.push(`Run the \`apply review:\` command shown above${substituteHint}.`);
|
|
37
|
+
instructions.push("Run the `apply review:` command shown above.");
|
|
43
38
|
return instructions;
|
|
44
39
|
}
|
|
45
40
|
export function buildFailingCheckInstructions(checks) {
|
|
@@ -50,21 +45,28 @@ export function buildFailingCheckInstructions(checks) {
|
|
|
50
45
|
const hasStartupFailure = checks.some((c) => c.runId && c.conclusion === "STARTUP_FAILURE");
|
|
51
46
|
const hasExternal = checks.some((c) => !c.runId && c.detailsUrl);
|
|
52
47
|
const hasBare = checks.some((c) => !c.runId && !c.detailsUrl);
|
|
53
|
-
const
|
|
48
|
+
const instructions = [];
|
|
54
49
|
if (hasRunId) {
|
|
55
|
-
|
|
50
|
+
instructions.push("For each GitHub Actions failure under `## Failing checks`, read the included log excerpt first.", "If the excerpt is insufficient, run `gh run view <runId> --log-failed`. Open the run URL only if the API still lacks detail.", "Rerun transient infrastructure failures with `gh run rerun <runId> --failed`. Apply a code fix for real test or build failures.");
|
|
56
51
|
}
|
|
57
52
|
if (hasCancelled) {
|
|
58
|
-
|
|
53
|
+
instructions.push("For each `[conclusion: CANCELLED]` failure, run `gh run rerun <runId>` unless this tick will push new commits.", "Do not treat a cancelled failure as resolved. `## Cancelled runs` is a different section.");
|
|
59
54
|
}
|
|
60
55
|
if (hasStartupFailure) {
|
|
61
|
-
|
|
56
|
+
instructions.push("For each `[conclusion: STARTUP_FAILURE]` failure, inspect it with `gh run view <runId>` and rerun it with `gh run rerun <runId>` if warranted.");
|
|
62
57
|
}
|
|
63
58
|
if (hasExternal) {
|
|
64
|
-
|
|
59
|
+
instructions.push("For each `external` failure, open its URL and inspect it.");
|
|
65
60
|
}
|
|
66
61
|
if (hasBare) {
|
|
67
|
-
|
|
62
|
+
instructions.push("For each `(no runId)` failure, escalate to a human because no log or URL is available.");
|
|
63
|
+
}
|
|
64
|
+
return instructions;
|
|
65
|
+
}
|
|
66
|
+
export function buildFixCompletionInstruction(checks) {
|
|
67
|
+
const requiresHumanHandoff = checks.some((check) => !check.runId && !check.detailsUrl);
|
|
68
|
+
if (requiresHumanHandoff) {
|
|
69
|
+
return "`[FIX_CODE]` requires a human handoff for an uninspectable failing check. Stop polling after escalating, and resume only after human direction.";
|
|
68
70
|
}
|
|
69
|
-
return [`
|
|
71
|
+
return "`[FIX_CODE]` is non-terminal. After completing these steps, continue with the next poll using the same interface and mode: rerun the current `pr-shepherd` CLI invocation with its flags, or call MCP `iterate` again.";
|
|
70
72
|
}
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { renderShellCommand } from "../../cli/runner.mjs";
|
|
2
|
-
import { buildFailingCheckInstructions, buildCrStaleClause, buildBehindBaseHintInstruction, buildResolveCommandInstruction, } from "./check-instructions.mjs";
|
|
2
|
+
import { buildFailingCheckInstructions, buildCrStaleClause, buildBehindBaseHintInstruction, buildResolveCommandInstruction, buildFixCompletionInstruction, } from "./check-instructions.mjs";
|
|
3
3
|
import { SHEPHERD_JOURNAL_FIRST_LOOK_GUIDANCE, SHEPHERD_JOURNAL_REFERENCE_GUIDANCE_THREADS_AND_COMMENTS_IN_ITEM_HEADINGS, buildShepherdJournalInstruction, } from "../shepherd-journal.mjs";
|
|
4
4
|
import { isFailingAgentCheck } from "../../checks/conclusions.mjs";
|
|
5
5
|
import { buildCommitSuggestionInstruction } from "../commit-suggestion-instruction.mjs";
|
|
6
|
-
const FIX_INSTRUCTION_STOP = "Stop this iteration — if you pushed new commits, CI needs time before the next tick; otherwise stop before the next tick.";
|
|
7
6
|
/** Render a resolve command as a shell snippet. Appends `--require-sha "$HEAD_SHA"` when set. */
|
|
8
7
|
export function renderResolveCommand(rc) {
|
|
9
8
|
const parts = [...rc.argv];
|
|
@@ -21,7 +20,7 @@ isBehind = false) {
|
|
|
21
20
|
hasAnnotations ||
|
|
22
21
|
changesRequestedReviews.length > 0 ||
|
|
23
22
|
actionableComments.length > 0;
|
|
24
|
-
//
|
|
23
|
+
// Start with interpretation. The agent decides what raw feedback warrants a code change.
|
|
25
24
|
if (hasNonConflictHints) {
|
|
26
25
|
const actionableSections = [];
|
|
27
26
|
if (threads.length > 0)
|
|
@@ -36,74 +35,74 @@ isBehind = false) {
|
|
|
36
35
|
if (changesRequestedReviews.length > 0)
|
|
37
36
|
actionableSections.push("`## Changes-requested reviews`");
|
|
38
37
|
const sectionRef = actionableSections.length > 0 ? `under ${actionableSections.join(", ")}` : "above";
|
|
39
|
-
|
|
40
|
-
? ", then run the `apply review:` command"
|
|
41
|
-
: "";
|
|
42
|
-
if (hasConflicts) {
|
|
43
|
-
// Conflicts make push mandatory regardless of whether code edits are needed.
|
|
44
|
-
instructions.push(`The branch has merge conflicts that must be resolved before merging (see \`**branch**\` above). Apply any code edits for items ${sectionRef}, then commit and push${resolveClause}.`);
|
|
45
|
-
}
|
|
46
|
-
else {
|
|
47
|
-
const skipClause = resolveCommand.hasMutations
|
|
48
|
-
? "skip the commit/push and run the `apply review:` command"
|
|
49
|
-
: "no push is needed";
|
|
50
|
-
instructions.push(`Decide for each item ${sectionRef} whether a code change is warranted. **If any code changes are needed:** apply edits, commit, push${resolveClause}. **If no code changes are needed:** ${skipClause}.`);
|
|
51
|
-
}
|
|
38
|
+
instructions.push(`Review each item ${sectionRef} and decide whether it needs a code change.`);
|
|
52
39
|
}
|
|
53
|
-
|
|
54
|
-
instructions.push(
|
|
40
|
+
if (hasConflicts) {
|
|
41
|
+
instructions.push("The branch has merge conflicts (see `**branch**` above). Resolve them before committing and pushing.");
|
|
42
|
+
}
|
|
43
|
+
const firstLookTotal = firstLookThreads.length + firstLookComments.length;
|
|
44
|
+
if (firstLookTotal > 0) {
|
|
45
|
+
instructions.push("Review every item under `## First-look items` before acting.", "If a first-look thread also appears under `## Review threads to resolve`, its ID is already in `apply review:`. Do not add first-look-only IDs to mutation flags.");
|
|
46
|
+
}
|
|
47
|
+
if (firstLookSummaries.length > 0)
|
|
48
|
+
instructions.push(SHEPHERD_JOURNAL_FIRST_LOOK_GUIDANCE);
|
|
49
|
+
const editedTotal = editedSummaries.length +
|
|
50
|
+
actionableComments.filter((c) => c.edited).length +
|
|
51
|
+
firstLookThreads.filter((t) => t.edited).length +
|
|
52
|
+
firstLookComments.filter((c) => c.edited).length;
|
|
53
|
+
if (editedTotal > 0) {
|
|
54
|
+
instructions.push("Read every item marked `[edited since first look]`, including edited summaries and edited first-look bullets, before deciding whether to resolve a matching thread.");
|
|
55
55
|
}
|
|
56
|
-
instructions.push(...buildBehindBaseHintInstruction(baseBranch, behindBaseHint, isBehind));
|
|
57
56
|
if (inProgressRunIds.length > 0) {
|
|
58
|
-
instructions.push(
|
|
57
|
+
instructions.push("If you will push, first cancel every ID under `## In-progress runs` with `gh run cancel <id>`.", "Ignore cancellation errors for runs that already finished.", "If you will not push, leave the in-progress runs alone.");
|
|
58
|
+
}
|
|
59
|
+
if (cancelledCount > 0) {
|
|
60
|
+
instructions.push("Do not cancel the IDs under `## Cancelled runs` again. The CLI already cancelled them.");
|
|
59
61
|
}
|
|
60
62
|
const hasSuggestions = threads.some((t) => t.suggestion);
|
|
61
63
|
if (hasSuggestions)
|
|
62
|
-
instructions.push(buildCommitSuggestionInstruction(prNumber, "## Review threads", false));
|
|
64
|
+
instructions.push(...buildCommitSuggestionInstruction(prNumber, "## Review threads", false));
|
|
63
65
|
if (threads.length > 0 || actionableComments.length > 0) {
|
|
64
66
|
// Actionable comments carry no file/line location (unlike threads), so "referenced above"
|
|
65
67
|
// is only accurate when threads are present.
|
|
66
68
|
const filesRef = threads.length > 0 ? "each file referenced above" : "the relevant files";
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
: "";
|
|
70
|
-
|
|
69
|
+
instructions.push(`Apply every warranted review fix in ${filesRef}.`);
|
|
70
|
+
if (hasSuggestions) {
|
|
71
|
+
instructions.push("After source drift prevents a generated suggestion patch from applying, replace the heading's exact `path:startLine-endLine` range with the `Replaces lines …` block verbatim. An empty replacement deletes the range. One blank line replaces it with one blank line.", "When `build-suggestion-patch` refuses because the suggestion is unsafe (an unsafe anchored range or nested/unbalanced suggestion fences), do not apply the replacement block verbatim. Inspect the surrounding source and reviewer intent, then make the intended edit manually.");
|
|
72
|
+
}
|
|
71
73
|
}
|
|
72
74
|
if (resolutionOnlyThreads.length > 0) {
|
|
73
|
-
instructions.push(
|
|
75
|
+
instructions.push("Review the threads under `## Review threads to resolve` before running mutations.", "Use the generated commands as shown. Human-authored IDs use `--reply-thread-ids`. Bot and non-human IDs use `--resolve-thread-ids`. Shepherd does not resolve human-authored threads.");
|
|
74
76
|
}
|
|
75
77
|
instructions.push(...buildFailingCheckInstructions(failingChecks));
|
|
76
78
|
if (hasAnnotations) {
|
|
77
|
-
instructions.push(
|
|
79
|
+
instructions.push("Inspect every referenced range under `## Check annotations` and apply any warranted change.", "Do not add annotation IDs to resolve or minimize mutations.");
|
|
78
80
|
}
|
|
79
81
|
if (changesRequestedReviews.length > 0) {
|
|
80
82
|
const staleClause = buildCrStaleClause(changesRequestedReviews);
|
|
81
|
-
instructions.push(`
|
|
83
|
+
instructions.push(`Read every body under \`## Changes-requested reviews\` and apply any warranted change.${staleClause}`);
|
|
82
84
|
if ((resolveCommand.dismissReviewIds?.length ?? 0) > 0)
|
|
83
|
-
instructions.push(
|
|
85
|
+
instructions.push("Keep every existing `--dismiss-review-ids` ID in `apply review:`. Each is a bot or non-human review that must be dismissed. Omitting one leaves the PR in `CHANGES_REQUESTED`.");
|
|
84
86
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
if (
|
|
89
|
-
instructions.push(`
|
|
90
|
-
}
|
|
91
|
-
const firstLookTotal = firstLookThreads.length + firstLookComments.length;
|
|
92
|
-
if (firstLookTotal > 0) {
|
|
93
|
-
instructions.push(`Items in \`## First-look items\` are shown so you can acknowledge their current status before acting. If a first-look thread also appears under \`## Review threads to resolve\`, its ID is already included in the \`apply review:\` command; otherwise do not pass first-look-only IDs to mutation flags.`);
|
|
87
|
+
instructions.push(...buildBehindBaseHintInstruction(baseBranch, behindBaseHint, isBehind));
|
|
88
|
+
const hasReviewMutations = resolveCommand.hasMutations || resolveOnlyCommand?.hasMutations === true;
|
|
89
|
+
const mutationSuffix = hasReviewMutations ? " before review mutations" : "";
|
|
90
|
+
if (hasConflicts) {
|
|
91
|
+
instructions.push(`Commit any remaining changes and push the conflict resolution${mutationSuffix}.`);
|
|
94
92
|
}
|
|
95
|
-
if (
|
|
96
|
-
instructions.push(
|
|
97
|
-
const editedTotal = editedSummaries.length +
|
|
98
|
-
actionableComments.filter((c) => c.edited).length +
|
|
99
|
-
firstLookThreads.filter((t) => t.edited).length +
|
|
100
|
-
firstLookComments.filter((c) => c.edited).length;
|
|
101
|
-
if (editedTotal > 0) {
|
|
102
|
-
instructions.push(`Items marked \`[edited since first look]\`, items under \`## Review summaries (edited since first look)\`, and any first-look bullet tagged \`, edited\` were updated by their author after you previously acknowledged them. Read the updated body before deciding whether any matching \`## Review threads to resolve\` item should be resolved.`);
|
|
93
|
+
else if (hasNonConflictHints) {
|
|
94
|
+
instructions.push(`If you changed code, commit any remaining changes and push${mutationSuffix}. Otherwise, do not commit or push.`);
|
|
103
95
|
}
|
|
104
|
-
if (
|
|
105
|
-
|
|
96
|
+
if (hasReviewMutations ||
|
|
97
|
+
hasNonConflictHints ||
|
|
98
|
+
firstLookTotal > 0 ||
|
|
99
|
+
firstLookSummaries.length > 0 ||
|
|
100
|
+
editedTotal > 0) {
|
|
101
|
+
instructions.push(...buildShepherdJournalInstruction(prNumber, SHEPHERD_JOURNAL_REFERENCE_GUIDANCE_THREADS_AND_COMMENTS_IN_ITEM_HEADINGS));
|
|
106
102
|
}
|
|
107
|
-
|
|
103
|
+
if (resolveOnlyCommand?.hasMutations)
|
|
104
|
+
instructions.push("Run the `resolve-only:` command shown above.");
|
|
105
|
+
instructions.push(...buildResolveCommandInstruction(resolveCommand));
|
|
106
|
+
instructions.push(buildFixCompletionInstruction(failingChecks));
|
|
108
107
|
return instructions;
|
|
109
108
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export declare const SHEPHERD_JOURNAL_SECTION = "## Shepherd Journal";
|
|
2
2
|
export declare const SHEPHERD_JOURNAL_SECTION_PATTERN: RegExp;
|
|
3
3
|
export declare const SHEPHERD_JOURNAL_APPEND_HINT = "If this section already exists, append your entries under it instead of creating a duplicate heading.";
|
|
4
|
-
export declare const SHEPHERD_JOURNAL_FIRST_LOOK_GUIDANCE = "Review
|
|
5
|
-
export declare function buildShepherdJournalInstruction(prNumber: number, itemReferenceGuidance: string): string;
|
|
6
|
-
export declare const SHEPHERD_JOURNAL_REFERENCE_GUIDANCE_THREADS_AND_COMMENTS_IN_ITEM_HEADINGS = "
|
|
7
|
-
export declare const SHEPHERD_JOURNAL_REFERENCE_GUIDANCE_THREADS_AND_COMMENTS_IN_ITEMS = "
|
|
4
|
+
export declare const SHEPHERD_JOURNAL_FIRST_LOOK_GUIDANCE = "Review each body under `## Review summaries (first look)`. Eligible non-human IDs are already in `--minimize-comment-ids`. Record any warranted Shepherd Journal note before review mutations.";
|
|
5
|
+
export declare function buildShepherdJournalInstruction(prNumber: number, itemReferenceGuidance: string): string[];
|
|
6
|
+
export declare const SHEPHERD_JOURNAL_REFERENCE_GUIDANCE_THREADS_AND_COMMENTS_IN_ITEM_HEADINGS = "Link threads and comments from their headings. Cite reviews by ID.";
|
|
7
|
+
export declare const SHEPHERD_JOURNAL_REFERENCE_GUIDANCE_THREADS_AND_COMMENTS_IN_ITEMS = "Link threads and comments from their item bullets. Cite reviews by ID.";
|
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
export const SHEPHERD_JOURNAL_SECTION = "## Shepherd Journal";
|
|
2
2
|
export const SHEPHERD_JOURNAL_SECTION_PATTERN = /^##\s+Shepherd\s+Journal$/;
|
|
3
3
|
export const SHEPHERD_JOURNAL_APPEND_HINT = "If this section already exists, append your entries under it instead of creating a duplicate heading.";
|
|
4
|
-
export const SHEPHERD_JOURNAL_FIRST_LOOK_GUIDANCE = "Review
|
|
4
|
+
export const SHEPHERD_JOURNAL_FIRST_LOOK_GUIDANCE = "Review each body under `## Review summaries (first look)`. Eligible non-human IDs are already in `--minimize-comment-ids`. Record any warranted Shepherd Journal note before review mutations.";
|
|
5
5
|
export function buildShepherdJournalInstruction(prNumber, itemReferenceGuidance) {
|
|
6
6
|
return [
|
|
7
|
-
`For any
|
|
7
|
+
`For any substantial decision or rejection, append \`- <decision>\` to \`${SHEPHERD_JOURNAL_SECTION}\` with \`pr-shepherd apply journal ${prNumber} '- <decision>'\`.`,
|
|
8
8
|
itemReferenceGuidance,
|
|
9
|
-
|
|
10
|
-
].join(" ");
|
|
9
|
+
];
|
|
11
10
|
}
|
|
12
|
-
export const SHEPHERD_JOURNAL_REFERENCE_GUIDANCE_THREADS_AND_COMMENTS_IN_ITEM_HEADINGS = "
|
|
13
|
-
export const SHEPHERD_JOURNAL_REFERENCE_GUIDANCE_THREADS_AND_COMMENTS_IN_ITEMS = "
|
|
11
|
+
export const SHEPHERD_JOURNAL_REFERENCE_GUIDANCE_THREADS_AND_COMMENTS_IN_ITEM_HEADINGS = "Link threads and comments from their headings. Cite reviews by ID.";
|
|
12
|
+
export const SHEPHERD_JOURNAL_REFERENCE_GUIDANCE_THREADS_AND_COMMENTS_IN_ITEMS = "Link threads and comments from their item bullets. Cite reviews by ID.";
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export declare const normalizeLine: (line: string) => string;
|
|
2
|
+
export declare function splitFileLines(originalContent: string): string[];
|
|
3
|
+
/**
|
|
4
|
+
* Strip leading/trailing replacement lines that exactly duplicate file lines
|
|
5
|
+
* immediately outside the anchored range.
|
|
6
|
+
*/
|
|
7
|
+
export declare function analyzeReplacementContext(fileLines: readonly string[], startLine: number, endLine: number, replacementLines: readonly string[]): {
|
|
8
|
+
leadingLength: number;
|
|
9
|
+
replacementLines: string[];
|
|
10
|
+
trailingLength: number;
|
|
11
|
+
};
|
|
12
|
+
export declare function trimReplacementToContext(fileLines: readonly string[], startLine: number, endLine: number, replacementLines: readonly string[]): readonly string[];
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
export const normalizeLine = (line) => line.endsWith("\r") ? line.slice(0, -1) : line;
|
|
2
|
+
function linesEqual(left, right) {
|
|
3
|
+
return (left.length === right.length &&
|
|
4
|
+
left.every((line, index) => normalizeLine(line) === normalizeLine(right[index])));
|
|
5
|
+
}
|
|
6
|
+
export function splitFileLines(originalContent) {
|
|
7
|
+
const body = originalContent.endsWith("\n") ? originalContent.slice(0, -1) : originalContent;
|
|
8
|
+
return originalContent === "" ? [] : body.split("\n");
|
|
9
|
+
}
|
|
10
|
+
function retainsBoundaryAnchor(replacementLines, removedLines) {
|
|
11
|
+
return (removedLines.length > 0 &&
|
|
12
|
+
(linesEqual(replacementLines.slice(0, removedLines.length), removedLines) ||
|
|
13
|
+
linesEqual(replacementLines.slice(-removedLines.length), removedLines)));
|
|
14
|
+
}
|
|
15
|
+
function largestPermittedContextTrim({ maxLength, matchesContext, remainingAfterTrim, mustRetainAnchor, removedLines, }) {
|
|
16
|
+
for (let length = maxLength; length >= 1; length--) {
|
|
17
|
+
if (!matchesContext(length))
|
|
18
|
+
continue;
|
|
19
|
+
if (mustRetainAnchor && !retainsBoundaryAnchor(remainingAfterTrim(length), removedLines)) {
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
return length;
|
|
23
|
+
}
|
|
24
|
+
return 0;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Strip leading/trailing replacement lines that exactly duplicate file lines
|
|
28
|
+
* immediately outside the anchored range.
|
|
29
|
+
*/
|
|
30
|
+
export function analyzeReplacementContext(fileLines, startLine, endLine, replacementLines) {
|
|
31
|
+
const removedLines = fileLines.slice(startLine - 1, endLine);
|
|
32
|
+
const leadingMayBeAnchor = removedLines.length > 0 &&
|
|
33
|
+
linesEqual(replacementLines.slice(0, removedLines.length), removedLines);
|
|
34
|
+
const leadingLength = largestPermittedContextTrim({
|
|
35
|
+
maxLength: Math.min(startLine - 1, replacementLines.length),
|
|
36
|
+
matchesContext: (length) => linesEqual(replacementLines.slice(0, length), fileLines.slice(startLine - 1 - length, startLine - 1)),
|
|
37
|
+
remainingAfterTrim: (length) => replacementLines.slice(length),
|
|
38
|
+
mustRetainAnchor: leadingMayBeAnchor,
|
|
39
|
+
removedLines,
|
|
40
|
+
});
|
|
41
|
+
const remainder = replacementLines.slice(leadingLength);
|
|
42
|
+
const trailingMayBeAnchor = removedLines.length > 0 && linesEqual(remainder.slice(-removedLines.length), removedLines);
|
|
43
|
+
const trailingLength = largestPermittedContextTrim({
|
|
44
|
+
maxLength: Math.min(fileLines.length - endLine, remainder.length),
|
|
45
|
+
matchesContext: (length) => linesEqual(remainder.slice(-length), fileLines.slice(endLine, endLine + length)),
|
|
46
|
+
remainingAfterTrim: (length) => remainder.slice(0, -length),
|
|
47
|
+
mustRetainAnchor: trailingMayBeAnchor,
|
|
48
|
+
removedLines,
|
|
49
|
+
});
|
|
50
|
+
const trimmedLines = trailingLength === 0 ? remainder : remainder.slice(0, -trailingLength);
|
|
51
|
+
return { leadingLength, replacementLines: trimmedLines, trailingLength };
|
|
52
|
+
}
|
|
53
|
+
export function trimReplacementToContext(fileLines, startLine, endLine, replacementLines) {
|
|
54
|
+
return analyzeReplacementContext(fileLines, startLine, endLine, replacementLines)
|
|
55
|
+
.replacementLines;
|
|
56
|
+
}
|
|
@@ -4,53 +4,10 @@
|
|
|
4
4
|
* The diff uses `--- a/<path>` / `+++ b/<path>` headers so `git apply` and
|
|
5
5
|
* `git apply --check` accept it without a `diff --git` preamble.
|
|
6
6
|
*/
|
|
7
|
-
|
|
8
|
-
* Strip leading/trailing replacement lines that are identical to the adjacent
|
|
9
|
-
* file lines just outside the removed range.
|
|
10
|
-
*
|
|
11
|
-
* GitHub stores a suggestion as a verbatim replacement for the *highlighted*
|
|
12
|
-
* line range, but reviewers often paste surrounding context into the box. Those
|
|
13
|
-
* context-duplicating lines must not appear as additions in the diff — they
|
|
14
|
-
* belong to the surrounding unchanged file content. Stripping them here produces
|
|
15
|
-
* a minimal diff that applies cleanly without duplicating lines.
|
|
16
|
-
*
|
|
17
|
-
* Comparison normalises trailing `\r` from file lines so CRLF files (which
|
|
18
|
-
* carry `\r` on each entry after `split("\n")`) still match suggestion lines
|
|
19
|
-
* delivered as LF-only by the GitHub API.
|
|
20
|
-
*/
|
|
21
|
-
function trimReplacementToContext(fileLines, startLine, endLine, replacementLines) {
|
|
22
|
-
const norm = (s) => (s.endsWith("\r") ? s.slice(0, -1) : s);
|
|
23
|
-
// Leading trim: largest L where replacement[0..L) == fileLines[startLine-1-L..startLine-1)
|
|
24
|
-
const maxL = Math.min(startLine - 1, replacementLines.length);
|
|
25
|
-
let L = 0;
|
|
26
|
-
leading: for (let l = maxL; l >= 1; l--) {
|
|
27
|
-
for (let i = 0; i < l; i++) {
|
|
28
|
-
if (norm(replacementLines[i]) !== norm(fileLines[startLine - 1 - l + i]))
|
|
29
|
-
continue leading;
|
|
30
|
-
}
|
|
31
|
-
L = l;
|
|
32
|
-
break;
|
|
33
|
-
}
|
|
34
|
-
const remainder = replacementLines.slice(L);
|
|
35
|
-
// Trailing trim: largest T where remainder[len-T..len) == fileLines[endLine..endLine+T)
|
|
36
|
-
const maxT = Math.min(fileLines.length - endLine, remainder.length);
|
|
37
|
-
let T = 0;
|
|
38
|
-
trailing: for (let t = maxT; t >= 1; t--) {
|
|
39
|
-
for (let j = 0; j < t; j++) {
|
|
40
|
-
if (norm(remainder[remainder.length - t + j]) !== norm(fileLines[endLine + j]))
|
|
41
|
-
continue trailing;
|
|
42
|
-
}
|
|
43
|
-
T = t;
|
|
44
|
-
break;
|
|
45
|
-
}
|
|
46
|
-
if (L === 0 && T === 0)
|
|
47
|
-
return replacementLines;
|
|
48
|
-
return T === 0 ? remainder : remainder.slice(0, remainder.length - T);
|
|
49
|
-
}
|
|
7
|
+
import { splitFileLines, trimReplacementToContext } from "./lines.mjs";
|
|
50
8
|
export function buildUnifiedDiff({ path, originalContent, startLine, endLine, replacementLines, context = 3, }) {
|
|
51
9
|
const endsWithNewline = originalContent.endsWith("\n");
|
|
52
|
-
const
|
|
53
|
-
const fileLines = body === "" ? [] : body.split("\n");
|
|
10
|
+
const fileLines = splitFileLines(originalContent);
|
|
54
11
|
const removedLines = fileLines.slice(startLine - 1, endLine);
|
|
55
12
|
const beforeStart = Math.max(0, startLine - 1 - context);
|
|
56
13
|
const beforeLines = fileLines.slice(beforeStart, startLine - 1);
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare function getAdjacentSuggestionRangeReason({ fileLines, removedLines, startLine, endLine, replacementLines, }: {
|
|
2
|
+
fileLines: readonly string[];
|
|
3
|
+
removedLines: readonly string[];
|
|
4
|
+
startLine: number;
|
|
5
|
+
endLine: number;
|
|
6
|
+
replacementLines: readonly string[];
|
|
7
|
+
}): string | null;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { findLineSequenceOffsets } from "./range-anchor.mjs";
|
|
2
|
+
import { sharesExactProperPrefix, sharesExactProperSuffix } from "./range-exact-overlap.mjs";
|
|
3
|
+
import { likelyRewritesChangedLineSubrange, likelyRewritesAdjacentSpan, } from "./range-similarity.mjs";
|
|
4
|
+
import { createScanWorkBudget } from "./range-work.mjs";
|
|
5
|
+
function adjacentSpansBefore(fileLines, startLine, replacementLineCount) {
|
|
6
|
+
const endIndex = startLine - 1;
|
|
7
|
+
const limit = Math.min(endIndex, Math.max(2, replacementLineCount * 2));
|
|
8
|
+
return Array.from({ length: limit }, (_, index) => fileLines.slice(endIndex - index - 1, endIndex));
|
|
9
|
+
}
|
|
10
|
+
function adjacentSpansAfter(fileLines, endLine, replacementLineCount) {
|
|
11
|
+
const limit = Math.min(fileLines.length - endLine, Math.max(2, replacementLineCount * 2));
|
|
12
|
+
return Array.from({ length: limit }, (_, index) => fileLines.slice(endLine, endLine + index + 1));
|
|
13
|
+
}
|
|
14
|
+
function likelyRewritesAdjacentSource(replacementLines, adjacentSpans, allowSingleLinePair) {
|
|
15
|
+
if (adjacentSpans
|
|
16
|
+
.slice(0, 8)
|
|
17
|
+
.some((span) => likelyRewritesAdjacentSpan(replacementLines, span, allowSingleLinePair))) {
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
return likelyRewritesChangedLineSubrange(replacementLines, adjacentSpans);
|
|
21
|
+
}
|
|
22
|
+
function extensionRewritesAfter(fileLines, endLine, extension) {
|
|
23
|
+
if (extension.length === 0)
|
|
24
|
+
return false;
|
|
25
|
+
const adjacentLines = fileLines.slice(endLine, endLine + extension.length);
|
|
26
|
+
return (sharesExactProperPrefix(extension, adjacentLines) ||
|
|
27
|
+
likelyRewritesAdjacentSource(extension, adjacentSpansAfter(fileLines, endLine, extension.length), true));
|
|
28
|
+
}
|
|
29
|
+
function extensionRewritesBefore(fileLines, startLine, extension) {
|
|
30
|
+
if (extension.length === 0)
|
|
31
|
+
return false;
|
|
32
|
+
const adjacentStart = startLine - 1 - extension.length;
|
|
33
|
+
const adjacentLines = fileLines.slice(Math.max(0, adjacentStart), startLine - 1);
|
|
34
|
+
return (sharesExactProperSuffix(extension, adjacentLines) ||
|
|
35
|
+
likelyRewritesAdjacentSource(extension, adjacentSpansBefore(fileLines, startLine, extension.length), true));
|
|
36
|
+
}
|
|
37
|
+
function scanExtension(fileLines, startLine, endLine, extension, direction, chargeScanWork) {
|
|
38
|
+
const available = direction === "before" ? startLine - 1 : fileLines.length - endLine;
|
|
39
|
+
if (chargeScanWork(extension.length, available))
|
|
40
|
+
return "work";
|
|
41
|
+
const rewrites = direction === "before"
|
|
42
|
+
? extensionRewritesBefore(fileLines, startLine, extension)
|
|
43
|
+
: extensionRewritesAfter(fileLines, endLine, extension);
|
|
44
|
+
return rewrites ? direction : null;
|
|
45
|
+
}
|
|
46
|
+
function retainedAnchorRewriteDirection(fileLines, removedLines, startLine, endLine, replacementLines, chargeScanWork) {
|
|
47
|
+
const offsets = findLineSequenceOffsets(replacementLines, removedLines);
|
|
48
|
+
// Bound repeated extension scans while rejecting ambiguous bodies safely.
|
|
49
|
+
if (offsets.length * replacementLines.length > 4_096)
|
|
50
|
+
return "ambiguous";
|
|
51
|
+
if (offsets.length === 0)
|
|
52
|
+
return null;
|
|
53
|
+
for (const offset of offsets) {
|
|
54
|
+
const before = replacementLines.slice(0, offset);
|
|
55
|
+
const after = replacementLines.slice(offset + removedLines.length);
|
|
56
|
+
const scans = [
|
|
57
|
+
[after, "after"],
|
|
58
|
+
[after, "before"],
|
|
59
|
+
[before, "before"],
|
|
60
|
+
[before, "after"],
|
|
61
|
+
];
|
|
62
|
+
for (const [extension, direction] of scans) {
|
|
63
|
+
const result = scanExtension(fileLines, startLine, endLine, extension, direction, chargeScanWork);
|
|
64
|
+
if (result !== null)
|
|
65
|
+
return result;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return "safe";
|
|
69
|
+
}
|
|
70
|
+
export function getAdjacentSuggestionRangeReason({ fileLines, removedLines, startLine, endLine, replacementLines, }) {
|
|
71
|
+
const chargeScanWork = createScanWorkBudget();
|
|
72
|
+
const retainedAnchorDirection = retainedAnchorRewriteDirection(fileLines, removedLines, startLine, endLine, replacementLines, chargeScanWork);
|
|
73
|
+
if (retainedAnchorDirection === "after") {
|
|
74
|
+
return "The replacement retains the complete anchored range and appears to rewrite source immediately after it.";
|
|
75
|
+
}
|
|
76
|
+
if (retainedAnchorDirection === "before") {
|
|
77
|
+
return "The replacement retains the complete anchored range and appears to rewrite source immediately before it.";
|
|
78
|
+
}
|
|
79
|
+
if (retainedAnchorDirection === "ambiguous") {
|
|
80
|
+
return "The replacement repeats the complete anchored range too many times to validate its surrounding source safely.";
|
|
81
|
+
}
|
|
82
|
+
if (retainedAnchorDirection === "work") {
|
|
83
|
+
return "The replacement and adjacent source require too much similarity work to validate the anchored range safely.";
|
|
84
|
+
}
|
|
85
|
+
if (retainedAnchorDirection === "safe")
|
|
86
|
+
return null;
|
|
87
|
+
if (chargeScanWork(replacementLines.length, startLine - 1)) {
|
|
88
|
+
return "The replacement and adjacent source require too much similarity work to validate the anchored range safely.";
|
|
89
|
+
}
|
|
90
|
+
if (likelyRewritesAdjacentSource(replacementLines, adjacentSpansBefore(fileLines, startLine, replacementLines.length), false)) {
|
|
91
|
+
return "The replacement partially rewrites a source block before the anchored range.";
|
|
92
|
+
}
|
|
93
|
+
if (chargeScanWork(replacementLines.length, fileLines.length - endLine)) {
|
|
94
|
+
return "The replacement and adjacent source require too much similarity work to validate the anchored range safely.";
|
|
95
|
+
}
|
|
96
|
+
if (likelyRewritesAdjacentSource(replacementLines, adjacentSpansAfter(fileLines, endLine, replacementLines.length), false)) {
|
|
97
|
+
return "The replacement partially rewrites a source block after the anchored range.";
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function findLineSequenceOffsets(lines: readonly string[], sequence: readonly string[]): readonly number[];
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { normalizeLine } from "./lines.mjs";
|
|
2
|
+
function buildPrefixTable(pattern) {
|
|
3
|
+
const table = Array.from({ length: pattern.length }, () => 0);
|
|
4
|
+
let prefixLength = 0;
|
|
5
|
+
for (let index = 1; index < pattern.length;) {
|
|
6
|
+
if (pattern[index] === pattern[prefixLength]) {
|
|
7
|
+
prefixLength++;
|
|
8
|
+
table[index] = prefixLength;
|
|
9
|
+
index++;
|
|
10
|
+
}
|
|
11
|
+
else if (prefixLength > 0) {
|
|
12
|
+
prefixLength = table[prefixLength - 1];
|
|
13
|
+
}
|
|
14
|
+
else {
|
|
15
|
+
index++;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return table;
|
|
19
|
+
}
|
|
20
|
+
export function findLineSequenceOffsets(lines, sequence) {
|
|
21
|
+
if (sequence.length === 0)
|
|
22
|
+
return [];
|
|
23
|
+
const pattern = sequence.map(normalizeLine);
|
|
24
|
+
const prefixTable = buildPrefixTable(pattern);
|
|
25
|
+
const offsets = [];
|
|
26
|
+
let matchedLength = 0;
|
|
27
|
+
for (let index = 0; index < lines.length; index++) {
|
|
28
|
+
const line = normalizeLine(lines[index]);
|
|
29
|
+
while (matchedLength > 0 && line !== pattern[matchedLength]) {
|
|
30
|
+
matchedLength = prefixTable[matchedLength - 1];
|
|
31
|
+
}
|
|
32
|
+
if (line === pattern[matchedLength])
|
|
33
|
+
matchedLength++;
|
|
34
|
+
if (matchedLength !== pattern.length)
|
|
35
|
+
continue;
|
|
36
|
+
offsets.push(index - pattern.length + 1);
|
|
37
|
+
matchedLength = prefixTable[matchedLength - 1];
|
|
38
|
+
}
|
|
39
|
+
return offsets;
|
|
40
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { normalizeLine } from "./lines.mjs";
|
|
2
|
+
function sharedExactPrefixLength(replacementLines, adjacentLines) {
|
|
3
|
+
const limit = Math.min(replacementLines.length, adjacentLines.length);
|
|
4
|
+
let length = 0;
|
|
5
|
+
while (length < limit &&
|
|
6
|
+
normalizeLine(replacementLines[length]) === normalizeLine(adjacentLines[length])) {
|
|
7
|
+
length++;
|
|
8
|
+
}
|
|
9
|
+
return length;
|
|
10
|
+
}
|
|
11
|
+
function sharedExactSuffixLength(replacementLines, adjacentLines) {
|
|
12
|
+
const limit = Math.min(replacementLines.length, adjacentLines.length);
|
|
13
|
+
let length = 0;
|
|
14
|
+
while (length < limit &&
|
|
15
|
+
normalizeLine(replacementLines[replacementLines.length - 1 - length]) ===
|
|
16
|
+
normalizeLine(adjacentLines[adjacentLines.length - 1 - length])) {
|
|
17
|
+
length++;
|
|
18
|
+
}
|
|
19
|
+
return length;
|
|
20
|
+
}
|
|
21
|
+
export function sharesExactProperPrefix(replacementLines, adjacentLines) {
|
|
22
|
+
const sharedLength = sharedExactPrefixLength(replacementLines, adjacentLines);
|
|
23
|
+
return sharedLength > 0 && sharedLength < replacementLines.length;
|
|
24
|
+
}
|
|
25
|
+
export function sharesExactProperSuffix(replacementLines, adjacentLines) {
|
|
26
|
+
const sharedLength = sharedExactSuffixLength(replacementLines, adjacentLines);
|
|
27
|
+
return sharedLength > 0 && sharedLength < replacementLines.length;
|
|
28
|
+
}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
export declare function likelyRewritesAdjacentSpan(replacementLines: readonly string[], adjacentLines: readonly string[], allowSingleLinePair: boolean): boolean;
|
|
2
|
+
export declare function likelyRewritesChangedLineSubrange(replacementLines: readonly string[], adjacentSpans: readonly (readonly string[])[]): boolean;
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { normalizeLine } from "./lines.mjs";
|
|
2
|
+
import { hasLetterOrNumber, isSubstantiveExactAlignedBlock, isSubstantiveLine, isSubstantiveSharedRun, } from "./range-substantive.mjs";
|
|
3
|
+
function closelyRewritesText(replacement, adjacent) {
|
|
4
|
+
const shorterLength = Math.min(replacement.length, adjacent.length);
|
|
5
|
+
let prefixLength = 0;
|
|
6
|
+
while (prefixLength < shorterLength && replacement[prefixLength] === adjacent[prefixLength]) {
|
|
7
|
+
prefixLength++;
|
|
8
|
+
}
|
|
9
|
+
let suffixLength = 0;
|
|
10
|
+
while (prefixLength + suffixLength < shorterLength &&
|
|
11
|
+
replacement[replacement.length - 1 - suffixLength] ===
|
|
12
|
+
adjacent[adjacent.length - 1 - suffixLength]) {
|
|
13
|
+
suffixLength++;
|
|
14
|
+
}
|
|
15
|
+
const sharedLength = prefixLength + suffixLength;
|
|
16
|
+
return sharedLength >= 12 && sharedLength >= Math.ceil(shorterLength * 0.6);
|
|
17
|
+
}
|
|
18
|
+
function normalizeBlockText(lines) {
|
|
19
|
+
return lines.map(normalizeLine).join(" ").trim().replace(/\s+/g, " ");
|
|
20
|
+
}
|
|
21
|
+
const normalizeSharedLine = (line) => normalizeLine(line).trim().replace(/\s+/g, " ");
|
|
22
|
+
function sharedInternalRunAt(replacementLines, adjacentLines, replacementStart, adjacentStart) {
|
|
23
|
+
const sharedLines = [];
|
|
24
|
+
while (replacementStart + sharedLines.length < replacementLines.length - 1 &&
|
|
25
|
+
adjacentStart + sharedLines.length < adjacentLines.length - 1) {
|
|
26
|
+
const replacement = normalizeSharedLine(replacementLines[replacementStart + sharedLines.length]);
|
|
27
|
+
const adjacent = normalizeSharedLine(adjacentLines[adjacentStart + sharedLines.length]);
|
|
28
|
+
if (replacement !== adjacent)
|
|
29
|
+
break;
|
|
30
|
+
sharedLines.push(replacement);
|
|
31
|
+
}
|
|
32
|
+
return sharedLines;
|
|
33
|
+
}
|
|
34
|
+
function hasSubstantiveInternalOverlap(replacementLines, adjacentLines) {
|
|
35
|
+
if (replacementLines.length < 4 || adjacentLines.length < 4)
|
|
36
|
+
return false;
|
|
37
|
+
for (let replacementStart = 1; replacementStart < replacementLines.length - 1; replacementStart++) {
|
|
38
|
+
for (let adjacentStart = 1; adjacentStart < adjacentLines.length - 1; adjacentStart++) {
|
|
39
|
+
if (isSubstantiveSharedRun(sharedInternalRunAt(replacementLines, adjacentLines, replacementStart, adjacentStart))) {
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
export function likelyRewritesAdjacentSpan(replacementLines, adjacentLines, allowSingleLinePair) {
|
|
47
|
+
if (replacementLines.length === 0 || adjacentLines.length === 0)
|
|
48
|
+
return false;
|
|
49
|
+
if (!allowSingleLinePair && replacementLines.length === 1 && adjacentLines.length === 1) {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
const replacement = normalizeBlockText(replacementLines);
|
|
53
|
+
const adjacent = normalizeBlockText(adjacentLines);
|
|
54
|
+
return (replacement === adjacent ||
|
|
55
|
+
closelyRewritesText(replacement, adjacent) ||
|
|
56
|
+
hasSubstantiveInternalOverlap(replacementLines, adjacentLines));
|
|
57
|
+
}
|
|
58
|
+
function alignedLineRelation(replacementLine, adjacentLine) {
|
|
59
|
+
const replacement = normalizeSharedLine(replacementLine);
|
|
60
|
+
const adjacent = normalizeSharedLine(adjacentLine);
|
|
61
|
+
if (replacement === adjacent) {
|
|
62
|
+
const isNeutral = replacement === "" || !hasLetterOrNumber(replacement);
|
|
63
|
+
return isNeutral || isSubstantiveLine(replacementLine) ? "exact" : null;
|
|
64
|
+
}
|
|
65
|
+
if (!isSubstantiveLine(replacementLine) || !isSubstantiveLine(adjacentLine))
|
|
66
|
+
return null;
|
|
67
|
+
return closelyRewritesText(replacement, adjacent) ? "changed" : null;
|
|
68
|
+
}
|
|
69
|
+
function isMixedAlignedRewrite(candidate, adjacentSpan) {
|
|
70
|
+
for (let index = 0; index < candidate.length - 1; index++) {
|
|
71
|
+
const first = alignedLineRelation(candidate[index], adjacentSpan[index]);
|
|
72
|
+
const second = alignedLineRelation(candidate[index + 1], adjacentSpan[index + 1]);
|
|
73
|
+
if ((first === "exact" && second === "changed") ||
|
|
74
|
+
(first === "changed" && second === "exact")) {
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
function isChangedWithUnrelatedSubstantiveNeighbors(candidate, adjacentSpan) {
|
|
81
|
+
const relations = new Set(candidate.map((line, index) => alignedLineRelation(line, adjacentSpan[index])));
|
|
82
|
+
if (!relations.has("changed") || relations.has("exact"))
|
|
83
|
+
return false;
|
|
84
|
+
if (![...candidate, ...adjacentSpan].every((line) => isSubstantiveLine(line) || !hasLetterOrNumber(line))) {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
return (candidate.filter((line, index) => isSubstantiveLine(line) && isSubstantiveLine(adjacentSpan[index])).length >= 2);
|
|
88
|
+
}
|
|
89
|
+
function likelyRewritesChangedWindow(replacementLines, adjacentSpan) {
|
|
90
|
+
const length = adjacentSpan.length;
|
|
91
|
+
for (let start = 0; start <= replacementLines.length - length; start++) {
|
|
92
|
+
const candidate = replacementLines.slice(start, start + length);
|
|
93
|
+
if (isMixedAlignedRewrite(candidate, adjacentSpan))
|
|
94
|
+
return true;
|
|
95
|
+
if (isChangedWithUnrelatedSubstantiveNeighbors(candidate, adjacentSpan))
|
|
96
|
+
return true;
|
|
97
|
+
const isProperSubrange = start > 0 || start + length < replacementLines.length;
|
|
98
|
+
if (isProperSubrange && isSubstantiveExactAlignedBlock(candidate, adjacentSpan)) {
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
if (!candidate.every(isSubstantiveLine) || !adjacentSpan.every(isSubstantiveLine))
|
|
102
|
+
continue;
|
|
103
|
+
const hasExactLine = candidate.some((line, index) => normalizeSharedLine(line) === normalizeSharedLine(adjacentSpan[index]));
|
|
104
|
+
if (length > 8 || hasExactLine)
|
|
105
|
+
continue;
|
|
106
|
+
if (likelyRewritesAdjacentSpan(candidate, adjacentSpan, true))
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
export function likelyRewritesChangedLineSubrange(replacementLines, adjacentSpans) {
|
|
112
|
+
if (replacementLines.length < 2)
|
|
113
|
+
return false;
|
|
114
|
+
const adjacentLine = adjacentSpans.find((span) => span.length === 1)?.[0];
|
|
115
|
+
if (adjacentLine !== undefined && isSubstantiveLine(adjacentLine)) {
|
|
116
|
+
const adjacent = normalizeSharedLine(adjacentLine);
|
|
117
|
+
if (replacementLines.some((line) => {
|
|
118
|
+
if (!isSubstantiveLine(line))
|
|
119
|
+
return false;
|
|
120
|
+
const replacement = normalizeSharedLine(line);
|
|
121
|
+
return replacement !== adjacent && closelyRewritesText(replacement, adjacent);
|
|
122
|
+
})) {
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const maxLength = Math.min(replacementLines.length, adjacentSpans.length);
|
|
127
|
+
for (let length = 2; length <= maxLength; length++) {
|
|
128
|
+
const adjacentSpan = adjacentSpans.find((span) => span.length === length);
|
|
129
|
+
if (adjacentSpan === undefined)
|
|
130
|
+
continue;
|
|
131
|
+
if (likelyRewritesChangedWindow(replacementLines, adjacentSpan))
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export declare const hasLetterOrNumber: (line: string) => boolean;
|
|
2
|
+
export declare const isSubstantiveLine: (line: string) => boolean;
|
|
3
|
+
export declare function isSubstantiveSharedRun(sharedLines: readonly string[]): boolean;
|
|
4
|
+
export declare function isSubstantiveExactAlignedBlock(candidate: readonly string[], adjacentLines: readonly string[]): boolean;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { normalizeLine } from "./lines.mjs";
|
|
2
|
+
export const hasLetterOrNumber = (line) => /[\p{L}\p{N}]/u.test(line);
|
|
3
|
+
export const isSubstantiveLine = (line) => hasLetterOrNumber(line) && line.replace(/\s/g, "").length >= 8;
|
|
4
|
+
export function isSubstantiveSharedRun(sharedLines) {
|
|
5
|
+
const substantiveLines = sharedLines.filter(isSubstantiveLine);
|
|
6
|
+
return substantiveLines.length >= 2 && substantiveLines.join("").replace(/\s/g, "").length >= 24;
|
|
7
|
+
}
|
|
8
|
+
export function isSubstantiveExactAlignedBlock(candidate, adjacentLines) {
|
|
9
|
+
return (isSubstantiveSharedRun(candidate) &&
|
|
10
|
+
candidate.every((line, index) => normalizeLine(line).trim().replace(/\s+/g, " ") ===
|
|
11
|
+
normalizeLine(adjacentLines[index]).trim().replace(/\s+/g, " ")));
|
|
12
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
const MAX_ADJACENT_SCAN_WORK = 1_000_000;
|
|
2
|
+
function estimatedScanWork(replacementLineCount, availableLineCount) {
|
|
3
|
+
const changedWindowLimit = Math.min(availableLineCount, replacementLineCount);
|
|
4
|
+
return replacementLineCount * changedWindowLimit ** 2;
|
|
5
|
+
}
|
|
6
|
+
export function createScanWorkBudget() {
|
|
7
|
+
let work = 0;
|
|
8
|
+
return (replacementLineCount, availableLineCount) => {
|
|
9
|
+
work += estimatedScanWork(replacementLineCount, availableLineCount);
|
|
10
|
+
return work > MAX_ADJACENT_SCAN_WORK;
|
|
11
|
+
};
|
|
12
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Return why a suggestion body cannot be reconciled safely with GitHub's
|
|
3
|
+
* anchored line range, or null when the exact range is safe to use.
|
|
4
|
+
*
|
|
5
|
+
* The range is authoritative: this validator never shifts or expands it. It
|
|
6
|
+
* only detects bodies that appear to reach beyond the anchor, leaving manual
|
|
7
|
+
* interpretation to the caller instead of emitting a structurally unsafe diff.
|
|
8
|
+
*/
|
|
9
|
+
export declare function getUnsafeSuggestionRangeReason({ originalContent, startLine, endLine, replacementLines, }: {
|
|
10
|
+
originalContent: string;
|
|
11
|
+
startLine: number;
|
|
12
|
+
endLine: number;
|
|
13
|
+
replacementLines: readonly string[];
|
|
14
|
+
}): string | null;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { analyzeReplacementContext, normalizeLine, splitFileLines } from "./lines.mjs";
|
|
2
|
+
import { getAdjacentSuggestionRangeReason } from "./range-adjacent.mjs";
|
|
3
|
+
import { findLineSequenceOffsets } from "./range-anchor.mjs";
|
|
4
|
+
import { hasLetterOrNumber } from "./range-substantive.mjs";
|
|
5
|
+
function discardedContextContainsAnchorLine({ leadingLength, removedLines, replacementLines, trailingLength, }) {
|
|
6
|
+
if (removedLines.length < 2)
|
|
7
|
+
return false;
|
|
8
|
+
const discardedLines = [
|
|
9
|
+
...replacementLines.slice(0, leadingLength),
|
|
10
|
+
...replacementLines.slice(replacementLines.length - trailingLength),
|
|
11
|
+
];
|
|
12
|
+
const normalizedAnchorLines = new Set(removedLines.map(normalizeLine).filter(hasLetterOrNumber));
|
|
13
|
+
return discardedLines.some((line) => normalizedAnchorLines.has(normalizeLine(line)));
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Return why a suggestion body cannot be reconciled safely with GitHub's
|
|
17
|
+
* anchored line range, or null when the exact range is safe to use.
|
|
18
|
+
*
|
|
19
|
+
* The range is authoritative: this validator never shifts or expands it. It
|
|
20
|
+
* only detects bodies that appear to reach beyond the anchor, leaving manual
|
|
21
|
+
* interpretation to the caller instead of emitting a structurally unsafe diff.
|
|
22
|
+
*/
|
|
23
|
+
export function getUnsafeSuggestionRangeReason({ originalContent, startLine, endLine, replacementLines, }) {
|
|
24
|
+
const fileLines = splitFileLines(originalContent);
|
|
25
|
+
if (!Number.isInteger(startLine) ||
|
|
26
|
+
!Number.isInteger(endLine) ||
|
|
27
|
+
startLine < 1 ||
|
|
28
|
+
endLine < startLine ||
|
|
29
|
+
endLine > fileLines.length) {
|
|
30
|
+
return `GitHub reported an invalid or out-of-bounds range (${startLine}-${endLine}) for a ${fileLines.length}-line file.`;
|
|
31
|
+
}
|
|
32
|
+
const removedLines = fileLines.slice(startLine - 1, endLine);
|
|
33
|
+
const contextTrim = analyzeReplacementContext(fileLines, startLine, endLine, replacementLines);
|
|
34
|
+
if (contextTrim.leadingLength > 0 || contextTrim.trailingLength > 0) {
|
|
35
|
+
const originalOccurrences = findLineSequenceOffsets(replacementLines, removedLines).length;
|
|
36
|
+
const trimmedOccurrences = findLineSequenceOffsets(contextTrim.replacementLines, removedLines).length;
|
|
37
|
+
if (originalOccurrences > trimmedOccurrences) {
|
|
38
|
+
return "Exact-context trimming would discard a complete copy of the anchored range, so the intended duplicate insertion is ambiguous.";
|
|
39
|
+
}
|
|
40
|
+
if (discardedContextContainsAnchorLine({
|
|
41
|
+
leadingLength: contextTrim.leadingLength,
|
|
42
|
+
removedLines,
|
|
43
|
+
replacementLines,
|
|
44
|
+
trailingLength: contextTrim.trailingLength,
|
|
45
|
+
})) {
|
|
46
|
+
return "Exact-context trimming would discard a partial copy of the anchored range, so the intended duplicate insertion is ambiguous.";
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return getAdjacentSuggestionRangeReason({
|
|
50
|
+
fileLines,
|
|
51
|
+
removedLines,
|
|
52
|
+
startLine,
|
|
53
|
+
endLine,
|
|
54
|
+
replacementLines: contextTrim.replacementLines,
|
|
55
|
+
});
|
|
56
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pr-shepherd",
|
|
3
|
-
"version": "0.37.
|
|
3
|
+
"version": "0.37.2",
|
|
4
4
|
"description": "Autonomous PR CI monitor and review-comment resolver for agentic coding tools",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"automation",
|
|
@@ -82,7 +82,7 @@
|
|
|
82
82
|
"@vitest/coverage-v8": "^4.1.4",
|
|
83
83
|
"husky": "^9.1.7",
|
|
84
84
|
"knip": "^6.14.1",
|
|
85
|
-
"oxfmt": "^0.
|
|
85
|
+
"oxfmt": "^0.63.0",
|
|
86
86
|
"oxlint": "^1.60.0",
|
|
87
87
|
"typescript": "^7.0.2",
|
|
88
88
|
"vitest": "^4.1.4"
|
|
@@ -16,4 +16,6 @@ Thin dispatcher for iterating a PR. Poll with the CLI; use MCP `iterate` only wh
|
|
|
16
16
|
|
|
17
17
|
2. Run the poll command `pr-shepherd` with the optional PR argument and print its full result. Do not run `pr-shepherd iterate`. If the CLI is unavailable and the `iterate` MCP tool is available, call `iterate` and print its full result.
|
|
18
18
|
|
|
19
|
-
3.
|
|
19
|
+
3. Print the full result and follow every returned `## Instructions` step exactly. For CLI output, run each printed mutation command when instructed. For MCP output, use MCP `apply` and `build_suggestion_patch`; do not run a shell `pr-shepherd apply` command.
|
|
20
|
+
|
|
21
|
+
4. After completing the returned instructions, repeat step 2 unless the action is `[CANCEL]` or `[ESCALATE]`, the instructions require a human handoff, or the human directs you to stop.
|