pr-shepherd 0.32.5 → 0.34.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 (37) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +9 -4
  3. package/bin/cli/default-poll.mjs +2 -1
  4. package/bin/cli/duration-flag.mjs +5 -4
  5. package/bin/cli/{exit-codes.mjs → duration.mjs} +0 -26
  6. package/bin/cli/handlers.mjs +9 -8
  7. package/bin/cli/help-command-pages.mjs +17 -72
  8. package/bin/cli/help-iterate-poll-pages.mjs +72 -0
  9. package/bin/cli/help-top-page.mjs +8 -5
  10. package/bin/cli/iterate-emitter.mjs +2 -2
  11. package/bin/cli/iterate-flags.mjs +1 -1
  12. package/bin/cli/journal-handler.mjs +40 -7
  13. package/bin/cli/poll-handler.mjs +1 -1
  14. package/bin/cli/resolve-validators.mjs +3 -2
  15. package/bin/cli-parser.mjs +5 -4
  16. package/bin/commands/check.mjs +2 -1
  17. package/bin/commands/commit-suggestion.mjs +18 -17
  18. package/bin/commands/iterate/check-instructions.mjs +39 -5
  19. package/bin/commands/iterate/fix-code.mjs +4 -1
  20. package/bin/commands/iterate/index.mjs +5 -3
  21. package/bin/commands/iterate/render.mjs +9 -23
  22. package/bin/commands/mark-files-as-viewed.mjs +8 -8
  23. package/bin/commands/resolve-mutate.mjs +2 -1
  24. package/bin/comments/resolve.mjs +3 -1
  25. package/bin/config.json +2 -1
  26. package/bin/exit-codes.mjs +74 -0
  27. package/bin/github/batch-response.mjs +21 -0
  28. package/bin/github/batch.mjs +12 -22
  29. package/bin/github/errors.mjs +28 -2
  30. package/bin/github/graphql-http.mjs +45 -13
  31. package/bin/github/graphql-response.mjs +47 -0
  32. package/bin/github/http-auth.mjs +2 -1
  33. package/bin/github/rest-http.mjs +19 -5
  34. package/bin/index.mjs +2 -1
  35. package/package.json +3 -3
  36. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  37. package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +1 -1
@@ -5,50 +5,51 @@ import { getRepoInfo, getCurrentPrNumber, getCurrentBranch } from "../github/cli
5
5
  import { fetchPrBatch } from "../github/batch.mjs";
6
6
  import { parseSuggestion, isCommittableSuggestion } from "../suggestions/parse.mjs";
7
7
  import { buildUnifiedDiff } from "../suggestions/patch.mjs";
8
+ import { EXIT, ShepherdError } from "../exit-codes.mjs";
8
9
  import { buildPrShepherdCommand } from "../cli/runner.mjs";
9
10
  const execFile = promisify(execFileCb);
10
11
  export async function runCommitSuggestion(opts) {
11
12
  if (!opts.threadId) {
12
- throw new Error("--thread-id is required");
13
+ throw new ShepherdError("--thread-id is required", EXIT.USAGE);
13
14
  }
14
15
  if (!opts.message || opts.message.trim() === "") {
15
- throw new Error("--message is required and must be non-empty");
16
+ throw new ShepherdError("--message is required and must be non-empty", EXIT.USAGE);
16
17
  }
17
18
  const repo = await getRepoInfo();
18
19
  const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
19
20
  if (prNumber === null) {
20
- throw new Error("No open PR found for current branch. Pass a PR number explicitly.");
21
+ throw new ShepherdError("No open PR found for current branch. Pass a PR number explicitly.", EXIT.UNAVAILABLE);
21
22
  }
22
23
  const currentBranch = await getCurrentBranch();
23
24
  const { stdout: localHeadOut } = await execFile("git", ["rev-parse", "HEAD"]);
24
25
  const localHeadSha = localHeadOut.trim();
25
26
  const { data } = await fetchPrBatch(prNumber, repo);
26
27
  if (!data.headRepoWithOwner) {
27
- throw new Error(`PR #${prNumber} head repository is unavailable (fork may have been deleted).`);
28
+ throw new ShepherdError(`PR #${prNumber} head repository is unavailable (fork may have been deleted).`, EXIT.UNAVAILABLE);
28
29
  }
29
30
  if (currentBranch !== data.headRefName) {
30
- throw new Error(`Current branch "${currentBranch}" does not match PR head branch "${data.headRefName}". ` +
31
- `Check out "${data.headRefName}" before applying suggestions.`);
31
+ throw new ShepherdError(`Current branch "${currentBranch}" does not match PR head branch "${data.headRefName}". ` +
32
+ `Check out "${data.headRefName}" before applying suggestions.`, EXIT.UNAVAILABLE);
32
33
  }
33
34
  if (localHeadSha !== data.headRefOid) {
34
- throw new Error(`Local HEAD ${localHeadSha} does not match PR head ${data.headRefOid}. ` +
35
- `Pull/rebase "${data.headRefName}" to the latest PR head and try again.`);
35
+ throw new ShepherdError(`Local HEAD ${localHeadSha} does not match PR head ${data.headRefOid}. ` +
36
+ `Pull/rebase "${data.headRefName}" to the latest PR head and try again.`, EXIT.UNAVAILABLE);
36
37
  }
37
38
  const thread = data.reviewThreads.find((t) => t.id === opts.threadId);
38
39
  if (!thread) {
39
- throw new Error(`Thread ${opts.threadId} not found on PR #${prNumber}.`);
40
+ throw new ShepherdError(`Thread ${opts.threadId} not found on PR #${prNumber}.`, EXIT.UNAVAILABLE);
40
41
  }
41
42
  if (thread.isResolved) {
42
- throw new Error(`Thread ${opts.threadId} is already resolved.`);
43
+ throw new ShepherdError(`Thread ${opts.threadId} is already resolved.`, EXIT.UNAVAILABLE);
43
44
  }
44
45
  if (thread.isOutdated) {
45
- throw new Error(`Thread ${opts.threadId} is outdated.`);
46
+ throw new ShepherdError(`Thread ${opts.threadId} is outdated.`, EXIT.UNAVAILABLE);
46
47
  }
47
48
  if (thread.isMinimized) {
48
- throw new Error(`Thread ${opts.threadId} is minimized.`);
49
+ throw new ShepherdError(`Thread ${opts.threadId} is minimized.`, EXIT.UNAVAILABLE);
49
50
  }
50
51
  if (!thread.path || thread.line === null) {
51
- throw new Error(`Thread ${opts.threadId} has no file/line anchor.`);
52
+ throw new ShepherdError(`Thread ${opts.threadId} has no file/line anchor.`, EXIT.UNAVAILABLE);
52
53
  }
53
54
  // Validate the target file is clean before generating the patch, so the emitted
54
55
  // `git add -- <file>` instruction cannot accidentally stage unrelated local edits.
@@ -59,15 +60,15 @@ export async function runCommitSuggestion(opts) {
59
60
  thread.path,
60
61
  ]);
