pr-shepherd 0.53.1 → 0.54.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 (68) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +1 -1
  3. package/bin/cli/args.mjs +3 -0
  4. package/bin/cli/check-blocker-handler.d.mts +2 -0
  5. package/bin/cli/check-blocker-handler.mjs +82 -0
  6. package/bin/cli/help-command-pages.d.mts +22 -3
  7. package/bin/cli/help-command-pages.mjs +21 -2
  8. package/bin/cli/help-iterate-poll-pages.d.mts +1 -1
  9. package/bin/cli/help-iterate-poll-pages.mjs +1 -1
  10. package/bin/cli/help-top-page.d.mts +1 -1
  11. package/bin/cli/help-top-page.mjs +3 -1
  12. package/bin/cli/help.d.mts +23 -4
  13. package/bin/cli/help.mjs +2 -0
  14. package/bin/cli/poll-handler.mjs +5 -1
  15. package/bin/cli-parser.mjs +4 -0
  16. package/bin/commands/apply-check-blocker.d.mts +22 -0
  17. package/bin/commands/apply-check-blocker.mjs +36 -0
  18. package/bin/commands/check-annotations.d.mts +1 -1
  19. package/bin/commands/check-annotations.mjs +27 -17
  20. package/bin/commands/check-blocker-ref.d.mts +3 -0
  21. package/bin/commands/check-blocker-ref.mjs +50 -0
  22. package/bin/commands/iterate/check-blocker-gate.d.mts +18 -0
  23. package/bin/commands/iterate/check-blocker-gate.mjs +115 -0
  24. package/bin/commands/iterate/check-instructions.d.mts +2 -0
  25. package/bin/commands/iterate/check-instructions.mjs +4 -0
  26. package/bin/commands/iterate/fix-code.d.mts +2 -0
  27. package/bin/commands/iterate/fix-code.mjs +31 -15
  28. package/bin/commands/iterate/index.mjs +22 -8
  29. package/bin/commands/poll-quota.d.mts +2 -0
  30. package/bin/commands/poll-quota.mjs +25 -12
  31. package/bin/commands/poll-rate-limit-cancel.d.mts +35 -0
  32. package/bin/commands/poll-rate-limit-cancel.mjs +101 -0
  33. package/bin/commands/poll-rate-limit-delay.d.mts +1 -0
  34. package/bin/commands/poll-rate-limit-delay.mjs +7 -0
  35. package/bin/commands/poll-rate-limit-wait.d.mts +14 -0
  36. package/bin/commands/poll-rate-limit-wait.mjs +125 -0
  37. package/bin/commands/poll-summary.mjs +21 -9
  38. package/bin/commands/poll.mjs +22 -17
  39. package/bin/config/load.d.mts +2 -0
  40. package/bin/config/load.mjs +24 -2
  41. package/bin/config.json +1 -0
  42. package/bin/github/check-annotation-cache.d.mts +8 -0
  43. package/bin/github/check-annotation-cache.mjs +23 -0
  44. package/bin/github/check-annotation-pages.d.mts +11 -0
  45. package/bin/github/check-annotation-pages.mjs +36 -0
  46. package/bin/github/check-annotation-shape.d.mts +21 -0
  47. package/bin/github/check-annotation-shape.mjs +50 -0
  48. package/bin/github/check-annotations-batch.d.mts +17 -0
  49. package/bin/github/check-annotations-batch.mjs +92 -0
  50. package/bin/github/check-annotations.d.mts +8 -11
  51. package/bin/github/check-annotations.mjs +14 -103
  52. package/bin/github/gql/batch-pr.gql +1 -1
  53. package/bin/github/gql/check-run-annotations-batch.gql +41 -0
  54. package/bin/github/pagination.d.mts +1 -1
  55. package/bin/github/pagination.mjs +1 -1
  56. package/bin/github/poll-summary-check-blockers.d.mts +16 -0
  57. package/bin/github/poll-summary-check-blockers.mjs +25 -0
  58. package/bin/github/poll-summary-checks.d.mts +2 -0
  59. package/bin/github/poll-summary-checks.mjs +35 -20
  60. package/bin/github/poll-summary-projector.mjs +5 -0
  61. package/bin/github/queries.d.mts +6 -0
  62. package/bin/github/queries.mjs +6 -0
  63. package/bin/state/check-blockers.d.mts +30 -0
  64. package/bin/state/check-blockers.mjs +88 -0
  65. package/package.json +1 -1
  66. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  67. package/plugins/pr-shepherd/.codex.mcp.json +1 -1
  68. package/plugins/pr-shepherd/.mcp.json +1 -1
