pr-shepherd 0.46.8 → 0.48.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 (55) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +2 -0
  3. package/bin/api.d.mts +1 -1
  4. package/bin/cli/args.mjs +1 -0
  5. package/bin/cli/default-poll.mjs +1 -0
  6. package/bin/cli/help-command-pages.d.mts +1 -1
  7. package/bin/cli/help-iterate-poll-pages.d.mts +1 -1
  8. package/bin/cli/help-iterate-poll-pages.mjs +6 -5
  9. package/bin/cli/help-top-page.d.mts +1 -1
  10. package/bin/cli/help-top-page.mjs +5 -4
  11. package/bin/cli/help.d.mts +2 -2
  12. package/bin/cli/poll-handler.mjs +15 -7
  13. package/bin/commands/check-fingerprint.d.mts +8 -0
  14. package/bin/commands/check-fingerprint.mjs +69 -0
  15. package/bin/commands/check.d.mts +1 -0
  16. package/bin/commands/check.mjs +23 -3
  17. package/bin/commands/iterate/api-usage.d.mts +1 -1
  18. package/bin/commands/iterate/api-usage.mjs +6 -2
  19. package/bin/commands/iterate/base.mjs +1 -0
  20. package/bin/commands/iterate/mark-ready.mjs +8 -0
  21. package/bin/commands/iterate/run.mjs +1 -1
  22. package/bin/commands/poll-progress.d.mts +11 -0
  23. package/bin/commands/poll-progress.mjs +52 -0
  24. package/bin/commands/poll-quota.d.mts +9 -0
  25. package/bin/commands/poll-quota.mjs +36 -0
  26. package/bin/commands/poll-run.d.mts +1 -1
  27. package/bin/commands/poll-run.mjs +2 -2
  28. package/bin/commands/poll.mjs +66 -66
  29. package/bin/config/load.d.mts +7 -0
  30. package/bin/config/load.mjs +81 -20
  31. package/bin/config.json +9 -3
  32. package/bin/github/batch-raw-rules.d.mts +4 -1
  33. package/bin/github/batch-raw-types.d.mts +14 -0
  34. package/bin/github/batch.d.mts +2 -0
  35. package/bin/github/batch.mjs +2 -0
  36. package/bin/github/fingerprint-fields.d.mts +58 -0
  37. package/bin/github/fingerprint-fields.mjs +39 -0
  38. package/bin/github/fingerprint.d.mts +35 -0
  39. package/bin/github/fingerprint.mjs +67 -0
  40. package/bin/github/gql/batch-pr.gql +25 -120
  41. package/bin/github/gql/commit-check-suites.gql +19 -0
  42. package/bin/github/gql/pr-fingerprint.gql +75 -0
  43. package/bin/github/gql/pr-merge-policy.gql +49 -0
  44. package/bin/github/merge-queue-checks.mjs +57 -30
  45. package/bin/github/queries.d.mts +2 -0
  46. package/bin/github/queries.mjs +4 -1
  47. package/bin/quota-warning.mjs +1 -1
  48. package/bin/state/pr-fingerprint.d.mts +20 -0
  49. package/bin/state/pr-fingerprint.mjs +90 -0
  50. package/bin/types/iterate.d.mts +12 -3
  51. package/bin/types/report.d.mts +2 -0
  52. package/package.json +2 -2
  53. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  54. package/plugins/pr-shepherd/.codex.mcp.json +1 -1
  55. package/plugins/pr-shepherd/.mcp.json +1 -1
@@ -1,69 +1,15 @@
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
- return withPollApiUsage(() => runPollCore(opts), opts.untilTerminal === true);
12
+ return withPollApiUsage(() => runPollCore(opts), opts.untilTerminal === true, opts.intervalSeconds / 60);
67
13
  }
68
14
  async function runPollCore(opts) {
69
15
  const { intervalSeconds, timeoutSeconds, debounceSeconds: debounceSecondsOpt, quietStatus: quietStatusOpt, untilTerminal: untilTerminalOpt, ...iterateOpts } = opts;
@@ -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,65 @@ 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,
48
+ quotaWarningMinimumPollIntervalMinutes: intervalSeconds / 60,
94
49
  });
