pr-shepherd 0.46.8 → 0.47.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/bin/api.d.mts +1 -1
  3. package/bin/commands/check-fingerprint.d.mts +8 -0
  4. package/bin/commands/check-fingerprint.mjs +69 -0
  5. package/bin/commands/check.d.mts +1 -0
  6. package/bin/commands/check.mjs +23 -3
  7. package/bin/commands/iterate/base.mjs +1 -0
  8. package/bin/commands/iterate/mark-ready.mjs +8 -0
  9. package/bin/commands/poll-progress.d.mts +11 -0
  10. package/bin/commands/poll-progress.mjs +52 -0
  11. package/bin/commands/poll-quota.d.mts +9 -0
  12. package/bin/commands/poll-quota.mjs +36 -0
  13. package/bin/commands/poll.mjs +64 -65
  14. package/bin/config/load.mjs +11 -1
  15. package/bin/github/batch-raw-rules.d.mts +4 -1
  16. package/bin/github/batch-raw-types.d.mts +14 -0
  17. package/bin/github/batch.d.mts +2 -0
  18. package/bin/github/batch.mjs +2 -0
  19. package/bin/github/fingerprint-fields.d.mts +58 -0
  20. package/bin/github/fingerprint-fields.mjs +39 -0
  21. package/bin/github/fingerprint.d.mts +35 -0
  22. package/bin/github/fingerprint.mjs +67 -0
  23. package/bin/github/gql/batch-pr.gql +25 -120
  24. package/bin/github/gql/commit-check-suites.gql +19 -0
  25. package/bin/github/gql/pr-fingerprint.gql +75 -0
  26. package/bin/github/gql/pr-merge-policy.gql +49 -0
  27. package/bin/github/merge-queue-checks.mjs +57 -30
  28. package/bin/github/queries.d.mts +2 -0
  29. package/bin/github/queries.mjs +4 -1
  30. package/bin/state/pr-fingerprint.d.mts +20 -0
  31. package/bin/state/pr-fingerprint.mjs +90 -0
  32. package/bin/types/iterate.d.mts +9 -0
  33. package/bin/types/report.d.mts +2 -0
  34. package/package.json +2 -2
  35. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  36. package/plugins/pr-shepherd/.codex.mcp.json +1 -1
  37. package/plugins/pr-shepherd/.mcp.json +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.46.8",
