pr-shepherd 0.31.0 → 0.32.0

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
3
  "description": "Autonomous PR CI monitor and review-comment resolver for agentic coding tools",
4
- "version": "0.31.0",
4
+ "version": "0.32.0",
5
5
  "author": {
6
6
  "name": "Jonathan Ong",
7
7
  "email": "jonathanrichardong@gmail.com"
package/README.md CHANGED
@@ -90,6 +90,7 @@ Direct CLI:
90
90
  pr-shepherd 42 # poll until non-WAIT or timeout
91
91
  pr-shepherd 42 --interval 60s --timeout 270s
92
92
  pr-shepherd 42 --quiet-status # print only changed WAIT status snapshots
93
+ pr-shepherd 42 --until-terminal # continue through WAIT/MARK_READY until work or terminal state
93
94
  pr-shepherd 42 --ready-delay 15m
94
95
  pr-shepherd iterate 42 # single tick
95
96
  pr-shepherd poll 42 # explicit poll command
package/bin/cli/args.mjs CHANGED
@@ -28,6 +28,7 @@ const BOOLEAN_FLAGS = new Set([
28
28
  "--no-auto-mark-ready",
29
29
  "--no-auto-cancel-actionable",
30
30
  "--quiet-status",
31
+ "--until-terminal",
31
32
  "--dry-run",
32
33
  "--verbose",
33
34
  ]);
@@ -13,6 +13,7 @@ const DEFAULT_POLL_BOOLEAN_FLAGS = new Set([
13
13
  "--no-auto-mark-ready",
14
14
  "--no-auto-cancel-actionable",
15
15
  "--quiet-status",
16
+ "--until-terminal",
16
17
  ]);
17
18
  export function isDefaultPollInvocation(subcommand) {
18
19
  if (subcommand === "--help" || subcommand === "-h")
@@ -1,3 +1,4 @@
1
+ import { LOG_FILE_USAGE } from "./help-log-file-page.mjs";
1
2
  export const COMMAND_USAGE = {
2
3
  resolve: `pr-shepherd resolve
3
4
 
@@ -103,7 +104,7 @@ Exit codes:
103
104
 
104
105
  Run iterate repeatedly while the action is WAIT. Print only the final tick to stdout.
105
106
  Poll exits as soon as iterate returns MARK_READY, FIX_CODE, CANCEL, or ESCALATE, or when timeout
106
- returns the last WAIT result.
107
+ returns the last WAIT result. With --until-terminal, poll also continues through MARK_READY.
107
108
 
108
109
  Usage:
109
110
  pr-shepherd poll [PR] [poll-flags] [iterate-flags]
@@ -112,6 +113,7 @@ Poll flags:
112
113
  --interval <duration> Sleep between WAIT ticks. Default: 60s.
113
114
  --timeout <duration> Maximum wall-clock wait. Default: 4.5m.
114
115
  --quiet-status During WAIT polling, print only changed status snapshots.
116
+ --until-terminal Continue through WAIT/MARK_READY until FIX_CODE/CANCEL/ESCALATE.
115
117
 
116
118
  Forwarded iterate flags:
117
119
  --ready-delay <duration> Settle window before a clean PR cancels. Example: 15m.
@@ -124,6 +126,7 @@ Forwarded iterate flags:
124
126
 
125
127
  Durations accept seconds, minutes, or hours: 30s, 4.5m, 1h, or bare seconds.
126
128
  Each WAIT tick writes a single dot to stderr by default; --quiet-status prints only changed WAIT snapshots, and --verbose emits detailed per-tick lines.
129
+ With --until-terminal, --timeout is ignored for WAIT ticks and polling continues until FIX_CODE, CANCEL, or ESCALATE.
127
130
 
128
131
  Exit codes:
129
132
  0 WAIT timeout or MARK_READY
@@ -173,21 +176,5 @@ Flags:
173
176
  --help, -h Print this help and exit before any GitHub I/O.
174
177
 
175
178
  Exit code: 0 on success (including no-change no-op); 1 on validation, lookup, or mutation failure.`,
176
- "log-file": `pr-shepherd log-file
177
-
178
- Print the per-worktree append-only debug log path for the current repository.
179
- The log is created by the first non-help pr-shepherd command that initializes logging.
180
-
181
- Usage:
182
- pr-shepherd log-file [--format text|json]
183
-
184
- Flags:
185
- --format text|json Print a raw path or {"path": "..."} JSON. Default: text.
186
- --help, -h Print this help and exit before logging setup.
187
-
188
- Environment:
189
- PR_SHEPHERD_LOG_DISABLED=1 disables logging.
190
- PR_SHEPHERD_STATE_DIR overrides the base state directory.
191
-
192
- Exit code: 0 on success; 1 if repository identity cannot be resolved.`,
179
+ "log-file": LOG_FILE_USAGE,
193
180
  };
@@ -0,0 +1,17 @@
1
+ export const LOG_FILE_USAGE = `pr-shepherd log-file
2
+
3
+ Print the per-worktree append-only debug log path for the current repository.
4
+ The log is created by the first non-help pr-shepherd command that initializes logging.
5
+
6
+ Usage:
7
+ pr-shepherd log-file [--format text|json]
8
+
9
+ Flags:
10
+ --format text|json Print a raw path or {"path": "..."} JSON. Default: text.
11
+ --help, -h Print this help and exit before logging setup.
12
+
13
+ Environment:
14
+ PR_SHEPHERD_LOG_DISABLED=1 disables logging.
15
+ PR_SHEPHERD_STATE_DIR overrides the base state directory.
16
+
17
+ Exit code: 0 on success; 1 if repository identity cannot be resolved.`;
@@ -45,6 +45,7 @@ Poll flags:
45
45
  --interval <duration> Delay between WAIT ticks. Default: 60s.
46
46
  --timeout <duration> Poll wall-clock cap. Default: 4.5m.
47
47
  --quiet-status During WAIT polling, print only changed status snapshots.
48
+ --until-terminal Continue through WAIT/MARK_READY until FIX_CODE/CANCEL/ESCALATE.
48
49
 
49
50
  Clean variants:
50
51
  pr [number] Remove state for one PR. Defaults to current branch PR.
@@ -33,6 +33,7 @@ export async function handlePoll(args) {
33
33
  intervalSeconds,
34
34
  timeoutSeconds,
35
35
  quietStatus: hasFlag(extra, "--quiet-status"),
36
+ untilTerminal: hasFlag(extra, "--until-terminal"),
36
37
  });
37
38
  emitIterateResult(result, {
38
39
  format: globalOpts.format,
@@ -55,39 +55,45 @@ function writeWaitProgress(opts) {
55
55
  return signature;
56
56
  }
57
57
  export async function runPoll(opts) {
58
- const { intervalSeconds, timeoutSeconds, ...iterateOpts } = opts;
58
+ const { intervalSeconds, timeoutSeconds, quietStatus: quietStatusOpt, untilTerminal: untilTerminalOpt, ...iterateOpts } = opts;
59
59
  const intervalMs = Math.min(intervalSeconds * 1000, MAX_TIMER_MS);
60
60
  const timeoutMs = Math.min(timeoutSeconds * 1000, MAX_TIMER_MS);
61
61
  const start = Date.now();
62
62
  let tick = 0;
63
63
  let lastResult;
64
64
  const verbose = opts.verbose === true;
65
- const quietStatus = opts.quietStatus === true;
65
+ const quietStatus = quietStatusOpt === true;
66
+ const untilTerminal = untilTerminalOpt === true;
66
67
  let dotsPrinted = false;
67
68
  let lastWaitSignature = null;
68
69
  while (true) {
69
70
  tick += 1;
70
71
  lastResult = await runIterate(iterateOpts);
71
- if (lastResult.action !== "wait")
72
+ if (lastResult.action !== "wait" && !(untilTerminal && lastResult.action === "mark_ready")) {
72
73
  break;
74
+ }
73
75
  const elapsedMs = Date.now() - start;
74
- const remainingMs = timeoutMs - elapsedMs;
75
- if (remainingMs <= 0)
76
- break;
77
- if (remainingMs + TIMER_DRIFT_TOLERANCE_MS < intervalMs)
78
- break;
76
+ if (!untilTerminal) {
77
+ const remainingMs = timeoutMs - elapsedMs;
78
+ if (remainingMs <= 0)
79
+ break;
80
+ if (remainingMs + TIMER_DRIFT_TOLERANCE_MS < intervalMs)
81
+ break;
82
+ }
79
83
  const nextSleepMs = intervalMs;
80
- lastWaitSignature = writeWaitProgress({
81
- tick,
82
- elapsedMs,
83
- sleepMs: nextSleepMs,
84
- result: lastResult,
85
- quietStatus,
86
- verbose,
87
- lastWaitSignature,
88
- });
89
- if (!quietStatus && !verbose)
90
- dotsPrinted = true;
84
+ if (lastResult.action === "wait") {
85
+ lastWaitSignature = writeWaitProgress({
86
+ tick,
87
+ elapsedMs,
88
+ sleepMs: nextSleepMs,
89
+ result: lastResult,
90
+ quietStatus,
91
+ verbose,
92
+ lastWaitSignature,
93
+ });
94
+ if (!quietStatus && !verbose)
95
+ dotsPrinted = true;
96
+ }
91
97
  await sleep(nextSleepMs);
92
98
  }
93
99
  if (dotsPrinted)
@@ -4,6 +4,49 @@
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
50
  export function buildUnifiedDiff({ path, originalContent, startLine, endLine, replacementLines, context = 3, }) {
8
51
  const endsWithNewline = originalContent.endsWith("\n");
9
52
  const body = endsWithNewline ? originalContent.slice(0, -1) : originalContent;
@@ -13,9 +56,12 @@ export function buildUnifiedDiff({ path, originalContent, startLine, endLine, re
13
56
  const beforeLines = fileLines.slice(beforeStart, startLine - 1);
14
57
  const afterEnd = Math.min(fileLines.length, endLine + context);
15
58
  const afterLines = fileLines.slice(endLine, afterEnd);
59
+ // Trim replacement lines that duplicate adjacent file context so the diff is
60
+ // minimal and `git apply`-clean (issue #294).
61
+ const replacement = trimReplacementToContext(fileLines, startLine, endLine, replacementLines);
16
62
  const hunkOrigStart = beforeStart + 1;
17
63
  const hunkOrigCount = beforeLines.length + removedLines.length + afterLines.length;
18
- const hunkNewCount = beforeLines.length + replacementLines.length + afterLines.length;
64
+ const hunkNewCount = beforeLines.length + replacement.length + afterLines.length;
19
65
  const noNewline = "\\n";
20
66
  const isLastOrigLine = (lineIdx) => !endsWithNewline && lineIdx === fileLines.length - 1;
21
67
  const out = [
@@ -37,10 +83,10 @@ export function buildUnifiedDiff({ path, originalContent, startLine, endLine, re
37
83
  // not just because context is 0 (there may still be unshown lines beyond the hunk).
38
84
  const addedEndsFile = !endsWithNewline && endLine >= fileLines.length;
39
85
  const hasCr = originalContent.includes("\r\n");
40
- for (let i = 0; i < replacementLines.length; i++) {
41
- const line = hasCr ? replacementLines[i] + "\r" : replacementLines[i];
86
+ for (let i = 0; i < replacement.length; i++) {
87
+ const line = hasCr ? replacement[i] + "\r" : replacement[i];
42
88
  out.push(`+${line}\n`);
43
- if (addedEndsFile && i === replacementLines.length - 1)
89
+ if (addedEndsFile && i === replacement.length - 1)
44
90
  out.push(noNewline);
45
91
  }
46
92
  for (let i = 0; i < afterLines.length; i++) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
- "version": "0.31.0",
3
+ "version": "0.32.0",
4
4
  "description": "Autonomous PR CI monitor and review-comment resolver for agentic coding tools",
5
5
  "license": "MIT",
6
6
  "author": "Jonathan Ong",
@@ -38,7 +38,7 @@
38
38
  "@vitest/coverage-v8": "^4.1.4",
39
39
  "husky": "^9.1.7",
40
40
  "knip": "^6.14.1",
41
- "oxfmt": "^0.53.0",
41
+ "oxfmt": "^0.54.0",
42
42
  "oxlint": "^1.60.0",
43
43
  "typescript": "^6.0.3",
44
44
  "vitest": "^4.1.4"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
- "version": "0.31.0",
3
+ "version": "0.32.0",
4
4
  "description": "Autonomous PR CI monitor and review-comment resolver for Codex.",
5
5
  "author": {
6
6
  "name": "Jonathan Ong",
@@ -16,9 +16,9 @@ Poll dispatcher for iterating a PR to completion.
16
16
 
17
17
  1. **Resolve the PR number** (`$N`): use the number or URL in `$ARGUMENTS`; otherwise infer it with `gh pr view --json number --jq .number`. If none is found, report an error and stop.
18
18
 
19
- 2. **Define the poll command once:** `pr-shepherd $N --interval 60s --timeout 4.5m --quiet-status`. Do not forward `$ARGUMENTS` as extra flags. Run `pr-shepherd --help` to inspect supported options.
19
+ 2. **Define the poll command once:** `pr-shepherd $N --interval 60s --until-terminal --quiet-status`. Do not forward `$ARGUMENTS` as extra flags. Run `pr-shepherd --help` to inspect supported options.
20
20
 
21
- 3. **Loop:** Run the poll, print its full output, and follow its `## Instructions` section exactly. Then run the poll again. Repeat until the CLI emits `[CANCEL]` or `[ESCALATE]`, unless the human directs you to stop. Every other action (`[WAIT]`, `[MARK_READY]`, `[FIX_CODE]`) is non-terminal: do its instructions, then poll again. The poll already bounds each wait via `--interval`/`--timeout`; do not add manual `sleep`s between ticks.
21
+ 3. **Loop:** Run the poll, print its full output, and follow its `## Instructions` section exactly. Then run the poll again. Repeat until the CLI emits `[CANCEL]` or `[ESCALATE]`, unless the human directs you to stop. `[FIX_CODE]` is non-terminal: do its instructions, then poll again. The poll already waits between ticks via `--interval`; do not add manual `sleep`s between ticks.
22
22
 
23
23
  4. **Nonzero exit codes:** Treat a nonzero poll exit as PR state only when the output contains a matching `# PR #$N [ACTION]` heading. Exit `1` can also mean a command or validation failure; if there is no `[ACTION]` heading, surface the error and stop instead of looping.
24
24