@@ -0,0 +1,125 @@
1
+ import { rest } from "../github/rest-http.mjs";
2
+ import { sleep } from "../util/sleep.mjs";
3
+ import { formatRateLimitGiveUpLine, formatRateLimitRetryLine, pollRateLimitRetryAfterMs, } from "./poll-quota.mjs";
4
+ const MAX_NO_PROGRESS_ATTEMPTS = 5;
5
+ const NO_PROGRESS_BACKOFF_MS = [15_000, 30_000, 60_000];
6
+ const MERGE_STATES = new Set([
7
+ "BEHIND",
8
+ "BLOCKED",
9
+ "CLEAN",
10
+ "DIRTY",
11
+ "DRAFT",
12
+ "HAS_HOOKS",
13
+ "UNKNOWN",
14
+ "UNSTABLE",
15
+ ]);
16
+ export function createUntilTerminalRateLimitRetry() {
17
+ const state = { armed: false, noProgress: 0 };
18
+ return {
19
+ reset() {
20
+ state.armed = false;
21
+ state.noProgress = 0;
22
+ state.lastResetAt = undefined;
23
+ },
24
+ wait(err, opts) {
25
+ return waitForRateLimit(state, err, opts);
26
+ },
27
+ };
28
+ }
29
+ function backoffMs(attempt) {
30
+ return NO_PROGRESS_BACKOFF_MS[Math.min(attempt, NO_PROGRESS_BACKOFF_MS.length) - 1] ?? 60_000;
31
+ }
32
+ async function waitForRateLimit(state, err, opts) {
33
+ const retry = opts.untilTerminal ? pollRateLimitRetryAfterMs(err) : null;
34
+ if (!retry)
35
+ throw err;
36
+ const elapsed = Math.round((Date.now() - opts.startedAt) / 1000);
37
+ const progressed = state.armed &&
38
+ retry.resetAt !== undefined &&
39
+ (state.lastResetAt === undefined || retry.resetAt > state.lastResetAt);
40
+ let sleepMs = retry.ms;
41
+ if (!state.armed || progressed) {
42
+ state.armed = true;
43
+ state.noProgress = 0;
44
+ if (retry.resetAt !== undefined)
45
+ state.lastResetAt = retry.resetAt;
46
+ }
47
+ else {
48
+ state.noProgress += 1;
49
+ if (state.noProgress >= MAX_NO_PROGRESS_ATTEMPTS) {
50
+ process.stderr.write(formatRateLimitGiveUpLine(opts.tickLabel, elapsed, retry));
51
+ throw err;
52
+ }
53
+ // Never sleep less than GitHub asked for. A short no-progress backoff must
54
+ // not undercut an explicit Retry-After or the secondary-limit default.
55
+ sleepMs = Math.max(retry.ms, backoffMs(state.noProgress));
56
+ }
57
+ process.stderr.write(formatRateLimitRetryLine(opts.tickLabel, elapsed, { ...retry, ms: sleepMs }));
58
+ return sleepRateLimit(sleepMs, opts, retry.resource);
59
+ }
60
+ async function sleepRateLimit(ms, opts, resource) {
61
+ const canProbe = resource !== "core" && opts.targets.length > 0;
62
+ if (ms <= opts.intervalMs || opts.intervalMs <= 0) {
63
+ await sleep(ms);
64
+ return undefined;
65
+ }
66
+ let remaining = ms;
67
+ let probesDisabled = !canProbe;
68
+ while (remaining > 0) {
69
+ const chunk = Math.min(opts.intervalMs, remaining);
70
+ await sleep(chunk);
71
+ remaining -= chunk;
72
+ if (remaining <= 0 || probesDisabled)
73
+ continue;
74
+ try {
75
+ const found = await probeOpenPulls(opts.targets, opts.onAllTerminal);
76
+ if (found !== undefined)
77
+ return found;
78
+ }
79
+ catch {
80
+ // The probe only notices a merge or close early. A 5xx, a 404, or a
81
+ // secondary limit must not abort the rate-limit sleep.
82
+ probesDisabled = true;
83
+ }
84
+ }
85
+ return undefined;
86
+ }
87
+ async function probeOpenPulls(targets, onAllTerminal) {
88
+ const pulls = [];
89
+ for (const target of targets) {
90
+ // GraphQL is exhausted for this sleep. REST core is a separate budget, so
91
+ // GET /repos/{owner}/{repo}/pulls/{n} can still see a merge or close.
92
+ const data = await rest("GET", `/repos/${target.owner}/${target.repo}/pulls/${target.pr}`);
93
+ const state = terminalState(data);
94
+ if (!state)
95
+ return undefined;
96
+ pulls.push(toProbePull(target.pr, state, data));
97
+ }
98
+ return onAllTerminal?.(pulls);
99
+ }
100
+ function terminalState(pull) {
101
+ if (!pull)
102
+ return undefined;
103
+ if (pull.merged === true || pull.merged_at != null)
104
+ return "MERGED";
105
+ if (pull.state?.toLowerCase() === "closed")
106
+ return "CLOSED";
107
+ return undefined;
108
+ }
109
+ function toProbePull(pr, state, pull) {
110
+ const mergeState = pull.mergeable_state?.toUpperCase();
111
+ return {
112
+ pr,
113
+ state,
114
+ title: pull.title ?? "",
115
+ url: pull.html_url ?? "",
116
+ draft: pull.draft === true,
117
+ mergeable: pull.mergeable === true ? "MERGEABLE" : pull.mergeable === false ? "CONFLICTING" : "UNKNOWN",
118
+ mergeStateStatus: MERGE_STATES.has(mergeState ?? "")
119
+ ? mergeState
120
+ : "UNKNOWN",
121
+ baseRefName: pull.base?.ref ?? "",
122
+ headRefName: pull.head?.ref ?? "",
123
+ headRefOid: pull.head?.sha ?? "",
124
+ };
125
+ }
@@ -5,7 +5,9 @@ import { getRepoInfo } from "../github/client.mjs";
5
5
  import { withApiTelemetryScope, summarizeApiTelemetry } from "../github/api-telemetry.mjs";