4
+ "version": "0.47.0",
5
5
  "author": {
6
6
  "name": "Jonathan Ong",
7
7
  "email": "jonathanrichardong@gmail.com"
package/bin/api.d.mts CHANGED
@@ -8,7 +8,7 @@ export interface CreatePrShepherdOptions {
8
8
  }
9
9
  /** A positive PR number, GitHub pull-request URL, or owner/repo#number reference. */
10
10
  export type PrReference = number | string;
11
- export type IterateInput = Omit<IterateCommandOptions, "format" | "prNumber" | "targetRepository" | "persistSeen" | "deferQuotaWarning"> & {
11
+ export type IterateInput = Omit<IterateCommandOptions, "format" | "prNumber" | "targetRepository" | "persistSeen" | "fingerprintCache" | "deferQuotaWarning"> & {
12
12
  pr?: PrReference;
13
13
  };
14
14
  export interface ReviewMutationsOperation {
@@ -0,0 +1,8 @@
1
+ import { type RepoInfo } from "../github/client.mts";
2
+ import type { PrShepherdConfig } from "../config/load.mts";
3
+ import type { ShepherdReport } from "../types.mts";
4
+ export declare function tryReuseFingerprintReport(prNumber: number, repo: RepoInfo, stateKey: {
5
+ owner: string;
6
+ repo: string;
7
+ pr: number;
8
+ }, config: PrShepherdConfig): Promise<ShepherdReport | null>;
@@ -0,0 +1,69 @@
1
+ import { getMergeableState } from "../github/client.mjs";
2
+ import { fetchPrFingerprint, fingerprintsEqual, } from "../github/fingerprint.mjs";
3
+ import { fingerprintInputDigest, loadPrFingerprint } from "../state/pr-fingerprint.mjs";
4
+ import { hasCheckDrivenActionableWork } from "./check-annotations.mjs";
5
+ function reportAllowsFingerprintSkip(report) {
6
+ if (report.status === "READY")
7
+ return false;
8
+ if (report.mergeStatus.state !== "OPEN")
9
+ return false;
10
+ if (report.mergeQueue?.inQueue === true)
11
+ return false;
12
+ return (report.threads.actionable.length === 0 &&
13
+ report.threads.resolutionOnly.length === 0 &&
14
+ report.threads.firstLook.length === 0 &&
15
+ (report.threads.ruleAutoResolveIds?.length ?? 0) === 0 &&
16
+ report.comments.actionable.length === 0 &&
17
+ (report.comments.minimizeIds?.length ?? 0) === 0 &&
18
+ report.comments.firstLook.length === 0 &&
19
+ report.changesRequestedReviews.length === 0 &&
20
+ report.approvedReviews.length === 0 &&
21
+ report.firstLookSummaries.length === 0 &&
22
+ report.editedSummaries.length === 0 &&
23
+ (report.ruleAutoResolveReviewSummaryIds?.length ?? 0) === 0 &&
24
+ !hasCheckDrivenActionableWork(report.checks, report.mergeStatus.status));
25
+ }
26
+ export async function tryReuseFingerprintReport(prNumber, repo, stateKey, config) {
27
+ const cached = await loadPrFingerprint(stateKey);
28
+ if (cached?.inputDigest == null || cached.inputDigest !== fingerprintInputDigest(config)) {
29
+ return null;
30
+ }
31
+ if (!reportAllowsFingerprintSkip(cached.report))
32
+ return null;
33
+ const live = await fetchPrFingerprint(prNumber, repo);
34
+ if (live.isInMergeQueue || cached.fingerprint.isInMergeQueue)
35
+ return null;
36
+ if (!live.checkSuitesComplete || !cached.fingerprint.checkSuitesComplete)
37
+ return null;
38
+ if (live.commentCount > 100 || cached.fingerprint.commentCount > 100)
39
+ return null;
40
+ if (live.reviewCount > 100 || cached.fingerprint.reviewCount > 100)
41
+ return null;
42
+ if (live.threadCount > 20 || cached.fingerprint.threadCount > 20)
43
+ return null;
44
+ if (live.hasMultiCommentThreads || cached.fingerprint.hasMultiCommentThreads)
45
+ return null;
46
+ if (!live.rulesComplete || !cached.fingerprint.rulesComplete)
47
+ return null;
48
+ if (!fingerprintsEqual(cached.fingerprint, live))
49
+ return null;
50
+ if (!(await cachedReportSurvivesMergeabilityRefresh(prNumber, repo, cached.report, cached.fingerprint))) {
51
+ return null;
52
+ }
53
+ return { ...cached.report, fingerprintReused: true };
54
+ }
55
+ async function cachedReportSurvivesMergeabilityRefresh(prNumber, repo, report, fingerprint) {
56
+ const restDerived = fingerprint.mergeable !== report.mergeStatus.mergeable ||
57
+ fingerprint.mergeStateStatus !== report.mergeStatus.mergeStateStatus;
58
+ if (report.status !== "READY" && report.mergeStatus.status !== "UNKNOWN" && !restDerived) {
59
+ return true;
60
+ }
61
+ const rest = await getMergeableState(prNumber, repo.owner, repo.name);
62
+ if (rest.state === "MERGED" || rest.state === "CLOSED")
63
+ return false;
64
+ if (rest.mergeable !== report.mergeStatus.mergeable)
65
+ return false;
66
+ if (rest.mergeStateStatus !== report.mergeStatus.mergeStateStatus)
67
+ return false;
68
+ return true;
69
+ }
@@ -4,5 +4,6 @@ export declare function runCheck(opts: GlobalOptions & {
4
4
  autoMinimizeSuppressed?: boolean;
5
5
  skipTriage?: boolean;
6
6
  persistSeen?: boolean;
7
+ fingerprintCache?: boolean;
7
8
  merge?: boolean;
8
9
  }): Promise<ShepherdReport>;
@@ -1,4 +1,6 @@
1
1
  import { fetchPrBatch } from "../github/batch.mjs";
2
+ import { storePrFingerprint } from "../state/pr-fingerprint.mjs";
3
+ import { tryReuseFingerprintReport } from "./check-fingerprint.mjs";
2
4
  import { getRepoInfo, getCurrentPrNumber } from "../github/client.mjs";
3
5
  import { classifyChecks, getCiVerdict } from "../checks/classify.mjs";
4
6
  import { mergeStartupFailureChecks } from "../checks/startup-failures.mjs";
@@ -30,6 +32,12 @@ export async function runCheck(opts) {
30
32
  }
31
33
  const stateKey = { owner: repo.owner, repo: repo.name, pr: prNumber };
32
34
  const config = loadConfig();
35
+ const reuseFingerprint = opts.fingerprintCache === true;
36
+ if (reuseFingerprint) {
37
+ const cached = await tryReuseFingerprintReport(prNumber, repo, stateKey, config);
38
+ if (cached)
39
+ return cached;
40
+ }
33
41
  const paginateApprovedReviews = config.iterate.minimizeApprovals;
34
42
  const result = await fetchPrBatch(prNumber, repo, { paginateApprovedReviews });
35
43
  let batchData = result.data;
@@ -38,7 +46,11 @@ export async function runCheck(opts) {
38
46
  const didRefreshMergeability = unknownRefresh.didRefresh;
39
47
  let mergeStatus = deriveMergeStatus(batchData);
40
48
  if (mergeStatus.state === "MERGED" || mergeStatus.state === "CLOSED") {
41
- return buildTerminalReport(prNumber, repo, batchData, mergeStatus, mergeStatus.state);
49
+ const terminal = buildTerminalReport(prNumber, repo, batchData, mergeStatus, mergeStatus.state);
50
+ if (result.fingerprint) {
51
+ await storePrFingerprint(stateKey, result.fingerprint, terminal, config);
52
+ }
53
+ return terminal;
42
54
  }
43
55
  const startupFailuresNeedAttempt = batchData.checks.some((check) => check.source === "startup_failure" && check.runAttempt === undefined);
44
56
  const startupFailureChecks = result.checkSuitesComplete && !startupFailuresNeedAttempt
@@ -145,7 +157,11 @@ export async function runCheck(opts) {
145
157
  mergeStatus = refreshed.mergeStatus;
146
158
  status = refreshed.status;
147
159
  if (mergeStatus.state === "MERGED" || mergeStatus.state === "CLOSED") {
148
- return buildTerminalReport(prNumber, repo, batchData, mergeStatus, mergeStatus.state);
160
+ const terminal = buildTerminalReport(prNumber, repo, batchData, mergeStatus, mergeStatus.state);
161
+ if (result.fingerprint) {
162
+ await storePrFingerprint(stateKey, result.fingerprint, terminal, config);
163
+ }
164
+ return terminal;
149
165
  }
150
166
  }
151
167
  // Mirrors the deferral gate in commands/iterate/index.mts exactly (via the shared
@@ -225,7 +241,7 @@ export async function runCheck(opts) {
225
241
  batchData.isInMergeQueue ||
226
242
  batchData.autoMergeRequest ||
227
243
  batchData.latestMergeQueueRemoval);
228
- return {
244
+ const report = {
229
245
  pr: prNumber,
230
246
  nodeId: batchData.nodeId,
231
247
  headSha: batchData.headRefOid,
@@ -290,6 +306,10 @@ export async function runCheck(opts) {
290
306
  },
291
307
  }),
292
308
  };
309
+ if (result.fingerprint) {
310
+ await storePrFingerprint(stateKey, result.fingerprint, report, config);
311
+ }
312
+ return report;
293
313
  }
294
314
  async function remainingRuleAutoResolveIds(partition, autoMinimizeSuppressed = false) {
295
315
  const consumedIds = autoMinimizeSuppressed
@@ -21,5 +21,6 @@ export function buildIterateBase(report, readyState) {
21
21
  ...buildSuppressedCheckFields(report),
22
22
  activity: report.activity,
23
23
  mergeQueue: report.mergeQueue,
24
+ ...(report.fingerprintReused === true && { fingerprintReused: true }),
24
25
  };
25
26
  }
@@ -4,6 +4,14 @@ import { buildEscalateHumanMessage, buildEscalateSuggestion } from "./escalate.m
4
4
  export async function markReadyIfAuthorized(enabled, base, report) {
5
5
  if (!enabled)
6
6
  return null;
7
+ if (report.fingerprintReused === true) {
8
+ return {
9
+ ...base,
10
+ action: "mark_ready",
11
+ markedReady: false,
12
+ log: `READY: PR #${report.pr} is ready to mark; refresh required before converting draft`,
13
+ };
14
+ }
7
15
  if (report.viewerAuthorization?.viewerCanUpdate === true) {
8
16
  await graphql(MARK_PR_READY_MUTATION, { pullRequestId: report.nodeId });
9
17
  return {
@@ -0,0 +1,11 @@
1
+ import type { IterateResult } from "../types.mts";
2
+ export declare function writeWaitProgress(opts: {
3
+ tick: number;
4
+ elapsedMs: number;
5
+ sleepMs: number;
6
+ result: IterateResult;
7
+ quietStatus: boolean;
8
+ verbose: boolean;
9
+ lastWaitSignature: string | null;
10
+ }): string | null;
11
+ export declare function writeDebounceProgress(tick: number, elapsedMs: number, remainingMs: number): void;
@@ -0,0 +1,52 @@
1
+ function writeTickProgress(tick, elapsedSeconds, detail) {
2
+ process.stderr.write(`[poll tick ${tick} / +${elapsedSeconds}s] WAIT — ${detail}\n`);
3
+ }
4
+ function waitSignature(result) {
5
+ const activity = result.activity ?? {
6
+ commitCount: 0,
7
+ reviewRoundCount: 0,
8
+ latestCommitCommittedAtUnix: null,
9
+ reviewItemsSinceLatestCommit: [],
10
+ };
11
+ return JSON.stringify({
12
+ status: result.status,
13
+ mergeStateStatus: result.mergeStateStatus,
14
+ reviewDecision: result.reviewDecision,
15
+ state: result.state,
16
+ active: (result.inProgressChecks ?? []).map((c) => [c.name, c.status, c.runId]),
17
+ commitCount: activity.commitCount,
18
+ latestCommitCommittedAtUnix: activity.latestCommitCommittedAtUnix,
19
+ reviewRoundCount: activity.reviewRoundCount,
20
+ reviewItemsSinceLatestCommit: activity.reviewItemsSinceLatestCommit.length,
21
+ });
22
+ }
23
+ function writeQuietStatus(tick, elapsedSeconds, sleepSeconds, result) {
24
+ const activeChecks = result.inProgressChecks ?? [];
25
+ const activeCheckText = activeChecks.map((c) => `${c.name} (${c.status})`).join(", ");
26
+ const active = activeChecks.length > 0 ? ` · active: ${activeCheckText}` : "";
27
+ const commitCount = result.activity?.commitCount ?? 0;
28
+ const reviewItems = result.activity?.reviewItemsSinceLatestCommit.length ?? 0;
29
+ const reviewRounds = result.activity?.reviewRoundCount ?? 0;
30
+ const commitSeg = commitCount > 0 ? ` · ${commitCount} commits` : "";
31
+ const reviewRoundSeg = reviewRounds > 0 ? ` · ${reviewRounds} review rounds` : "";
32
+ const reviewSeg = reviewItems > 0 ? ` · ${reviewItems} review items since latest commit` : "";
33
+ process.stderr.write(`[poll tick ${tick} / +${elapsedSeconds}s] WAIT ${result.status}/${result.mergeStateStatus}/${result.reviewDecision ?? "NO_REVIEW_DECISION"}${active}${commitSeg}${reviewRoundSeg}${reviewSeg} — sleeping ${sleepSeconds}s\n`);
34
+ }
35
+ export function writeWaitProgress(opts) {
36
+ const elapsedSeconds = Math.round(opts.elapsedMs / 1000);
37
+ const sleepSeconds = Math.round(opts.sleepMs / 1000);
38
+ if (!opts.quietStatus) {
39
+ writeTickProgress(opts.tick, elapsedSeconds, opts.verbose ? `sleeping ${sleepSeconds}s` : `still running; next tick in ${sleepSeconds}s`);
40
+ return opts.lastWaitSignature;
41
+ }
42
+ const signature = waitSignature(opts.result);
43
+ if (signature !== opts.lastWaitSignature) {
44
+ writeQuietStatus(opts.tick, elapsedSeconds, sleepSeconds, opts.result);
45
+ }
46
+ return signature;
47
+ }
48
+ export function writeDebounceProgress(tick, elapsedMs, remainingMs) {
49
+ const elapsedSeconds = Math.round(elapsedMs / 1000);
50
+ const remainingSeconds = Math.round(remainingMs / 1000);
51
+ process.stderr.write(`[poll tick ${tick} / +${elapsedSeconds}s] FIX_CODE — debounce ${remainingSeconds}s remaining\n`);
52
+ }
@@ -0,0 +1,9 @@
1
+ import type { GraphqlQuotaWarningBand } from "../config/load.mts";
2
+ import type { GraphqlApiUsage } from "../types.mts";
3
+ /** Sleep at least `--interval`, and at least the active crossed quota band. */
4
+ export declare function graphqlQuotaPollIntervalMs(bands: GraphqlQuotaWarningBand[], usage: Pick<GraphqlApiUsage, "remaining" | "limit"> | undefined, fallbackMs: number, maxMs: number): number;
5
+ /**
6
+ * Retry delay for `--until-terminal` when GitHub returns a GraphQL 429 / secondary
7
+ * limit. `null` means the error is not a retryable rate limit.
8
+ */
9
+ export declare function pollGraphQlRetryAfterMs(err: unknown): number | null;
@@ -0,0 +1,36 @@
1
+ import { GitHubRequestError } from "../github/errors.mjs";
2
+ import { isRateLimitMessage } from "../comments/rate-limit.mjs";
3
+ const GRAPHQL_RETRY_AFTER_DEFAULT_MS = 60_000;
4
+ /** Sleep at least `--interval`, and at least the active crossed quota band. */
5
+ export function graphqlQuotaPollIntervalMs(bands, usage, fallbackMs, maxMs) {
6
+ if (usage === undefined || usage.limit <= 0 || bands.length === 0) {
7
+ return Math.min(fallbackMs, maxMs);
8
+ }
9
+ const crossed = bands.filter((band) => usage.remaining * 100 <= usage.limit * band.remainingPercent);
10
+ if (crossed.length === 0)
11
+ return Math.min(fallbackMs, maxMs);
12
+ const active = crossed.reduce((lowest, band) => band.remainingPercent < lowest.remainingPercent ? band : lowest);
13
+ const bandMs = active.pollIntervalMinutes * 60_000;
14
+ return Math.min(Math.max(fallbackMs, bandMs), maxMs);
15
+ }
16
+ /**
17
+ * Retry delay for `--until-terminal` when GitHub returns a GraphQL 429 / secondary
18
+ * limit. `null` means the error is not a retryable rate limit.
19
+ */
20
+ export function pollGraphQlRetryAfterMs(err) {
21
+ if (!(err instanceof GitHubRequestError))
22
+ return null;
23
+ const retryable = err.status === 429 ||
24
+ err.retryAfterSeconds !== undefined ||
25
+ isRateLimitMessage(err.message) ||
26
+ (err.graphqlErrors?.some((error) => isRateLimitMessage(error.message)) ?? false) ||
27
+ (err.rateLimit !== undefined && err.rateLimit.remaining <= 0);
28
+ if (!retryable)
29
+ return null;
30
+ if (err.retryAfterSeconds !== undefined)
31
+ return Math.max(err.retryAfterSeconds, 0) * 1000;
32
+ if (err.rateLimit !== undefined && err.rateLimit.remaining <= 0) {
33
+ return Math.max(err.rateLimit.resetAt * 1000 - Date.now(), 0);
34
+ }
35
+ return GRAPHQL_RETRY_AFTER_DEFAULT_MS;
36
+ }
@@ -1,66 +1,12 @@
1
1
  import { runIterate } from "./iterate/index.mjs";
2
2
  import { sleep } from "../util/sleep.mjs";
3
3
  import { withPollApiUsage } from "./poll-run.mjs";
4
+ import { loadConfig } from "../config/load.mjs";
5
+ import { graphqlQuotaPollIntervalMs, pollGraphQlRetryAfterMs } from "./poll-quota.mjs";
6
+ import { writeDebounceProgress, writeWaitProgress } from "./poll-progress.mjs";
4
7
  const DEFAULT_POLL_DEBOUNCE_SECONDS = 60;
5
- function writeTickProgress(tick, elapsedSeconds, sleepSeconds, verbose) {
6
- if (verbose) {
7
- process.stderr.write(`[poll tick ${tick} / +${elapsedSeconds}s] WAIT — sleeping ${sleepSeconds}s\n`);
8
- }
9
- else {
10
- process.stderr.write(`[poll tick ${tick} / +${elapsedSeconds}s] WAIT — still running; next tick in ${sleepSeconds}s\n`);
11
- }
12
- }
13
- function waitSignature(result) {
14
- const activity = result.activity ?? {
15
- commitCount: 0,
16
- reviewRoundCount: 0,
17
- latestCommitCommittedAtUnix: null,
18
- reviewItemsSinceLatestCommit: [],
19
- };
20
- return JSON.stringify({
21
- status: result.status,
22
- mergeStateStatus: result.mergeStateStatus,
23
- reviewDecision: result.reviewDecision,
24
- state: result.state,
25
- active: (result.inProgressChecks ?? []).map((c) => [c.name, c.status, c.runId]),
26
- commitCount: activity.commitCount,
27
- latestCommitCommittedAtUnix: activity.latestCommitCommittedAtUnix,
28
- reviewRoundCount: activity.reviewRoundCount,
29
- reviewItemsSinceLatestCommit: activity.reviewItemsSinceLatestCommit.length,
30
- });
31
- }
32
- function writeQuietStatus(tick, elapsedSeconds, sleepSeconds, result) {
33
- const activeChecks = result.inProgressChecks ?? [];
34
- const activeCheckText = activeChecks.map((c) => `${c.name} (${c.status})`).join(", ");
35
- const active = activeChecks.length > 0 ? ` · active: ${activeCheckText}` : "";
36
- const commitCount = result.activity?.commitCount ?? 0;
37
- const reviewItems = result.activity?.reviewItemsSinceLatestCommit.length ?? 0;
38
- const reviewRounds = result.activity?.reviewRoundCount ?? 0;
39
- const commitSeg = commitCount > 0 ? ` · ${commitCount} commits` : "";
40
- const reviewRoundSeg = reviewRounds > 0 ? ` · ${reviewRounds} review rounds` : "";
41
- const reviewSeg = reviewItems > 0 ? ` · ${reviewItems} review items since latest commit` : "";
42
- process.stderr.write(`[poll tick ${tick} / +${elapsedSeconds}s] WAIT ${result.status}/${result.mergeStateStatus}/${result.reviewDecision ?? "NO_REVIEW_DECISION"}${active}${commitSeg}${reviewRoundSeg}${reviewSeg} — sleeping ${sleepSeconds}s\n`);
43
- }
44
8
  const MAX_TIMER_MS = 2 ** 31 - 1;
45
9
  const TIMER_DRIFT_TOLERANCE_MS = 500;
46
- function writeWaitProgress(opts) {
47
- const elapsedSeconds = Math.round(opts.elapsedMs / 1000);
48
- const sleepSeconds = Math.round(opts.sleepMs / 1000);
49
- if (!opts.quietStatus) {
50
- writeTickProgress(opts.tick, elapsedSeconds, sleepSeconds, opts.verbose);
51
- return opts.lastWaitSignature;
52
- }
53
- const signature = waitSignature(opts.result);
54
- if (signature !== opts.lastWaitSignature) {
55
- writeQuietStatus(opts.tick, elapsedSeconds, sleepSeconds, opts.result);
56
- }
57
- return signature;
58
- }
59
- function writeDebounceProgress(tick, elapsedMs, remainingMs) {
60
- const elapsedSeconds = Math.round(elapsedMs / 1000);
61
- const remainingSeconds = Math.round(remainingMs / 1000);
62
- process.stderr.write(`[poll tick ${tick} / +${elapsedSeconds}s] FIX_CODE — debounce ${remainingSeconds}s remaining\n`);
63
- }
64
10
  /** @deprecated Hidden implementation for the legacy `poll` alias. */
65
11
  export function runPoll(opts) {
66
12
  return withPollApiUsage(() => runPollCore(opts), opts.untilTerminal === true);
@@ -71,6 +17,7 @@ async function runPollCore(opts) {
71
17
  const timeoutMs = Math.min(timeoutSeconds * 1000, MAX_TIMER_MS);
72
18
  const debounceSeconds = debounceSecondsOpt ?? DEFAULT_POLL_DEBOUNCE_SECONDS;
73
19
  const debounceMs = Math.min(debounceSeconds * 1000, MAX_TIMER_MS);
20
+ const quotaBands = loadConfig().watch.graphqlQuotaWarnings;
74
21
  const start = Date.now();
75
22
  let tick = 0;
76
23
  let lastResult;
@@ -82,23 +29,64 @@ async function runPollCore(opts) {
82
29
  // Pin the PR resolved by the first tick; branch inference only matches OPEN PRs.
83
30
  let prNumber = opts.prNumber;
84
31
  let debounceUntil = null;
32
+ let rateLimitRetries = 0;
85
33
  while (true) {
86
34
  tick += 1;
87
35
  const pastDebounce = debounceUntil !== null && Date.now() >= debounceUntil;
88
- lastResult = await runIterate({
36
+ const remainingBefore = untilTerminal
37
+ ? Number.POSITIVE_INFINITY
38
+ : timeoutMs - (Date.now() - start);
39
+ // Cache only internal continuation ticks. Last bounded tick, FIX_CODE debounce,
40
+ // and any tick we return to the caller must fetch BatchPr.
41
+ const allowCache = debounceUntil === null && remainingBefore + TIMER_DRIFT_TOLERANCE_MS >= intervalMs;
42
+ const iterateTick = (fingerprintCache) => runIterate({
89
43
  ...iterateOpts,
90
44
  prNumber,
91
45
  persistSeen: debounceSeconds === 0 || pastDebounce,
92
- // Until-terminal must persist before deciding to return.
46
+ fingerprintCache,
93
47
  deferQuotaWarning: !untilTerminal,
94
48
  });
49
+ const runTick = async (fingerprintCache) => {
50
+ try {
51
+ const result = await iterateTick(fingerprintCache);
52
+ rateLimitRetries = 0;
53
+ return result;
54
+ }
55
+ catch (err) {
56
+ const retryMs = untilTerminal ? pollGraphQlRetryAfterMs(err) : null;
57
+ if (retryMs === null || rateLimitRetries >= 1)
58
+ throw err;
59
+ rateLimitRetries += 1;
60
+ process.stderr.write(`[poll tick ${tick} / +${Math.round((Date.now() - start) / 1000)}s] GraphQL rate limit — retrying in ${Math.round(retryMs / 1000)}s\n`);
61
+ await sleep(retryMs);
62
+ const result = await iterateTick(fingerprintCache);
63
+ rateLimitRetries = 0;
64
+ return result;
65
+ }
66
+ };
67
+ lastResult = await runTick(allowCache);
95
68
  prNumber ??= lastResult.pr;
96
69
  if (lastResult.quotaWarning !== undefined)
97
70
  pendingQuotaWarning = lastResult.quotaWarning;
71
+ const refreshIfReturning = async () => {
72
+ if (lastResult?.fingerprintReused !== true)
73
+ return;
74
+ lastResult = await runTick(false);
75
+ if (lastResult.quotaWarning !== undefined)
76
+ pendingQuotaWarning = lastResult.quotaWarning;
77
+ };
98
78
  if (untilTerminal &&
99
79
  pendingQuotaWarning !== undefined &&
100
80
  !(lastResult.action === "fix_code" && debounceSeconds > 0 && !pastDebounce) &&
101
81
  !(debounceUntil !== null && !pastDebounce)) {
82
+ await refreshIfReturning();
83
+ if (lastResult.action === "fix_code" && debounceSeconds > 0 && !pastDebounce) {
84
+ debounceUntil ??= Date.now() + debounceMs;
85
+ const remainingMs = Math.max(debounceUntil - Date.now(), 0);
86
+ writeDebounceProgress(tick, Date.now() - start, remainingMs);
87
+ await sleep(Math.min(intervalMs, remainingMs));
88
+ continue;
89
+ }
102
90
  if (["cancel", "escalate"].includes(lastResult.action)) {
103
91
  const { quotaWarning: _quotaWarning, ...withoutQuotaWarning } = lastResult;
104
92
  lastResult = withoutQuotaWarning;
@@ -112,30 +100,40 @@ async function runPollCore(opts) {
112
100
  if (pendingQuotaWarning === undefined)
113
101
  debounceUntil = null;
114
102
  const elapsedMs = Date.now() - start;
103
+ const sleepMs = graphqlQuotaPollIntervalMs(quotaBands, lastResult.apiUsage?.graphql, intervalMs, MAX_TIMER_MS);
115
104
  if (!untilTerminal) {
116
105
  const remainingMs = timeoutMs - elapsedMs;
117
- if (remainingMs <= 0)
118
- break;
119
- if (remainingMs + TIMER_DRIFT_TOLERANCE_MS < intervalMs)
106
+ if (remainingMs <= 0 || remainingMs + TIMER_DRIFT_TOLERANCE_MS < sleepMs) {
107
+ await refreshIfReturning();
120
108
  break;
109
+ }
121
110
  }
122
111
  lastWaitSignature = writeWaitProgress({
123
112
  tick,
124
113
  elapsedMs,
125
- sleepMs: intervalMs,
114
+ sleepMs,
126
115
  result: lastResult,
127
116
  quietStatus,
128
117
  verbose,
129
118
  lastWaitSignature,
130
119
  });
131
- await sleep(intervalMs);
120
+ await sleep(sleepMs);
132
121
  continue;
133
122
  }
134
123
  if ((untilTerminal || iterateOpts.merge) &&
135
124
  lastResult.action === "mark_ready" &&
136
125
  !pastDebounce) {
137
126
  debounceUntil = null;
138
- await sleep(intervalMs);
127
+ const elapsedMs = Date.now() - start;
128
+ const sleepMs = graphqlQuotaPollIntervalMs(quotaBands, lastResult.apiUsage?.graphql, intervalMs, MAX_TIMER_MS);
129
+ if (!untilTerminal) {
130
+ const remainingMs = timeoutMs - elapsedMs;
131
+ if (remainingMs <= 0 || remainingMs + TIMER_DRIFT_TOLERANCE_MS < sleepMs) {
132
+ await refreshIfReturning();
133
+ break;
134
+ }
135
+ }
136
+ await sleep(sleepMs);
139
137
  continue;
140
138
  }
141
139
  if (lastResult.action === "fix_code" && debounceSeconds > 0 && !pastDebounce) {
@@ -147,6 +145,7 @@ async function runPollCore(opts) {
147
145
  }
148
146
  continue;
149
147
  }
148
+ await refreshIfReturning();
150
149
  break;
151
150
  }
152
151
  return lastResult;
@@ -166,7 +166,17 @@ function parseGraphqlQuotaWarnings(value) {
166
166
  seen.add(remainingPercent);
167
167
  return { remainingPercent, pollIntervalMinutes };
168
168
  });
169
- return parsed.sort((left, right) => right.remainingPercent - left.remainingPercent);
169
+ parsed.sort((left, right) => right.remainingPercent - left.remainingPercent);
170
+ for (let index = 1; index < parsed.length; index += 1) {
171
+ const previous = parsed[index - 1];
172
+ const current = parsed[index];
173
+ if (previous !== undefined &&
174
+ current !== undefined &&
175
+ current.pollIntervalMinutes < previous.pollIntervalMinutes) {
176
+ throw new Error("Invalid config: watch.graphqlQuotaWarnings pollIntervalMinutes must not decrease as remainingPercent decreases");
177
+ }
178
+ }
179
+ return parsed;
170
180
  }
171
181
  const KNOWN_CONFIG_KEYS = new Set([
172
182
  "classify",
@@ -21,7 +21,7 @@ interface RawCheckCommit {
21
21
  oid: string;
22
22
  }>;
23
23
  };
24
- statusCheckRollup: {
24
+ statusCheckRollup?: {
25
25
  contexts: {
26
26
  pageInfo: {
27
27
  hasNextPage: boolean;
@@ -82,6 +82,9 @@ interface RawRuleParameters {
82
82
  export interface RawBaseRef {
83
83
  branchProtectionRule: RawBranchProtectionRule | null;
84
84
  rules: {
85
+ pageInfo?: {
86
+ hasNextPage: boolean;
87
+ };
85
88
  nodes: RawRepositoryRule[];
86
89
  } | null;
87
90
  }
@@ -1,6 +1,9 @@
1
1
  import type { CommentAuthorAssociation } from "../types/github.mts";
2
2
  import type { RawPrMergeFields } from "./batch-raw-rules.mts";
3
3
  export interface RawBatchResponse {
4
+ viewer?: {
5
+ login: string | null;
6
+ } | null;
4
7
  repository: {
5
8
  viewerPermission: string | null;
6
9
  viewerCanAdminister: boolean;
@@ -10,6 +13,7 @@ export interface RawBatchResponse {
10
13
  export interface RawPr extends RawPrMergeFields {
11
14
  id: string;
12
15
  number: number;
16
+ updatedAt?: string;
13
17
  state: string;
14
18
  isDraft: boolean;
15
19
  viewerDidAuthor: boolean;
@@ -42,6 +46,7 @@ export interface RawPr extends RawPrMergeFields {
42
46
  }>;
43
47
  };
44
48
  reviewThreads: {
49
+ totalCount?: number;
45
50
  pageInfo: {
46
51
  hasPreviousPage: boolean;
47
52
  startCursor: string | null;
@@ -49,6 +54,7 @@ export interface RawPr extends RawPrMergeFields {
49
54
  nodes: RawThread[];
50
55
  };
51
56
  comments: {
57
+ totalCount?: number;
52
58
  pageInfo: {
53
59
  hasPreviousPage: boolean;
54
60
  startCursor: string | null;
@@ -71,6 +77,10 @@ export interface RawPr extends RawPrMergeFields {
71
77
  };
72
78
  allReviews?: {
73
79
  totalCount: number;
80
+ nodes?: Array<{
81
+ id: string;
82
+ updatedAt?: string;
83
+ }>;
74
84
  };
75
85
  approvedReviews: {
76
86
  pageInfo: {
@@ -87,6 +97,7 @@ export interface RawPr extends RawPrMergeFields {
87
97
  committedDate?: string;
88
98
  checkSuites?: RawCheckSuites;
89
99
  statusCheckRollup: {
100
+ state?: string | null;
90
101
  contexts: {
91
102
  pageInfo: {
92
103
  hasNextPage: boolean;
@@ -104,6 +115,7 @@ interface RawCheckSuites {
104
115
  hasNextPage: boolean;
105
116
  };
106
117
  nodes: Array<{
118
+ id?: string;
107
119
  conclusion: string | null;
108
120
  workflowRun: {
109
121
  databaseId: number | null;
@@ -134,6 +146,7 @@ export interface RawThreadComment {
134
146
  line: number | null;
135
147
  startLine: number | null;
136
148
  createdAt?: string;
149
+ updatedAt?: string;
137
150
  }
138
151
  export interface RawThread {
139
152
  id: string;
@@ -173,6 +186,7 @@ export interface RawComment {
173
186
  author: RawAuthor | null;
174
187
  body: string;
175
188
  createdAt?: string;
189
+ updatedAt?: string;
176
190
  }
177
191
  export interface RawReview {
178
192
  id: string;
@@ -1,7 +1,9 @@
1
1
  import { type RateLimitInfo, type RepoInfo } from "./client.mts";
2
2
  import type { BatchPrData } from "../types.mts";
3
+ import { type PrFingerprint } from "./fingerprint.mts";
3
4
  interface BatchResult {
4
5
  data: BatchPrData;
6
+ fingerprint?: PrFingerprint;
5
7
  rateLimit?: RateLimitInfo;
6
8
  /** True when GraphQL returned a complete CheckSuite page; skip REST startup-failure fetch. */
7
9
  checkSuitesComplete?: boolean;
@@ -7,6 +7,7 @@ import { mergeStartupFailureChecks } from "../checks/startup-failures.mjs";
7
7
  import { paginateBatchConnections } from "./batch-page.mjs";
8
8
  import { requireRawPr } from "./batch-response.mjs";
9
9
  import { hydrateMergeQueueChecks } from "./merge-queue-checks.mjs";
10
+ import { fingerprintFromRaw } from "./fingerprint.mjs";
10
11
  /**
11
12
  * Fetch all PR data needed for a `shepherd check` in one (or a few, if paginating) GraphQL requests.
12
13
  */
@@ -24,6 +25,7 @@ export async function fetchPrBatch(pr, repo, opts = {}) {
24
25
  data.checks = mergeStartupFailureChecks(data.checks, parseSuiteStartupFailures(raw));
25
26
  return {
26
27
  data,
28
+ fingerprint: fingerprintFromRaw(raw, result.data.repository?.viewerPermission ?? null, result.data.viewer?.login ?? null),
27
29
  rateLimit: paged.rateLimit ?? result.rateLimit,
28
30
  ...(parseCheckSuitesComplete(raw) && { checkSuitesComplete: true }),
29
31
  };