pr-shepherd 0.21.0 → 0.22.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 +5 -3
  3. package/bin/checks/triage.mjs +1 -0
  4. package/bin/cli/fix-formatter.mjs +36 -8
  5. package/bin/cli/iterate-lean.mjs +4 -0
  6. package/bin/cli/list-formatters.mjs +53 -4
  7. package/bin/commands/check-annotations.mjs +40 -0
  8. package/bin/commands/check.mjs +9 -6
  9. package/bin/commands/check.test-support.mjs +9 -2
  10. package/bin/commands/iterate/escalate.mjs +16 -2
  11. package/bin/commands/iterate/fix-code.mjs +9 -2
  12. package/bin/commands/iterate/helpers.mjs +1 -0
  13. package/bin/commands/iterate/render.mjs +6 -0
  14. package/bin/commands/iterate/stall.mjs +43 -2
  15. package/bin/commands/iterate-test-support.mjs +1 -1
  16. package/bin/commands/iterate.fix-code-in-progress.test-support.mjs +6 -1
  17. package/bin/commands/resolve.mjs +5 -4
  18. package/bin/config.json +1 -1
  19. package/bin/github/batch-parsers.mjs +30 -3
  20. package/bin/github/batch.mjs +2 -0
  21. package/bin/github/check-annotations.mjs +75 -0
  22. package/bin/github/client.test-support.mjs +55 -0
  23. package/bin/github/gql/batch-pr.gql +15 -2
  24. package/bin/github/gql/check-run-annotations.gql +32 -0
  25. package/bin/github/gql/review-thread-comments.gql +27 -0
  26. package/bin/github/queries.mjs +4 -0
  27. package/bin/github/thread-comments.mjs +34 -0
  28. package/bin/reporters/agent.mjs +26 -0
  29. package/bin/state/seen-comments.test-support.mjs +19 -0
  30. package/bin/threads/transcript.mjs +31 -0
  31. package/bin/types/agent-thread.mjs +1 -0
  32. package/bin/types/check-annotations.mjs +1 -0
  33. package/bin/types/review-thread.mjs +1 -0
  34. package/bin/types.mjs +3 -0
  35. package/package.json +1 -1
  36. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  37. package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +1 -1