6
6
  import { fetchPollSummary } from "../github/poll-summary.mjs";
7
7
  import { sleep } from "../util/sleep.mjs";
8
- import { aggregateQuotaWarning, formatRateLimitRetryLine, pollRateLimitRetryAfterMs, quotaPollIntervalMs, } from "./poll-quota.mjs";
8
+ import { aggregateQuotaWarning, quotaPollIntervalMs } from "./poll-quota.mjs";
9
+ import { aggregateCancelFromPulls, aggregateRateLimitTargets } from "./poll-rate-limit-cancel.mjs";
10
+ import { createUntilTerminalRateLimitRetry } from "./poll-rate-limit-wait.mjs";
9
11
  import { planPollSummary, withPollSummaryInstructions } from "./poll-summary-instructions.mjs";
10
12
  import { summaryStatusSignature } from "./poll-summary-signature.mjs";
11
13
  import { applyStackStallGuard } from "./stack-stall.mjs";
@@ -42,13 +44,13 @@ async function runAggregatePollCore(opts) {
42
44
  let debounceUntil = null;
43
45
  let last;
44
46
  let lastStatusSignature = null;
45
- let rateLimitRetries = 0;
47
+ const rateLimitRetry = createUntilTerminalRateLimitRetry();
46
48
  let pendingQuotaWarning;
47
49
  while (true) {
48
50
  tick += 1;
49
51
  try {
50
52
  last = await runPollSummaryCore(opts);
51
- rateLimitRetries = 0;
53
+ rateLimitRetry.reset();
52
54
  }
53
55
  catch (error) {
54
56
  if (last?.selection.kind === "stack" && isMissingStack(error)) {
@@ -83,12 +85,17 @@ async function runAggregatePollCore(opts) {
83
85
  ...(pendingQuotaWarning && { quotaWarning: pendingQuotaWarning }),
84
86
  }, opts.merge);
85
87
  }
86
- const retry = opts.untilTerminal ? pollRateLimitRetryAfterMs(error) : null;
87
- if (retry === null || rateLimitRetries >= 1)
88
- throw error;
89
- rateLimitRetries += 1;
90
- process.stderr.write(formatRateLimitRetryLine(`aggregate poll tick ${tick}`, Math.round((Date.now() - start) / 1000), retry));
91
- await sleep(retry.ms);
88
+ const early = await rateLimitRetry.wait(error, {
89
+ untilTerminal: opts.untilTerminal === true,
90
+ intervalMs,
91
+ tickLabel: `aggregate poll tick ${tick}`,
92
+ startedAt: start,
93
+ targets: aggregateRateLimitTargets(last, opts.targetRepository, opts.prNumbers),
94
+ onAllTerminal: (pulls) => aggregateCancelFromPulls(last, opts.targetRepository, pulls),
95
+ });
96
+ if (early) {
97
+ return presentProbedCancel(pendingQuotaWarning ? { ...early, quotaWarning: pendingQuotaWarning } : early, opts.merge);
98
+ }
92
99
  continue;
93
100
  }
94
101
  const allTerminal = last.selection.kind === "stack"
@@ -159,6 +166,11 @@ async function runAggregatePollCore(opts) {
159
166
  await sleep(sleepMs);
160
167
  }
161
168
  }
