pr-shepherd 0.17.0 → 0.18.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.
Files changed (40) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +6 -12
  3. package/bin/cli/args.mjs +2 -0
  4. package/bin/cli/default-iterate.mjs +1 -1
  5. package/bin/cli/duration-flag.mjs +22 -0
  6. package/bin/cli/exit-codes.mjs +14 -0
  7. package/bin/cli/fix-formatter.mjs +1 -2
  8. package/bin/cli/handlers.mjs +16 -36
  9. package/bin/cli/iterate-emitter.mjs +19 -0
  10. package/bin/cli/iterate-flags.mjs +21 -0
  11. package/bin/cli/iterate-formatter.mjs +50 -13
  12. package/bin/cli/iterate-instructions.mjs +10 -16
  13. package/bin/cli/iterate-lean.mjs +9 -12
  14. package/bin/cli/poll-handler.mjs +42 -0
  15. package/bin/cli-parser.iterate-fix.test-support.mjs +0 -4
  16. package/bin/cli-parser.iterate-fixtures.mjs +4 -1
  17. package/bin/cli-parser.iterate.test-support.mjs +0 -4
  18. package/bin/cli-parser.mjs +8 -1
  19. package/bin/commands/check-terminal-report.mjs +1 -0
  20. package/bin/commands/check.mjs +1 -0
  21. package/bin/commands/check.test-support.mjs +1 -0
  22. package/bin/commands/commit-suggestion.apply.test-support.mjs +1 -0
  23. package/bin/commands/commit-suggestion.test-support.mjs +1 -0
  24. package/bin/commands/iterate/check-instructions.mjs +19 -17
  25. package/bin/commands/iterate/escalate.mjs +17 -21
  26. package/bin/commands/iterate/fix-code.mjs +22 -11
  27. package/bin/commands/iterate/index.mjs +2 -0
  28. package/bin/commands/iterate/render.mjs +50 -45
  29. package/bin/commands/iterate-test-support.mjs +1 -0
  30. package/bin/commands/poll.mjs +32 -0
  31. package/bin/commands/poll.test-support.mjs +77 -0
  32. package/bin/commands/resolve-instructions.mjs +2 -5
  33. package/bin/github/batch-parser-helpers.mjs +38 -0
  34. package/bin/github/batch-parsers.mjs +12 -38
  35. package/bin/github/gql/batch-pr.gql +9 -0
  36. package/bin/state/seen-comments.mjs +29 -15
  37. package/package.json +1 -1
  38. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  39. package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +13 -2
  40. package/bin/agent-runtime.mjs +0 -7
@@ -151,5 +151,6 @@ export async function runCheck(opts) {
151
151
  firstLookSummaries,
152
152
  editedSummaries,
153
153
  approvedReviews: batchData.approvedReviews,
154
+ branchProtection: batchData.branchProtection,
154
155
  };
155
156
  }
@@ -94,6 +94,7 @@ function makeBatchData(overrides = {}) {
94
94
  changesRequestedReviews: [],
95
95
  reviewSummaries: [],
96
96
  approvedReviews: [],
97
+ branchProtection: null,
97
98
  checks: [makeCheck()],
98
99
  ...overrides,
99
100
  };
@@ -68,6 +68,7 @@ function makeBatch(threads) {
68
68
  changesRequestedReviews: [],
69
69
  reviewSummaries: [],
70
70
  approvedReviews: [],
71
+ branchProtection: null,
71
72
  };
72
73
  }
73
74
  const FILE_CONTENT = "line1\n" +
@@ -76,6 +76,7 @@ function makeBatch(threads, headRepoWithOwner = "owner/repo") {
76
76
  changesRequestedReviews: [],
77
77
  reviewSummaries: [],
78
78
  approvedReviews: [],
79
+ branchProtection: null,
79
80
  };
80
81
  }
81
82
  const FILE_CONTENT = "line1\n" +
