pr-shepherd 0.20.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 (62) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +11 -9
  3. package/bin/checks/triage.mjs +1 -0
  4. package/bin/checks/triage.test-support.mjs +1 -2
  5. package/bin/cli/default-poll.mjs +32 -0
  6. package/bin/cli/fix-formatter.mjs +36 -8
  7. package/bin/cli/help-command-pages.mjs +3 -4
  8. package/bin/cli/help-top-page.mjs +5 -5
  9. package/bin/cli/iterate-lean.mjs +4 -0
  10. package/bin/cli/iterate-lean.test-support.mjs +0 -2
  11. package/bin/cli/list-formatters.mjs +53 -4
  12. package/bin/cli/validate-default-args.mjs +32 -0
  13. package/bin/cli-parser.clean.test-support.mjs +0 -1
  14. package/bin/cli-parser.commit-suggestion.test-support.mjs +1 -2
  15. package/bin/cli-parser.iterate-fix.test-support.mjs +1 -2
  16. package/bin/cli-parser.iterate.test-support.mjs +1 -2
  17. package/bin/cli-parser.mjs +7 -7
  18. package/bin/cli-parser.test-support.mjs +1 -2
  19. package/bin/commands/check-annotations.mjs +40 -0
  20. package/bin/commands/check.mjs +11 -7
  21. package/bin/commands/check.test-support.mjs +10 -4
  22. package/bin/commands/clean.test-support.mjs +0 -1
  23. package/bin/commands/commit-suggestion.apply.test-support.mjs +1 -2
  24. package/bin/commands/commit-suggestion.test-support.mjs +1 -2
  25. package/bin/commands/iterate/escalate.mjs +16 -2
  26. package/bin/commands/iterate/fix-code.mjs +9 -2
  27. package/bin/commands/iterate/helpers.mjs +1 -0
  28. package/bin/commands/iterate/render.mjs +6 -0
  29. package/bin/commands/iterate/stall.mjs +43 -2
  30. package/bin/commands/iterate-stall.test-support.mjs +0 -1
  31. package/bin/commands/iterate-test-support.mjs +1 -1
  32. package/bin/commands/iterate.fix-code-in-progress.test-support.mjs +8 -3
  33. package/bin/commands/poll.mjs +12 -3
  34. package/bin/commands/resolve.mjs +5 -4
  35. package/bin/commands/resolve.test-support.mjs +2 -2
  36. package/bin/commands/shepherd-journal.test-support.mjs +0 -2
  37. package/bin/comments/resolve.test-support.mjs +1 -2
  38. package/bin/config.json +1 -1
  39. package/bin/github/batch-parsers.mjs +30 -3
  40. package/bin/github/batch-parsers.test-support.mjs +1 -2
  41. package/bin/github/batch.mjs +2 -0
  42. package/bin/github/batch.test-support.mjs +1 -2
  43. package/bin/github/check-annotations.mjs +75 -0
  44. package/bin/github/client.test-support.mjs +55 -0
  45. package/bin/github/gql/batch-pr.gql +15 -2
  46. package/bin/github/gql/check-run-annotations.gql +32 -0
  47. package/bin/github/gql/review-thread-comments.gql +27 -0
  48. package/bin/github/http.test-support.mjs +1 -2
  49. package/bin/github/queries.mjs +4 -0
  50. package/bin/github/thread-comments.mjs +34 -0
  51. package/bin/reporters/agent.mjs +26 -0
  52. package/bin/state/seen-comments.test-support.mjs +19 -0
  53. package/bin/suggestions/patch.test-support.mjs +0 -2
  54. package/bin/threads/transcript.mjs +31 -0
  55. package/bin/types/agent-thread.mjs +1 -0
  56. package/bin/types/check-annotations.mjs +1 -0
  57. package/bin/types/review-thread.mjs +1 -0
  58. package/bin/types.mjs +3 -0
  59. package/package.json +1 -1
  60. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  61. package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +9 -9
  62. package/bin/cli/default-iterate.mjs +0 -50