169
+ function presentProbedCancel(result, merge) {
170
+ // The stack planner already cancels an all-merged stack and escalates a
171
+ // closed-unmerged layer. Do not rewrite that into a successful stop.
172
+ return attachUsage({ ...result, reason: "all_terminal" }, merge);
173
+ }
162
174
  function isMissingStack(error) {
163
175
  return (error instanceof ShepherdError && error.message.includes("not part of a native GitHub stack"));
164
176
  }
@@ -2,7 +2,9 @@ import { runIterate } from "./iterate/index.mjs";
2
2
  import { sleep } from "../util/sleep.mjs";
3
3
  import { withPollApiUsage } from "./poll-run.mjs";
4
4
  import { loadConfig } from "../config/load.mjs";
5
- import { formatRateLimitRetryLine, pollRateLimitRetryAfterMs, quotaPollIntervalMs, } from "./poll-quota.mjs";
5
+ import { quotaPollIntervalMs } from "./poll-quota.mjs";
6
+ import { onePrCancelFromPulls, onePrRateLimitTargets } from "./poll-rate-limit-cancel.mjs";
7
+ import { createUntilTerminalRateLimitRetry } from "./poll-rate-limit-wait.mjs";
6
8
  import { writeDebounceProgress, writeWaitProgress } from "./poll-progress.mjs";
7
9
  const DEFAULT_POLL_DEBOUNCE_SECONDS = 60;
8
10
  const MAX_TIMER_MS = 2 ** 31 - 1;