50
+ const runTick = async (fingerprintCache) => {
51
+ try {
52
+ const result = await iterateTick(fingerprintCache);
53
+ rateLimitRetries = 0;
54
+ return result;
55
+ }
56
+ catch (err) {
57
+ const retryMs = untilTerminal ? pollGraphQlRetryAfterMs(err) : null;
58
+ if (retryMs === null || rateLimitRetries >= 1)
59
+ throw err;
60
+ rateLimitRetries += 1;
61
+ process.stderr.write(`[poll tick ${tick} / +${Math.round((Date.now() - start) / 1000)}s] GraphQL rate limit — retrying in ${Math.round(retryMs / 1000)}s\n`);
62
+ await sleep(retryMs);
63
+ const result = await iterateTick(fingerprintCache);
64
+ rateLimitRetries = 0;
65
+ return result;
66
+ }
67
+ };
68
+ lastResult = await runTick(allowCache);
95
69
  prNumber ??= lastResult.pr;
96
70
  if (lastResult.quotaWarning !== undefined)
97
71
  pendingQuotaWarning = lastResult.quotaWarning;
72
+ const refreshIfReturning = async () => {
73
+ if (lastResult?.fingerprintReused !== true)
74
+ return;
75
+ lastResult = await runTick(false);
76
+ if (lastResult.quotaWarning !== undefined)
77
+ pendingQuotaWarning = lastResult.quotaWarning;
78
+ };
98
79
  if (untilTerminal &&
99
80
  pendingQuotaWarning !== undefined &&
100
81
  !(lastResult.action === "fix_code" && debounceSeconds > 0 && !pastDebounce) &&
101
82
  !(debounceUntil !== null && !pastDebounce)) {
83
+ await refreshIfReturning();
84
+ if (lastResult.action === "fix_code" && debounceSeconds > 0 && !pastDebounce) {
85
+ debounceUntil ??= Date.now() + debounceMs;
86
+ const remainingMs = Math.max(debounceUntil - Date.now(), 0);
87
+ writeDebounceProgress(tick, Date.now() - start, remainingMs);
88
+ await sleep(Math.min(intervalMs, remainingMs));
89
+ continue;
90
+ }
102
91
  if (["cancel", "escalate"].includes(lastResult.action)) {
103
92
  const { quotaWarning: _quotaWarning, ...withoutQuotaWarning } = lastResult;
104
93
  lastResult = withoutQuotaWarning;
@@ -112,30 +101,40 @@ async function runPollCore(opts) {
112
101
  if (pendingQuotaWarning === undefined)
113
102
  debounceUntil = null;
114
103
  const elapsedMs = Date.now() - start;
104
+ const sleepMs = graphqlQuotaPollIntervalMs(quotaBands, lastResult.apiUsage?.graphql, intervalMs, MAX_TIMER_MS);
115
105
  if (!untilTerminal) {
116
106
  const remainingMs = timeoutMs - elapsedMs;
117
- if (remainingMs <= 0)
118
- break;
119
- if (remainingMs + TIMER_DRIFT_TOLERANCE_MS < intervalMs)
107
+ if (remainingMs <= 0 || remainingMs + TIMER_DRIFT_TOLERANCE_MS < sleepMs) {
108
+ await refreshIfReturning();
120
109
  break;
110
+ }
121
111
  }
122
112
  lastWaitSignature = writeWaitProgress({
123
113
  tick,
124
114
  elapsedMs,
125
- sleepMs: intervalMs,
115
+ sleepMs,
126
116
  result: lastResult,
127
117
  quietStatus,
128
118
  verbose,
129
119
  lastWaitSignature,
130
120
  });
131
- await sleep(intervalMs);
121
+ await sleep(sleepMs);
132
122
  continue;
133
123
  }
134
124
  if ((untilTerminal || iterateOpts.merge) &&
135
125
  lastResult.action === "mark_ready" &&
136
126
  !pastDebounce) {
137
127
  debounceUntil = null;
138
- await sleep(intervalMs);
128
+ const elapsedMs = Date.now() - start;
129
+ const sleepMs = graphqlQuotaPollIntervalMs(quotaBands, lastResult.apiUsage?.graphql, intervalMs, MAX_TIMER_MS);
130
+ if (!untilTerminal) {
131
+ const remainingMs = timeoutMs - elapsedMs;
132
+ if (remainingMs <= 0 || remainingMs + TIMER_DRIFT_TOLERANCE_MS < sleepMs) {
133
+ await refreshIfReturning();
134
+ break;
135
+ }
136
+ }
137
+ await sleep(sleepMs);
139
138
  continue;
140
139
  }
141
140
  if (lastResult.action === "fix_code" && debounceSeconds > 0 && !pastDebounce) {
@@ -147,6 +146,7 @@ async function runPollCore(opts) {
147
146
  }
148
147
  continue;
149
148
  }
149
+ await refreshIfReturning();
150
150
  break;
151
151
  }
152
152
  return lastResult;
@@ -6,6 +6,12 @@ export interface GraphqlQuotaWarningBand {
6
6
  remainingPercent: number;
7
7
  pollIntervalMinutes: number;
8
8
  }
9
+ interface PollConfig {
10
+ intervalSeconds: number;
11
+ timeoutSeconds: number;
12
+ debounceSeconds: number;
13
+ quietStatus: boolean;
14
+ }
9
15
  export interface PrShepherdConfig {
10
16
  /** Optional user classification configuration; preserved for rule consumers. */
11
17
  classify?: unknown;
@@ -40,6 +46,7 @@ export interface PrShepherdConfig {
40
46
  */
41
47
  resolveOtherHumanThreads: ResolveOtherHumanThreads;
42
48
  };
49
+ poll: PollConfig;
43
50
  watch: {
44
51
  readyDelayMinutes: number;
45
52
  graphqlQuotaWarnings: GraphqlQuotaWarningBand[];
@@ -137,7 +137,30 @@ function parseMergeCommandArgs(value) {
137
137
  }
138
138
  return strategies.length === 0 ? [...value, "--merge"] : [...value];
139
139
  }
140
- function parseGraphqlQuotaWarnings(value) {
140
+ function parsePollConfig(value) {
141
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
142
+ throw new Error("Invalid config: poll must be a plain object");
143
+ }
144
+ const record = value;
145
+ const intervalSeconds = parsePollDuration(record["intervalSeconds"], "intervalSeconds");
146
+ const timeoutSeconds = parsePollDuration(record["timeoutSeconds"], "timeoutSeconds");
147
+ const debounceSeconds = parsePollDuration(record["debounceSeconds"], "debounceSeconds", true);
148
+ const quietStatus = record["quietStatus"];
149
+ if (typeof quietStatus !== "boolean") {
150
+ throw new Error(`Invalid config: poll.quietStatus must be a boolean, got ${JSON.stringify(quietStatus)}`);
151
+ }
152
+ return { intervalSeconds, timeoutSeconds, debounceSeconds, quietStatus };
153
+ }
154
+ function parsePollDuration(value, key, allowZero = false) {
155
+ if (typeof value !== "number" ||
156
+ !Number.isFinite(value) ||
157
+ (allowZero ? value < 0 : value <= 0)) {
158
+ const range = allowZero ? "a non-negative" : "a positive";
159
+ throw new Error(`Invalid config: poll.${key} must be ${range} finite number, got ${JSON.stringify(value)}`);
160
+ }
161
+ return value;
162
+ }
163
+ function parseGraphqlQuotaWarnings(value, pollIntervalSeconds) {
141
164
  if (!Array.isArray(value)) {
142
165
  throw new Error("Invalid config: watch.graphqlQuotaWarnings must be an array");
143
166
  }
@@ -149,30 +172,61 @@ function parseGraphqlQuotaWarnings(value) {
149
172
  const record = item;
150
173
  const remainingPercent = record["remainingPercent"];
151
174
  const pollIntervalMinutes = record["pollIntervalMinutes"];
175
+ const pollIntervalFactor = record["pollIntervalFactor"];
152
176
  if (typeof remainingPercent !== "number" ||
153
177
  !Number.isInteger(remainingPercent) ||
154
178
  remainingPercent < 1 ||
155
179
  remainingPercent > 100) {
156
180
  throw new Error(`Invalid config: watch.graphqlQuotaWarnings[${index}].remainingPercent must be an integer from 1 to 100`);
157
181
  }
158
- if (typeof pollIntervalMinutes !== "number" ||
159
- !Number.isFinite(pollIntervalMinutes) ||
160
- pollIntervalMinutes <= 0) {
182
+ if (pollIntervalMinutes === undefined && pollIntervalFactor === undefined) {
183
+ throw new Error(`Invalid config: watch.graphqlQuotaWarnings[${index}] must define pollIntervalMinutes, pollIntervalFactor, or both`);
184
+ }
185
+ if (pollIntervalMinutes !== undefined &&
186
+ (typeof pollIntervalMinutes !== "number" ||
187
+ !Number.isFinite(pollIntervalMinutes) ||
188
+ pollIntervalMinutes <= 0)) {
161
189
  throw new Error(`Invalid config: watch.graphqlQuotaWarnings[${index}].pollIntervalMinutes must be a positive number`);
162
190
  }
191
+ if (pollIntervalFactor !== undefined &&
192
+ (typeof pollIntervalFactor !== "number" ||
193
+ !Number.isFinite(pollIntervalFactor) ||
194
+ pollIntervalFactor < 1)) {
195
+ throw new Error(`Invalid config: watch.graphqlQuotaWarnings[${index}].pollIntervalFactor must be a number greater than or equal to 1`);
196
+ }
163
197
  if (seen.has(remainingPercent)) {
164
198
  throw new Error(`Invalid config: watch.graphqlQuotaWarnings has duplicate remainingPercent ${remainingPercent}`);
165
199
  }
166
200
  seen.add(remainingPercent);
167
- return { remainingPercent, pollIntervalMinutes };
201
+ const factorMinutes = typeof pollIntervalFactor === "number" ? (pollIntervalSeconds * pollIntervalFactor) / 60 : 0;
202
+ const absoluteMinutes = typeof pollIntervalMinutes === "number" ? pollIntervalMinutes : 0;
203
+ const resolvedPollIntervalMinutes = Math.max(absoluteMinutes, factorMinutes);
204
+ if (!Number.isFinite(resolvedPollIntervalMinutes)) {
205
+ throw new Error(`Invalid config: watch.graphqlQuotaWarnings[${index}] resolves to a non-finite poll interval`);
206
+ }
207
+ return {
208
+ remainingPercent,
209
+ pollIntervalMinutes: resolvedPollIntervalMinutes,
210
+ };
168
211
  });
169
- return parsed.sort((left, right) => right.remainingPercent - left.remainingPercent);
212
+ parsed.sort((left, right) => right.remainingPercent - left.remainingPercent);
213
+ for (let index = 1; index < parsed.length; index += 1) {
214
+ const previous = parsed[index - 1];
215
+ const current = parsed[index];
216
+ if (previous !== undefined &&
217
+ current !== undefined &&
218
+ current.pollIntervalMinutes < previous.pollIntervalMinutes) {
219
+ throw new Error("Invalid config: watch.graphqlQuotaWarnings pollIntervalMinutes must not decrease as remainingPercent decreases");
220
+ }
221
+ }
222
+ return parsed;
170
223
  }
171
224
  const KNOWN_CONFIG_KEYS = new Set([
172
225
  "classify",
173
226
  "botUsernames",
174
227
  "ignoreChecks",
175
228
  "iterate",
229
+ "poll",
176
230
  "watch",
177
231
  "resolve",
178
232
  "checks",
@@ -189,6 +243,7 @@ const KNOWN_NESTED_KEYS = {
189
243
  "behindBaseHint",
190
244
  "resolveOtherHumanThreads",
191
245
  ]),
246
+ poll: new Set(["intervalSeconds", "timeoutSeconds", "debounceSeconds", "quietStatus"]),
192
247
  watch: new Set(["readyDelayMinutes", "graphqlQuotaWarnings"]),
193
248
  resolve: new Set(["shaPoll"]),
194
249
  checks: new Set(["ciTriggerEvents", "ignoreLogLines"]),
@@ -226,7 +281,23 @@ function warnUnknownConfigKeys(config) {
226
281
  }
227
282
  }
228
283
  }
229
- const defaults = builtins;
284
+ const rawDefaults = builtins;
285
+ function parseConfig(value, normalizeMergeArgs = true) {
286
+ const config = value;
287
+ config.botUsernames = parseBotUsernames(config.botUsernames);
288
+ config.ignoreChecks = parseIgnoreChecks(config.ignoreChecks);
289
+ config.actions.neverCancelRuns = parseNeverCancelRuns(config.actions.neverCancelRuns);
290
+ if (config.merge && normalizeMergeArgs) {
291
+ config.merge.commandArgs = parseMergeCommandArgs(config.merge.commandArgs);
292
+ }
293
+ config.poll = parsePollConfig(config.poll);
294
+ config.watch.graphqlQuotaWarnings = parseGraphqlQuotaWarnings(config.watch.graphqlQuotaWarnings, config.poll.intervalSeconds);
295
+ config.iterate.minimizeComments = parseMinimizeCommentsPolicy(config.iterate.minimizeComments);
296
+ config.iterate.resolveOtherHumanThreads = parseResolveOtherHumanThreads(config.iterate.resolveOtherHumanThreads);
297
+ config.checks.ignoreLogLines = parseIgnoreLogLines(config.checks.ignoreLogLines);
298
+ return config;
299
+ }
300
+ const defaults = parseConfig(structuredClone(rawDefaults), false);
230
301
  const configCache = new Map();
231
302
  function stripDeprecatedActionKeys(parsed) {
232
303
  const rawActions = parsed.actions;
@@ -277,24 +348,14 @@ export function loadConfig() {
277
348
  configCache.set(cwd, defaults);
278
349
  return defaults;
279
350
  }
280
- const config = deepMerge(structuredClone(defaults), overlay);
281
- config.botUsernames = parseBotUsernames(config.botUsernames);
282
- config.ignoreChecks = parseIgnoreChecks(config.ignoreChecks);
283
- config.actions.neverCancelRuns = parseNeverCancelRuns(config.actions.neverCancelRuns);
284
- if (config.merge)
285
- config.merge.commandArgs = parseMergeCommandArgs(config.merge.commandArgs);
286
- config.watch.graphqlQuotaWarnings = parseGraphqlQuotaWarnings(config.watch.graphqlQuotaWarnings);
287
- config.iterate.minimizeComments = parseMinimizeCommentsPolicy(config.iterate.minimizeComments);
288
- config.iterate.resolveOtherHumanThreads = parseResolveOtherHumanThreads(config.iterate.resolveOtherHumanThreads);
289
- config.checks.ignoreLogLines = parseIgnoreLogLines(config.checks.ignoreLogLines);
351
+ const config = parseConfig(deepMerge(structuredClone(rawDefaults), overlay));
290
352
  configCache.set(cwd, config);
291
353
  return config;
292
354
  }
293
355
  catch (err) {
294
356
  process.stderr.write(`pr-shepherd: failed to parse ${rcPaths[0]}: ${err instanceof Error ? err.message : String(err)}\n`);
295
- const fallback = { ...defaults };
296
- configCache.set(cwd, fallback);
297
- return fallback;
357
+ configCache.set(cwd, defaults);
358
+ return defaults;
298
359
  }
299
360
  }