61
62
  if (fileStatus.trim() !== "") {
62
- throw new Error(`${thread.path} has uncommitted changes. Commit or stash them before running commit-suggestion.`);
63
+ throw new ShepherdError(`${thread.path} has uncommitted changes. Commit or stash them before running commit-suggestion.`, EXIT.UNAVAILABLE);
63
64
  }
64
65
  const parsed = parseSuggestion(thread.body);
65
66
  if (!parsed) {
66
- throw new Error(`Thread ${opts.threadId} has no suggestion block in the comment body.`);
67
+ throw new ShepherdError(`Thread ${opts.threadId} has no suggestion block in the comment body.`, EXIT.UNAVAILABLE);
67
68
  }
68
69
  if (!isCommittableSuggestion(parsed)) {
69
- throw new Error(`Thread ${opts.threadId}'s suggestion body contains nested suggestion fencing or unbalanced ` +
70
- `3+ backtick fences — refusing to apply (could silently truncate).`);
70
+ throw new ShepherdError(`Thread ${opts.threadId}'s suggestion body contains nested suggestion fencing or unbalanced ` +
71
+ `3+ backtick fences — refusing to apply (could silently truncate).`, EXIT.UNAVAILABLE);
71
72
  }
72
73
  const startLine = thread.startLine ?? thread.line;
73
74
  const endLine = thread.line;
@@ -8,6 +8,40 @@ export function buildCrStaleClause(reviews) {
8
8
  : "";
9
9
  return bot + human;
10
10
  }