@@ -1,5 +1,4 @@
1
- // @ts-nocheck
2
- import { describe, it, expect, vi, beforeEach } from "vitest";
1
+ import { vi } from "vitest";
3
2
  // ---------------------------------------------------------------------------
4
3
  // Hoisted mocks
5
4
  // ---------------------------------------------------------------------------
@@ -1,5 +1,4 @@
1
- // @ts-nocheck
2
- import { describe, it, expect, vi, beforeEach } from "vitest";
1
+ import { vi, beforeEach } from "vitest";
3
2
  // ---------------------------------------------------------------------------
4
3
  // Hoisted mocks
5
4
  // ---------------------------------------------------------------------------
@@ -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
+ }
@@ -1,4 +1,3 @@
1
- // @ts-nocheck
2
1
  import { vi } from "vitest";
3
2
  import { NOW, makeOpts } from "./iterate-test-support.mjs";
4
3
  import { readStallState, writeStallState } from "../state/iterate-stall.mjs";
@@ -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
  },
@@ -1,5 +1,4 @@
1
- // @ts-nocheck
2
- import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
1
+ import { vi, beforeEach, afterEach } from "vitest";
3
2
  const mockFetch = vi.fn();
4
3
  vi.stubGlobal("fetch", mockFetch);
5
4
  const { mockExecFile } = vi.hoisted(() => ({ mockExecFile: vi.fn() }));
@@ -24,6 +23,9 @@ vi.mock("../state/iterate-stall.mts", () => ({
24
23
  readStallState: vi.fn().mockResolvedValue(null),
25
24
  writeStallState: vi.fn().mockResolvedValue(undefined),
26
25
  }));
26
+ vi.mock("../state/seen-comments.mts", () => ({
27
+ markSeen: vi.fn().mockResolvedValue(undefined),
28
+ }));
27
29
  const { mockLoadConfig } = vi.hoisted(() => ({ mockLoadConfig: vi.fn() }));
28
30
  vi.mock("../config/load.mts", () => ({ loadConfig: mockLoadConfig }));
29
31
  import { runIterate } from "./iterate/index.mjs";
@@ -31,12 +33,14 @@ import { runCheck } from "./check.mjs";
31
33
  import { updateReadyDelay } from "./ready-delay.mjs";
32
34
  import { readFixAttempts, writeFixAttempts } from "../state/fix-attempts.mjs";
33
35
  import { readStallState, writeStallState } from "../state/iterate-stall.mjs";
36
+ import { markSeen } from "../state/seen-comments.mjs";
34
37
  const mockRunCheck = vi.mocked(runCheck);
35
38
  const mockUpdateReadyDelay = vi.mocked(updateReadyDelay);
36
39
  const mockReadFixAttempts = vi.mocked(readFixAttempts);
37
40
  const mockWriteFixAttempts = vi.mocked(writeFixAttempts);
38
41
  const mockReadStallState = vi.mocked(readStallState);
39
42
  const mockWriteStallState = vi.mocked(writeStallState);