300
361
  /** Reset the config cache — for use in tests that change directories. */
package/bin/config.json CHANGED
@@ -22,12 +22,18 @@
22
22
  "behindBaseHint": "",
23
23
  "resolveOtherHumanThreads": "none"
24
24
  },
25
+ "poll": {
26
+ "intervalSeconds": 60,
27
+ "timeoutSeconds": 270,
28
+ "debounceSeconds": 60,
29
+ "quietStatus": false
30
+ },
25
31
  "watch": {
26
32
  "readyDelayMinutes": 10,
27
33
  "graphqlQuotaWarnings": [
28
- { "remainingPercent": 30, "pollIntervalMinutes": 2 },
29
- { "remainingPercent": 20, "pollIntervalMinutes": 5 },
30
- { "remainingPercent": 10, "pollIntervalMinutes": 10 }
34
+ { "remainingPercent": 30, "pollIntervalFactor": 2 },
35
+ { "remainingPercent": 20, "pollIntervalFactor": 5 },
36
+ { "remainingPercent": 10, "pollIntervalFactor": 10 }
31
37
  ]
32
38
  },
33
39
  "resolve": {
@@ -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
  };
@@ -0,0 +1,58 @@
1
+ import type { RawBaseRef } from "./batch-raw-rules.mts";
2
+ export interface FingerprintSuites {
3
+ pageInfo?: {
4
+ hasNextPage: boolean;
5
+ };
6
+ nodes?: Array<{
7
+ id?: string;
8
+ conclusion: string | null;
9
+ workflowRun?: {
10
+ databaseId: number | null;
11
+ } | null;
12
+ }>;
13
+ }
14
+ export interface FingerprintComment {
15
+ id: string;
16
+ updatedAt?: string;
17
+ }
18
+ export declare function suiteFingerprint(suites: FingerprintSuites | undefined): {
19
+ checkSuiteConclusions: string;
20
+ checkSuitesComplete: boolean;
21
+ };
22
+ export declare function mergePolicyFingerprint(raw: {
23
+ isMergeQueueEnabled?: boolean;
24
+ baseRef?: RawBaseRef | null;
25
+ }): string;
26
+ export declare function commentRevisions(nodes: FingerprintComment[]): string;
27
+ export declare function threadCommentRevisions(nodes: Array<{
28
+ id: string;
29
+ comments?: {
30
+ totalCount?: number;
31
+ nodes: Array<{
32
+ id: string;
33
+ updatedAt?: string;
34
+ }>;
35
+ };
36
+ }>): string;
37
+ export declare function hasMultiCommentThreads(nodes: Array<{
38
+ comments?: {
39
+ totalCount?: number;
40
+ };
41
+ }>): boolean;
42
+ export declare function rulesComplete(baseRef?: {
43
+ rules?: {
44
+ pageInfo?: {
45
+ hasNextPage: boolean;
46
+ };
47
+ } | null;
48
+ } | null): boolean;
49
+ export declare function stackKey(raw: {
50
+ stack?: {
51
+ number: number;
52
+ size: number;
53
+ baseRefName: string;
54
+ } | null;
55
+ stackEntry?: {
56
+ position: number;
57
+ } | null;
58
+ }): string;
@@ -0,0 +1,39 @@
1
+ import { parseBranchRules } from "./batch-parsers-rules.mjs";
2
+ export function suiteFingerprint(suites) {
3
+ if (suites === undefined)
4
+ return { checkSuiteConclusions: "", checkSuitesComplete: false };
5
+ return {
6
+ checkSuiteConclusions: (suites.nodes ?? [])
7
+ .map((node) => `${node.id ?? node.workflowRun?.databaseId ?? ""}:${node.conclusion ?? ""}`)
8
+ .join(","),
9
+ checkSuitesComplete: suites.pageInfo?.hasNextPage === false,
10
+ };
11
+ }
12
+ export function mergePolicyFingerprint(raw) {
13
+ return JSON.stringify({
14
+ isMergeQueueEnabled: Boolean(raw.isMergeQueueEnabled),
15
+ rules: parseBranchRules(raw.baseRef),
16
+ });
17
+ }
18
+ export function commentRevisions(nodes) {
19
+ return nodes.map((node) => `${node.id}:${node.updatedAt ?? ""}`).join(",");
20
+ }
21
+ export function threadCommentRevisions(nodes) {
22
+ return nodes
23
+ .map((thread) => {
24
+ const last = thread.comments?.nodes.at(-1);
25
+ return `${thread.id}:${last?.id ?? ""}:${last?.updatedAt ?? ""}`;
26
+ })
27
+ .join(",");
28
+ }
29
+ export function hasMultiCommentThreads(nodes) {
30
+ return nodes.some((thread) => (thread.comments?.totalCount ?? 0) > 1);
31
+ }
32
+ export function rulesComplete(baseRef) {
33
+ return baseRef?.rules?.pageInfo?.hasNextPage !== true;
34
+ }
35
+ export function stackKey(raw) {
36
+ if (!raw.stack)
37
+ return "";
38
+ return `${raw.stack.number}:${raw.stack.size}:${raw.stackEntry?.position ?? 0}:${raw.stack.baseRefName}`;
39
+ }
@@ -0,0 +1,35 @@
1
+ import type { RepoInfo } from "./client.mts";
2
+ import type { RawPr } from "./batch-raw-types.mts";
3
+ export interface PrFingerprint {
4
+ headRefOid: string;
5
+ updatedAt: string;
6
+ state: string;
7
+ isDraft: boolean;
8
+ mergeable: string;
9
+ mergeStateStatus: string;
10
+ reviewDecision: string | null;
11
+ isInMergeQueue: boolean;
12
+ isMergeQueueEnabled: boolean;
13
+ mergePolicy: string;
14
+ commentCount: number;
15
+ commentRevisions: string;
16
+ threadCount: number;
17
+ reviewCount: number;
18
+ reviewRevisions: string;
19
+ latestCommentId: string | null;
20
+ latestThreadId: string | null;
21
+ latestReviewId: string | null;
22
+ checkRollupState: string | null;
23
+ checkSuiteConclusions: string;
24
+ checkSuitesComplete: boolean;
25
+ viewerCanUpdate: boolean;
26
+ viewerPermission: string | null;
27
+ viewerLogin: string | null;
28
+ stackKey: string;
29
+ threadCommentRevisions: string;
30
+ rulesComplete: boolean;
31
+ hasMultiCommentThreads: boolean;
32
+ }
33
+ export declare function fingerprintFromRaw(raw: RawPr, viewerPermission?: string | null, viewerLogin?: string | null): PrFingerprint;
34
+ export declare function fingerprintsEqual(left: PrFingerprint, right: PrFingerprint): boolean;
35
+ export declare function fetchPrFingerprint(pr: number, repo: RepoInfo): Promise<PrFingerprint>;