@@ -1,24 +1,26 @@
1
1
  export function buildFailingCheckInstructions(checks) {
2
- const instructions = [];
3
- const failedRunIdChecks = checks.filter((c) => c.runId && c.conclusion !== "CANCELLED" && c.conclusion !== "STARTUP_FAILURE");
4
- const cancelledRunIdChecks = checks.filter((c) => c.runId && c.conclusion === "CANCELLED");
5
- const startupFailureRunIdChecks = checks.filter((c) => c.runId && c.conclusion === "STARTUP_FAILURE");
6
- const externalChecks = checks.filter((c) => !c.runId && c.detailsUrl);
7
- const bareChecks = checks.filter((c) => !c.runId && !c.detailsUrl);
8
- if (failedRunIdChecks.length > 0) {
9
- instructions.push(`For each failing check under \`## Failing checks\` with a run ID and no \`[conclusion: CANCELLED]\` or \`[conclusion: STARTUP_FAILURE]\` tag: run \`gh run view <runId> --log-failed\` to fetch the failing job's log. If the log shows a transient infrastructure failure (network timeout, runner setup crash, OOM kill), run \`gh run rerun <runId> --failed\`. If the log shows a real test/build failure, apply a code fix.`);
2
+ if (checks.length === 0)
3
+ return [];
4
+ const hasRunId = checks.some((c) => c.runId && c.conclusion !== "CANCELLED" && c.conclusion !== "STARTUP_FAILURE");
5
+ const hasCancelled = checks.some((c) => c.runId && c.conclusion === "CANCELLED");
6
+ const hasStartupFailure = checks.some((c) => c.runId && c.conclusion === "STARTUP_FAILURE");
7
+ const hasExternal = checks.some((c) => !c.runId && c.detailsUrl);
8
+ const hasBare = checks.some((c) => !c.runId && !c.detailsUrl);
9
+ const parts = [];
10
+ if (hasRunId) {
11
+ parts.push("fetch the log with `gh run view <runId> --log-failed` and decide: rerun with `gh run rerun <runId> --failed` for transient infrastructure failures (network timeout, OOM kill, runner crash), or apply a code fix for real test/build failures");
10
12
  }
11
- if (cancelledRunIdChecks.length > 0) {
12
- instructions.push(`For each \`[conclusion: CANCELLED]\` bullet under \`## Failing checks\`: the run was cancelled outside Shepherd's control (manual cancel, newer push, concurrency-group eviction). Run \`gh run rerun <runId>\` only if the cancellation looks unintended; otherwise treat it as resolved by the superseding run. Do NOT confuse these with IDs under \`## Cancelled runs\` — those were cancelled by Shepherd itself.`);
13
+ if (hasCancelled) {
14
+ parts.push("for `[conclusion: CANCELLED]` entries: rerun with `gh run rerun <runId>` if the cancellation looks unintended (not superseded by a newer push or concurrency-group eviction); otherwise treat as resolved — do NOT confuse with IDs under `## Cancelled runs`");
13
15
  }
14
- if (startupFailureRunIdChecks.length > 0) {
15
- instructions.push(`For each \`[conclusion: STARTUP_FAILURE]\` bullet under \`## Failing checks\`: the workflow failed before jobs/logs were created. Run \`gh run view <runId>\` to inspect the run metadata, then run \`gh run rerun <runId>\` if the workflow should be attempted again.`);
16
+ if (hasStartupFailure) {
17
+ parts.push("for `[conclusion: STARTUP_FAILURE]` entries: inspect with `gh run view <runId>` and rerun with `gh run rerun <runId>` if the workflow should be retried");
16
18
  }
17
- if (externalChecks.length > 0) {
18
- instructions.push(`For each bullet in \`## Failing checks\` starting with \`external\` (external status check): open the linked URL in a browser to inspect the failure — log tails are not available for external checks.`);
19
+ if (hasExternal) {
20
+ parts.push("for `external` entries (no run ID, has URL): open the URL to inspect the failure");
19
21
  }
20
- if (bareChecks.length > 0) {
21
- instructions.push(`For each bullet in \`## Failing checks\` starting with \`(no runId)\`: there is no run or details URL to inspect. Escalate these to a human — they require manual investigation outside the pr-shepherd flow.`);
22
+ if (hasBare) {
23
+ parts.push("for `(no runId)` entries: no log or URL is available — escalate to a human for manual investigation");
22
24
  }
23
- return instructions;
25
+ return [`For each failing check under \`## Failing checks\`: ${parts.join("; ")}.`];
24
26
  }
@@ -1,5 +1,5 @@
1
1
  import { loadConfig } from "../../config/load.mjs";
2
- export function checkEscalateTriggers(actionableThreads, resolutionOnlyThreads, actionableComments, changesRequestedReviews, failingChecks, threadAttempts, hasConflicts) {
2
+ export function checkEscalateTriggers(actionableThreads, threadAttempts) {
3
3
  const triggers = [];
4
4
  const maxAttempts = loadConfig().iterate.fixAttemptsPerThread;
5
5
  // Trigger 1: fix thrash — same thread dispatched too many times without resolving.
@@ -7,17 +7,7 @@ export function checkEscalateTriggers(actionableThreads, resolutionOnlyThreads,
7
7
  if (thrashThreads.length > 0) {
8
8
  triggers.push("fix-thrash");
9
9
  }
10
- // Trigger 2: PR-level CHANGES_REQUESTED with no inline threads/comments/CI to act on.
11
- // Skip when there are merge conflicts — fix_code handles conflict resolution, not escalation.
12
- if (changesRequestedReviews.length > 0 &&
13
- actionableThreads.length === 0 &&
14
- resolutionOnlyThreads.length === 0 &&
15
- actionableComments.length === 0 &&
16
- failingChecks.length === 0 &&
17
- !hasConflicts) {
18
- triggers.push("pr-level-changes-requested");
19
- }
20
- // Trigger 3: actionable thread has no file/line — cannot locate code to edit.
10
+ // Trigger 2: actionable thread has no file/line — cannot locate code to edit.
21
11
  const unlocatable = actionableThreads.filter((t) => t.path === null || t.line === null);
22
12
  if (unlocatable.length > 0) {
23
13
  triggers.push("thread-missing-location");
@@ -69,16 +59,25 @@ export function buildEscalateHumanMessage(escalate, pr) {
69
59
  lines.push("");
70
60
  for (const t of escalate.unresolvedThreads) {
71
61
  const loc = t.path ? `\`${t.path}:${t.line ?? "?"}\`` : "(no location)";
72
- const firstLine = t.body.split("\n")[0] ?? "";
73
- lines.push(`- thread \`${t.id}\` — ${loc} (@${t.author}): ${firstLine}`);
62
+ lines.push(`- thread \`${t.id}\` — ${loc} (@${t.author}):`);
63
+ lines.push("");
64
+ for (const bodyLine of t.body.split("\n"))
65
+ lines.push(` > ${bodyLine}`);
66
+ lines.push("");
74
67
  }
75
68
  for (const r of escalate.changesRequestedReviews) {
76
- const firstLine = r.body.split("\n")[0] ?? "";
77
- lines.push(`- review \`${r.id}\` (@${r.author}): ${firstLine}`);
69
+ lines.push(`- review \`${r.id}\` (@${r.author}):`);
70
+ lines.push("");
71
+ for (const bodyLine of r.body.split("\n"))
72
+ lines.push(` > ${bodyLine}`);
73
+ lines.push("");
78
74
  }
79
75
  for (const c of escalate.ambiguousComments) {
80
- const firstLine = c.body.split("\n")[0] ?? "";
81
- lines.push(`- comment \`${c.id}\` (@${c.author}): ${firstLine}`);
76
+ lines.push(`- comment \`${c.id}\` (@${c.author}):`);
77
+ lines.push("");
78
+ for (const bodyLine of c.body.split("\n"))
79
+ lines.push(` > ${bodyLine}`);
80
+ lines.push("");
82
81
  }
83
82
  }
84
83
  if (escalate.thrashHistory && escalate.thrashHistory.length > 0) {
@@ -107,9 +106,6 @@ export function buildEscalateSuggestion(triggers, detail) {
107
106
  if (triggers.includes("fix-thrash")) {
108
107
  return "Same thread(s) reached the automated attempt limit — treat this as a manual handoff. Apply the fix by hand.";
109
108
  }
110
- if (triggers.includes("pr-level-changes-requested")) {
111
- return "Reviewer requested changes but left no inline comments — read the review and act manually.";
112
- }
113
109
  if (triggers.includes("thread-missing-location")) {
114
110
  return "Review thread has no file/line reference — automated location routing failed and manual handling is required.";
115
111
  }
@@ -17,7 +17,7 @@ export async function handleFixCode(ctx) {
17
17
  currentAttempts[t.id] = (currentAttempts[t.id] ?? 0) + 1;
18
18
  }
19
19
  }
20
- const escalateTriggers = checkEscalateTriggers(report.threads.actionable, report.threads.resolutionOnly, report.comments.actionable, report.changesRequestedReviews, failingChecks, currentAttempts, report.mergeStatus.status === "CONFLICTS");
20
+ const escalateTriggers = checkEscalateTriggers(report.threads.actionable, currentAttempts);
21
21
  if (escalateTriggers.triggers.length > 0) {
22
22
  const escalateBase = {
23
23
  triggers: escalateTriggers.triggers,
@@ -53,14 +53,15 @@ export async function handleFixCode(ctx) {
53
53
  const checks = toAgentChecks(failingChecks);
54
54
  const { changesRequestedReviews } = report;
55
55
  const hasConflicts = report.mergeStatus.status === "CONFLICTS";
56
- const hasReviewRequestedCodeLikeChanges = changesRequestedReviews.length > 0 &&
57
- (actionableComments.length > 0 || resolutionOnlyThreads.length > 0);
58
- const hasGuaranteedPush = threads.length > 0 || checks.length > 0 || hasConflicts || hasReviewRequestedCodeLikeChanges;
59
- const shouldPush = hasGuaranteedPush;
60
- // Only cancel in-progress runs for paths that produce a new code commit. A
61
- // conflict-only rebase push will supersede any in-progress run on its own.
62
- const hasCodeLikePush = threads.length > 0 || checks.length > 0 || hasReviewRequestedCodeLikeChanges;
63
- const inProgressRunIds = hasCodeLikePush ? buildInProgressRunIds(report, cancelledSet) : [];
56
+ // Only surface in-progress runs when a push is plausible — resolution-only and
57
+ // summary-only iterations have no path to a push, so listing runs would prompt
58
+ // unnecessary cancellation.
59
+ const pushLikely = threads.length > 0 ||
60
+ checks.length > 0 ||
61
+ hasConflicts ||
62
+ changesRequestedReviews.length > 0 ||
63
+ actionableComments.length > 0;
64
+ const inProgressRunIds = pushLikely ? buildInProgressRunIds(report, cancelledSet) : [];
64
65
  const commentMinimizeIds = report.comments.minimizeIds ?? actionableComments.map((c) => c.id);
65
66
  const allCommentIds = [...commentMinimizeIds, ...reviewSummaryIds];
66
67
  const resolveCommand = buildResolveCommand(threads, resolutionOnlyThreads, allCommentIds, changesRequestedReviews, checks, prNumber, cliRunner);
@@ -70,7 +71,17 @@ export async function handleFixCode(ctx) {
70
71
  `review IDs were also in minimize/comment IDs and were dropped from --dismiss-review-ids: ` +
71
72
  `${overlappingReviewIds.join(", ")}\n`);
72
73
  }
73
- if (baseLookup.isFallback && shouldPush) {
74
+ // Safety: if the base branch is unknown, escalate when a push is plausible — the agent
75
+ // would need the correct base to rebase safely. This is a conservative guard, not a
76
+ // prediction that the agent *will* push. Intentionally broader than `pushLikely` above:
77
+ // resolution-only threads also need a known base in case the agent does push.
78
+ const pushIsPlausible = threads.length > 0 ||
79
+ checks.length > 0 ||
80
+ hasConflicts ||
81
+ changesRequestedReviews.length > 0 ||
82
+ actionableComments.length > 0 ||
83
+ resolutionOnlyThreads.length > 0;
84
+ if (baseLookup.isFallback && pushIsPlausible) {
74
85
  const fallbackEscalateBase = {
75
86
  triggers: ["base-branch-unknown"],
76
87
  unresolvedThreads: [...threads, ...resolutionOnlyThreads.map(toAgentThread)],
@@ -89,7 +100,7 @@ export async function handleFixCode(ctx) {
89
100
  }
90
101
  const firstLookThreads = report.threads.firstLook;
91
102
  const firstLookComments = report.comments.firstLook;
92
- const instructions = buildFixInstructions(threads, actionableComments, checks, changesRequestedReviews, baseLookup.branch, resolveCommand, hasConflicts, prNumber, cancelled.length, firstLookThreads, firstLookComments, firstLookSummaries, editedSummaries, inProgressRunIds, resolutionOnlyThreads, cliRunner, shouldPush);
103
+ const instructions = buildFixInstructions(threads, actionableComments, checks, changesRequestedReviews, baseLookup.branch, resolveCommand, hasConflicts, prNumber, cancelled.length, firstLookThreads, firstLookComments, firstLookSummaries, editedSummaries, inProgressRunIds, resolutionOnlyThreads, cliRunner);
93
104
  return applyStallGuard(stallKey, stallTimeoutSeconds, headSha, base, prNumber, {
94
105
  ...base,
95
106
  baseBranch: baseLookup.branch,
@@ -45,6 +45,7 @@ export async function runIterate(opts) {
45
45
  state: report.mergeStatus.state,
46
46
  summary: buildSummary(report),
47
47
  baseBranch: report.baseBranch,
48
+ branchProtection: report.branchProtection,
48
49
  checks: buildRelevantChecks(report),
49
50
  action: "cancel",
50
51
  reason: report.mergeStatus.state === "MERGED" ? "merged" : "closed",
@@ -84,6 +85,7 @@ export async function runIterate(opts) {
84
85
  remainingSeconds: readyState.remainingSeconds,
85
86
  summary: buildSummary(report),
86
87
  baseBranch: report.baseBranch,
88
+ branchProtection: report.branchProtection,
87
89
  checks: buildRelevantChecks(report),
88
90
  };
89
91
  if (readyState.shouldCancel) {
@@ -2,12 +2,11 @@ import { renderShellCommand } from "../../cli/runner.mjs";
2
2
  import { buildFailingCheckInstructions } 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 { buildCommitSuggestionInstruction } from "../commit-suggestion-instruction.mjs";
5
- export const FIX_INSTRUCTION_STOP_AFTER_PUSH = "Stop this iteration — CI needs time to run on the new push before the next tick.";
6
- export const FIX_INSTRUCTION_STOP_BEFORE_NEXT_TICK = "Stop this iteration before the next tick.";
5
+ export 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
  /**
8
7
  * Render a resolve command as a shell snippet. Wraps `$DISMISS_MESSAGE`, `$HEAD_SHA`, and
9
- * whitespace-bearing argv entries for placeholder substitution. `$HEAD_SHA` is appended separately
10
- * when `requiresHeadSha` is set.
8
+ * whitespace-bearing argv entries for placeholder substitution. `$HEAD_SHA` is never in `argv` —
9
+ * `renderResolveCommand` appends `--require-sha "$HEAD_SHA"` when `requiresHeadSha` is set.
11
10
  */
12
11
  export function renderResolveCommand(rc) {
13
12
  const parts = [...rc.argv];
@@ -16,12 +15,41 @@ export function renderResolveCommand(rc) {
16
15
  }
17
16
  return renderShellCommand(parts);
18
17
  }
19
- export function buildFixInstructions(threads, actionableComments, checks, changesRequestedReviews, baseBranch, resolveCommand, hasConflicts, prNumber, cancelledCount, firstLookThreads = [], firstLookComments = [], firstLookSummaries = [], editedSummaries = [], inProgressRunIds = [], resolutionOnlyThreads = [], runner, needsPushInput) {
18
+ export function buildFixInstructions(threads, actionableComments, checks, changesRequestedReviews, baseBranch, resolveCommand, hasConflicts, prNumber, cancelledCount, firstLookThreads = [], firstLookComments = [], firstLookSummaries = [], editedSummaries = [], inProgressRunIds = [], resolutionOnlyThreads = [], runner) {
20
19
  const instructions = [];
21
- const hasCodeWork = threads.length > 0 || checks.length > 0;
22
- const needsPush = needsPushInput ?? (hasCodeWork || hasConflicts);
20
+ const hasNonConflictHints = threads.length > 0 ||
21
+ checks.length > 0 ||
22
+ changesRequestedReviews.length > 0 ||
23
+ actionableComments.length > 0;
24
+ // Leading decision or mandatory instruction depending on what actionable items exist.
25
+ if (hasNonConflictHints) {
26
+ const actionableSections = [];
27
+ if (threads.length > 0)
28
+ actionableSections.push("`## Review threads`");
29
+ if (actionableComments.length > 0)
30
+ actionableSections.push("`## Actionable comments`");
31
+ if (checks.length > 0)
32
+ actionableSections.push("`## Failing checks`");
33
+ if (changesRequestedReviews.length > 0)
34
+ actionableSections.push("`## Changes-requested reviews`");
35
+ const sectionRef = actionableSections.length > 0 ? `under ${actionableSections.join(", ")}` : "above";
36
+ const resolveClause = resolveCommand.hasMutations ? ", then run the `resolve:` command" : "";
37
+ if (hasConflicts) {
38
+ // Conflicts make push mandatory regardless of whether code edits are needed.
39
+ instructions.push(`The branch has merge conflicts that require rebase before merging. Apply any code edits for items ${sectionRef}, commit if edits were made, rebase onto \`origin/${baseBranch}\` per your repository's conventions, push${resolveClause}.`);
40
+ }
41
+ else {
42
+ const skipClause = resolveCommand.hasMutations
43
+ ? "skip cancellation/commit/push and run the `resolve:` command"
44
+ : "no push is needed";
45
+ instructions.push(`Decide for each item ${sectionRef} whether a code change is warranted. **If any code changes are needed:** cancel in-progress runs first, apply edits, commit, rebase, push${resolveClause}. **If no code changes are needed:** ${skipClause}.`);
46
+ }
47
+ }
48
+ else if (hasConflicts) {
49
+ instructions.push(`The branch has merge conflicts — rebase onto \`origin/${baseBranch}\` per your repository's conventions to resolve them, then push.`);
50
+ }
23
51
  if (inProgressRunIds.length > 0) {
24
- instructions.push(`Cancel in-progress CI runs first: for each ID under \`## In-progress runs\`, run \`gh run cancel <id>\` before applying code fixes. If \`gh\` reports a run is already completed, ignore it and continue with the next ID.`);
52
+ instructions.push(`If you decide to push new commits: cancel each in-progress run listed under \`## In-progress runs\` before applying code fixes (e.g. \`gh run cancel <id>\`). Runs may complete between the tick and your action; treat cancellation errors on already-finished runs as non-fatal. Skip this step if you are only resolving threads without pushing — the existing runs remain relevant.`);
25
53
  }
26
54
  const hasSuggestions = threads.some((t) => t.suggestion);
27
55
  if (hasSuggestions) {
@@ -40,25 +68,22 @@ export function buildFixInstructions(threads, actionableComments, checks, change
40
68
  if (changesRequestedReviews.length > 0) {
41
69
  instructions.push(`For each bullet under \`## Changes-requested reviews\` above: read the review body and apply the requested changes.`);
42
70
  }
43
- if (needsPush && (hasCodeWork || changesRequestedReviews.length > 0)) {
44
- instructions.push(`Commit changed files: \`git add <files> && git commit -m "<descriptive message>"\``);
45
- }
46
- if (changesRequestedReviews.length > 0) {
47
- instructions.push(`Keep the PR title and description current: if the changes alter the PR's scope or intent, run \`gh pr edit ${prNumber} --title "<new title>" --body "<new body>"\` to reflect them. Skip if the existing title/body still accurately describe the PR.`);
48
- }
49
- if (!needsPush && resolveCommand.requiresHeadSha) {
50
- instructions.push("Capture the current HEAD SHA before resolving with: `HEAD_SHA=$(git rev-parse HEAD)`.");
71
+ if (hasNonConflictHints) {
72
+ instructions.push(`If you applied code edits: commit them with a descriptive message, then rebase onto \`origin/${baseBranch}\` per your repository's conventions before pushing.`);
51
73
  }
52
- if (needsPush) {
53
- const captureHint = resolveCommand.requiresHeadSha
54
- ? ` — capture \`HEAD_SHA=$(git rev-parse HEAD)\``
55
- : "";
56
- if (hasConflicts) {
57
- instructions.push(`Rebase with conflict resolution: run \`git fetch origin && git rebase origin/${baseBranch}\`. If the rebase halts with conflicts, edit the conflicted files to resolve them, \`git add <files>\`, then \`git rebase --continue\`. Repeat until the rebase completes, then \`git push --force-with-lease\`${captureHint}.`);
74
+ if (resolveCommand.hasMutations) {
75
+ const substituteParts = [];
76
+ if (resolveCommand.requiresHeadSha) {
77
+ substituteParts.push(`\`$HEAD_SHA\` with the pushed commit SHA (or \`$(git rev-parse HEAD)\` if you did not push)`);
58
78
  }
59
- else {
60
- instructions.push(`Rebase and push: \`git fetch origin && git rebase origin/${baseBranch} && git push --force-with-lease\`${captureHint}`);
79
+ if (resolveCommand.requiresDismissMessage) {
80
+ substituteParts.push(`\`$DISMISS_MESSAGE\` with a one-sentence description of what you changed`);
61
81
  }
82
+ const substituteHint = substituteParts.length > 0 ? `, substituting ${substituteParts.join(" and ")}` : "";
83
+ instructions.push(`Run the \`resolve:\` command shown above${substituteHint}.`);
84
+ }
85
+ if (cancelledCount > 0) {
86
+ instructions.push(`Do not re-run \`gh run cancel\` on the IDs listed under \`## Cancelled runs\` — those runs were already cancelled by the CLI before this turn.`);
62
87
  }
63
88
  const firstLookTotal = firstLookThreads.length + firstLookComments.length;
64
89
  if (firstLookTotal > 0) {
@@ -73,29 +98,9 @@ export function buildFixInstructions(threads, actionableComments, checks, change
73
98
  if (editedTotal > 0) {
74
99
  instructions.push(`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.`);
75
100
  }
76
- if (resolveCommand.hasMutations) {
77
- const substituteParts = [];
78
- if (resolveCommand.requiresHeadSha) {
79
- const shaSource = needsPush ? "pushed commit SHA" : "current HEAD SHA";
80
- substituteParts.push(`"$HEAD_SHA" with the ${shaSource}`);
81
- }
82
- if (resolveCommand.requiresDismissMessage) {
83
- substituteParts.push(`$DISMISS_MESSAGE with a one-sentence description of what you changed`);
84
- }
85
- const substituteHint = substituteParts.length > 0 ? `, substituting ${substituteParts.join(" and ")}` : "";
86
- instructions.push(`Run the \`resolve:\` command shown above${substituteHint}.`);
87
- }
88
- if (needsPush && cancelledCount > 0) {
89
- instructions.push(`Do not re-run \`gh run cancel\` on the IDs listed under \`## Cancelled runs\` — the CLI cancelled those runs before your push, and your push has already triggered new runs with different IDs.`);
90
- }
91
101
  if (resolveCommand.hasMutations) {
92
102
  instructions.push(buildShepherdJournalInstruction(prNumber, SHEPHERD_JOURNAL_REFERENCE_GUIDANCE_THREADS_AND_COMMENTS_IN_ITEM_HEADINGS));
93
103
  }
94
- if (needsPush) {
95
- instructions.push(FIX_INSTRUCTION_STOP_AFTER_PUSH);
96
- }
97
- else {
98
- instructions.push(FIX_INSTRUCTION_STOP_BEFORE_NEXT_TICK);
99
- }
104
+ instructions.push(FIX_INSTRUCTION_STOP);
100
105
  return instructions;
101
106
  }
@@ -89,6 +89,7 @@ function makeReport(overrides = {}) {
89
89
  firstLookSummaries: [],
90
90
  editedSummaries: [],
91
91
  approvedReviews: [],
92
+ branchProtection: null,
92
93
  ...overrides,
93
94
  };
94
95
  }
@@ -0,0 +1,32 @@
1
+ import { runIterate } from "./iterate/index.mjs";
2
+ function sleep(ms) {
3
+ return new Promise((resolve) => setTimeout(resolve, ms));
4
+ }
5
+ function writeTickProgress(tick, elapsedSeconds, sleepSeconds, verbose) {
6
+ if (process.stderr.isTTY || verbose) {
7
+ process.stderr.write(`[poll tick ${tick} / +${elapsedSeconds}s] WAIT — sleeping ${sleepSeconds}s\n`);
8
+ }
9
+ }
10
+ const MAX_TIMER_MS = 2 ** 31 - 1;
11
+ export async function runPoll(opts) {
12
+ const { intervalSeconds, timeoutSeconds, ...iterateOpts } = opts;
13
+ const intervalMs = Math.min(intervalSeconds * 1000, MAX_TIMER_MS);
14
+ const timeoutMs = Math.min(timeoutSeconds * 1000, MAX_TIMER_MS);
15
+ const start = Date.now();
16
+ let tick = 0;
17
+ let lastResult;
18
+ const verbose = opts.verbose === true;
19
+ while (true) {
20
+ tick += 1;
21
+ lastResult = await runIterate(iterateOpts);
22
+ if (lastResult.action !== "wait")
23
+ return lastResult;
24
+ const elapsedMs = Date.now() - start;
25
+ const remainingMs = timeoutMs - elapsedMs;
26
+ if (remainingMs <= 0)
27
+ return lastResult;
28
+ const nextSleepMs = Math.min(intervalMs, remainingMs);
29
+ writeTickProgress(tick, Math.round(elapsedMs / 1000), Math.round(nextSleepMs / 1000), verbose);
30
+ await sleep(nextSleepMs);
31
+ }
32
+ }
@@ -0,0 +1,77 @@
1
+ import { vi, beforeEach, afterEach } from "vitest";
2
+ vi.mock("./iterate/index.mts", () => ({ runIterate: vi.fn() }));
3
+ import { runIterate } from "./iterate/index.mjs";
4
+ const mockRunIterate = vi.mocked(runIterate);
5
+ function makeWaitResult(overrides = {}) {
6
+ return {
7
+ action: "wait",
8
+ pr: 42,
9
+ repo: "owner/repo",
10
+ status: "IN_PROGRESS",
11
+ state: "OPEN",
12
+ mergeStateStatus: "BLOCKED",
13
+ mergeStatus: "BLOCKED",
14
+ reviewDecision: "REVIEW_REQUIRED",
15
+ blockingBotReviewInProgress: false,
16
+ isDraft: false,
17
+ shouldCancel: false,
18
+ remainingSeconds: 0,
19
+ summary: { passing: 2, failing: 0, inProgress: 1, skipped: 0, filtered: 0 },
20
+ baseBranch: "main",
21
+ checks: [],
22
+ log: "WAIT: 2 passing, 1 in-progress",
23
+ ...overrides,
24
+ };
25
+ }
26
+ function makeCancelResult() {
27
+ return {
28
+ action: "cancel",
29
+ pr: 42,
30
+ repo: "owner/repo",
31
+ status: "READY",
32
+ state: "MERGED",
33
+ mergeStateStatus: "CLEAN",
34
+ mergeStatus: "CLEAN",
35
+ reviewDecision: "APPROVED",
36
+ blockingBotReviewInProgress: false,
37
+ isDraft: false,
38
+ shouldCancel: true,
39
+ remainingSeconds: 0,
40
+ summary: { passing: 3, failing: 0, inProgress: 0, skipped: 0, filtered: 0 },
41
+ baseBranch: "main",
42
+ checks: [],
43
+ reason: "merged",
44
+ log: "CANCEL: PR #42 is merged — stopping",
45
+ };
46
+ }
47
+ function makeMarkReadyResult() {
48
+ return {
49
+ action: "mark_ready",
50
+ pr: 42,
51
+ repo: "owner/repo",
52
+ status: "READY",
53
+ state: "OPEN",
54
+ mergeStateStatus: "CLEAN",
55
+ mergeStatus: "CLEAN",
56
+ reviewDecision: "APPROVED",
57
+ blockingBotReviewInProgress: false,
58
+ isDraft: false,
59
+ shouldCancel: false,
60
+ remainingSeconds: 0,
61
+ summary: { passing: 3, failing: 0, inProgress: 0, skipped: 0, filtered: 0 },
62
+ baseBranch: "main",
63
+ checks: [],
64
+ markedReady: true,
65
+ log: "MARKED READY: PR #42 converted from draft to ready for review",
66
+ };
67
+ }
68
+ function registerPollHooks() {
69
+ beforeEach(() => {
70
+ vi.clearAllMocks();
71
+ vi.useFakeTimers();
72
+ });
73
+ afterEach(() => {
74
+ vi.useRealTimers();
75
+ });
76
+ }
77
+ export { mockRunIterate, makeWaitResult, makeCancelResult, makeMarkReadyResult, registerPollHooks };
@@ -37,16 +37,13 @@ export function buildFetchInstructions(prNumber, result, runner) {
37
37
  }
38
38
  if (hasCodeItems) {
39
39
  instructions.push(`Read and edit each file referenced under \`## Actionable Review Threads\`, \`## Actionable PR Comments\`, and \`## Pending CHANGES_REQUESTED reviews\` above. Reclassify each fixed item as Fixed. If an item is too complex to address, leave it as Actionable for the final report.`);
40
- instructions.push(`Commit changed files: \`git add <files>\` (not \`git add -A\`) \`&& git commit -m "<descriptive message>"\`.`);
41
- instructions.push(`Keep the PR title and description current: if the fixes alter the PR's scope or intent, run \`gh pr edit ${prNumber} --title "<new title>" --body "<new body>"\` to reflect them. Skip if the existing title/body still accurately describe the PR.`);
42
- instructions.push(`Rebase and push: \`BASE_BRANCH=$(gh pr view ${prNumber} --json baseRefName --jq .baseRefName) && git fetch origin && git rebase "origin/$BASE_BRANCH" && git push --force-with-lease\`.`);
43
- instructions.push(`Cancel stale in-progress runs: \`BRANCH=$(git rev-parse --abbrev-ref HEAD) && CURRENT_SHA=$(git rev-parse HEAD) && gh run list --branch "$BRANCH" --status in_progress --json databaseId,headSha --jq ".[] | select(.headSha != \\"$CURRENT_SHA\\") | .databaseId" | xargs -I{} gh run cancel {}\`.`);
40
+ instructions.push(`If you applied code edits: commit them with a descriptive message, cancel any stale in-progress runs, then rebase and push per your repository's conventions.`);
44
41
  }
45
42
  if (resolutionOnlyThreads.length > 0) {
46
43
  instructions.push(`Resolve each thread under \`## Review threads to resolve\` with \`--resolve-thread-ids\`. These threads are already outdated or minimized, so no code edit is required for them unless their body reveals separate work you choose to do.`);
47
44
  }
48
45
  const requireShaHint = hasCodeItems
49
- ? ` Include \`--require-sha $(git rev-parse HEAD)\` only when the rebase-and-push step above ran.`
46
+ ? ` Include \`--require-sha $(git rev-parse HEAD)\` only when you pushed new commits.`
50
47
  : "";
51
48
  const dismissNote = changesRequestedReviews.length > 0
52
49
  ? ` For \`--dismiss-review-ids\`: \`--message\` is required with one specific sentence describing the fix or the reason for not acting (no boilerplate like "address review comments"); omit \`--message\` when not dismissing. Review-summary IDs (\`PRR_…\` from \`## Review summaries\`) go into \`--minimize-comment-ids\`, never \`--dismiss-review-ids\`.`
@@ -0,0 +1,38 @@
1
+ export function mapAuthorType(typeName) {
2
+ if (typeName === "User" || typeName === "Bot")
3
+ return typeName;
4
+ return "Unknown";
5
+ }
6
+ export function parseCreatedAt(iso) {
7
+ const ms = new Date(iso).getTime();
8
+ return Number.isFinite(ms) ? Math.floor(ms / 1000) : 0;
9
+ }
10
+ export function extractRunId(url) {
11
+ if (!url)
12
+ return null;
13
+ const m = /\/runs\/(\d+)/.exec(url);
14
+ return m ? (m[1] ?? null) : null;
15
+ }
16
+ export function extractCheckRunSummary(title, summary) {
17
+ const t = title?.trim();
18
+ if (t)
19
+ return t;
20
+ const firstLine = summary
21
+ ?.split("\n")
22
+ ?.find((l) => l.trim() !== "")
23
+ ?.trim();
24
+ return firstLine || undefined;
25
+ }
26
+ export function mapStatusContextState(state) {
27
+ switch (state) {
28
+ case "SUCCESS":
29
+ return { status: "COMPLETED", conclusion: "SUCCESS" };
30
+ case "FAILURE":
31
+ case "ERROR":
32
+ return { status: "COMPLETED", conclusion: "FAILURE" };
33
+ case "PENDING":
34
+ case "EXPECTED":
35
+ default:
36
+ return { status: "IN_PROGRESS", conclusion: null };
37
+ }
38
+ }
@@ -1,3 +1,4 @@
1
+ import { mapAuthorType, parseCreatedAt, extractRunId, extractCheckRunSummary, mapStatusContextState, } from "./batch-parser-helpers.mjs";
1
2
  export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes, rawReviewSummaryNodes, rawApprovedReviewNodes, rawCheckNodes) {
2
3
  const reviewRequests = (raw.reviewRequests?.nodes ?? []).flatMap((n) => {
3
4
  const login = n.requestedReviewer?.login ?? n.requestedReviewer?.name;
@@ -92,6 +93,16 @@ export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes,
92
93
  }
93
94
  return [];
94
95
  });
96
+ const rawProtection = raw.baseRef?.branchProtectionRule ?? null;
97
+ const branchProtection = rawProtection
98
+ ? {
99
+ requiresApprovingReviews: rawProtection.requiresApprovingReviews,
100
+ requiredApprovingReviewCount: rawProtection.requiredApprovingReviewCount,
101
+ requiresConversationResolution: rawProtection.requiresConversationResolution,
102
+ requiresStatusChecks: rawProtection.requiresStatusChecks,
103
+ requiredStatusCheckContexts: rawProtection.requiredStatusCheckContexts ?? [],
104
+ }
105
+ : null;
95
106
  return {
96
107
  nodeId: raw.id,
97
108
  number: raw.number,
@@ -112,43 +123,6 @@ export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes,
112
123
  reviewSummaries,
113
124
  approvedReviews,
114
125
  checks,
126
+ branchProtection,
115
127
  };
116
128
  }
117
- function mapAuthorType(typeName) {
118
- if (typeName === "User" || typeName === "Bot")
119
- return typeName;
120
- return "Unknown";
121
- }
122
- function parseCreatedAt(iso) {
123
- const ms = new Date(iso).getTime();
124
- return Number.isFinite(ms) ? Math.floor(ms / 1000) : 0;
125
- }
126
- function extractRunId(url) {
127
- if (!url)
128
- return null;
129
- const m = /\/runs\/(\d+)/.exec(url);
130
- return m ? (m[1] ?? null) : null;
131
- }
132
- function extractCheckRunSummary(title, summary) {
133
- const t = title?.trim();
134
- if (t)
135
- return t;
136
- const firstLine = summary
137
- ?.split("\n")
138
- ?.find((l) => l.trim() !== "")
139
- ?.trim();
140
- return firstLine || undefined;
141
- }
142
- function mapStatusContextState(state) {
143
- switch (state) {
144
- case "SUCCESS":
145
- return { status: "COMPLETED", conclusion: "SUCCESS" };
146
- case "FAILURE":
147
- case "ERROR":
148
- return { status: "COMPLETED", conclusion: "FAILURE" };
149
- case "PENDING":
150
- case "EXPECTED":
151
- default:
152
- return { status: "IN_PROGRESS", conclusion: null };
153
- }
154
- }
@@ -24,6 +24,15 @@ query BatchPr(
24
24
  nameWithOwner
25
25
  }
26
26
  baseRefName
27
+ baseRef {
28
+ branchProtectionRule {
29
+ requiresApprovingReviews
30
+ requiredApprovingReviewCount
31
+ requiresConversationResolution
32
+ requiresStatusChecks
33
+ requiredStatusCheckContexts
34
+ }
35
+ }
27
36
  # Not paginated — capped at 50; PRs with more pending reviewers truncate silently.
28
37
  reviewRequests(last: 50) {
29
38
  nodes {