11
+ /**
12
+ * Build the optional behind-base push hint. Empty unless the branch is actually behind its base
13
+ * and the user configured a non-blank `iterate.behindBaseHint` — the CLI never prescribes
14
+ * rebase/merge mechanics itself (see "Keep skills and loop prompts minimal" in CLAUDE.md); this
15
+ * only echoes back the caller's own configured pointer. `hint` is trimmed and type-checked at the
16
+ * point of use (rather than at config load) so a malformed rc file value (non-string, or
17
+ * whitespace-only) degrades to "no hint" instead of rendering garbage into agent-facing text or
18
+ * discarding the rest of the user's config.
19
+ */
20
+ export function buildBehindBaseHintInstruction(baseBranch, hint, isBehind) {
21
+ const trimmedHint = typeof hint === "string" ? hint.trim() : "";
22
+ if (!isBehind || trimmedHint === "")
23
+ return [];
24
+ return [`The branch is behind \`origin/${baseBranch}\` — ${trimmedHint} before pushing.`];
25
+ }
26
+ /** Build the `Run the resolve: command` instruction, including its optional substitution hint. */
27
+ export function buildResolveCommandInstruction(resolveCommand) {
28
+ if (!resolveCommand.hasMutations)
29
+ return [];
30
+ const instructions = [];
31
+ if ((resolveCommand.replyThreadIds?.length ?? 0) > 0) {
32
+ instructions.push(`Before running the \`resolve:\` command, remove any thread from \`--reply-thread-ids\` if the latest visible comment in that thread is your own prior Shepherd reply. Do not reply to your own comments.`);
33
+ }
34
+ const substituteParts = [];
35
+ if (resolveCommand.requiresHeadSha) {
36
+ substituteParts.push(`\`$HEAD_SHA\` with the pushed commit SHA (or \`$(git rev-parse HEAD)\` if you did not push)`);
37
+ }
38
+ if (resolveCommand.requiresDismissMessage) {
39
+ substituteParts.push(`\`$DISMISS_MESSAGE\` with a one-sentence reply/description of what you changed`);
40
+ }
41
+ const substituteHint = substituteParts.length > 0 ? `, substituting ${substituteParts.join(" and ")}` : "";
42
+ instructions.push(`Run the \`resolve:\` command shown above${substituteHint}.`);
43
+ return instructions;
44
+ }
11
45
  export function buildFailingCheckInstructions(checks) {
12
46
  if (checks.length === 0)
13
47
  return [];
@@ -18,19 +52,19 @@ export function buildFailingCheckInstructions(checks) {
18
52
  const hasBare = checks.some((c) => !c.runId && !c.detailsUrl);
19
53
  const parts = [];
20
54
  if (hasRunId) {
21
- parts.push("read any included log excerpt first; fetch the full log with `gh run view <runId> --log-failed` when the excerpt is insufficient; decide whether to 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; if GitHub omits workflow-evaluation details from API/log output, open the run URL in the GitHub UI");
55
+ parts.push("read any included log excerpt first; fetch the full log with `gh run view <runId> --log-failed` if insufficient; rerun with `gh run rerun <runId> --failed` for transient infra failures, or apply a code fix for real test/build failures; if API/log output lacks detail, open the run URL in the GitHub UI");
22
56
  }
23
57
  if (hasCancelled) {
24
- parts.push("for `[conclusion: CANCELLED]` entries: these are not concurrency-superseded (superseded CANCELLED checks are excluded from this section and reported under `**superseded**` instead) — rerun with `gh run rerun <runId>` unless you are already pushing new commits this tick for other reasons, in which case the fresh run naturally supersedes it; do not silently treat a required CANCELLED check as resolved — do NOT confuse with IDs under `## Cancelled runs`");
58
+ parts.push("for `[conclusion: CANCELLED]` entries (not concurrency-superseded — see `**superseded**`): rerun with `gh run rerun <runId>` unless already pushing new commits this tick, in which case the fresh run supersedes it; don't treat as resolved — distinct from `## Cancelled runs`");
25
59
  }
26
60
  if (hasStartupFailure) {
27
- 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");
61
+ parts.push("for `[conclusion: STARTUP_FAILURE]` entries: inspect with `gh run view <runId>`, rerun with `gh run rerun <runId>` if warranted");
28
62
  }
29
63
  if (hasExternal) {
30
- parts.push("for `external` entries (no run ID, has URL): open the URL to inspect the failure");
64
+ parts.push("for `external` entries: open the URL to inspect the failure");
31
65
  }
32
66
  if (hasBare) {
33
- parts.push("for `(no runId)` entries: no log or URL is available — escalate to a human for manual investigation");
67
+ parts.push("for `(no runId)` entries: no log or URL available — escalate to a human");
34
68
  }
35
69
  return [`For each failing check under \`## Failing checks\`: ${parts.join("; ")}.`];
36
70
  }
@@ -11,6 +11,7 @@ import { tryCancelRun, buildAutoCancelRunIdsWithOptions, buildInProgressRunIds,
11
11
  import { annotationMarkerBody } from "../check-annotations.mjs";
12
12
  import { threadTranscriptBody } from "../../threads/transcript.mjs";
13
13
  import { isHumanAuthor, isConfiguredBotAuthor } from "../../comments/authors.mjs";
14
+ import { loadConfig } from "../../config/load.mjs";
14
15
  function nextFixAttempts(stored, headSha, threads) {
15
16
  const threadAttempts = stored ? { ...stored.threadAttempts } : {};
16
17
  const threadBodyHashes = stored?.threadBodyHashes
@@ -91,6 +92,8 @@ export async function handleFixCode(ctx) {
91
92
  const checks = toAgentChecks(failingChecks);
92
93
  const { changesRequestedReviews } = report;
93
94
  const hasConflicts = report.mergeStatus.status === "CONFLICTS";
95
+ const isBehind = report.mergeStatus.status === "BEHIND";
96
+ const { behindBaseHint } = loadConfig().iterate;
94
97
  // Only surface in-progress runs when a push is plausible — resolution-only and
95
98
  // summary-only iterations have no path to a push, so listing runs would prompt
96
99
  // unnecessary cancellation.
@@ -137,7 +140,7 @@ export async function handleFixCode(ctx) {
137
140
  }
138
141
  const firstLookThreads = report.threads.firstLook;
139
142
  const firstLookComments = report.comments.firstLook;
140
- const instructions = buildFixInstructions(threads, actionableComments, checks, changesRequestedReviews, baseLookup.branch, resolveCommand, hasConflicts, prNumber, cancelled.length, firstLookThreads, firstLookComments, firstLookSummaries, editedSummaries, inProgressRunIds, resolutionOnlyThreads, resolveOnlyCommand);
143
+ const instructions = buildFixInstructions(threads, actionableComments, checks, changesRequestedReviews, baseLookup.branch, resolveCommand, hasConflicts, prNumber, cancelled.length, firstLookThreads, firstLookComments, firstLookSummaries, editedSummaries, inProgressRunIds, resolutionOnlyThreads, resolveOnlyCommand, behindBaseHint, isBehind);
141
144
  const prospectiveResult = {
142
145
  ...base,
143
146
  baseBranch: baseLookup.branch,
@@ -4,6 +4,7 @@ import { getCurrentPrNumber } from "../../github/client.mjs";
4
4
  import { graphql } from "../../github/http.mjs";
5
5
  import { MARK_PR_READY_MUTATION } from "../../github/queries.mjs";
6
6
  import { loadConfig } from "../../config/load.mjs";
7
+ import { EXIT, ShepherdError } from "../../exit-codes.mjs";
7
8
  import { getCurrentHeadSha, buildSummary, buildRelevantChecks, buildActiveChecks, buildWaitLog, buildSuppressedCheckFields, buildTerminalCancelResult, } from "./helpers.mjs";
8
9
  import { classifyReviewSummaries } from "./classify.mjs";
9
10
  import { applyStallGuard } from "./stall.mjs";
@@ -17,8 +18,9 @@ export async function runIterate(opts) {
17
18
  const readyDelaySeconds = opts.readyDelaySeconds ?? config.watch.readyDelayMinutes * 60;
18
19
  const stallTimeoutSeconds = opts.stallTimeoutSeconds ?? config.iterate.stallTimeoutMinutes * 60;
19
20
  const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
20
- if (prNumber === null)
21
- throw new Error("No open PR found for current branch. Pass a PR number explicitly.");
21
+ if (prNumber === null) {
22
+ throw new ShepherdError("No open PR found for current branch. Pass a PR number explicitly.", EXIT.UNAVAILABLE);
23
+ }
22
24
  const neverCancelRuns = opts.neverCancelRuns ?? config.actions.neverCancelRuns;
23
25
  const report = await runCheck({
24
26
  ...opts,
@@ -28,7 +30,7 @@ export async function runIterate(opts) {
28
30
  });
29
31
  const [repoOwner, repoName] = report.repo.split("/");
30
32
  if (!repoOwner || !repoName) {
31
- throw new Error(`Unexpected repo format: "${report.repo}" (expected "owner/name")`);
33
+ throw new ShepherdError(`Unexpected repo format: "${report.repo}" (expected "owner/name")`, EXIT.DATAERR);
32
34
  }
33
35
  const stallKey = { owner: repoOwner, repo: repoName, pr: prNumber };
34
36
  if (report.mergeStatus.state !== "OPEN") {
@@ -1,5 +1,5 @@
1
1
  import { renderShellCommand } from "../../cli/runner.mjs";
2
- import { buildFailingCheckInstructions, buildCrStaleClause } from "./check-instructions.mjs";
2
+ import { buildFailingCheckInstructions, buildCrStaleClause, buildBehindBaseHintInstruction, buildResolveCommandInstruction, } 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
5
  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.";
@@ -10,8 +10,8 @@ export function renderResolveCommand(rc) {
10
10
  parts.push("--require-sha", "$HEAD_SHA");
11
11
  return renderShellCommand(parts);
12
12
  }
13
- export function buildFixInstructions(threads, actionableComments, checks, changesRequestedReviews, _baseBranch, // retained for call-site stability; rebase mechanics now defer to the caller
14
- resolveCommand, hasConflicts, prNumber, cancelledCount, firstLookThreads = [], firstLookComments = [], firstLookSummaries = [], editedSummaries = [], inProgressRunIds = [], resolutionOnlyThreads = [], resolveOnlyCommand) {
13
+ export function buildFixInstructions(threads, actionableComments, checks, changesRequestedReviews, baseBranch, resolveCommand, hasConflicts, prNumber, cancelledCount, firstLookThreads = [], firstLookComments = [], firstLookSummaries = [], editedSummaries = [], inProgressRunIds = [], resolutionOnlyThreads = [], resolveOnlyCommand, behindBaseHint = "", // iterate.behindBaseHint — see buildBehindBaseHintInstruction
14
+ isBehind = false) {
15
15
  const instructions = [];
16
16
  const hasNonConflictHints = threads.length > 0 ||
17
17
  checks.length > 0 ||
@@ -47,6 +47,7 @@ resolveCommand, hasConflicts, prNumber, cancelledCount, firstLookThreads = [], f
47
47
  else if (hasConflicts) {
48
48
  instructions.push(`The branch has merge conflicts that must be resolved before merging (see \`**branch**\` above). Resolve them and push.`);
49
49
  }
50
+ instructions.push(...buildBehindBaseHintInstruction(baseBranch, behindBaseHint, isBehind));
50
51
  if (inProgressRunIds.length > 0) {
51
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.`);
52
53
  }
@@ -54,15 +55,13 @@ resolveCommand, hasConflicts, prNumber, cancelledCount, firstLookThreads = [], f
54
55
  if (hasSuggestions)
55
56
  instructions.push(buildCommitSuggestionInstruction(prNumber, "## Review threads", false));
56
57
  if (threads.length > 0 || actionableComments.length > 0) {
57
- const fixSections = [];
58
- if (threads.length > 0)
59
- fixSections.push("`## Review threads`");
60
- if (actionableComments.length > 0)
61
- fixSections.push("`## Actionable comments`");
58
+ // Actionable comments carry no file/line location (unlike threads), so "referenced above"
59
+ // is only accurate when threads are present.
60
+ const filesRef = threads.length > 0 ? "each file referenced above" : "the relevant files";
62
61
  const suggestionFallback = hasSuggestions
63
62
  ? ` When applying a \`[suggestion]\` thread manually (e.g. after a failed \`commit-suggestion\` run), replace the exact line range shown in the heading (\`path:startLine-endLine\`) with the replacement shown in its \`Replaces lines …\` block verbatim — an empty replacement deletes those lines, a single blank line replaces the range with one blank line.`
64
63
  : "";
65
- instructions.push(`Apply code fixes: read and edit each file referenced under ${fixSections.join(" and ")} above.${suggestionFallback}`);
64
+ instructions.push(`Apply code fixes: read and edit ${filesRef}.${suggestionFallback}`);
66
65
  }
67
66
  if (resolutionOnlyThreads.length > 0) {
68
67
  instructions.push(`Review the threads under \`## Review threads to resolve\`. Human-authored threads are replied to by the \`resolve:\` command shown below; Shepherd does not resolve them. Bot/non-human threads are included in \`--resolve-thread-ids\`.`);
@@ -79,20 +78,7 @@ resolveCommand, hasConflicts, prNumber, cancelledCount, firstLookThreads = [], f
79
78
  }
80
79
  if (resolveOnlyCommand?.hasMutations)
81
80
  instructions.push(`Run the \`resolve-only:\` command shown above — no substitutions needed.`);
82
- if (resolveCommand.hasMutations) {
83
- if ((resolveCommand.replyThreadIds?.length ?? 0) > 0) {
84
- instructions.push(`Before running the \`resolve:\` command, remove any thread from \`--reply-thread-ids\` if the latest visible comment in that thread is your own prior Shepherd reply. Do not reply to your own comments.`);
85
- }
86
- const substituteParts = [];
87
- if (resolveCommand.requiresHeadSha) {
88
- substituteParts.push(`\`$HEAD_SHA\` with the pushed commit SHA (or \`$(git rev-parse HEAD)\` if you did not push)`);
89
- }
90
- if (resolveCommand.requiresDismissMessage) {
91
- substituteParts.push(`\`$DISMISS_MESSAGE\` with a one-sentence reply/description of what you changed`);
92
- }
93
- const substituteHint = substituteParts.length > 0 ? `, substituting ${substituteParts.join(" and ")}` : "";
94
- instructions.push(`Run the \`resolve:\` command shown above${substituteHint}.`);
95
- }
81
+ instructions.push(...buildResolveCommandInstruction(resolveCommand));
96
82
  if (cancelledCount > 0) {
97
83
  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.`);
98
84
  }
@@ -2,6 +2,7 @@
2
2
  import { graphql, graphqlWithRateLimit, getCurrentPrNumber, getRepoInfo, } from "../github/client.mjs";
3
3
  import { paginateForward } from "../github/pagination.mjs";
4
4
  import { isRateLimitMessage, rateLimitFromError, rateLimitFromGraphQlResult, } from "../comments/rate-limit.mjs";
5
+ import { EXIT, ShepherdError } from "../exit-codes.mjs";
5
6
  const FILES_QUERY = `query PullRequestFiles($owner: String!, $repo: String!, $pr: Int!, $filesCursor: String) {
6
7
  repository(owner: $owner, name: $repo) {
7
8
  pullRequest(number: $pr) {
@@ -25,8 +26,9 @@ const BULK_CHUNK_SIZE = 10;
25
26
  export async function runMarkFilesAsViewed(opts) {
26
27
  const repo = await getRepoInfo();
27
28
  const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
28
- if (!prNumber)
29
- throw new Error("No PR number provided and no current branch PR found");
29
+ if (!prNumber) {
30
+ throw new ShepherdError("No PR number provided and no current branch PR found", EXIT.UNAVAILABLE);
31
+ }
30
32
  const matchPatterns = opts.matchPatterns ?? [];
31
33
  const matchRegexes = matchPatterns.map((pattern) => compilePattern(pattern));
32
34
  const fetched = await fetchPullRequestFiles(prNumber, repo);
@@ -61,7 +63,7 @@ async function fetchPullRequestFiles(pr, repo) {
61
63
  });
62
64
  const raw = first.data.repository?.pullRequest;
63
65
  if (!raw)
64
- throw new Error(`PR #${pr} not found`);
66
+ throw new ShepherdError(`PR #${pr} not found`, EXIT.UNAVAILABLE);
65
67
  let files = raw.files.nodes;
66
68
  if (raw.files.pageInfo.hasNextPage && raw.files.pageInfo.endCursor) {
67
69
  const extra = await paginateForward(async (cursor) => {
@@ -73,7 +75,7 @@ async function fetchPullRequestFiles(pr, repo) {
73
75
  });
74
76
  const pr2 = res.data.repository?.pullRequest;
75
77
  if (!pr2)
76
- throw new Error(`PR #${pr} not found`);
78
+ throw new ShepherdError(`PR #${pr} not found`, EXIT.UNAVAILABLE);
77
79
  return pr2.files;
78
80
  }, raw.files.pageInfo.endCursor);
79
81
  files = [...files, ...extra];
@@ -86,7 +88,7 @@ function compilePattern(pattern) {
86
88
  }
87
89
  catch (e) {
88
90
  const msg = e instanceof Error ? e.message : String(e);
89
- throw new Error(`Invalid --match regex ${JSON.stringify(pattern)}: ${msg}`);
91
+ throw new ShepherdError(`Invalid --match regex ${JSON.stringify(pattern)}: ${msg}`, EXIT.USAGE);
90
92
  }
91
93
  }
92
94
  function selectChangedFiles(changedFiles, opts) {
@@ -153,9 +155,7 @@ async function bulkMarkFilesAsViewedChunk(pullRequestId, paths, result, hasPendi
153
155
  let suppressCurrentChunkErrors = false;
154
156
  let rateLimitStop;
155
157
  try {
156
- const resp = await graphqlWithRateLimit(buildBulkMutation(paths), {
157
- pullRequestId,
158
- });
158
+ const resp = await graphqlWithRateLimit(buildBulkMutation(paths), { pullRequestId }, { allowPartialData: true });
159
159
  data = resp.data;
160
160
  graphQlErrors = (resp.errors ?? []);
161
161
  const messages = graphQlErrors.map((e) => e.message);
@@ -6,11 +6,12 @@ import { isConfiguredBotAuthor, isHumanAuthor, normalizeBotUsernames, } from "..
6
6
  import { markReplySeen } from "../state/seen-comments.mjs";
7
7
  import { threadTranscriptBody } from "../threads/transcript.mjs";
8
8
  import { addPrShepherdMarker } from "../comments/marker.mjs";
9
+ import { EXIT, ShepherdError } from "../exit-codes.mjs";
9
10
  export async function runResolveMutate(opts) {
10
11
  const repo = await getRepoInfo();
11
12
  const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
12
13
  if (prNumber === null) {
13
- throw new Error("No open PR found for current branch. Pass a PR number explicitly.");
14
+ throw new ShepherdError("No open PR found for current branch. Pass a PR number explicitly.", EXIT.UNAVAILABLE);
14
15
  }
15
16
  const { data } = await fetchPrBatch(prNumber, repo, { paginateApprovedReviews: true });
16
17
  const config = loadConfig();
@@ -128,7 +128,9 @@ async function bulkApplyChunk(resolveIds, replyIds, minimizeIds, dismissIds, dis
128
128
  let rateLimitStop;
129
129
  let suppressCurrentChunkErrors = false;
130
130
  try {
131
- const resp = await graphqlWithRateLimit(doc, {});
131
+ const resp = await graphqlWithRateLimit(doc, {}, {
132
+ allowPartialData: true,
133
+ });
132
134
  data = resp.data;
133
135
  graphQlErrors = (resp.errors ?? []);
134
136
  const graphQlErrorMessages = graphQlErrors.map((e) => e.message);
package/bin/config.json CHANGED
@@ -18,7 +18,8 @@
18
18
  "fixAttemptsPerThread": 3,
19
19
  "stallTimeoutMinutes": 60,
20
20
  "minimizeApprovals": false,
21
- "minimizeComments": "all"
21
+ "minimizeComments": "all",
22
+ "behindBaseHint": ""
22
23
  },
23
24
  "watch": {
24
25
  "readyDelayMinutes": 10
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Process exit codes for the pr-shepherd CLI.
3
+ *
4
+ * Three bands:
5
+ * 0 done — shepherd finished and the PR is in a good terminal state
6
+ * 10-19 shepherd RAN successfully; the code reports PR state
7
+ * 64-78 shepherd FAILED (BSD `sysexits.h` codes)
8
+ *
9
+ * Caller rule: `$? >= 64` means shepherd itself failed. `0` or `10-19` means it
10
+ * ran to completion and is reporting PR state. See docs/exit-codes.md.
11
+ */
12
+ export const EXIT = Object.freeze({
13
+ /** `cancel` + `merged` or `ready-delay-elapsed` — shepherd finished cleanly. */
14
+ OK: 0,
15
+ /** Nothing to do yet; CI still in progress. */
16
+ WAIT: 10,
17
+ /** Draft PR converted to ready for review. */
18
+ MARK_READY: 11,
19
+ /** Agent work required. */
20
+ FIX_CODE: 12,
21
+ /** Human attention required. */
22
+ ESCALATE: 13,
23
+ /** `cancel` + `closed` — PR closed without merging. */
24
+ CLOSED: 14,
25
+ /** Bad/unknown flag, unknown subcommand, missing required arg, invalid duration. */
26
+ USAGE: 64,
27
+ /** Malformed caller data: bad `--require-sha`, `PRRC_*` IDs, bad repo string. */
28
+ DATAERR: 65,
29
+ /** Input file/stdin could not be read. */
30
+ NOINPUT: 66,
31
+ /** Precondition unmet: no open PR for branch, thread not eligible, unclassified 4xx. */
32
+ UNAVAILABLE: 69,
33
+ /** Unexpected/unclassified internal error — the fallback. */
34
+ SOFTWARE: 70,
35
+ /** Retryable GitHub failure: 429, 5xx, rate limit exhausted, `Retry-After` present. */
36
+ TEMPFAIL: 75,
37
+ /** GitHub 401/403 — missing token or insufficient PAT scopes. */
38
+ NOPERM: 77,
39
+ /** `.pr-shepherdrc.yml` validation failure. */
40
+ CONFIG: 78,
41
+ });
42
+ /** An error that carries its own exit code, so the top-level handler doesn't have to guess. */
43
+ export class ShepherdError extends Error {
44
+ exitCode;
45
+ constructor(message, exitCode, opts) {
46
+ super(message, opts);
47
+ this.name = "ShepherdError";
48
+ this.exitCode = exitCode;
49
+ }
50
+ }
51
+ const CANCEL_REASON_EXIT_CODE = {
52
+ merged: EXIT.OK,
53
+ "ready-delay-elapsed": EXIT.OK,
54
+ closed: EXIT.CLOSED,
55
+ };
56
+ export function iterateResultToExitCode(result) {
57
+ switch (result.action) {
58
+ case "cancel":
59
+ return CANCEL_REASON_EXIT_CODE[result.reason];
60
+ case "wait":
61
+ return EXIT.WAIT;
62
+ case "mark_ready":
63
+ return EXIT.MARK_READY;
64
+ case "fix_code":
65
+ return EXIT.FIX_CODE;
66
+ case "escalate":
67
+ return EXIT.ESCALATE;
68
+ }
69
+ }
70
+ export function errorToExitCode(err) {
71
+ if (err instanceof ShepherdError)
72
+ return err.exitCode;
73
+ return EXIT.SOFTWARE;
74
+ }
@@ -0,0 +1,21 @@
1
+ import { EXIT, ShepherdError } from "../exit-codes.mjs";
2
+ import { GitHubRequestError } from "./errors.mjs";
3
+ export function requireRawPr(response, pr, repo) {
4
+ if (!response?.repository) {
5
+ throw new GitHubRequestError(`GitHub GraphQL response did not include repository ${repo.owner}/${repo.name} (not found or access denied)`, { status: 200 });
6
+ }
7
+ if (!response.repository.pullRequest) {
8
+ throw new ShepherdError(`PR #${pr} not found`, EXIT.UNAVAILABLE);
9
+ }
10
+ return response.repository.pullRequest;
11
+ }
12
+ export function requireContextNodes(nodes) {
13
+ const nullIndex = nodes.findIndex((node) => node === null);
14
+ if (nullIndex !== -1) {
15
+ // A null context node is an unexpected/malformed shape, not a precondition or
16
+ // permission problem — force EX_SOFTWARE rather than falling through to the
17
+ // (200-status-derived) EX_UNAVAILABLE default.
18
+ throw new GitHubRequestError(`Malformed GitHub GraphQL response: null check context at repository.pullRequest.commits.nodes.0.commit.statusCheckRollup.contexts.nodes.${nullIndex}`, { status: 200, exitCodeOverride: EXIT.SOFTWARE });
19
+ }
20
+ return nodes;
21
+ }
@@ -3,6 +3,7 @@ import { paginateForward, paginateBackward } from "./pagination.mjs";
3
3
  import { hydrateThreadCommentPages } from "./thread-comments.mjs";
4
4
  import { BATCH_PR_QUERY } from "./queries.mjs";
5
5
  import { parseRawPr } from "./batch-parsers.mjs";
6
+ import { requireContextNodes, requireRawPr } from "./batch-response.mjs";
6
7
  /**
7
8
  * Fetch all PR data needed for a `shepherd check` in one (or a few, if paginating) GraphQL requests.
8
9
  */
@@ -13,10 +14,7 @@ export async function fetchPrBatch(pr, repo, opts = {}) {
13
14
  repo: repo.name,
14
15
  pr,
15
16
  });
16
- const raw = result.data.repository.pullRequest;
17
- if (!raw) {
18
- throw new Error(`PR #${pr} not found`);
19
- }
17
+ const raw = requireRawPr(result.data, pr, repo);
20
18
  // Paginate reviewThreads backward if the first page is incomplete.
21
19
  let rawThreadPages = raw.reviewThreads.nodes;
22
20
  if (raw.reviewThreads.pageInfo.hasPreviousPage && raw.reviewThreads.pageInfo.startCursor) {
@@ -29,9 +27,7 @@ export async function fetchPrBatch(pr, repo, opts = {}) {
29
27
  pr,
30
28
  ...(cursor ? { threadsCursor: cursor } : {}),
31
29
  });
32
- const pr2 = res.data.repository.pullRequest;
33
- if (!pr2)
34
- throw new Error(`PR #${pr} not found`);
30
+ const pr2 = requireRawPr(res.data, pr, repo);
35
31
  return pr2.reviewThreads;
36
32
  }, raw.reviewThreads.pageInfo.startCursor);
37
33
  // extra contains pages before the first page.
@@ -48,9 +44,7 @@ export async function fetchPrBatch(pr, repo, opts = {}) {
48
44
  pr,
49
45
  ...(cursor ? { commentsCursor: cursor } : {}),
50
46
  });
51
- const pr2 = res.data.repository.pullRequest;
52
- if (!pr2)
53
- throw new Error(`PR #${pr} not found`);
47
+ const pr2 = requireRawPr(res.data, pr, repo);
54
48
  return pr2.comments;
55
49
  }, raw.comments.pageInfo.startCursor);
56
50
  rawCommentNodes = [...extra, ...rawCommentNodes];
@@ -66,9 +60,7 @@ export async function fetchPrBatch(pr, repo, opts = {}) {
66
60
  pr,
67
61
  ...(cursor ? { changesRequestedCursor: cursor } : {}),
68
62
  });
69
- const pr2 = res.data.repository.pullRequest;
70
- if (!pr2)
71
- throw new Error(`PR #${pr} not found`);
63
+ const pr2 = requireRawPr(res.data, pr, repo);
72
64
  return pr2.changesRequestedReviews;
73
65
  }, raw.changesRequestedReviews.pageInfo.startCursor);
74
66
  rawReviewNodes = [...extra, ...rawReviewNodes];
@@ -83,9 +75,7 @@ export async function fetchPrBatch(pr, repo, opts = {}) {
83
75
  pr,
84
76
  ...(cursor ? { reviewSummariesCursor: cursor } : {}),
85
77
  });
86
- const pr2 = res.data.repository.pullRequest;
87
- if (!pr2)
88
- throw new Error(`PR #${pr} not found`);
78
+ const pr2 = requireRawPr(res.data, pr, repo);
89
79
  return pr2.reviewSummaries;
90
80
  }, raw.reviewSummaries.pageInfo.startCursor);
91
81
  rawReviewSummaryNodes = [...extra, ...rawReviewSummaryNodes];
@@ -103,15 +93,13 @@ export async function fetchPrBatch(pr, repo, opts = {}) {
103
93
  pr,
104
94
  ...(cursor ? { approvedReviewsCursor: cursor } : {}),
105
95
  });
106
- const pr2 = res.data.repository.pullRequest;
107
- if (!pr2)
108
- throw new Error(`PR #${pr} not found`);
96
+ const pr2 = requireRawPr(res.data, pr, repo);
109
97
  return pr2.approvedReviews;
110
98
  }, raw.approvedReviews.pageInfo.startCursor);
111
99
  rawApprovedReviewNodes = [...extra, ...rawApprovedReviewNodes];
112
100
  }
113
101
  // Paginate check contexts forward if the first page is incomplete.
114
- let rawCheckNodes = raw.commits.nodes[0]?.commit.statusCheckRollup?.contexts.nodes ?? [];
102
+ let rawCheckNodes = requireContextNodes(raw.commits.nodes[0]?.commit.statusCheckRollup?.contexts.nodes ?? []);
115
103
  const checksPageInfo = raw.commits.nodes[0]?.commit.statusCheckRollup?.contexts.pageInfo;
116
104
  const firstOid = raw.commits.nodes[0]?.commit.oid;
117
105
  if (checksPageInfo?.hasNextPage && checksPageInfo.endCursor) {
@@ -125,7 +113,7 @@ export async function fetchPrBatch(pr, repo, opts = {}) {
125
113
  pr,
126
114
  ...(cursor ? { checksCursor: cursor } : {}),
127
115
  });
128
- const pr2 = res.data.repository.pullRequest;
116
+ const pr2 = requireRawPr(res.data, pr, repo);
129
117
  if (!pr2?.commits.nodes[0]?.commit.statusCheckRollup) {
130
118
  throw new Error(`Check-context pagination interrupted: statusCheckRollup disappeared on page ${pageCount + 2} (possible force-push race). Retry after the push stabilizes.`);
131
119
  }
@@ -135,7 +123,9 @@ export async function fetchPrBatch(pr, repo, opts = {}) {
135
123
  }
136
124
  pageCount++;
137
125
  const ctxs = pr2.commits.nodes[0]?.commit.statusCheckRollup?.contexts;
138
- return ctxs ?? { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] };
126
+ if (!ctxs)
127
+ return { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] };
128
+ return { ...ctxs, nodes: requireContextNodes(ctxs.nodes) };
139
129
  }, checksPageInfo.endCursor);