@@ -29,7 +31,7 @@ async function runPollCore(opts) {
29
31
  // Pin the PR resolved by the first tick; branch inference only matches OPEN PRs.
30
32
  let prNumber = opts.prNumber;
31
33
  let debounceUntil = null;
32
- let rateLimitRetries = 0;
34
+ const rateLimitRetry = createUntilTerminalRateLimitRetry();
33
35
  while (true) {
34
36
  tick += 1;
35
37
  const pastDebounce = debounceUntil !== null && Date.now() >= debounceUntil;
@@ -48,21 +50,24 @@ async function runPollCore(opts) {
48
50
  quotaWarningMinimumPollIntervalMinutes: intervalSeconds / 60,
49
51
  });
50
52
  const runTick = async (fingerprintCache) => {
51
- try {
52
- const result = await iterateTick(fingerprintCache);
53
- rateLimitRetries = 0;
54
- return result;
55
- }
56
- catch (err) {
57
- const retry = untilTerminal ? pollRateLimitRetryAfterMs(err) : null;
58
- if (retry === null || rateLimitRetries >= 1)
59
- throw err;
60
- rateLimitRetries += 1;
61
- process.stderr.write(formatRateLimitRetryLine(`poll tick ${tick}`, Math.round((Date.now() - start) / 1000), retry));
62
- await sleep(retry.ms);
63
- const result = await iterateTick(fingerprintCache);
64
- rateLimitRetries = 0;
65
- return result;
53
+ while (true) {
54
+ try {
55
+ const result = await iterateTick(fingerprintCache);
56
+ rateLimitRetry.reset();
57
+ return result;
58
+ }
59
+ catch (err) {
60
+ const early = await rateLimitRetry.wait(err, {
61
+ untilTerminal,
62
+ intervalMs,
63
+ tickLabel: `poll tick ${tick}`,
64
+ startedAt: start,
65
+ targets: onePrRateLimitTargets(iterateOpts.targetRepository, prNumber, lastResult?.repo),
66
+ onAllTerminal: (pulls) => onePrCancelFromPulls(iterateOpts.targetRepository, lastResult?.repo, pulls),
67
+ });
68
+ if (early)
69
+ return early;
70
+ }
66
71
  }
67
72
  };
68
73
  lastResult = await runTick(allowCache);
@@ -8,6 +8,8 @@ export interface GraphqlQuotaWarningBand {
8
8
  }
9
9
  interface PollConfig {
10
10
  intervalSeconds: number;
11
+ /** Multiplier for aggregate polls when `--interval` is omitted. Finite and at least 1. */
12
+ stackIntervalFactor: number;
11
13
  timeoutSeconds: number;
12
14
  debounceSeconds: number;
13
15
  quietStatus: boolean;
@@ -153,11 +153,27 @@ function parsePollConfig(value) {
153
153
  const intervalSeconds = parsePollDuration(record["intervalSeconds"], "intervalSeconds");
154
154
  const timeoutSeconds = parsePollDuration(record["timeoutSeconds"], "timeoutSeconds");
155
155
  const debounceSeconds = parsePollDuration(record["debounceSeconds"], "debounceSeconds", true);
156
+ const stackIntervalFactor = parseStackIntervalFactor(record["stackIntervalFactor"]);
157
+ assertAggregateIntervalFits(intervalSeconds, stackIntervalFactor);
156
158
  const quietStatus = record["quietStatus"];
157
159
  if (typeof quietStatus !== "boolean") {
158
160
  throw new Error(`Invalid config: poll.quietStatus must be a boolean, got ${JSON.stringify(quietStatus)}`);
159
161
  }
160
- return { intervalSeconds, timeoutSeconds, debounceSeconds, quietStatus };
162
+ return { intervalSeconds, stackIntervalFactor, timeoutSeconds, debounceSeconds, quietStatus };
163
+ }
164
+ function parseStackIntervalFactor(value) {
165
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 1) {
166
+ throw new Error(`Invalid config: poll.stackIntervalFactor must be a finite number greater than or equal to 1, got ${JSON.stringify(value)}`);
167
+ }
168
+ return value;
169
+ }
170
+ /** Largest `setTimeout` delay. A bigger aggregate sleep would clamp to ~24.8 days. */
171
+ const MAX_POLL_INTERVAL_MS = 2 ** 31 - 1;
172
+ function assertAggregateIntervalFits(intervalSeconds, factor) {
173
+ const ms = intervalSeconds * factor * 1000;
174
+ if (!Number.isFinite(ms) || ms > MAX_POLL_INTERVAL_MS) {
175
+ throw new Error(`Invalid config: poll.intervalSeconds * poll.stackIntervalFactor must be finite and at most ${MAX_POLL_INTERVAL_MS} milliseconds, got ${JSON.stringify(ms)}`);
176
+ }
161
177
  }
162
178
  function parsePollDuration(value, key, allowZero = false) {
163
179
  if (typeof value !== "number" ||
@@ -252,7 +268,13 @@ const KNOWN_NESTED_KEYS = {
252
268
  "behindBaseHint",
253
269
  "resolveOtherHumanThreads",
254
270
  ]),
255
- poll: new Set(["intervalSeconds", "timeoutSeconds", "debounceSeconds", "quietStatus"]),
271
+ poll: new Set([
272
+ "intervalSeconds",
273
+ "stackIntervalFactor",
274
+ "timeoutSeconds",
275
+ "debounceSeconds",
276
+ "quietStatus",
277
+ ]),
256
278
  watch: new Set(["readyDelayMinutes", "graphqlQuotaWarnings"]),
257
279
  resolve: new Set(["shaPoll"]),
258
280
  checks: new Set(["ciTriggerEvents", "ignoreLogLines"]),
package/bin/config.json CHANGED
@@ -25,6 +25,7 @@
25
25
  },
26
26
  "poll": {
27
27
  "intervalSeconds": 60,
28
+ "stackIntervalFactor": 2,
28
29
  "timeoutSeconds": 270,
29
30
  "debounceSeconds": 60,
30
31
  "quietStatus": false
@@ -0,0 +1,8 @@
1
+ import { type StateKey } from "../state/rest-cache.mts";
2
+ import type { CheckAnnotation } from "../types.mts";
3
+ export interface AnnotationCacheOptions {
4
+ stateKey: StateKey;
5
+ headSha?: string;
6
+ }
7
+ export declare function readFreshCheckAnnotations(checkRunId: string, cacheOpts: AnnotationCacheOptions | undefined): Promise<CheckAnnotation[] | undefined>;
8
+ export declare function storeCheckAnnotations(checkRunId: string, annotations: CheckAnnotation[], cacheOpts: AnnotationCacheOptions | undefined): Promise<void>;
@@ -0,0 +1,23 @@
1
+ import { loadDerived, storeDerived } from "../state/rest-cache.mjs";
2
+ /**
3
+ * Some Checks-API publishers PATCH additional annotations onto an already
4
+ * COMPLETED check run without minting a new node id, so COMPLETED is not a
5
+ * reliable immutability signal. Bound the cache instead of trusting it forever.
6
+ */
7
+ const ANNOTATION_CACHE_MAX_AGE_MS = 60 * 60 * 1000;
8
+ function cacheName(checkRunId) {
9
+ return `annotations-${checkRunId}`;
10
+ }
11
+ export async function readFreshCheckAnnotations(checkRunId, cacheOpts) {
12
+ if (!cacheOpts)
13
+ return undefined;
14
+ const cached = await loadDerived(cacheOpts.stateKey, cacheName(checkRunId));
15
+ if (cached && Date.now() - cached.storedAt < ANNOTATION_CACHE_MAX_AGE_MS)
16
+ return cached.value;
17
+ return undefined;
18
+ }
19
+ export async function storeCheckAnnotations(checkRunId, annotations, cacheOpts) {
20
+ if (!cacheOpts)
21
+ return;
22
+ await storeDerived(cacheOpts.stateKey, cacheName(checkRunId), annotations, cacheOpts.headSha);
23
+ }
@@ -0,0 +1,11 @@
1
+ import { type RawCheckAnnotation } from "./check-annotation-shape.mts";
2
+ import type { CheckAnnotation } from "../types.mts";
3
+ export interface AnnotationPage {
4
+ pageInfo: {
5
+ hasNextPage: boolean;
6
+ endCursor: string | null;
7
+ };
8
+ nodes: RawCheckAnnotation[];
9
+ }
10
+ /** Pages a single check run. `firstPage` skips the initial `node(id:)` request. */
11
+ export declare function collectAnnotations(checkRunId: string, firstPage?: AnnotationPage): Promise<CheckAnnotation[]>;
@@ -0,0 +1,36 @@
1
+ import { graphql } from "./client.mjs";
2
+ import { toCheckAnnotation } from "./check-annotation-shape.mjs";
3
+ import { CHECK_RUN_ANNOTATIONS_QUERY } from "./queries.mjs";
4
+ const ANNOTATIONS_PER_PAGE = 100;
5
+ const MAX_ANNOTATION_PAGES = 10;
6
+ /** Pages a single check run. `firstPage` skips the initial `node(id:)` request. */
7
+ export async function collectAnnotations(checkRunId, firstPage) {
8
+ const nodes = [];
9
+ let cursor = null;
10
+ let seeded = firstPage;
11
+ for (let pageNumber = 1; pageNumber <= MAX_ANNOTATION_PAGES; pageNumber++) {
12
+ // eslint-disable-next-line no-await-in-loop
13
+ const current = seeded ?? (await fetchAnnotationPage(checkRunId, cursor));
14
+ seeded = undefined;
15
+ nodes.push(...current.nodes);
16
+ if (!current.pageInfo.hasNextPage || !current.pageInfo.endCursor)
17
+ break;
18
+ if (pageNumber === MAX_ANNOTATION_PAGES) {
19
+ 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`);
20
+ break;
21
+ }
22
+ cursor = current.pageInfo.endCursor;
23
+ }
24
+ return nodes.map((node) => toCheckAnnotation(checkRunId, node));
25
+ }
26
+ async function fetchAnnotationPage(checkRunId, cursor) {
27
+ const res = await graphql(CHECK_RUN_ANNOTATIONS_QUERY, {
28
+ id: checkRunId,
29
+ ...(cursor ? { cursor } : {}),
30
+ });
31
+ const node = res.data.node;
32
+ if (node?.__typename !== "CheckRun" || node.annotations === undefined) {
33
+ return { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] };
34
+ }
35
+ return node.annotations;
36
+ }
@@ -0,0 +1,21 @@
1
+ import type { CheckAnnotation } from "../types.mts";
2
+ export interface RawCheckAnnotation {
3
+ fullDatabaseId: string | null;
4
+ path: string;
5
+ annotationLevel: string;
6
+ title: string | null;
7
+ message: string;
8
+ rawDetails: string | null;
9
+ blobUrl: string | null;
10
+ location: {
11
+ start: {
12
+ line: number | null;
13
+ column: number | null;
14
+ };
15
+ end: {
16
+ line: number | null;
17
+ column: number | null;
18
+ };
19
+ } | null;
20
+ }
21
+ export declare function toCheckAnnotation(checkRunId: string, raw: RawCheckAnnotation): CheckAnnotation;
@@ -0,0 +1,50 @@
1
+ import { createHash } from "node:crypto";
2
+ const ANNOTATION_TEXT_MAX_CHARS = 4_000;
3
+ const TRUNCATED_SUFFIX = "\n[truncated]";
4
+ export function toCheckAnnotation(checkRunId, raw) {
5
+ const id = `check_annotation_${raw.fullDatabaseId ?? fallbackId(checkRunId, raw)}`;
6
+ const title = raw.title?.trim() || undefined;
7
+ const rawDetails = raw.rawDetails?.trim() || undefined;
8
+ const blobUrl = raw.blobUrl?.trim() || undefined;
9
+ return {
10
+ id,
11
+ path: raw.path,
12
+ startLine: raw.location?.start.line ?? null,
13
+ endLine: raw.location?.end.line ?? raw.location?.start.line ?? null,
14
+ ...(raw.location?.start.column !== undefined && {
15
+ startColumn: raw.location.start.column,
16
+ }),
17
+ ...(raw.location?.end.column !== undefined && {
18
+ endColumn: raw.location.end.column,
19
+ }),
20
+ level: raw.annotationLevel,
21
+ ...(title !== undefined && { title }),
22
+ message: truncateAnnotationText(raw.message),
23
+ ...(rawDetails !== undefined && { rawDetails: truncateAnnotationText(rawDetails) }),
24
+ ...(blobUrl !== undefined && { blobUrl }),
25
+ };
26
+ }
27
+ function truncateAnnotationText(text) {
28
+ if (text.length <= ANNOTATION_TEXT_MAX_CHARS)
29
+ return text;
30
+ return `${text.slice(0, ANNOTATION_TEXT_MAX_CHARS - TRUNCATED_SUFFIX.length).trimEnd()}${TRUNCATED_SUFFIX}`;
31
+ }
32
+ function fallbackId(checkRunId, raw) {
33
+ const start = raw.location?.start;
34
+ const end = raw.location?.end;
35
+ const parts = [
36
+ checkRunId,
37
+ raw.path,
38
+ raw.annotationLevel,
39
+ raw.title ?? "",
40
+ raw.message,
41
+ raw.rawDetails ?? "",
42
+ raw.blobUrl ?? "",
43
+ String(start?.line ?? ""),
44
+ String(start?.column ?? ""),
45
+ String(end?.line ?? ""),
46
+ String(end?.column ?? ""),
47
+ ];
48
+ const input = parts.map((part) => `${part.length}:${part}`).join("|");
49
+ return createHash("sha256").update(input).digest("hex").slice(0, 24);
50
+ }
@@ -0,0 +1,17 @@
1
+ import type { CheckAnnotation } from "../types.mts";
2
+ import { type AnnotationCacheOptions } from "./check-annotation-cache.mts";
3
+ interface AnnotationBatchFailure {
4
+ checkRunId: string;
5
+ error: unknown;
6
+ }
7
+ interface CheckAnnotationBatchResult {
8
+ annotations: Map<string, CheckAnnotation[]>;
9
+ failures: AnnotationBatchFailure[];
10
+ }
11
+ /**
12
+ * First annotation page for every uncached id, in chunks of 20. Further pages
13
+ * use the single-node query and only run when that first page has `hasNextPage`.
14
+ * A retryable rate limit aborts the remaining chunks and pages.
15
+ */
16
+ export declare function fetchCheckRunAnnotationsBatch(checkRunIds: string[], cacheOpts?: AnnotationCacheOptions): Promise<CheckAnnotationBatchResult>;
17
+ export {};
@@ -0,0 +1,92 @@
1
+ import { pollRateLimitRetryAfterMs } from "../commands/poll-quota.mjs";
2
+ import { readFreshCheckAnnotations, } from "./check-annotation-cache.mjs";
3
+ import {} from "./check-annotation-shape.mjs";
4
+ import { fetchCheckRunAnnotations } from "./check-annotations.mjs";
5
+ import { graphql } from "./client.mjs";
6
+ import { CHECK_RUN_ANNOTATIONS_BATCH_QUERY } from "./queries.mjs";
7
+ /**
8
+ * One `nodes` connection plus one nested `annotations(first: 100)` per id.
9
+ * 20 ids are 21 connection-requests, which GitHub prices as 1 point.
10
+ */
11
+ const ANNOTATION_BATCH_CHUNK_SIZE = 20;
12
+ function chunksOf(items, size) {
13
+ const chunks = [];
14
+ for (let index = 0; index < items.length; index += size) {
15
+ chunks.push(items.slice(index, index + size));
16
+ }
17
+ return chunks;
18
+ }
19
+ function retryableRateLimit(err) {
20
+ return pollRateLimitRetryAfterMs(err) !== null;
21
+ }
22
+ /**
23
+ * First annotation page for every uncached id, in chunks of 20. Further pages
24
+ * use the single-node query and only run when that first page has `hasNextPage`.
25
+ * A retryable rate limit aborts the remaining chunks and pages.
26
+ */
27
+ export async function fetchCheckRunAnnotationsBatch(checkRunIds, cacheOpts) {
28
+ const annotations = new Map();
29
+ const failures = [];
30
+ const cached = await Promise.all(checkRunIds.map((id) => readFreshCheckAnnotations(id, cacheOpts)));
31
+ const uncached = [];
32
+ checkRunIds.forEach((id, index) => {
33
+ const hit = cached[index];
34
+ if (hit)
35
+ annotations.set(id, hit);
36
+ else
37
+ uncached.push(id);
38
+ });
39
+ for (const chunk of chunksOf(uncached, ANNOTATION_BATCH_CHUNK_SIZE)) {
40
+ // eslint-disable-next-line no-await-in-loop
41
+ await fetchChunk(chunk, annotations, failures, cacheOpts);
42
+ }
43
+ return { annotations, failures };
44
+ }
45
+ async function fetchChunk(chunk, annotations, failures, cacheOpts) {
46
+ try {
47
+ const nodes = await requestFirstPages(chunk);
48
+ await applyBatchNodes(chunk, nodes, annotations, failures, cacheOpts);
49
+ }
50
+ catch (err) {
51
+ if (retryableRateLimit(err))
52
+ throw err;
53
+ // A non-rate-limit batch failure (one NOT_FOUND or FORBIDDEN node rejects
54
+ // the whole `nodes(ids:)` request) must not drop the other ids.
55
+ await fetchChunkOneByOne(chunk, annotations, failures, cacheOpts);
56
+ }
57
+ }
58
+ async function applyBatchNodes(chunk, nodes, annotations, failures, cacheOpts) {
59
+ for (const id of chunk) {
60
+ const node = nodes.find((candidate) => candidate?.id === id) ?? null;
61
+ if (node?.__typename !== "CheckRun" || node.annotations === undefined) {
62
+ failures.push({ checkRunId: id, error: new Error("check run annotations unavailable") });
63
+ continue;
64
+ }
65
+ try {
66
+ // eslint-disable-next-line no-await-in-loop
67
+ annotations.set(id, await fetchCheckRunAnnotations(id, cacheOpts, node.annotations));
68
+ }
69
+ catch (err) {
70
+ if (retryableRateLimit(err))
71
+ throw err;
72
+ failures.push({ checkRunId: id, error: err });
73
+ }
74
+ }
75
+ }
76
+ async function fetchChunkOneByOne(chunk, annotations, failures, cacheOpts) {
77
+ for (const id of chunk) {
78
+ try {
79
+ // eslint-disable-next-line no-await-in-loop
80
+ annotations.set(id, await fetchCheckRunAnnotations(id, cacheOpts));
81
+ }
82
+ catch (err) {
83
+ if (retryableRateLimit(err))
84
+ throw err;
85
+ failures.push({ checkRunId: id, error: err });
86
+ }
87
+ }
88
+ }
89
+ async function requestFirstPages(ids) {
90
+ const res = await graphql(CHECK_RUN_ANNOTATIONS_BATCH_QUERY, { ids }, { allowPartialData: true });
91
+ return res.data.nodes ?? [];
92
+ }
@@ -1,15 +1,12 @@
1
- import { type StateKey } from "../state/rest-cache.mts";
1
+ import { type AnnotationCacheOptions } from "./check-annotation-cache.mts";
2
+ import { type AnnotationPage } from "./check-annotation-pages.mts";
2
3
  import type { CheckAnnotation } from "../types.mts";
3
- export interface AnnotationCacheOptions {
4
- stateKey: StateKey;
5
- headSha?: string;
6
- }
7
4
  /**
8
- * Fetches all inline annotations for a check run.
5
+ * Fetches all inline annotations for one check run.
9
6
  *
10
- * When `cacheOpts` is provided, the result is cached by `checkRunId` — safe
11
- * because callers only pass `cacheOpts` for COMPLETED check runs (a re-run
12
- * mints a new check-run node id, so a COMPLETED run's annotations are
13
- * immutable once fetched).
7
+ * When `cacheOpts` is provided, the result is cached by `checkRunId`. Callers
8
+ * only pass `cacheOpts` for COMPLETED runs (a re-run mints a new node id).
9
+ * Prefer `fetchCheckRunAnnotationsBatch` when attaching many checks at once;
10
+ * this single-node query remains for direct callers and follow-up pages.
14
11
  */
15
- export declare function fetchCheckRunAnnotations(checkRunId: string, cacheOpts?: AnnotationCacheOptions): Promise<CheckAnnotation[]>;
12
+ export declare function fetchCheckRunAnnotations(checkRunId: string, cacheOpts?: AnnotationCacheOptions, initialPage?: AnnotationPage): Promise<CheckAnnotation[]>;