43
+ const mockMarkSeen = vi.mocked(markSeen);
40
44
  function makeReport(overrides = {}) {
41
45
  return {
42
46
  pr: 42,
@@ -44,6 +48,7 @@ function makeReport(overrides = {}) {
44
48
  repo: "owner/repo",
45
49
  status: "READY",
46
50
  baseBranch: "main",
51
+ branchProtection: null,
47
52
  mergeStatus: {
48
53
  status: "CLEAN",
49
54
  state: "OPEN",
@@ -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, };
@@ -3,9 +3,12 @@ function sleep(ms) {
3
3
  return new Promise((resolve) => setTimeout(resolve, ms));
4
4
  }
5
5
  function writeTickProgress(tick, elapsedSeconds, sleepSeconds, verbose) {
6
- if (process.stderr.isTTY || verbose) {
6
+ if (verbose) {
7
7
  process.stderr.write(`[poll tick ${tick} / +${elapsedSeconds}s] WAIT — sleeping ${sleepSeconds}s\n`);
8
8
  }
9
+ else {
10
+ process.stderr.write(".");
11
+ }
9
12
  }
10
13
  const MAX_TIMER_MS = 2 ** 31 - 1;
11
14
  export async function runPoll(opts) {
@@ -16,17 +19,23 @@ export async function runPoll(opts) {
16
19
  let tick = 0;
17
20
  let lastResult;
18
21
  const verbose = opts.verbose === true;
22
+ let dotsPrinted = false;
19
23
  while (true) {
20
24
  tick += 1;
21
25
  lastResult = await runIterate(iterateOpts);
22
26
  if (lastResult.action !== "wait")
23
- return lastResult;
27
+ break;
24
28
  const elapsedMs = Date.now() - start;
25
29
  const remainingMs = timeoutMs - elapsedMs;
26
30
  if (remainingMs <= 0)
27
- return lastResult;
31
+ break;
28
32
  const nextSleepMs = Math.min(intervalMs, remainingMs);
29
33
  writeTickProgress(tick, Math.round(elapsedMs / 1000), Math.round(nextSleepMs / 1000), verbose);
34
+ if (!verbose)
35
+ dotsPrinted = true;
30
36
  await sleep(nextSleepMs);
31
37
  }
38
+ if (dotsPrinted)
39
+ process.stderr.write("\n");
40
+ return lastResult;
32
41
  }
@@ -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
  ]);
@@ -1,5 +1,4 @@
1
- // @ts-nocheck
2
- import { describe, it, expect, vi, beforeEach } from "vitest";
1
+ import { vi, beforeEach } from "vitest";
3
2
  vi.mock("../github/client.mts", () => ({
4
3
  getRepoInfo: vi.fn().mockResolvedValue({ owner: "owner", name: "repo" }),
5
4
  getCurrentPrNumber: vi.fn().mockResolvedValue(42),
@@ -67,6 +66,7 @@ function makeBatchData(overrides = {}) {
67
66
  reviewSummaries: [],
68
67
  approvedReviews: [],
69
68
  checks: [],
69
+ branchProtection: null,
70
70
  ...overrides,
71
71
  };
72
72
  }
@@ -1,5 +1,3 @@
1
- // @ts-nocheck
2
- import { describe, expect, it } from "vitest";
3
1
  import { buildShepherdJournalInstruction, SHEPHERD_JOURNAL_APPEND_HINT, SHEPHERD_JOURNAL_FIRST_LOOK_GUIDANCE, SHEPHERD_JOURNAL_REFERENCE_GUIDANCE_THREADS_AND_COMMENTS_IN_ITEM_HEADINGS, SHEPHERD_JOURNAL_REFERENCE_GUIDANCE_THREADS_AND_COMMENTS_IN_ITEMS, SHEPHERD_JOURNAL_SECTION, SHEPHERD_JOURNAL_SECTION_PATTERN, } from "./shepherd-journal.mjs";
4
2
  import { buildFixInstructions } from "./iterate/render.mjs";
5
3
  import { buildFetchInstructions } from "./resolve-instructions.mjs";
@@ -1,5 +1,4 @@
1
- // @ts-nocheck
2
- import { describe, it, expect, vi, beforeEach } from "vitest";
1
+ import { vi, beforeEach } from "vitest";
3
2
  // ---------------------------------------------------------------------------
4
3
  // Mock github/client.mts before any imports.
5
4
  // ---------------------------------------------------------------------------
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,4 @@
1
- // @ts-nocheck
2
- import { describe, it, expect, vi, beforeEach } from "vitest";
1
+ import { vi, beforeEach } from "vitest";
3
2
  vi.mock("./client.mts", () => ({
4
3
  graphql: vi.fn(),
5
4
  graphqlWithRateLimit: vi.fn(),
@@ -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) {
@@ -1,5 +1,4 @@
1
- // @ts-nocheck
2
- import { describe, it, expect, vi, beforeEach } from "vitest";
1
+ import { vi, beforeEach } from "vitest";
3
2
  vi.mock("./client.mts", () => ({
4
3
  graphql: vi.fn(),
5
4
  graphqlWithRateLimit: vi.fn(),
@@ -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
  }