@@ -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.21.0",
4
+ "version": "0.22.0",
5
5
  "author": {
6
6
  "name": "Jonathan Ong",
7
7
  "email": "jonathanrichardong@gmail.com"
package/README.md CHANGED
@@ -34,7 +34,9 @@ At a high level, the skill invokes `pr-shepherd <PR>` (which polls by default),
34
34
 
35
35
  ## Review threads
36
36
 
37
- ### `PRRT_kwDOSGizTs58XB1L` — `src/commands/iterate/index.mts:42` (@alice)
37
+ ### `threadId=PRRT_kwDOSGizTs58XB1L` — `src/commands/iterate/index.mts:42` (@alice)
38
+
39
+ #### `commentId=PRRC_kwDOSGizTs58XB1M` (@alice)
38
40
 
39
41
  > The variable name is misleading.
40
42
  >
@@ -97,7 +99,7 @@ Some other workflow improvements:
97
99
 
98
100
  Recommendations:
99
101
 
100
- - Run `pr-shepherd` on all your PRs before you go to sleep so that you wake up to reviewable PRs. Keep an active goal cycling `pr-shepherd <PR>` until Shepherd emits `[CANCEL]` for ready-delay completion or merged/closed, or `[ESCALATE]` (including `stall-timeout` for repeated unchanged CI failures).
102
+ - Run `pr-shepherd` on all your PRs before you go to sleep so that you wake up to reviewable PRs. Keep an active goal cycling `pr-shepherd <PR>` until Shepherd emits `[CANCEL]` for ready-delay completion or merged/closed, or `[ESCALATE]` (including `stall-timeout` for repeated unchanged CI failures or CI that never starts).
101
103
  - Instruct your agents to write comments in a single review (comment, changes requested, or approved). This allows the review's comments/threads to be minimized or resolved together, keeping your pull request history clean. If you write inline comments outside of a review, each comment would still show up in the pull request history and take up space.
102
104
  - Avoid sticky comments as they will continue to be hidden. Instead, just make a new comment, especially on reviews. If you really want sticky comments, instruct your agent to unhide/unminimize them when updating them.
103
105
  - Avoid having automation edit comments, reviews, or threads in place because updated items get minimized. Instead, always make a new review, comment, thread, etc.
@@ -197,7 +199,7 @@ Iterate a PR from Codex:
197
199
  pr-shepherd iterate 42
198
200
  ```
199
201
 
200
- Or ask Codex to use the `pr-shepherd` skill, for example: `run pr-shepherd until this PR is ready`. Follow the output's `## Instructions`. Continue until Shepherd emits `[CANCEL]` or `[ESCALATE]` (including `stall-timeout` for repeated unchanged CI failures). `pr-shepherd iterate 42` remains supported for existing workflows.
202
+ Or ask Codex to use the `pr-shepherd` skill, for example: `run pr-shepherd until this PR is ready`. Follow the output's `## Instructions`. Continue until Shepherd emits `[CANCEL]` or `[ESCALATE]` (including `stall-timeout` for repeated unchanged CI failures or CI that never starts). `pr-shepherd iterate 42` remains supported for existing workflows.
201
203
 
202
204
  ## Configuration
203
205
 
@@ -53,6 +53,7 @@ function workflowRunToCheckRun(run) {
53
53
  name: run.name?.trim() || `workflow run ${run.id}`,
54
54
  status: "COMPLETED",
55
55
  conclusion: "STARTUP_FAILURE",
56
+ source: "startup_failure",
56
57
  detailsUrl: run.html_url,
57
58
  event: run.event,
58
59
  runId: String(run.id),
@@ -1,7 +1,7 @@
1
1
  import { renderResolveCommand } from "../commands/iterate/render.mjs";
2
2
  import { joinSections } from "../util/markdown.mjs";
3
3
  import { renderSuggestionBlock, renderLineRange } from "./suggestion-renderer.mjs";
4
- import { renderThreadBullet, renderReviewBullet, renderThreadResolutionStatusTag, renderAuthor, buildFirstLookBullets, } from "./list-formatters.mjs";
4
+ import { renderThreadBullet, renderReviewBullet, renderThreadResolutionStatusTag, renderAuthor, buildFirstLookBullets, renderThreadConversation, blockquote, } from "./list-formatters.mjs";
5
5
  import { adaptFixCodeInstructions, numberInstructions } from "./iterate-instructions.mjs";
6
6
  export function formatFixCodeResult(header, result, opts) {
7
7
  const readyDelaySuffix = opts?.readyDelaySuffix;
@@ -14,7 +14,7 @@ export function formatFixCodeResult(header, result, opts) {
14
14
  const heading = t.url ? `[threadId=${t.id}](${t.url})` : `\`threadId=${t.id}\``;
15
15
  const suggestionMarker = t.suggestion ? " [suggestion]" : "";
16
16
  sections.push(`### ${heading} — ${loc} (${renderAuthor(t.author, t.authorType)})${suggestionMarker}`);
17
- sections.push(blockquote(t.body));
17
+ sections.push(renderThreadConversation(t));
18
18
  if (t.suggestion) {
19
19
  sections.push(renderSuggestionBlock(t.suggestion, ""));
20
20
  }
@@ -57,6 +57,21 @@ export function formatFixCodeResult(header, result, opts) {
57
57
  });
58
58
  sections.push(bullets.join("\n\n"));
59
59
  }
60
+ const checksWithAnnotations = result.fix.checks.filter((ch) => (ch.annotations?.length ?? 0) > 0);
61
+ if (checksWithAnnotations.length > 0) {
62
+ sections.push("## Check annotations");
63
+ for (const ch of checksWithAnnotations) {
64
+ const workflowPrefix = ch.workflowName ? `${ch.workflowName} › ` : "";
65
+ const jobLabel = ch.jobName ? ch.jobName : ch.name;
66
+ const locator = ch.runId
67
+ ? `\`${ch.runId}\``
68
+ : ch.detailsUrl
69
+ ? `external \`${ch.detailsUrl}\``
70
+ : "(no runId)";
71
+ sections.push(`### ${locator} — \`${workflowPrefix}${jobLabel}\``);
72
+ sections.push(ch.annotations.map(renderCheckAnnotation).join("\n\n"));
73
+ }
74
+ }
60
75
  if (result.fix.changesRequestedReviews.length > 0) {
61
76
  sections.push("## Changes-requested reviews");
62
77
  sections.push(result.fix.changesRequestedReviews.map((r) => renderReviewBullet(r)).join("\n"));
@@ -112,10 +127,23 @@ export function formatFixCodeResult(header, result, opts) {
112
127
  sections.push(numberInstructions(adaptFixCodeInstructions(result.fix.instructions, result.pr, readyDelaySuffix)));
113
128
  return joinSections(sections);
114
129
  }
115
- function blockquote(body) {
116
- return body
117
- .replace(/\r\n/g, "\n")
118
- .split("\n")
119
- .map((line) => (line === "" ? ">" : `> ${line}`))
120
- .join("\n");
130
+ function renderCheckAnnotation(a) {
131
+ const loc = `${a.path}:${renderAnnotationRange(a)}`;
132
+ const link = a.blobUrl ? ` [↗](${a.blobUrl})` : "";
133
+ const title = a.title ? ` — ${a.title}` : "";
134
+ const lines = [`- \`${a.id}\`${link} \`${loc}\` [${a.level}]${title}`];
135
+ if (a.message.trim() !== "")
136
+ lines.push(blockquote(a.message));
137
+ if (a.rawDetails !== undefined && a.rawDetails.trim() !== "")
138
+ lines.push(blockquote(a.rawDetails));
139
+ return lines.join("\n");
140
+ }
141
+ function renderAnnotationRange(a) {
142
+ if (a.startLine === null && a.endLine === null)
143
+ return "?";
144
+ const start = a.startLine ?? a.endLine;
145
+ const end = a.endLine ?? a.startLine;
146
+ if (start === end)
147
+ return String(start);
148
+ return `${start}-${end}`;
121
149
  }
@@ -111,6 +111,10 @@ export function projectIterateLean(result, opts) {
111
111
  ...(result.escalate.changesRequestedReviews.length > 0 && {
112
112
  changesRequestedReviews: result.escalate.changesRequestedReviews,
113
113
  }),
114
+ ...(result.escalate.stalledChecks &&
115
+ result.escalate.stalledChecks.length > 0 && {
116
+ stalledChecks: result.escalate.stalledChecks,
117
+ }),
114
118
  ...(result.escalate.thrashHistory &&
115
119
  result.escalate.thrashHistory.length > 0 && {
116
120
  thrashHistory: result.escalate.thrashHistory,
@@ -1,4 +1,5 @@
1
1
  import { renderLineRange, renderSuggestionBlock } from "./suggestion-renderer.mjs";
2
+ import { threadComments } from "../threads/transcript.mjs";
2
3
  const BODY_PREVIEW_MAX = 100;
3
4
  export function renderAuthor(author, authorType) {
4
5
  return authorType ? `@${author} · ${authorType}` : `@${author}`;
@@ -27,12 +28,60 @@ export function renderThreadBullet(t, opts = {}) {
27
28
  : "`(no location)`";
28
29
  const suggestionMarker = t.suggestion ? " [suggestion]" : "";
29
30
  const statusSuffix = opts.statusTag ? ` ${opts.statusTag}` : "";
30
- const bodySuffix = opts.noBody ? "" : `: ${renderBodyPreview(t.body)}`;
31
- const bulletLine = `- \`threadId=${t.id}\`${link} ${loc} (${renderAuthor(t.author, t.authorType)})${suggestionMarker}${statusSuffix}${bodySuffix}`;
31
+ const bulletLine = `- \`threadId=${t.id}\`${link} ${loc} (${renderAuthor(t.author, t.authorType)})${suggestionMarker}${statusSuffix}`;
32
+ if (!opts.noBody && (!t.comments || t.comments.length === 0)) {
33
+ const legacyLine = `${bulletLine}: ${renderBodyPreview(t.body)}`;
34
+ return t.suggestion && opts.renderSuggestion
35
+ ? `${legacyLine}\n${renderSuggestionBlock(t.suggestion)}`
36
+ : legacyLine;
37
+ }
38
+ const parts = [bulletLine];
39
+ if (!opts.noBody) {
40
+ parts.push(renderThreadCommentBullets(t));
41
+ }
32
42
  if (t.suggestion && opts.renderSuggestion) {
33
- return `${bulletLine}\n${renderSuggestionBlock(t.suggestion)}`;
43
+ parts.push(renderSuggestionBlock(t.suggestion));
34
44
  }
35
- return bulletLine;
45
+ return parts.join("\n");
46
+ }
47
+ export function renderThreadConversation(t) {
48
+ if (!t.comments || t.comments.length === 0)
49
+ return blockquote(t.body);
50
+ return threadComments(t)
51
+ .map((c) => {
52
+ const heading = c.id
53
+ ? c.url
54
+ ? `#### [commentId=${c.id}](${c.url}) (${renderAuthor(c.author, c.authorType)})`
55
+ : `#### \`commentId=${c.id}\` (${renderAuthor(c.author, c.authorType)})`
56
+ : `#### (${renderAuthor(c.author, c.authorType)})`;
57
+ return `${heading}\n\n${blockquote(c.body)}`;
58
+ })
59
+ .join("\n\n");
60
+ }
61
+ export function blockquote(body) {
62
+ return body
63
+ .replace(/\r\n/g, "\n")
64
+ .split("\n")
65
+ .map((line) => (line === "" ? ">" : `> ${line}`))
66
+ .join("\n");
67
+ }
68
+ function renderThreadCommentBullets(t) {
69
+ return threadComments(t)
70
+ .map((c) => {
71
+ const link = c.url ? ` [↗](${c.url})` : "";
72
+ const id = c.id ? `\`commentId=${c.id}\`` : "comment";
73
+ return [
74
+ ` - ${id}${link} (${renderAuthor(c.author, c.authorType)})`,
75
+ indentBlockquote(c.body, " "),
76
+ ].join("\n");
77
+ })
78
+ .join("\n");
79
+ }
80
+ function indentBlockquote(body, indent) {
81
+ return blockquote(body)
82
+ .split("\n")
83
+ .map((line) => `${indent}${line}`)
84
+ .join("\n");
36
85
  }
37
86
  export function renderCommentBullet(c, opts = {}) {
38
87
  const link = c.url ? ` [↗](${c.url})` : "";
@@ -0,0 +1,40 @@
1
+ import { fetchCheckRunAnnotations } from "../github/check-annotations.mjs";
2
+ export async function attachUnseenCheckAnnotations(checks, seenMap, prNumber) {
3
+ const checksWithAnnotations = [];
4
+ for (const check of checks) {
5
+ // eslint-disable-next-line no-await-in-loop
6
+ checksWithAnnotations.push(await attachForCheck(check, seenMap, prNumber));
7
+ }
8
+ return checksWithAnnotations;
9
+ }
10
+ async function attachForCheck(check, seenMap, prNumber) {
11
+ if (check.id == null)
12
+ return check;
13
+ let annotations;
14
+ try {
15
+ annotations = await fetchCheckRunAnnotations(check.id);
16
+ }
17
+ catch (err) {
18
+ const msg = err instanceof Error ? err.message : String(err);
19
+ process.stderr.write(`pr-shepherd: annotation fetch failed for PR #${prNumber} check "${check.name}" (ignored): ${msg}\n`);
20
+ return check;
21
+ }
22
+ const unseen = annotations.filter((a) => !seenMap.has(a.id));
23
+ if (unseen.length === 0)
24
+ return check;
25
+ return { ...check, annotations: unseen };
26
+ }
27
+ export function annotationMarkerBody(a) {
28
+ return JSON.stringify({
29
+ path: a.path,
30
+ startLine: a.startLine,
31
+ endLine: a.endLine,
32
+ startColumn: a.startColumn,
33
+ endColumn: a.endColumn,
34
+ level: a.level,
35
+ title: a.title,
36
+ message: a.message,
37
+ rawDetails: a.rawDetails,
38
+ blobUrl: a.blobUrl,
39
+ });
40
+ }
@@ -9,9 +9,11 @@ import { deriveMergeStatus } from "../merge-status/derive.mjs";
9
9
  import { loadConfig } from "../config/load.mjs";
10
10
  import { classifyVisibleComments } from "../comments/visible-comments.mjs";
11
11
  import { computeStatus } from "./check-status.mjs";
12
+ import { attachUnseenCheckAnnotations } from "./check-annotations.mjs";
12
13
  import { buildTerminalReport } from "./check-terminal-report.mjs";
13
14
  import { isBlockedByFilteredCheck, refreshReadyMergeability, refreshUnknownMergeability, } from "./ready-mergeability.mjs";
14
15
  import { loadSeenMap, markSeen, classifyItem } from "../state/seen-comments.mjs";
16
+ import { threadTranscriptBody } from "../threads/transcript.mjs";
15
17
  export async function runCheck(opts) {
16
18
  const repo = await getRepoInfo();
17
19
  const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
@@ -38,8 +40,10 @@ export async function runCheck(opts) {
38
40
  const inProgress = classifiedChecks.filter((c) => c.category === "in_progress");
39
41
  const skipped = classifiedChecks.filter((c) => c.category === "skipped");
40
42
  const filtered = classifiedChecks.filter((c) => c.category === "filtered");
41
- const triaged = failing.length > 0 && !opts.skipTriage ? await triageFailingChecks(failing, repo) : failing;
43
+ const triagedBase = failing.length > 0 && !opts.skipTriage ? await triageFailingChecks(failing, repo) : failing;
42
44
  const stateKey = { owner: repo.owner, repo: repo.name, pr: prNumber };
45
+ const seenMap = await loadSeenMap(stateKey);
46
+ const triaged = await attachUnseenCheckAnnotations(triagedBase, seenMap, prNumber);
43
47
  const unresolvedThreads = batchData.reviewThreads.filter((t) => !t.isResolved);
44
48
  const outdated = getOutdatedThreads(unresolvedThreads);
45
49
  let autoResolved = [];
@@ -55,12 +59,11 @@ export async function runCheck(opts) {
55
59
  const resolvedCandidates = batchData.reviewThreads.filter((t) => t.isResolved && !t.isOutdated);
56
60
  const minimizedThreadCandidates = batchData.reviewThreads.filter((t) => t.isMinimized && !t.isResolved && !t.isOutdated);
57
61
  const minimizedCommentCandidates = batchData.comments.filter((c) => c.isMinimized);
58
- const seenMap = await loadSeenMap(stateKey);
59
62
  const visibleCommentClassification = classifyVisibleComments(batchData.comments, seenMap, config.iterate.minimizeComments);
60
63
  const autoResolvedIds = new Set(autoResolved.map((t) => t.id));
61
64
  const firstLookThreads = [
62
65
  ...outdatedCandidates.flatMap((t) => {
63
- const cls = classifyItem(t.id, t.body, seenMap);
66
+ const cls = classifyItem(t.id, threadTranscriptBody(t), seenMap);
64
67
  if (cls === "unchanged")
65
68
  return [];
66
69
  const base = {
@@ -71,14 +74,14 @@ export async function runCheck(opts) {
71
74
  return cls === "edited" ? [{ ...base, edited: true }] : [base];
72
75
  }),
73
76
  ...resolvedCandidates.flatMap((t) => {
74
- const cls = classifyItem(t.id, t.body, seenMap);
77
+ const cls = classifyItem(t.id, threadTranscriptBody(t), seenMap);
75
78
  if (cls === "unchanged")
76
79
  return [];
77
80
  const base = { ...t, firstLookStatus: "resolved" };
78
81
  return cls === "edited" ? [{ ...base, edited: true }] : [base];
79
82
  }),
80
83
  ...minimizedThreadCandidates.flatMap((t) => {
81
- const cls = classifyItem(t.id, t.body, seenMap);
84
+ const cls = classifyItem(t.id, threadTranscriptBody(t), seenMap);
82
85
  if (cls === "unchanged")
83
86
  return [];
84
87
  const base = { ...t, firstLookStatus: "minimized" };
@@ -105,7 +108,7 @@ export async function runCheck(opts) {
105
108
  seenSummaries.push(r);
106
109
  }
107
110
  await Promise.allSettled([
108
- ...firstLookThreads.map((t) => markSeen(stateKey, t.id, t.body)),
111
+ ...firstLookThreads.map((t) => markSeen(stateKey, t.id, threadTranscriptBody(t))),
109
112
  ...firstLookComments.map((c) => markSeen(stateKey, c.id, c.body)),
110
113
  ...visibleCommentClassification.toMarkSeen.map((c) => markSeen(stateKey, c.id, c.body)),
111
114
  ...[...firstLookSummaries, ...editedSummaries].map((r) => markSeen(stateKey, r.id, r.body)),
@@ -9,6 +9,9 @@ vi.mock("../checks/triage.mts", () => ({
9
9
  triageFailingChecks: vi.fn((checks) => Promise.resolve(checks)),
10
10
  fetchStartupFailureChecks: vi.fn().mockResolvedValue([]),
11
11
  }));
12
+ vi.mock("../github/check-annotations.mts", () => ({
13
+ fetchCheckRunAnnotations: vi.fn().mockResolvedValue([]),
14
+ }));
12
15
  vi.mock("../comments/resolve.mts", () => ({
13
16
  autoResolveOutdated: vi.fn().mockResolvedValue({ resolved: [], errors: [] }),
14
17
  }));
@@ -26,6 +29,7 @@ import { runCheck } from "./check.mjs";
26
29
  import { fetchPrBatch } from "../github/batch.mjs";
27
30
  import { getCurrentPrNumber, getMergeableState } from "../github/client.mjs";
28
31
  import { fetchStartupFailureChecks, triageFailingChecks } from "../checks/triage.mjs";
32
+ import { fetchCheckRunAnnotations } from "../github/check-annotations.mjs";
29
33
  import { loadSeenMap, markSeen, hashBody } from "../state/seen-comments.mjs";
30
34
  import { autoResolveOutdated } from "../comments/resolve.mjs";
31
35
  const mockFetchPrBatch = vi.mocked(fetchPrBatch);
@@ -33,6 +37,7 @@ const mockGetCurrentPrNumber = vi.mocked(getCurrentPrNumber);
33
37
  const mockGetMergeableState = vi.mocked(getMergeableState);
34
38
  const mockTriageFailingChecks = vi.mocked(triageFailingChecks);
35
39
  const mockFetchStartupFailureChecks = vi.mocked(fetchStartupFailureChecks);
40
+ const mockFetchCheckRunAnnotations = vi.mocked(fetchCheckRunAnnotations);
36
41
  const mockLoadSeenMap = vi.mocked(loadSeenMap);
37
42
  const mockMarkSeen = vi.mocked(markSeen);
38
43
  const mockAutoResolveOutdated = vi.mocked(autoResolveOutdated);
@@ -41,7 +46,7 @@ function defaultConfig() {
41
46
  return {
42
47
  iterate: {
43
48
  fixAttemptsPerThread: 3,
44
- stallTimeoutMinutes: 30,
49
+ stallTimeoutMinutes: 60,
45
50
  minimizeApprovals: false,
46
51
  minimizeComments: "all",
47
52
  },
@@ -63,6 +68,7 @@ function defaultConfig() {
63
68
  }
64
69
  function makeCheck(overrides = {}) {
65
70
  return {
71
+ id: null,
66
72
  name: "tests",
67
73
  status: "COMPLETED",
68
74
  conclusion: "SUCCESS",
@@ -135,6 +141,7 @@ export function registerHooks() {
135
141
  mockFetchPrBatch.mockResolvedValue({ data: makeBatchData() });
136
142
  mockGetMergeableState.mockResolvedValue({ mergeable: "MERGEABLE", mergeStateStatus: "CLEAN" });
137
143
  mockFetchStartupFailureChecks.mockResolvedValue([]);
144
+ mockFetchCheckRunAnnotations.mockResolvedValue([]);
138
145
  });
139
146
  }
140
- export { BASE_OPTS, autoResolveOutdated, defaultConfig, fetchPrBatch, fetchStartupFailureChecks, getCurrentPrNumber, getMergeableState, hashBody, loadSeenMap, makeBatchData, makeCheck, makeComment, makeThread, markSeen, mockAutoResolveOutdated, mockFetchPrBatch, mockFetchStartupFailureChecks, mockGetCurrentPrNumber, mockGetMergeableState, mockLoadConfig, mockLoadSeenMap, mockMarkSeen, mockTriageFailingChecks, runCheck, triageFailingChecks, };
147
+ export { BASE_OPTS, autoResolveOutdated, defaultConfig, fetchPrBatch, fetchStartupFailureChecks, fetchCheckRunAnnotations, getCurrentPrNumber, getMergeableState, hashBody, loadSeenMap, makeBatchData, makeCheck, makeComment, makeThread, markSeen, mockAutoResolveOutdated, mockFetchPrBatch, mockFetchStartupFailureChecks, mockFetchCheckRunAnnotations, mockGetCurrentPrNumber, mockGetMergeableState, mockLoadConfig, mockLoadSeenMap, mockMarkSeen, mockTriageFailingChecks, runCheck, triageFailingChecks, };
@@ -52,11 +52,25 @@ export function buildEscalateHumanMessage(escalate, pr) {
52
52
  lines.push(escalate.suggestion);
53
53
  const hasItems = escalate.unresolvedThreads.length > 0 ||
54
54
  escalate.changesRequestedReviews.length > 0 ||
55
- escalate.ambiguousComments.length > 0;
55
+ escalate.ambiguousComments.length > 0 ||
56
+ (escalate.stalledChecks?.length ?? 0) > 0;
56
57
  if (hasItems) {
57
58
  lines.push("");
58
59
  lines.push("## Items needing attention");
59
60
  lines.push("");
61
+ for (const c of escalate.stalledChecks ?? []) {
62
+ const target = c.runId
63
+ ? `run \`${c.runId}\``
64
+ : c.detailsUrl
65
+ ? `external \`${c.detailsUrl}\``
66
+ : "no run ID";
67
+ const ageMinutes = Math.floor(c.ageSeconds / 60);
68
+ lines.push(`- check \`${c.name}\` — ${c.status} ${c.source}, ${target}, waiting ${ageMinutes} minute${ageMinutes === 1 ? "" : "s"}`);
69
+ if (c.summary)
70
+ lines.push(` > ${c.summary}`);
71
+ }
72
+ if ((escalate.stalledChecks?.length ?? 0) > 0)
73
+ lines.push("");
60
74
  for (const t of escalate.unresolvedThreads) {
61
75
  const loc = t.path ? `\`${t.path}:${t.line ?? "?"}\`` : "(no location)";
62
76
  lines.push(`- thread \`${t.id}\` — ${loc} (@${t.author}):`);
@@ -96,7 +110,7 @@ export function buildEscalateHumanMessage(escalate, pr) {
96
110
  }
97
111
  export function buildEscalateSuggestion(triggers, detail) {
98
112
  if (triggers.includes("stall-timeout")) {
99
- const mins = detail ?? "30";
113
+ const mins = detail ?? "60";
100
114
  return `No progress detected for ${mins} minute${parseInt(mins, 10) === 1 ? "" : "s"} — state has not changed. This is a manual checkpoint: inspect the PR and apply a manual fix before resuming.`;
101
115
  }
102
116
  if (triggers.includes("base-branch-unknown")) {
@@ -1,11 +1,13 @@
1
1
  /* eslint-disable max-lines */
2
2
  import { readFixAttempts, writeFixAttempts } from "../../state/fix-attempts.mjs";
3
3
  import { toAgentThread, toAgentComment, toAgentChecks } from "../../reporters/agent.mjs";
4
+ import { markSeen } from "../../state/seen-comments.mjs";
4
5
  import { checkEscalateTriggers, validateBaseBranch, buildEscalateSuggestion, buildEscalateHumanMessage, } from "./escalate.mjs";
5
6
  import { buildResolveCommand } from "./classify.mjs";
6
7
  import { buildFixInstructions } from "./render.mjs";
7
8
  import { applyStallGuard } from "./stall.mjs";
8
9
  import { tryCancelRun, buildInProgressRunIds } from "./helpers.mjs";
10
+ import { annotationMarkerBody } from "../check-annotations.mjs";
9
11
  export async function handleFixCode(ctx) {
10
12
  const { base, report, opts, headSha, stallKey, prNumber, stallTimeoutSeconds, repoOwner, repoName, reviewSummaryIds, firstLookSummaries, editedSummaries, surfacedApprovals, } = ctx;
11
13
  const failingChecks = report.checks.failing;
@@ -101,7 +103,7 @@ export async function handleFixCode(ctx) {
101
103
  const firstLookThreads = report.threads.firstLook;
102
104
  const firstLookComments = report.comments.firstLook;
103
105
  const instructions = buildFixInstructions(threads, actionableComments, checks, changesRequestedReviews, baseLookup.branch, resolveCommand, hasConflicts, prNumber, cancelled.length, firstLookThreads, firstLookComments, firstLookSummaries, editedSummaries, inProgressRunIds, resolutionOnlyThreads);
104
- return applyStallGuard(stallKey, stallTimeoutSeconds, headSha, base, prNumber, {
106
+ const prospectiveResult = {
105
107
  ...base,
106
108
  baseBranch: baseLookup.branch,
107
109
  action: "fix_code",
@@ -122,5 +124,10 @@ export async function handleFixCode(ctx) {
122
124
  inProgressRunIds,
123
125
  },
124
126
  cancelled,
125
- }, report, reviewSummaryIds);
127
+ };
128
+ const result = await applyStallGuard(stallKey, stallTimeoutSeconds, headSha, base, prNumber, prospectiveResult, report, reviewSummaryIds);
129
+ if (result.action === "fix_code") {
130
+ await Promise.allSettled(result.fix.checks.flatMap((ch) => (ch.annotations ?? []).map((a) => markSeen(stallKey, a.id, annotationMarkerBody(a)))));
131
+ }
132
+ return result;
126
133
  }
@@ -53,6 +53,7 @@ export function buildRelevantChecks(report) {
53
53
  ...(c.jobName !== undefined && { jobName: c.jobName }),
54
54
  ...(c.failedStep !== undefined && { failedStep: c.failedStep }),
55
55
  ...(c.summary !== undefined && { summary: c.summary }),
56
+ ...(c.annotations !== undefined && { annotations: c.annotations }),
56
57
  },
57
58
  ];
58
59
  });
@@ -30,6 +30,9 @@ export function buildFixInstructions(threads, actionableComments, checks, change
30
30
  actionableSections.push("`## Actionable comments`");
31
31
  if (checks.length > 0)
32
32
  actionableSections.push("`## Failing checks`");
33
+ if (checks.some((c) => (c.annotations?.length ?? 0) > 0)) {
34
+ actionableSections.push("`## Check annotations`");
35
+ }
33
36
  if (changesRequestedReviews.length > 0)
34
37
  actionableSections.push("`## Changes-requested reviews`");
35
38
  const sectionRef = actionableSections.length > 0 ? `under ${actionableSections.join(", ")}` : "above";
@@ -65,6 +68,9 @@ export function buildFixInstructions(threads, actionableComments, checks, change
65
68
  instructions.push(`Resolve the threads under \`## Review threads to resolve\` with the \`resolve:\` command shown below. 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.`);
66
69
  }
67
70
  instructions.push(...buildFailingCheckInstructions(checks));
71
+ if (checks.some((c) => (c.annotations?.length ?? 0) > 0)) {
72
+ instructions.push(`For each item under \`## Check annotations\`: inspect the referenced file range and decide whether the annotation requires a code change. These annotations are surfaced once per PR and do not need any resolve/minimize mutation.`);
73
+ }
68
74
  if (changesRequestedReviews.length > 0) {
69
75
  instructions.push(`For each bullet under \`## Changes-requested reviews\` above: read the review body and apply the requested changes.`);
70
76
  }
@@ -1,5 +1,5 @@
1
1
  import { readStallState, writeStallState } from "../../state/iterate-stall.mjs";
2
- import { toAgentThread, toAgentComment } from "../../reporters/agent.mjs";
2
+ import { toAgentThread, toAgentComment, toAgentStalledCheck } from "../../reporters/agent.mjs";
3
3
  import { buildEscalateSuggestion, buildEscalateHumanMessage } from "./escalate.mjs";
4
4
  export function computeStallFingerprint(action, headSha, base, report, reviewSummaryIds) {
5
5
  const checks = [
@@ -27,8 +27,31 @@ export function computeStallFingerprint(action, headSha, base, report, reviewSum
27
27
  });
28
28
  }
29
29
  export async function applyStallGuard(stallKey, stallTimeoutSeconds, headSha, base, prNumber, prospectiveResult, report, reviewSummaryIds) {
30
- const fingerprint = computeStallFingerprint(prospectiveResult.action, headSha, base, report, reviewSummaryIds);
31
30
  const nowSeconds = Math.floor(Date.now() / 1000);
31
+ const stalledChecks = findCiStartStalledChecks(report.checks.inProgress, nowSeconds, {
32
+ stallTimeoutSeconds,
33
+ action: prospectiveResult.action,
34
+ });
35
+ if (stalledChecks.length > 0) {
36
+ const stalledMinutes = Math.floor(Math.max(...stalledChecks.map((c) => c.ageSeconds)) / 60);
37
+ const escalateBase = {
38
+ triggers: ["stall-timeout"],
39
+ unresolvedThreads: [],
40
+ ambiguousComments: [],
41
+ changesRequestedReviews: [],
42
+ stalledChecks,
43
+ suggestion: buildEscalateSuggestion(["stall-timeout"], String(stalledMinutes)),
44
+ };
45
+ return {
46
+ ...base,
47
+ action: "escalate",
48
+ escalate: {
49
+ ...escalateBase,
50
+ humanMessage: buildEscalateHumanMessage(escalateBase, prNumber),
51
+ },
52
+ };
53
+ }
54
+ const fingerprint = computeStallFingerprint(prospectiveResult.action, headSha, base, report, reviewSummaryIds);
32
55
  const stored = await readStallState(stallKey);
33
56
  if (stored && stored.fingerprint === fingerprint) {
34
57
  const ageSeconds = nowSeconds - stored.firstSeenAt;
@@ -65,3 +88,21 @@ export async function applyStallGuard(stallKey, stallTimeoutSeconds, headSha, ba
65
88
  await writeStallState(stallKey, { fingerprint, firstSeenAt: nowSeconds });
66
89
  return prospectiveResult;
67
90
  }
91
+ function findCiStartStalledChecks(checks, nowSeconds, opts) {
92
+ if (opts.stallTimeoutSeconds <= 0 || opts.action !== "wait")
93
+ return [];
94
+ return checks
95
+ .filter((c) => isUnstartedCheck(c))
96
+ .map((c) => toAgentStalledCheck(c, nowSeconds))
97
+ .filter((c) => c.createdAtUnix !== undefined && c.ageSeconds >= opts.stallTimeoutSeconds);
98
+ }
99
+ function isUnstartedCheck(check) {
100
+ if (check.source === "status_context")
101
+ return true;
102
+ if (check.startedAtUnix !== undefined)
103
+ return false;
104
+ return (check.status === "PENDING" ||
105
+ check.status === "QUEUED" ||
106
+ check.status === "REQUESTED" ||
107
+ check.status === "WAITING");
108
+ }
@@ -103,7 +103,7 @@ function defaultConfig() {
103
103
  return {
104
104
  iterate: {
105
105
  fixAttemptsPerThread: 3,
106
- stallTimeoutMinutes: 30,
106
+ stallTimeoutMinutes: 60,
107
107
  minimizeApprovals: false,
108
108
  minimizeComments: "all",
109
109
  },
@@ -23,6 +23,9 @@ vi.mock("../state/iterate-stall.mts", () => ({
23
23
  readStallState: vi.fn().mockResolvedValue(null),
24
24
  writeStallState: vi.fn().mockResolvedValue(undefined),
25
25
  }));
26
+ vi.mock("../state/seen-comments.mts", () => ({
27
+ markSeen: vi.fn().mockResolvedValue(undefined),
28
+ }));
26
29
  const { mockLoadConfig } = vi.hoisted(() => ({ mockLoadConfig: vi.fn() }));
27
30
  vi.mock("../config/load.mts", () => ({ loadConfig: mockLoadConfig }));
28
31
  import { runIterate } from "./iterate/index.mjs";
@@ -30,12 +33,14 @@ import { runCheck } from "./check.mjs";
30
33
  import { updateReadyDelay } from "./ready-delay.mjs";
31
34
  import { readFixAttempts, writeFixAttempts } from "../state/fix-attempts.mjs";
32
35
  import { readStallState, writeStallState } from "../state/iterate-stall.mjs";
36
+ import { markSeen } from "../state/seen-comments.mjs";
33
37
  const mockRunCheck = vi.mocked(runCheck);
34
38
  const mockUpdateReadyDelay = vi.mocked(updateReadyDelay);
35
39
  const mockReadFixAttempts = vi.mocked(readFixAttempts);
36
40
  const mockWriteFixAttempts = vi.mocked(writeFixAttempts);
37
41
  const mockReadStallState = vi.mocked(readStallState);
38
42
  const mockWriteStallState = vi.mocked(writeStallState);
43
+ const mockMarkSeen = vi.mocked(markSeen);
39
44
  function makeReport(overrides = {}) {
40
45
  return {
41
46
  pr: 42,
@@ -115,4 +120,4 @@ export function registerHooks() {
115
120
  vi.restoreAllMocks();
116
121
  });
117
122
  }
118
- export { makeOpts, makeReport, mockExecFile, mockFetch, mockLoadConfig, mockReadFixAttempts, mockReadStallState, mockRunCheck, mockUpdateReadyDelay, mockWriteFixAttempts, mockWriteStallState, readFixAttempts, readStallState, runCheck, runIterate, updateReadyDelay, writeFixAttempts, writeStallState, };
123
+ export { makeOpts, makeReport, mockExecFile, mockFetch, mockLoadConfig, mockReadFixAttempts, mockReadStallState, mockMarkSeen, mockRunCheck, mockUpdateReadyDelay, mockWriteFixAttempts, mockWriteStallState, readFixAttempts, readStallState, runCheck, runIterate, updateReadyDelay, writeFixAttempts, writeStallState, };
@@ -7,6 +7,7 @@ import { classifyVisibleComments } from "../comments/visible-comments.mjs";
7
7
  import { extractSuggestion } from "../suggestions/extract.mjs";
8
8
  import { buildFetchInstructions } from "./resolve-instructions.mjs";
9
9
  import { loadSeenMap, markSeen, classifyItem } from "../state/seen-comments.mjs";
10
+ import { threadTranscriptBody } from "../threads/transcript.mjs";
10
11
  export { runResolveMutate } from "./resolve-mutate.mjs";
11
12
  export async function runResolveFetch(opts) {
12
13
  const repo = await getRepoInfo();
@@ -25,7 +26,7 @@ export async function runResolveFetch(opts) {
25
26
  const unseenOutdated = [];
26
27
  const editedOutdated = [];
27
28
  for (const t of outdatedCandidates) {
28
- const cls = classifyItem(t.id, t.body, seenMap);
29
+ const cls = classifyItem(t.id, threadTranscriptBody(t), seenMap);
29
30
  if (cls === "new")
30
31
  unseenOutdated.push(t);
31
32
  else if (cls === "edited")
@@ -34,7 +35,7 @@ export async function runResolveFetch(opts) {
34
35
  const unseenResolved = [];
35
36
  const editedResolved = [];
36
37
  for (const t of resolvedCandidates) {
37
- const cls = classifyItem(t.id, t.body, seenMap);
38
+ const cls = classifyItem(t.id, threadTranscriptBody(t), seenMap);
38
39
  if (cls === "new")
39
40
  unseenResolved.push(t);
40
41
  else if (cls === "edited")
@@ -43,7 +44,7 @@ export async function runResolveFetch(opts) {
43
44
  const unseenMinimizedThreads = [];
44
45
  const editedMinimizedThreads = [];
45
46
  for (const t of minimizedThreadCandidates) {
46
- const cls = classifyItem(t.id, t.body, seenMap);
47
+ const cls = classifyItem(t.id, threadTranscriptBody(t), seenMap);
47
48
  if (cls === "new")
48
49
  unseenMinimizedThreads.push(t);
49
50
  else if (cls === "edited")
@@ -114,7 +115,7 @@ export async function runResolveFetch(opts) {
114
115
  ];
115
116
  // Mark new and edited items as seen (best-effort — markSeen never throws).
116
117
  await Promise.allSettled([
117
- ...firstLookThreads.map((t) => markSeen(stateKey, t.id, t.body)),
118
+ ...firstLookThreads.map((t) => markSeen(stateKey, t.id, threadTranscriptBody(t))),
118
119
  ...firstLookComments.map((c) => markSeen(stateKey, c.id, c.body)),
119
120
  ...visibleCommentClassification.toMarkSeen.map((c) => markSeen(stateKey, c.id, c.body)),
120
121
  ]);
package/bin/config.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "iterate": {
3
3
  "fixAttemptsPerThread": 3,
4
- "stallTimeoutMinutes": 30,
4
+ "stallTimeoutMinutes": 60,
5
5
  "minimizeApprovals": false,
6
6
  "minimizeComments": "all"
7
7
  },
@@ -10,19 +10,29 @@ export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes,
10
10
  }));
11
11
  const reviewThreads = rawThreadPages.map((t) => {
12
12
  const comment = t.comments.nodes[0];
13
+ const comments = t.comments.nodes.map((c) => ({
14
+ id: c.id,
15
+ isMinimized: c.isMinimized,
16
+ author: c.author?.login ?? "unknown",
17
+ authorType: mapAuthorType(c.author?.__typename),
18
+ body: c.body,
19
+ url: c.url,
20
+ createdAtUnix: parseCreatedAt(c.createdAt),
21
+ }));
13
22
  return {
14
23
  id: t.id,
15
24
  isResolved: t.isResolved,
16
25
  isOutdated: t.isOutdated,
17
26
  isMinimized: comment?.isMinimized ?? false,
18
- path: comment?.path ?? null,
19
- line: comment?.line ?? null,
20
- startLine: comment?.startLine ?? null,
27
+ path: t.path ?? comment?.path ?? null,
28
+ line: t.line ?? comment?.line ?? null,
29
+ startLine: t.startLine ?? comment?.startLine ?? null,
21
30
  author: comment?.author?.login ?? "unknown",
22
31
  authorType: mapAuthorType(comment?.author?.__typename),
23
32
  body: comment?.body ?? "",
24
33
  url: comment?.url ?? "",
25
34
  createdAtUnix: comment ? parseCreatedAt(comment.createdAt) : 0,
35
+ comments,
26
36
  };
27
37
  });
28
38
  const comments = rawCommentNodes.map((c) => ({
@@ -64,14 +74,28 @@ export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes,
64
74
  const event = node.checkSuite?.workflowRun?.event ?? null;
65
75
  const runId = extractRunId(node.detailsUrl);
66
76
  const summary = extractCheckRunSummary(node.title, node.summary);
77
+ const rawCreatedAt = node.checkSuite
78
+ ? (node.checkSuite.workflowRun?.createdAt ?? node.checkSuite.createdAt)
79
+ : undefined;
80
+ const rawUpdatedAt = node.checkSuite
81
+ ? (node.checkSuite.workflowRun?.updatedAt ?? node.checkSuite.updatedAt)
82
+ : undefined;
83
+ const createdAtUnix = rawCreatedAt ? parseCreatedAt(rawCreatedAt) : undefined;
84
+ const startedAtUnix = node.startedAt ? parseCreatedAt(node.startedAt) : undefined;
85
+ const updatedAtUnix = rawUpdatedAt ? parseCreatedAt(rawUpdatedAt) : undefined;
67
86
  return [
68
87
  {
88
+ id: node.id,
69
89
  name: node.name,
70
90
  status: node.status,
71
91
  conclusion: node.conclusion,
92
+ source: "check_run",
72
93
  detailsUrl: node.detailsUrl ?? "",
73
94
  event,
74
95
  runId,
96
+ ...(createdAtUnix !== undefined && { createdAtUnix }),
97
+ ...(startedAtUnix !== undefined && { startedAtUnix }),
98
+ ...(updatedAtUnix !== undefined && { updatedAtUnix }),
75
99
  ...(summary !== undefined && { summary }),
76
100
  },
77
101
  ];
@@ -79,14 +103,17 @@ export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes,
79
103
  if (node.__typename === "StatusContext") {
80
104
  const { status, conclusion } = mapStatusContextState(node.state);
81
105
  const summary = node.description?.trim() || undefined;
106
+ const createdAtUnix = node.createdAt ? parseCreatedAt(node.createdAt) : undefined;
82
107
  return [
83
108
  {
84
109
  name: node.context,
85
110
  status,
86
111
  conclusion,
112
+ source: "status_context",
87
113
  detailsUrl: node.targetUrl ?? "",
88
114
  event: null,
89
115
  runId: null,
116
+ ...(createdAtUnix !== undefined && { createdAtUnix }),
90
117
  ...(summary !== undefined && { summary }),
91
118
  },
92
119
  ];
@@ -1,5 +1,6 @@
1
1
  import { graphql, graphqlWithRateLimit } from "./client.mjs";
2
2
  import { paginateForward, paginateBackward } from "./pagination.mjs";
3
+ import { hydrateThreadCommentPages } from "./thread-comments.mjs";
3
4
  import { BATCH_PR_QUERY } from "./queries.mjs";
4
5
  import { parseRawPr } from "./batch-parsers.mjs";
5
6
  /**
@@ -36,6 +37,7 @@ export async function fetchPrBatch(pr, repo, opts = {}) {
36
37
  // extra contains pages before the first page.
37
38
  rawThreadPages = [...extra, ...rawThreadPages];
38
39
  }
40
+ rawThreadPages = await hydrateThreadCommentPages(rawThreadPages);
39
41
  // Paginate comments backward if the first page is incomplete.
40
42
  let rawCommentNodes = raw.comments.nodes;
41
43
  if (raw.comments.pageInfo.hasPreviousPage && raw.comments.pageInfo.startCursor) {
@@ -0,0 +1,75 @@
1
+ import { createHash } from "node:crypto";
2
+ import { graphql } from "./client.mjs";
3
+ import { CHECK_RUN_ANNOTATIONS_QUERY } from "./queries.mjs";
4
+ const ANNOTATIONS_PER_PAGE = 100;
5
+ const MAX_ANNOTATION_PAGES = 10;
6
+ export async function fetchCheckRunAnnotations(checkRunId) {
7
+ let cursor = null;
8
+ const nodes = [];
9
+ for (let page = 1; page <= MAX_ANNOTATION_PAGES; page++) {
10
+ // eslint-disable-next-line no-await-in-loop
11
+ const result = await fetchAnnotationPage(checkRunId, cursor);
12
+ nodes.push(...result.nodes);
13
+ if (!result.pageInfo.hasNextPage || !result.pageInfo.endCursor)
14
+ break;
15
+ if (page === MAX_ANNOTATION_PAGES) {
16
+ process.stderr.write(`pr-shepherd: annotation pagination cap (${MAX_ANNOTATION_PAGES * ANNOTATIONS_PER_PAGE} annotations) reached for check run ${checkRunId} — annotation output may be incomplete\n`);
17
+ break;
18
+ }
19
+ cursor = result.pageInfo.endCursor;
20
+ }
21
+ return nodes.map((node) => toCheckAnnotation(checkRunId, node));
22
+ }
23
+ async function fetchAnnotationPage(checkRunId, cursor) {
24
+ const res = await graphql(CHECK_RUN_ANNOTATIONS_QUERY, {
25
+ id: checkRunId,
26
+ ...(cursor ? { cursor } : {}),
27
+ });
28
+ const node = res.data.node;
29
+ if (node?.__typename !== "CheckRun" || node.annotations === undefined) {
30
+ return { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] };
31
+ }
32
+ return node.annotations;
33
+ }
34
+ function toCheckAnnotation(checkRunId, raw) {
35
+ const id = `check_annotation_${raw.fullDatabaseId ?? fallbackId(checkRunId, raw)}`;
36
+ const title = raw.title?.trim() || undefined;
37
+ const rawDetails = raw.rawDetails?.trim() || undefined;
38
+ const blobUrl = raw.blobUrl?.trim() || undefined;
39
+ return {
40
+ id,
41
+ path: raw.path,
42
+ startLine: raw.location?.start.line ?? null,
43
+ endLine: raw.location?.end.line ?? raw.location?.start.line ?? null,
44
+ ...(raw.location?.start.column !== undefined && {
45
+ startColumn: raw.location.start.column,
46
+ }),
47
+ ...(raw.location?.end.column !== undefined && {
48
+ endColumn: raw.location.end.column,
49
+ }),
50
+ level: raw.annotationLevel,
51
+ ...(title !== undefined && { title }),
52
+ message: raw.message,
53
+ ...(rawDetails !== undefined && { rawDetails }),
54
+ ...(blobUrl !== undefined && { blobUrl }),
55
+ };
56
+ }
57
+ function fallbackId(checkRunId, raw) {
58
+ const start = raw.location?.start;
59
+ const end = raw.location?.end;
60
+ const parts = [
61
+ checkRunId,
62
+ raw.path,
63
+ raw.annotationLevel,
64
+ raw.title ?? "",
65
+ raw.message,
66
+ raw.rawDetails ?? "",
67
+ raw.blobUrl ?? "",
68
+ String(start?.line ?? ""),
69
+ String(start?.column ?? ""),
70
+ String(end?.line ?? ""),
71
+ String(end?.column ?? ""),
72
+ ];
73
+ const input = parts.map((part) => `${part.length}:${part}`).join("|");
74
+ return createHash("sha256").update(input).digest("hex").slice(0, 24);
75
+ }
@@ -0,0 +1,55 @@
1
+ import { vi, beforeEach, afterEach } from "vitest";
2
+ import { _resetTokenCache } from "./http.mjs";
3
+ export const mockFetch = vi.fn();
4
+ vi.stubGlobal("fetch", mockFetch);
5
+ const { _mockExecFile } = vi.hoisted(() => ({ _mockExecFile: vi.fn() }));
6
+ export const mockExecFile = _mockExecFile;
7
+ vi.mock("node:child_process", () => ({
8
+ execFile: (cmd, args, optsOrCb, maybeCb) => {
9
+ const cb = typeof optsOrCb === "function" ? optsOrCb : maybeCb;
10
+ _mockExecFile(cmd, args)
11
+ .then((result) => cb(null, result))
12
+ .catch((err) => cb(err, { stdout: "", stderr: "" }));
13
+ },
14
+ }));
15
+ export function gqlOk(data) {
16
+ return {
17
+ ok: true,
18
+ status: 200,
19
+ headers: new Headers({ "content-type": "application/json" }),
20
+ json: () => Promise.resolve({ data }),
21
+ text: () => Promise.resolve(JSON.stringify({ data })),
22
+ };
23
+ }
24
+ export function restOk(data) {
25
+ return {
26
+ ok: true,
27
+ status: 200,
28
+ headers: new Headers({ "content-type": "application/json" }),
29
+ json: () => Promise.resolve(data),
30
+ text: () => Promise.resolve(JSON.stringify(data)),
31
+ };
32
+ }
33
+ export function gqlErrors(errors) {
34
+ return {
35
+ ok: true,
36
+ status: 200,
37
+ headers: new Headers({ "content-type": "application/json" }),
38
+ json: () => Promise.resolve({ data: null, errors }),
39
+ text: () => Promise.resolve(JSON.stringify({ data: null, errors })),
40
+ };
41
+ }
42
+ export function registerClientHooks() {
43
+ beforeEach(() => {
44
+ mockFetch.mockReset();
45
+ mockExecFile.mockReset();
46
+ _resetTokenCache();
47
+ delete process.env["GITHUB_TOKEN"];
48
+ delete process.env["GITHUB_PERSONAL_ACCESS_TOKEN"];
49
+ process.env["GH_TOKEN"] = "test-token";
50
+ });
51
+ afterEach(() => {
52
+ delete process.env["GH_TOKEN"];
53
+ _resetTokenCache();
54
+ });
55
+ }
@@ -67,8 +67,14 @@ query BatchPr(
67
67
  id
68
68
  isResolved
69
69
  isOutdated
70
- # first: 1 — we want the reviewer's original comment, not the latest reply.
71
- comments(first: 1) {
70
+ path
71
+ line
72
+ startLine
73
+ comments(first: 100) {
74
+ pageInfo {
75
+ hasNextPage
76
+ endCursor
77
+ }
72
78
  nodes {
73
79
  id
74
80
  isMinimized
@@ -164,21 +170,28 @@ query BatchPr(
164
170
  nodes {
165
171
  __typename
166
172
  ... on CheckRun {
173
+ id
167
174
  name
168
175
  status
169
176
  conclusion
170
177
  detailsUrl
178
+ startedAt
171
179
  title
172
180
  summary
173
181
  checkSuite {
182
+ createdAt
183
+ updatedAt
174
184
  workflowRun {
175
185
  event
186
+ createdAt
187
+ updatedAt
176
188
  }
177
189
  }
178
190
  }
179
191
  ... on StatusContext {
180
192
  context
181
193
  state
194
+ createdAt
182
195
  targetUrl
183
196
  description
184
197
  }
@@ -0,0 +1,32 @@
1
+ query CheckRunAnnotations($id: ID!, $cursor: String) {
2
+ node(id: $id) {
3
+ __typename
4
+ ... on CheckRun {
5
+ annotations(first: 100, after: $cursor) {
6
+ pageInfo {
7
+ hasNextPage
8
+ endCursor
9
+ }
10
+ nodes {
11
+ fullDatabaseId
12
+ path
13
+ annotationLevel
14
+ title
15
+ message
16
+ rawDetails
17
+ blobUrl
18
+ location {
19
+ start {
20
+ line
21
+ column
22
+ }
23
+ end {
24
+ line
25
+ column
26
+ }
27
+ }
28
+ }
29
+ }
30
+ }
31
+ }
32
+ }
@@ -0,0 +1,27 @@
1
+ query ReviewThreadComments($threadId: ID!, $commentsCursor: String) {
2
+ node(id: $threadId) {
3
+ __typename
4
+ ... on PullRequestReviewThread {
5
+ comments(first: 100, after: $commentsCursor) {
6
+ pageInfo {
7
+ hasNextPage
8
+ endCursor
9
+ }
10
+ nodes {
11
+ id
12
+ isMinimized
13
+ url
14
+ author {
15
+ __typename
16
+ login
17
+ }
18
+ body
19
+ path
20
+ line
21
+ startLine
22
+ createdAt
23
+ }
24
+ }
25
+ }
26
+ }
27
+ }
@@ -11,6 +11,10 @@ import { join } from "node:path";
11
11
  const gql = (name) => readFileSync(join(import.meta.dirname, "gql", name), "utf8");
12
12
  /** The primary batch query that fetches CI + comments + merge status in one round-trip. */
13
13
  export const BATCH_PR_QUERY = gql("batch-pr.gql");
14
+ /** Fetches additional comments for a single review thread when its nested connection paginates. */
15
+ export const REVIEW_THREAD_COMMENTS_QUERY = gql("review-thread-comments.gql");
16
+ /** Fetch inline annotations for a single CheckRun by node ID. */
17
+ export const CHECK_RUN_ANNOTATIONS_QUERY = gql("check-run-annotations.gql");
14
18
  /** Returns the current head commit SHA for a PR. Used by waitForSha polling. */
15
19
  export const GET_PR_HEAD_SHA_QUERY = gql("get-pr-head-sha.gql");
16
20
  /** Look up PR number by branch name (for getCurrentPrNumber). */
@@ -0,0 +1,34 @@
1
+ import { graphql } from "./client.mjs";
2
+ import { paginateForward } from "./pagination.mjs";
3
+ import { REVIEW_THREAD_COMMENTS_QUERY } from "./queries.mjs";
4
+ export async function hydrateThreadCommentPages(threads) {
5
+ const hydrated = [];
6
+ for (const thread of threads) {
7
+ hydrated.push(await hydrateThreadCommentPage(thread));
8
+ }
9
+ return hydrated;
10
+ }
11
+ async function hydrateThreadCommentPage(thread) {
12
+ const pageInfo = thread.comments.pageInfo;
13
+ if (!pageInfo?.hasNextPage || !pageInfo.endCursor)
14
+ return thread;
15
+ const extra = await paginateForward(async (cursor) => {
16
+ const res = await graphql(REVIEW_THREAD_COMMENTS_QUERY, {
17
+ threadId: thread.id,
18
+ ...(cursor ? { commentsCursor: cursor } : {}),
19
+ });
20
+ const node = res.data.node;
21
+ if (!node?.comments) {
22
+ const nodeType = node?.__typename ?? "null";
23
+ throw new Error(`Review thread ${thread.id} did not resolve to PullRequestReviewThread while paginating comments (node type: ${nodeType})`);
24
+ }
25
+ return node.comments;
26
+ }, pageInfo.endCursor);
27
+ return {
28
+ ...thread,
29
+ comments: {
30
+ pageInfo: { hasNextPage: false, endCursor: null },
31
+ nodes: [...thread.comments.nodes, ...extra],
32
+ },
33
+ };
34
+ }
@@ -22,6 +22,15 @@ export function toAgentThread(t) {
22
22
  ...(t.authorType !== undefined && { authorType: t.authorType }),
23
23
  body: t.body,
24
24
  url: t.url,
25
+ ...(t.comments !== undefined && {
26
+ comments: t.comments.map((c) => ({
27
+ id: c.id,
28
+ author: c.author,
29
+ ...(c.authorType !== undefined && { authorType: c.authorType }),
30
+ body: c.body,
31
+ url: c.url,
32
+ })),
33
+ }),
25
34
  ...(suggestion !== undefined && { suggestion }),
26
35
  };
27
36
  }
@@ -48,6 +57,23 @@ export function toAgentCheck(c) {
48
57
  ...(c.jobName !== undefined && { jobName: c.jobName }),
49
58
  ...(c.failedStep !== undefined && { failedStep: c.failedStep }),
50
59
  ...(c.summary !== undefined && { summary: c.summary }),
60
+ ...(c.annotations !== undefined && { annotations: c.annotations }),
61
+ };
62
+ }
63
+ export function toAgentStalledCheck(c, nowSeconds) {
64
+ const createdAtUnix = c.createdAtUnix ?? nowSeconds;
65
+ const activityAtUnix = c.updatedAtUnix ?? createdAtUnix;
66
+ return {
67
+ name: c.name,
68
+ status: c.status,
69
+ source: c.source ?? "check_run",
70
+ runId: c.runId,
71
+ detailsUrl: c.detailsUrl || null,
72
+ ...(c.createdAtUnix !== undefined && { createdAtUnix: c.createdAtUnix }),
73
+ ...(c.startedAtUnix !== undefined && { startedAtUnix: c.startedAtUnix }),
74
+ ...(c.updatedAtUnix !== undefined && { updatedAtUnix: c.updatedAtUnix }),
75
+ ageSeconds: Math.max(0, nowSeconds - activityAtUnix),
76
+ ...(c.summary !== undefined && { summary: c.summary }),
51
77
  };
52
78
  }
53
79
  /**
@@ -0,0 +1,19 @@
1
+ import { beforeEach, afterEach } from "vitest";
2
+ import { randomBytes, createHash } from "node:crypto";
3
+ import { rm } from "node:fs/promises";
4
+ export function idToFilename(id) {
5
+ return createHash("sha256").update(id, "utf8").digest("hex") + ".json";
6
+ }
7
+ export const testKey = { owner: "test-owner", repo: "test-repo", pr: 123 };
8
+ export const testId = "PRRT_kwDOTest123";
9
+ export let testStateDir;
10
+ export function registerHooks() {
11
+ beforeEach(() => {
12
+ testStateDir = `${process.env["TMPDIR"] ?? "/tmp"}/shepherd-seen-test-${randomBytes(4).toString("hex")}`;
13
+ process.env["PR_SHEPHERD_STATE_DIR"] = testStateDir;
14
+ });
15
+ afterEach(async () => {
16
+ delete process.env["PR_SHEPHERD_STATE_DIR"];
17
+ await rm(testStateDir, { recursive: true, force: true });
18
+ });
19
+ }
@@ -0,0 +1,31 @@
1
+ export function threadComments(thread) {
2
+ if (thread.comments && thread.comments.length > 0) {
3
+ return thread.comments.map((c) => ({
4
+ id: c.id,
5
+ isMinimized: c.isMinimized ?? false,
6
+ author: c.author,
7
+ authorType: c.authorType ?? "Unknown",
8
+ body: c.body,
9
+ url: c.url,
10
+ createdAtUnix: c.createdAtUnix ?? 0,
11
+ }));
12
+ }
13
+ return [
14
+ {
15
+ id: "",
16
+ isMinimized: false,
17
+ author: thread.author,
18
+ authorType: thread.authorType ?? "Unknown",
19
+ body: thread.body,
20
+ url: thread.url ?? "",
21
+ createdAtUnix: thread.createdAtUnix ?? 0,
22
+ },
23
+ ];
24
+ }
25
+ export function threadTranscriptBody(thread) {
26
+ if (!thread.comments || thread.comments.length === 0)
27
+ return thread.body;
28
+ return threadComments(thread)
29
+ .map((c) => `${c.id}\n${c.body}`)
30
+ .join("\n\n--- thread comment ---\n\n");
31
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
package/bin/types.mjs CHANGED
@@ -1,4 +1,7 @@
1
1
  /** Shared type definitions for the shepherd CLI. */
2
2
  export * from "./types/github.mjs";
3
+ export * from "./types/review-thread.mjs";
4
+ export * from "./types/agent-thread.mjs";
5
+ export * from "./types/check-annotations.mjs";
3
6
  export * from "./types/report.mjs";
4
7
  export * from "./types/iterate.mjs";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
- "version": "0.21.0",
3
+ "version": "0.22.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.21.0",
3
+ "version": "0.22.0",
4
4
  "description": "Autonomous PR CI monitor and review-comment resolver for Codex.",
5
5
  "author": {
6
6
  "name": "Jonathan Ong",
@@ -39,5 +39,5 @@ Poll dispatcher for iterating a PR to completion.
39
39
 
40
40
  4. **Stop conditions (terminal states):**
41
41
  - Stop when the CLI emits `[CANCEL]` (ready-delay completed, or PR merged/closed).
42
- - Stop when the CLI emits `[ESCALATE]`, including `stall-timeout` for repeated unchanged CI failures.
42
+ - Stop when the CLI emits `[ESCALATE]`, including `stall-timeout` for repeated unchanged CI failures or CI that never starts.
43
43
  - **Do NOT merge the pull request** unless the human has explicitly requested or allowed it.