140
130
  rawCheckNodes = [...rawCheckNodes, ...extra];
141
131
  }
@@ -1,12 +1,38 @@
1
- export class GitHubRequestError extends Error {
1
+ import { EXIT, ShepherdError } from "../exit-codes.mjs";
2
+ // GitHub's GraphQL API reports field-level permission failures (e.g. a fine-grained
3
+ // PAT missing a scope) as an `errors[].message` entry at HTTP 200, not as an HTTP
4
+ // 401/403 — the transport-level request succeeded even though one field could not
5
+ // be resolved. Status alone can't see this, so classification must also inspect the
6
+ // GraphQL error messages themselves.
7
+ const GRAPHQL_PERMISSION_ERROR = /resource not accessible/i;
8
+ function hasPermissionError(graphqlErrors) {
9
+ return graphqlErrors?.some((e) => GRAPHQL_PERMISSION_ERROR.test(e.message)) ?? false;
10
+ }
11
+ function classifyStatus(status, rateLimit, retryAfterSeconds, graphqlErrors) {
12
+ // Retry signals take priority over everything else: GitHub's secondary rate limit
13
+ // returns 403 with a Retry-After header, which is a transient throttle — not the
14
+ // permission-denied 403 a bad/missing token produces. Treat any retry signal as
15
+ // TEMPFAIL first so it isn't shadowed by the checks below.
16
+ const rateLimitExhausted = rateLimit !== undefined && rateLimit.remaining <= 0;
17
+ if (status === 429 || status >= 500 || retryAfterSeconds !== undefined || rateLimitExhausted) {
18
+ return EXIT.TEMPFAIL;
19
+ }
20
+ if (status === 401 || status === 403 || hasPermissionError(graphqlErrors))
21
+ return EXIT.NOPERM;
22
+ return EXIT.UNAVAILABLE;
23
+ }
24
+ export class GitHubRequestError extends ShepherdError {
2
25
  status;
3
26
  rateLimit;
4
27
  retryAfterSeconds;
28
+ graphqlErrors;
5
29
  constructor(message, opts) {
6
- super(message);
30
+ super(message, opts.exitCodeOverride ??
31
+ classifyStatus(opts.status, opts.rateLimit, opts.retryAfterSeconds, opts.graphqlErrors));
7
32
  this.name = "GitHubRequestError";
8
33
  this.status = opts.status;
9
34
  this.rateLimit = opts.rateLimit;
10
35
  this.retryAfterSeconds = opts.retryAfterSeconds;
36
+ this.graphqlErrors = opts.graphqlErrors;
11
37
  }
12
38
  }