pr-shepherd 0.32.4 → 0.33.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.32.4",
4
+ "version": "0.33.0",
5
5
  "author": {
6
6
  "name": "Jonathan Ong",
7
7
  "email": "jonathanrichardong@gmail.com"
package/README.md CHANGED
@@ -190,7 +190,7 @@ actions:
190
190
 
191
191
  Environment variables:
192
192
 
193
- - `GH_TOKEN` / `GITHUB_TOKEN` / `GITHUB_PERSONAL_ACCESS_TOKEN` for auth; `gh auth token` is used as a fallback.
193
+ - `GH_TOKEN` / `GITHUB_TOKEN` / `GITHUB_PERSONAL_ACCESS_TOKEN` for auth; `gh auth token` is used as a fallback. See [GitHub authentication and token access](docs/authentication.md) for required PAT permissions.
194
194
  - `PR_SHEPHERD_STATE_DIR` to override state and log location.
195
195
  - `PR_SHEPHERD_LOG_DISABLED=1` to disable per-worktree debug logging.
196
196
 
@@ -221,7 +221,7 @@ Ready-to-use examples for common patterns are in [`examples/classification/`](ex
221
221
  ## Requirements
222
222
 
223
223
  - Node.js >= 22.18.0, Bun, or Deno
224
- - A GitHub token or authenticated `gh` CLI; private repositories require `repo` scope.
224
+ - A GitHub token or authenticated `gh` CLI with the [required repository access](docs/authentication.md). A classic PAT needs the `repo` scope for complete operation.
225
225
  - `git`
226
226
 
227
227
  ## Docs
@@ -169,12 +169,18 @@ Creates the section at the end if absent. Idempotent — duplicate items are ski
169
169
 
170
170
  Usage:
171
171
  pr-shepherd journal [PR] <item> [--dry-run] [--format text|json]
172
+ pr-shepherd journal [PR] --file <path> [--dry-run] [--format text|json]
173
+ pr-shepherd journal [PR] --file - [--dry-run] [--format text|json]
172
174
 
173
175
  PR PR number or GitHub pull request URL. Defaults to current branch PR.
174
176
  item Markdown list item: must start with "- " followed by non-whitespace text.
175
177
  Example: '- Rejected suggestion: kept existing pattern for consistency.'
178
+ Provide it as a positional argument, or via --file to avoid shell-escaping
179
+ backticks and multi-line Markdown. Exactly one of the two is required.
176
180
 
177
181
  Flags:
182
+ --file <path> Read the entry from a file instead of a positional argument.
183
+ Pass --file - to read from stdin.
178
184
  --dry-run Preview the new PR body without writing it to GitHub.
179
185
  --format text|json Output format. Default: text.
180
186
  --help, -h Print this help and exit before any GitHub I/O.
@@ -1,5 +1,6 @@
1
+ import { readFile } from "node:fs/promises";
1
2
  import { runJournal } from "../commands/journal/index.mjs";
2
- import { parsePrNumber } from "./args.mjs";
3
+ import { getFlag, parsePrNumber } from "./args.mjs";
3
4
  import { USAGE } from "./help.mjs";
4
5
  export async function handleJournal(args) {
5
6
  for (const a of args) {
@@ -7,13 +8,29 @@ export async function handleJournal(args) {
7
8
  continue;
8
9
  if (a === "--dry-run" || a === "--format" || a.startsWith("--format="))
9
10
  continue;
11
+ if (a === "--file" || a.startsWith("--file="))
12
+ continue;
10
13
  process.stderr.write(`pr-shepherd: journal: unknown flag: "${a}"\n`);
11
14
  process.exitCode = 1;
12
15
  return;
13
16
  }
14
17
  const { prNumber, extra } = parseJournalArgs(args);
15
- const rawItem = extra[0];
16
- if (!rawItem) {
18
+ const filePath = getFlag(args, "--file");
19
+ if (filePath !== null && extra[0]) {
20
+ process.stderr.write(`pr-shepherd: journal: provide the entry as a positional argument or via --file, not both\n`);
21
+ process.exitCode = 1;
22
+ return;
23
+ }
24
+ let rawItem;
25
+ try {
26
+ rawItem = filePath !== null ? await readItemSource(filePath) : extra[0];
27
+ }
28
+ catch (e) {
29
+ process.stderr.write(`pr-shepherd: journal: ${String(e)}\n`);
30
+ process.exitCode = 1;
31
+ return;
32
+ }
33
+ if (rawItem === undefined) {
17
34
  process.stderr.write(`${USAGE.journal}\n`);
18
35
  process.exitCode = 1;
19
36
  return;
@@ -35,11 +52,26 @@ export async function handleJournal(args) {
35
52
  process.exitCode = 1;
36
53
  }
37
54
  }
55
+ /** Reads the journal entry from a file, or from stdin when `filePath` is `-`. */
56
+ async function readItemSource(filePath) {
57
+ if (filePath === "-")
58
+ return readStdin();
59
+ return readFile(filePath, "utf8");
60
+ }
61
+ async function readStdin() {
62
+ const chunks = [];
63
+ for await (const chunk of process.stdin) {
64
+ chunks.push(chunk);
65
+ }
66
+ return Buffer.concat(chunks).toString("utf8");
67
+ }
38
68
  function parseJournalArgs(args) {
39
69
  const flagConsumedIndices = new Set();
40
70
  for (let i = 0; i < args.length; i++) {
41
71
  const a = args[i];
42
- if (a === "--format" && i + 1 < args.length && !args[i + 1].startsWith("--")) {
72
+ if ((a === "--format" || a === "--file") &&
73
+ i + 1 < args.length &&
74
+ !args[i + 1].startsWith("--")) {
43
75
  flagConsumedIndices.add(i);
44
76
  flagConsumedIndices.add(i + 1);
45
77
  }
@@ -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
  }
@@ -14,16 +14,21 @@ function dedupeIds(ids) {
14
14
  }
15
15
  export function classifyReviewSummaries(summaries, approvals, minimizeApprovals, minimizeComments = "all", botUsernames = new Set(), unresolvedThreads = [], ruleAutoResolveIds = []) {
16
16
  const blockedReviewIds = new Set(unresolvedThreads.flatMap((t) => (t.reviewId !== undefined ? [t.reviewId] : [])));
17
- // First-look and seen summaries go into the minimize mutation; edited summaries do NOT —
18
- // they are already minimized server-side (body changed after minimize was applied).
19
- // First-look bodies are rendered so the agent sees them before the minimize happens.
20
- const minimizeIds = [...summaries.firstLook, ...summaries.seen]
21
- .filter((r) => shouldMinimizeAuthor(r.authorType, minimizeComments, r.author, botUsernames))
22
- .filter((r) => !blockedReviewIds.has(r.id))
23
- .map((r) => r.id);
24
- // Rule-matched summaries are already suppressed from agent output; bypass normal policy gates.
17
+ const eligible = (r) => shouldMinimizeAuthor(r.authorType, minimizeComments, r.author, botUsernames) &&
18
+ !blockedReviewIds.has(r.id);
19
+ // First-look summaries still need one tick to surface their body to the agent,
20
+ // so their minimize IDs ride in the agent-facing resolve command. Seen summaries
21
+ // (already surfaced in a prior tick) have no new content to show — the CLI
22
+ // self-minimizes them in-process (selfMinimizeIds) instead of routing a
23
+ // cosmetic-only mutation through fix_code (issue #313). Edited summaries are
24
+ // excluded entirely: they are already minimized server-side (body changed after
25
+ // minimize was applied).
26
+ const minimizeIds = summaries.firstLook.filter(eligible).map((r) => r.id);
27
+ const selfMinimizeIds = summaries.seen.filter(eligible).map((r) => r.id);
28
+ // Rule-matched summaries are already suppressed from agent output; bypass normal
29
+ // policy gates. Keep the two sets disjoint.
25
30
  for (const id of ruleAutoResolveIds) {
26
- if (!minimizeIds.includes(id))
31
+ if (!minimizeIds.includes(id) && !selfMinimizeIds.includes(id))
27
32
  minimizeIds.push(id);
28
33
  }
29
34
  if (minimizeApprovals) {
@@ -36,6 +41,7 @@ export function classifyReviewSummaries(summaries, approvals, minimizeApprovals,
36
41
  }
37
42
  return {
38
43
  minimizeIds,
44
+ selfMinimizeIds,
39
45
  firstLookSummaries: summaries.firstLook,
40
46
  editedSummaries: summaries.edited,
41
47
  surfacedApprovals,
@@ -43,6 +49,7 @@ export function classifyReviewSummaries(summaries, approvals, minimizeApprovals,
43
49
  }
44
50
  return {
45
51
  minimizeIds,
52
+ selfMinimizeIds,
46
53
  firstLookSummaries: summaries.firstLook,
47
54
  editedSummaries: summaries.edited,
48
55
  surfacedApprovals: approvals,
@@ -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,
@@ -10,6 +10,7 @@ import { applyStallGuard } from "./stall.mjs";
10
10
  import { clearStallState } from "../../state/iterate-stall.mjs";
11
11
  import { handleFixCode } from "./fix-code.mjs";
12
12
  import { normalizeBotUsernames } from "../../comments/authors.mjs";
13
+ import { autoMinimizeComments } from "../../comments/resolve.mjs";
13
14
  export async function runIterate(opts) {
14
15
  const config = loadConfig();
15
16
  const botUsernames = normalizeBotUsernames(config.botUsernames);
@@ -35,11 +36,25 @@ export async function runIterate(opts) {
35
36
  await clearStallState(stallKey);
36
37
  return buildTerminalCancelResult(report);
37
38
  }
38
- const { minimizeIds: reviewSummaryIds, firstLookSummaries, editedSummaries, surfacedApprovals, } = classifyReviewSummaries({
39
+ const { minimizeIds, selfMinimizeIds, firstLookSummaries, editedSummaries, surfacedApprovals } = classifyReviewSummaries({
39
40
  firstLook: report.firstLookSummaries,
40
41
  seen: report.reviewSummaries,
41
42
  edited: report.editedSummaries,
42
43
  }, report.approvedReviews, config.iterate.minimizeApprovals, config.iterate.minimizeComments, botUsernames, [...report.threads.actionable, ...report.threads.resolutionOnly], report.ruleAutoResolveReviewSummaryIds);
44
+ // Already-seen review summaries have no new content to surface — minimize them
45
+ // in-process so they never register as agent-facing actionable work (#313).
46
+ // GitHub can still return a null/error/rate-limit result per ID without
47
+ // throwing (autoMinimizeComments reports this via `errors`, not a rejection);
48
+ // any ID it did not confirm minimized falls back into the agent-facing set so
49
+ // the resolve command remains a working fallback instead of silently dropping it.
50
+ let reviewSummaryIds = minimizeIds;
51
+ if (selfMinimizeIds.length > 0) {
52
+ const { minimized } = await autoMinimizeComments(selfMinimizeIds);
53
+ const minimizedIds = new Set(minimized);
54
+ const unminimized = selfMinimizeIds.filter((id) => !minimizedIds.has(id));
55
+ if (unminimized.length > 0)
56
+ reviewSummaryIds = [...reviewSummaryIds, ...unminimized];
57
+ }
43
58
  const hasActionableWork = report.threads.actionable.length > 0 ||
44
59
  report.threads.resolutionOnly.length > 0 ||
45
60
  report.threads.firstLook.length > 0 ||
@@ -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
  }
@@ -153,9 +153,7 @@ async function bulkMarkFilesAsViewedChunk(pullRequestId, paths, result, hasPendi
153
153
  let suppressCurrentChunkErrors = false;
154
154
  let rateLimitStop;
155
155
  try {
156
- const resp = await graphqlWithRateLimit(buildBulkMutation(paths), {
157
- pullRequestId,
158
- });
156
+ const resp = await graphqlWithRateLimit(buildBulkMutation(paths), { pullRequestId }, { allowPartialData: true });
159
157
  data = resp.data;
160
158
  graphQlErrors = (resp.errors ?? []);
161
159
  const messages = graphQlErrors.map((e) => e.message);
@@ -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,16 @@
1
+ import { GitHubRequestError } from "./errors.mjs";
2
+ export function requireRawPr(response, pr, repo) {
3
+ if (!response?.repository) {
4
+ throw new GitHubRequestError(`GitHub GraphQL response did not include repository ${repo.owner}/${repo.name} (not found or access denied)`, { status: 200 });
5
+ }
6
+ if (!response.repository.pullRequest)
7
+ throw new Error(`PR #${pr} not found`);
8
+ return response.repository.pullRequest;
9
+ }
10
+ export function requireContextNodes(nodes) {
11
+ const nullIndex = nodes.findIndex((node) => node === null);
12
+ if (nullIndex !== -1) {
13
+ throw new GitHubRequestError(`Malformed GitHub GraphQL response: null check context at repository.pullRequest.commits.nodes.0.commit.statusCheckRollup.contexts.nodes.${nullIndex}`, { status: 200 });
14
+ }
15
+ return nodes;
16
+ }
@@ -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
  }
@@ -2,11 +2,13 @@ export class GitHubRequestError extends Error {
2
2
  status;
3
3
  rateLimit;
4
4
  retryAfterSeconds;
5
+ graphqlErrors;
5
6
  constructor(message, opts) {
6
7
  super(message);
7
8
  this.name = "GitHubRequestError";
8
9
  this.status = opts.status;
9
10
  this.rateLimit = opts.rateLimit;
10
11
  this.retryAfterSeconds = opts.retryAfterSeconds;
12
+ this.graphqlErrors = opts.graphqlErrors;
11
13
  }
12
14
  }
@@ -1,11 +1,12 @@
1
1
  import { appendEntry, nextEntry } from "../log/log-file.mjs";
2
2
  import { formatRequestEntry, formatResponseEntry } from "../log/session.mjs";
3
3
  import { GitHubRequestError } from "./errors.mjs";
4
+ import { formatGraphQlErrors, parseGraphQlPayload } from "./graphql-response.mjs";
4
5
  import { makeHeaders } from "./http-auth.mjs";
5
6
  import { requestWithTokenRetry } from "./http-request.mjs";
6
7
  import { parseRateLimit, parseRetryAfter, redactToken, sanitizeBody, } from "./http-utils.mjs";
7
8
  const BASE_URL = "https://api.github.com";
8
- async function graphqlInner(query, vars) {
9
+ async function graphqlInner(query, vars, opts) {
9
10
  const url = `${BASE_URL}/graphql`;
10
11
  const n = nextEntry();
11
12
  appendEntry(formatRequestEntry({
@@ -38,7 +39,28 @@ async function graphqlInner(query, vars) {
38
39
  }));
39
40
  throw new GitHubRequestError(`GitHub GraphQL request failed: ${res.status} ${sanitizeBody(body)}`, { status: res.status, rateLimit: rateLimit ?? undefined, retryAfterSeconds });
40
41
  }
41
- const parsed = (await res.json());
42
+ let parsed;
43
+ try {
44
+ parsed = await res.json();
45
+ }
46
+ catch (err) {
47
+ const detail = err instanceof Error ? `: ${err.message}` : "";
48
+ appendEntry(formatResponseEntry({
49
+ n,
50
+ kind: "GraphQL",
51
+ method: "POST",
52
+ url,
53
+ status: res.status,
54
+ durationMs,
55
+ textBody: `Invalid JSON response${detail}`,
56
+ attempt: attempt > 1 ? attempt : undefined,
57
+ }));
58
+ throw new GitHubRequestError(`GitHub GraphQL response was not valid JSON${detail}`, {
59
+ status: res.status,
60
+ rateLimit: rateLimit ?? undefined,
61
+ retryAfterSeconds,
62
+ });
63
+ }
42
64
  appendEntry(formatResponseEntry({
43
65
  n,
44
66
  kind: "GraphQL",
@@ -49,25 +71,35 @@ async function graphqlInner(query, vars) {
49
71
  body: parsed,
50
72
  attempt: attempt > 1 ? attempt : undefined,
51
73
  }));
52
- if (parsed.data == null) {
53
- const messages = (parsed.errors ?? []).map((e) => e.message).join("; ");
54
- throw new GitHubRequestError(`GitHub GraphQL error (no data): ${messages}`, {
74
+ const payload = parseGraphQlPayload(parsed, res.status, rateLimit, retryAfterSeconds);
75
+ if (payload.data == null) {
76
+ const detail = formatGraphQlErrors(payload.errors);
77
+ throw new GitHubRequestError(`GitHub GraphQL error (no data)${detail ? `: ${detail}` : ""}`, {
78
+ status: res.status,
79
+ rateLimit: rateLimit ?? undefined,
80
+ retryAfterSeconds,
81
+ graphqlErrors: payload.errors,
82
+ });
83
+ }
84
+ if (payload.errors?.length && !opts.allowPartialData) {
85
+ throw new GitHubRequestError(`GitHub GraphQL error: ${formatGraphQlErrors(payload.errors)}`, {
55
86
  status: res.status,
56
87
  rateLimit: rateLimit ?? undefined,
57
88
  retryAfterSeconds,
89
+ graphqlErrors: payload.errors,
58
90
  });
59
91
  }
60
- if (parsed.errors?.length) {
61
- const messages = parsed.errors.map((e) => e.message).join("; ");
92
+ if (payload.errors?.length) {
93
+ const messages = payload.errors.map((e) => e.message).join("; ");
62
94
  process.stderr.write(`pr-shepherd: GraphQL non-fatal errors: ${messages}\n`);
63
95
  }
64
- return { data: parsed.data, rateLimit, retryAfterSeconds, errors: parsed.errors };
96
+ return { data: payload.data, rateLimit, retryAfterSeconds, errors: payload.errors };
65
97
  }
66
- export async function graphql(query, vars = {}) {
67
- const { data } = await graphqlInner(query, vars);
68
- return { data };
98
+ export async function graphql(query, vars = {}, opts = {}) {
99
+ const { data, errors } = await graphqlInner(query, vars, opts);
100
+ return { data, errors };
69
101
  }
70
- export async function graphqlWithRateLimit(query, vars = {}) {
71
- const { data, rateLimit, retryAfterSeconds, errors } = await graphqlInner(query, vars);
102
+ export async function graphqlWithRateLimit(query, vars = {}, opts = {}) {
103
+ const { data, rateLimit, retryAfterSeconds, errors } = await graphqlInner(query, vars, opts);
72
104
  return { data, rateLimit: rateLimit ?? undefined, retryAfterSeconds, errors };
73
105
  }
@@ -0,0 +1,42 @@
1
+ import { GitHubRequestError } from "./errors.mjs";
2
+ export function parseGraphQlPayload(parsed, status, rateLimit, retryAfterSeconds) {
3
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
4
+ throw malformedGraphQlResponse("expected a JSON object", status, rateLimit, retryAfterSeconds);
5
+ }
6
+ const record = parsed;
7
+ let errors;
8
+ if (record["errors"] !== undefined) {
9
+ if (!Array.isArray(record["errors"]) ||
10
+ !record["errors"].every((error) => typeof error === "object" &&
11
+ error !== null &&
12
+ typeof error["message"] === "string")) {
13
+ throw malformedGraphQlResponse("errors field is not an array of GraphQL errors", status, rateLimit, retryAfterSeconds);
14
+ }
15
+ errors = record["errors"];
16
+ }
17
+ if (!("data" in record)) {
18
+ if (errors?.length)
19
+ return { data: null, errors };
20
+ throw malformedGraphQlResponse("missing data field", status, rateLimit, retryAfterSeconds);
21
+ }
22
+ if (record["data"] !== null &&
23
+ (typeof record["data"] !== "object" || Array.isArray(record["data"]))) {
24
+ throw malformedGraphQlResponse("data field is not an object or null", status, rateLimit, retryAfterSeconds);
25
+ }
26
+ return { data: record["data"] ?? null, errors };
27
+ }
28
+ export function formatGraphQlErrors(errors) {
29
+ return (errors ?? [])
30
+ .map((error) => {
31
+ const path = Array.isArray(error.path) ? error.path.map(String).join(".") : "";
32
+ return path ? `${error.message} (path: ${path})` : error.message;
33
+ })
34
+ .join("; ");
35
+ }
36
+ function malformedGraphQlResponse(detail, status, rateLimit, retryAfterSeconds) {
37
+ return new GitHubRequestError(`Malformed GitHub GraphQL response: ${detail}`, {
38
+ status,
39
+ rateLimit: rateLimit ?? undefined,
40
+ retryAfterSeconds,
41
+ });
42
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
- "version": "0.32.4",
3
+ "version": "0.33.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",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
- "version": "0.32.4",
3
+ "version": "0.33.0",
4
4
  "description": "Autonomous PR CI monitor and review-comment resolver for Codex.",
5
5
  "author": {
6
6
  "name": "Jonathan Ong",