pr-shepherd 0.35.0 → 0.36.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 (80) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +32 -12
  3. package/bin/checks/triage.mjs +11 -3
  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 +10 -5
  9. package/bin/cli/help-top-page.d.mts +1 -1
  10. package/bin/cli/help-top-page.mjs +2 -1
  11. package/bin/cli/help.d.mts +2 -2
  12. package/bin/cli/iterate-formatter.mjs +11 -2
  13. package/bin/cli/iterate-lean.mjs +1 -0
  14. package/bin/cli/poll-handler.mjs +7 -0
  15. package/bin/commands/check.d.mts +1 -0
  16. package/bin/commands/check.mjs +26 -22
  17. package/bin/commands/commit-suggestion.mjs +11 -5
  18. package/bin/commands/iterate/fix-code.mjs +1 -1
  19. package/bin/commands/iterate/helpers.d.mts +1 -0
  20. package/bin/commands/iterate/helpers.mjs +15 -6
  21. package/bin/commands/iterate/index.mjs +3 -10
  22. package/bin/commands/poll.d.mts +2 -0
  23. package/bin/commands/poll.mjs +51 -17
  24. package/bin/commands/ready-delay.mjs +3 -13
  25. package/bin/config/load.mjs +67 -25
  26. package/bin/github/batch-page-helpers.d.mts +45 -0
  27. package/bin/github/batch-page-helpers.mjs +63 -0
  28. package/bin/github/batch-page.d.mts +14 -0
  29. package/bin/github/batch-page.mjs +62 -0
  30. package/bin/github/batch-parse-suites.d.mts +4 -0
  31. package/bin/github/batch-parse-suites.mjs +25 -0
  32. package/bin/github/batch-parsers-rules.d.mts +6 -0
  33. package/bin/github/batch-parsers-rules.mjs +122 -0
  34. package/bin/github/batch-parsers.mjs +6 -0
  35. package/bin/github/batch-raw-rules.d.mts +59 -0
  36. package/bin/github/batch-raw-rules.mjs +1 -0
  37. package/bin/github/batch-raw-types.d.mts +20 -11
  38. package/bin/github/batch.d.mts +2 -0
  39. package/bin/github/batch.mjs +14 -120
  40. package/bin/github/gql/batch-pr-page.gql +189 -0
  41. package/bin/github/gql/batch-pr.gql +74 -21
  42. package/bin/github/gql/commit-suggestion-thread.gql +40 -0
  43. package/bin/github/http.d.mts +2 -1
  44. package/bin/github/http.mjs +2 -1
  45. package/bin/github/pagination.d.mts +3 -2
  46. package/bin/github/pagination.mjs +3 -2
  47. package/bin/github/queries.d.mts +4 -0
  48. package/bin/github/queries.mjs +4 -0
  49. package/bin/github/rest-http.d.mts +6 -1
  50. package/bin/github/rest-http.mjs +25 -86
  51. package/bin/github/rest-text.d.mts +1 -0
  52. package/bin/github/rest-text.mjs +88 -0
  53. package/bin/github/suggestion-thread.d.mts +9 -0
  54. package/bin/github/suggestion-thread.mjs +45 -0
  55. package/bin/github/thread-comments.mjs +12 -8
  56. package/bin/merge-status/derive.mjs +2 -0
  57. package/bin/merge-status/requirements-format.d.mts +3 -0
  58. package/bin/merge-status/requirements-format.mjs +88 -0
  59. package/bin/merge-status/requirements.d.mts +2 -0
  60. package/bin/merge-status/requirements.mjs +51 -0
  61. package/bin/state/base.d.mts +9 -0
  62. package/bin/state/base.mjs +23 -0
  63. package/bin/state/bot-cr-seen.mjs +3 -13
  64. package/bin/state/fix-attempts.mjs +3 -13
  65. package/bin/state/iterate-stall.mjs +3 -13
  66. package/bin/state/seen-comments.mjs +6 -13
  67. package/bin/types/github.d.mts +4 -7
  68. package/bin/types/iterate.d.mts +6 -7
  69. package/bin/types/merge-requirements.d.mts +82 -0
  70. package/bin/types/merge-requirements.mjs +2 -0
  71. package/bin/types.d.mts +1 -0
  72. package/bin/types.mjs +1 -0
  73. package/bin/util/path-segment.d.mts +2 -0
  74. package/bin/util/path-segment.mjs +2 -0
  75. package/bin/util/pool.d.mts +2 -0
  76. package/bin/util/pool.mjs +18 -0
  77. package/package.json +1 -1
  78. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  79. package/plugins/pr-shepherd/.codex.mcp.json +1 -1
  80. package/plugins/pr-shepherd/.mcp.json +1 -1
@@ -5,7 +5,7 @@ import { graphql } from "../../github/http.mjs";
5
5
  import { MARK_PR_READY_MUTATION } from "../../github/queries.mjs";
6
6
  import { loadConfig } from "../../config/load.mjs";
7
7
  import { EXIT, ShepherdError } from "../../exit-codes.mjs";
8
- import { getCurrentHeadSha, buildSummary, buildRelevantChecks, buildActiveChecks, buildWaitLog, buildSuppressedCheckFields, buildTerminalCancelResult, } from "./helpers.mjs";
8
+ import { getCurrentHeadSha, buildSummary, buildRelevantChecks, buildActiveChecks, buildWaitLog, buildSuppressedCheckFields, buildTerminalCancelResult, blockedCancelNote, } from "./helpers.mjs";
9
9
  import { classifyReviewSummaries } from "./classify.mjs";
10
10
  import { applyStallGuard } from "./stall.mjs";
11
11
  import { clearStallState } from "../../state/iterate-stall.mjs";
@@ -90,6 +90,7 @@ export async function runIterate(opts) {
90
90
  summary: buildSummary(report),
91
91
  baseBranch: report.baseBranch,
92
92
  branchProtection: report.branchProtection,
93
+ mergeRequirements: report.mergeStatus.mergeRequirements,
93
94
  checks: buildRelevantChecks(report),
94
95
  inProgressChecks: buildActiveChecks(report),
95
96
  ...buildSuppressedCheckFields(report),
@@ -97,15 +98,7 @@ export async function runIterate(opts) {
97
98
  };
98
99
  if (readyState.shouldCancel) {
99
100
  await clearStallState(stallKey);
100
- let cancelNote;
101
- if (base.mergeStatus !== "BLOCKED")
102
- cancelNote = "has been ready for review";
103
- else if (base.reviewDecision === "REVIEW_REQUIRED")
104
- cancelNote = "is awaiting human review";
105
- else if (base.reviewDecision === "APPROVED")
106
- cancelNote = "is awaiting additional approvals";
107
- else
108
- cancelNote = "is awaiting human review or branch protection resolution";
101
+ const cancelNote = blockedCancelNote(base);
109
102
  return {
110
103
  ...base,
111
104
  action: "cancel",
@@ -2,6 +2,8 @@ import type { IterateCommandOptions, IterateResult } from "../types.mts";
2
2
  interface PollCommandOptions extends IterateCommandOptions {
3
3
  intervalSeconds: number;
4
4
  timeoutSeconds: number;
5
+ /** Settle window after first FIX_CODE before returning. Default 60. 0 disables. */
6
+ debounceSeconds?: number;
5
7
  quietStatus?: boolean;
6
8
  untilTerminal?: boolean;
7
9
  }
@@ -1,5 +1,6 @@
1
1
  import { runIterate } from "./iterate/index.mjs";
2
2
  import { sleep } from "../util/sleep.mjs";
3
+ const DEFAULT_POLL_DEBOUNCE_SECONDS = 60;
3
4
  function writeTickProgress(tick, elapsedSeconds, sleepSeconds, verbose) {
4
5
  if (verbose) {
5
6
  process.stderr.write(`[poll tick ${tick} / +${elapsedSeconds}s] WAIT — sleeping ${sleepSeconds}s\n`);
@@ -54,11 +55,18 @@ function writeWaitProgress(opts) {
54
55
  }
55
56
  return signature;
56
57
  }
58
+ function writeDebounceProgress(tick, elapsedMs, remainingMs) {
59
+ const elapsedSeconds = Math.round(elapsedMs / 1000);
60
+ const remainingSeconds = Math.round(remainingMs / 1000);
61
+ process.stderr.write(`[poll tick ${tick} / +${elapsedSeconds}s] FIX_CODE — debounce ${remainingSeconds}s remaining\n`);
62
+ }
57
63
  /** @deprecated Hidden implementation for the legacy `poll` alias. */
58
64
  export async function runPoll(opts) {
59
- const { intervalSeconds, timeoutSeconds, quietStatus: quietStatusOpt, untilTerminal: untilTerminalOpt, ...iterateOpts } = opts;
65
+ const { intervalSeconds, timeoutSeconds, debounceSeconds: debounceSecondsOpt, quietStatus: quietStatusOpt, untilTerminal: untilTerminalOpt, ...iterateOpts } = opts;
60
66
  const intervalMs = Math.min(intervalSeconds * 1000, MAX_TIMER_MS);
61
67
  const timeoutMs = Math.min(timeoutSeconds * 1000, MAX_TIMER_MS);
68
+ const debounceSeconds = debounceSecondsOpt ?? DEFAULT_POLL_DEBOUNCE_SECONDS;
69
+ const debounceMs = Math.min(debounceSeconds * 1000, MAX_TIMER_MS);
62
70
  const start = Date.now();
63
71
  let tick = 0;
64
72
  let lastResult;
@@ -73,27 +81,30 @@ export async function runPoll(opts) {
73
81
  // terminal CANCEL/merged result. Pin the PR resolved by the first tick so later ticks target it
74
82
  // directly and never re-run branch discovery.
75
83
  let prNumber = opts.prNumber;
84
+ let debounceUntil = null;
76
85
  while (true) {
77
86
  tick += 1;
78
- lastResult = await runIterate({ ...iterateOpts, prNumber });
87
+ const pastDebounce = debounceUntil !== null && Date.now() >= debounceUntil;
88
+ lastResult = await runIterate({
89
+ ...iterateOpts,
90
+ prNumber,
91
+ persistSeen: debounceSeconds === 0 || pastDebounce,
92
+ });
79
93
  prNumber ??= lastResult.pr;
80
- if (lastResult.action !== "wait" && !(untilTerminal && lastResult.action === "mark_ready")) {
81
- break;
82
- }
83
- const elapsedMs = Date.now() - start;
84
- if (!untilTerminal) {
85
- const remainingMs = timeoutMs - elapsedMs;
86
- if (remainingMs <= 0)
87
- break;
88
- if (remainingMs + TIMER_DRIFT_TOLERANCE_MS < intervalMs)
89
- break;
90
- }
91
- const nextSleepMs = intervalMs;
92
- if (lastResult.action === "wait") {
94
+ if (lastResult.action === "wait" && !pastDebounce) {
95
+ debounceUntil = null;
96
+ const elapsedMs = Date.now() - start;
97
+ if (!untilTerminal) {
98
+ const remainingMs = timeoutMs - elapsedMs;
99
+ if (remainingMs <= 0)
100
+ break;
101
+ if (remainingMs + TIMER_DRIFT_TOLERANCE_MS < intervalMs)
102
+ break;
103
+ }
93
104
  lastWaitSignature = writeWaitProgress({
94
105
  tick,
95
106
  elapsedMs,
96
- sleepMs: nextSleepMs,
107
+ sleepMs: intervalMs,
97
108
  result: lastResult,
98
109
  quietStatus,
99
110
  verbose,
@@ -101,8 +112,31 @@ export async function runPoll(opts) {
101
112
  });
102
113
  if (!quietStatus && !verbose)
103
114
  dotsPrinted = true;
115
+ await sleep(intervalMs);
116
+ continue;
117
+ }
118
+ if (untilTerminal && lastResult.action === "mark_ready" && !pastDebounce) {
119
+ debounceUntil = null;
120
+ await sleep(intervalMs);
121
+ continue;
122
+ }
123
+ if (lastResult.action === "fix_code" && debounceSeconds > 0 && !pastDebounce) {
124
+ debounceUntil ??= Date.now() + debounceMs;
125
+ const remainingMs = debounceUntil - Date.now();
126
+ if (remainingMs > 0) {
127
+ if (dotsPrinted) {
128
+ process.stderr.write("\n");
129
+ dotsPrinted = false;
130
+ }
131
+ writeDebounceProgress(tick, Date.now() - start, remainingMs);
132
+ await sleep(Math.min(intervalMs, remainingMs));
133
+ }
134
+ // Always continue so a tick that *started* before debounceUntil but *finished*
135
+ // after it cannot return with persistSeen: false. The next loop iteration
136
+ // sees pastDebounce and persists.
137
+ continue;
104
138
  }
105
- await sleep(nextSleepMs);
139
+ break;
106
140
  }
107
141
  if (dotsPrinted)
108
142
  process.stderr.write("\n");
@@ -6,9 +6,8 @@
6
6
  * `readyDelaySeconds` consecutively. Any not-READY result resets the timer.
7
7
  */
8
8
  import { readFile, writeFile, mkdir, unlink } from "node:fs/promises";
9
- import { join, dirname } from "node:path";
10
- import { SAFE_SEGMENT } from "../util/path-segment.mjs";
11
- import { resolveStateBase } from "../state/base.mjs";
9
+ import { dirname } from "node:path";
10
+ import { resolvePrStatePath } from "../state/base.mjs";
12
11
  /**
13
12
  * Update the ready-delay state machine and return the current decision.
14
13
  *
@@ -57,16 +56,7 @@ export async function updateReadyDelay(prNumber, isReady, readyDelaySeconds, own
57
56
  // Helpers
58
57
  // ---------------------------------------------------------------------------
59
58
  function readySincePath(pr, owner, repo) {
60
- for (const [field, value] of [
61
- ["owner", owner],
62
- ["repo", repo],
63
- ]) {
64
- if (!SAFE_SEGMENT.test(value)) {
65
- throw new Error(`Invalid path segment "${field}": ${value}`);
66
- }
67
- }
68
- const base = resolveStateBase();
69
- return join(base, `${owner}-${repo}`, String(pr), "ready-since.txt");
59
+ return resolvePrStatePath({ owner, repo, pr }, "ready-since.txt");
70
60
  }
71
61
  async function safeUnlink(path) {
72
62
  try {
@@ -1,24 +1,40 @@
1
1
  /* eslint-disable max-lines */
2
2
  import { readFileSync, statSync } from "node:fs";
3
- import { join, dirname } from "node:path";
3
+ import { dirname, join, resolve } from "node:path";
4
4
  import { homedir } from "node:os";
5
5
  import { parse } from "yaml";
6
6
  import builtins from "../config.json" with { type: "json" };
7
7
  import { getEffectiveCwd } from "../execution-context.mjs";
8
8
  const MINIMIZE_COMMENTS_POLICIES = ["all", "bots", "users", "none"];
9
9
  const RC_FILENAME = ".pr-shepherdrc.yml";
10
- function findRcFile(startDir) {
11
- const home = homedir();
12
- let current = startDir;
13
- while (true) {
14
- const candidate = join(current, RC_FILENAME);
10
+ /**
11
+ * Collect `.pr-shepherdrc.yml` files from `startDir` toward `$HOME` (closest first).
12
+ * `$HOME/.pr-shepherdrc.yml` is always included when it exists, even if cwd is
13
+ * outside the home directory. The walk never includes the filesystem root, so a
14
+ * `/.pr-shepherdrc.yml` cannot override the user-level file.
15
+ */
16
+ function collectRcFiles(startDir) {
17
+ const home = resolve(homedir());
18
+ const seen = new Set();
19
+ const files = [];
20
+ const add = (dir) => {
21
+ const candidate = resolve(join(dir, RC_FILENAME));
22
+ if (seen.has(candidate))
23
+ return;
15
24
  if (statSync(candidate, { throwIfNoEntry: false })?.isFile()) {
16
- return candidate;
25
+ seen.add(candidate);
26
+ files.push(candidate);
17
27
  }
18
- if (current === home || current === dirname(current))
19
- return null;
28
+ };
29
+ let current = resolve(startDir);
30
+ while (current !== dirname(current)) {
31
+ add(current);
32
+ if (current === home)
33
+ break;
20
34
  current = dirname(current);
21
35
  }
36
+ add(home);
37
+ return files;
22
38
  }
23
39
  function deepMerge(base, override) {
24
40
  const result = { ...base };
@@ -125,30 +141,56 @@ function warnUnknownConfigKeys(config) {
125
141
  }
126
142
  const defaults = builtins;
127
143
  const configCache = new Map();
144
+ function stripDeprecatedActionKeys(parsed) {
145
+ const rawActions = parsed.actions;
146
+ if (rawActions === null || typeof rawActions !== "object" || Array.isArray(rawActions))
147
+ return;
148
+ const actions = rawActions;
149
+ for (const key of ["autoResolveOutdated", "commitSuggestions"]) {
150
+ if (key in actions) {
151
+ process.stderr.write(`pr-shepherd: config: actions.${key} is deprecated and ignored.\n`);
152
+ delete actions[key];
153
+ }
154
+ }
155
+ }
156
+ function readRcFile(rcPath) {
157
+ try {
158
+ const raw = readFileSync(rcPath, "utf8");
159
+ const parsed = (parse(raw) ?? {});
160
+ stripDeprecatedActionKeys(parsed);
161
+ warnUnknownConfigKeys(parsed);
162
+ return parsed;
163
+ }
164
+ catch (err) {
165
+ process.stderr.write(`pr-shepherd: failed to parse ${rcPath}: ${err instanceof Error ? err.message : String(err)}\n`);
166
+ return null;
167
+ }
168
+ }
128
169
  export function loadConfig() {
129
170
  const cwd = getEffectiveCwd();
130
171
  if (configCache.has(cwd))
131
172
  return configCache.get(cwd);
132
- const rcPath = findRcFile(cwd);
133
- if (!rcPath) {
173
+ const rcPaths = collectRcFiles(cwd);
174
+ if (rcPaths.length === 0) {
134
175
  configCache.set(cwd, defaults);
135
176
  return defaults;
136
177
  }
137
178
  try {
138
- const raw = readFileSync(rcPath, "utf8");
139
- const parsed = (parse(raw) ?? {});
140
- const rawActions = parsed.actions;
141
- if (rawActions !== null && typeof rawActions === "object" && !Array.isArray(rawActions)) {
142
- const actions = rawActions;
143
- for (const key of ["autoResolveOutdated", "commitSuggestions"]) {
144
- if (key in actions) {
145
- process.stderr.write(`pr-shepherd: config: actions.${key} is deprecated and ignored.\n`);
146
- delete actions[key];
147
- }
148
- }
179
+ // Farthest file first so closer directories override, ESLint-style.
180
+ let overlay = {};
181
+ let loaded = false;
182
+ for (const rcPath of [...rcPaths].reverse()) {
183
+ const parsed = readRcFile(rcPath);
184
+ if (parsed === null)
185
+ continue;
186
+ overlay = deepMerge(overlay, parsed);
187
+ loaded = true;
149
188
  }
150
- warnUnknownConfigKeys(parsed);
151
- const config = deepMerge(defaults, parsed);
189
+ if (!loaded) {
190
+ configCache.set(cwd, defaults);
191
+ return defaults;
192
+ }
193
+ const config = deepMerge(structuredClone(defaults), overlay);
152
194
  config.botUsernames = parseBotUsernames(config.botUsernames);
153
195
  config.ignoreChecks = parseIgnoreChecks(config.ignoreChecks);
154
196
  config.actions.neverCancelRuns = parseNeverCancelRuns(config.actions.neverCancelRuns);
@@ -157,7 +199,7 @@ export function loadConfig() {
157
199
  return config;
158
200
  }
159
201
  catch (err) {
160
- process.stderr.write(`pr-shepherd: failed to parse ${rcPath}: ${err instanceof Error ? err.message : String(err)}\n`);
202
+ process.stderr.write(`pr-shepherd: failed to parse ${rcPaths[0]}: ${err instanceof Error ? err.message : String(err)}\n`);
161
203
  const fallback = { ...defaults };
162
204
  configCache.set(cwd, fallback);
163
205
  return fallback;
@@ -0,0 +1,45 @@
1
+ import type { RawContextNode, RawComment, RawPr, RawReview, RawReviewSummary, RawThread } from "./batch-raw-types.mts";
2
+ export interface RawPageResponse {
3
+ repository: {
4
+ pullRequest: {
5
+ reviewThreads?: RawPr["reviewThreads"];
6
+ comments?: RawPr["comments"];
7
+ changesRequestedReviews?: RawPr["changesRequestedReviews"];
8
+ reviewSummaries?: RawPr["reviewSummaries"];
9
+ approvedReviews?: RawPr["approvedReviews"];
10
+ commits?: RawPr["commits"];
11
+ } | null;
12
+ };
13
+ }
14
+ export declare function backwardCursor(conn: {
15
+ pageInfo: {
16
+ hasPreviousPage: boolean;
17
+ startCursor: string | null;
18
+ };
19
+ } | undefined): string | undefined;
20
+ export declare function forwardCursor(conn: {
21
+ pageInfo: {
22
+ hasNextPage: boolean;
23
+ endCursor: string | null;
24
+ };
25
+ } | undefined): string | undefined;
26
+ export interface PageCursors {
27
+ threads?: string;
28
+ comments?: string;
29
+ changesRequested?: string;
30
+ reviewSummaries?: string;
31
+ approvedReviews?: string;
32
+ checks?: string;
33
+ }
34
+ export interface PageAccumulator {
35
+ threads: RawThread[];
36
+ comments: RawComment[];
37
+ changesRequested: RawReview[];
38
+ reviewSummaries: RawReviewSummary[];
39
+ approvedReviews: RawReviewSummary[];
40
+ checks: RawContextNode[];
41
+ }
42
+ export declare function applyIncludedPage(dest: PageAccumulator, pr2: NonNullable<RawPageResponse["repository"]["pullRequest"]>, cursors: PageCursors, firstOid: string | undefined, pageCount: number, pr: number): {
43
+ cursors: PageCursors;
44
+ pageCount: number;
45
+ };
@@ -0,0 +1,63 @@
1
+ import { requireContextNodes } from "./batch-response.mjs";
2
+ export function backwardCursor(conn) {
3
+ if (conn?.pageInfo.hasPreviousPage === true && conn.pageInfo.startCursor) {
4
+ return conn.pageInfo.startCursor;
5
+ }
6
+ return undefined;
7
+ }
8
+ export function forwardCursor(conn) {
9
+ if (conn?.pageInfo.hasNextPage === true && conn.pageInfo.endCursor) {
10
+ return conn.pageInfo.endCursor;
11
+ }
12
+ return undefined;
13
+ }
14
+ function prependConnection(dest, conn, pr) {
15
+ if (!conn)
16
+ throw new Error(`PR #${pr} not found`);
17
+ dest.unshift(...conn.nodes);
18
+ }
19
+ function takeCheckPage(pr2, firstOid, pageCount) {
20
+ if (!pr2.commits?.nodes[0]?.commit.statusCheckRollup) {
21
+ throw new Error(`Check-context pagination interrupted: statusCheckRollup disappeared on page ${pageCount + 1} (possible force-push race). Retry after the push stabilizes.`);
22
+ }
23
+ const currentOid = pr2.commits.nodes[0]?.commit.oid;
24
+ if (firstOid !== undefined && currentOid !== undefined && currentOid !== firstOid) {
25
+ throw new Error(`Check-context pagination interrupted: head commit changed from ${firstOid} to ${currentOid} between pages (force-push race). Retry.`);
26
+ }
27
+ const ctxs = pr2.commits.nodes[0]?.commit.statusCheckRollup?.contexts;
28
+ if (!ctxs) {
29
+ return { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] };
30
+ }
31
+ return { ...ctxs, nodes: requireContextNodes(ctxs.nodes) };
32
+ }
33
+ export function applyIncludedPage(dest, pr2, cursors, firstOid, pageCount, pr) {
34
+ const next = {};
35
+ let nextPageCount = pageCount;
36
+ if (cursors.threads !== undefined) {
37
+ prependConnection(dest.threads, pr2.reviewThreads, pr);
38
+ next.threads = backwardCursor(pr2.reviewThreads);
39
+ }
40
+ if (cursors.comments !== undefined) {
41
+ prependConnection(dest.comments, pr2.comments, pr);
42
+ next.comments = backwardCursor(pr2.comments);
43
+ }
44
+ if (cursors.changesRequested !== undefined) {
45
+ prependConnection(dest.changesRequested, pr2.changesRequestedReviews, pr);
46
+ next.changesRequested = backwardCursor(pr2.changesRequestedReviews);
47
+ }
48
+ if (cursors.reviewSummaries !== undefined) {
49
+ prependConnection(dest.reviewSummaries, pr2.reviewSummaries, pr);
50
+ next.reviewSummaries = backwardCursor(pr2.reviewSummaries);
51
+ }
52
+ if (cursors.approvedReviews !== undefined) {
53
+ prependConnection(dest.approvedReviews, pr2.approvedReviews, pr);
54
+ next.approvedReviews = backwardCursor(pr2.approvedReviews);
55
+ }
56
+ if (cursors.checks !== undefined) {
57
+ nextPageCount++;
58
+ const extra = takeCheckPage(pr2, firstOid, nextPageCount);
59
+ dest.checks.push(...extra.nodes);
60
+ next.checks = forwardCursor(extra);
61
+ }
62
+ return { cursors: next, pageCount: nextPageCount };
63
+ }
@@ -0,0 +1,14 @@
1
+ import { type RateLimitInfo, type RepoInfo } from "./client.mts";
2
+ import { type PageAccumulator } from "./batch-page-helpers.mts";
3
+ import type { RawPr } from "./batch-raw-types.mts";
4
+ export interface PaginatedBatchNodes extends PageAccumulator {
5
+ rateLimit?: RateLimitInfo;
6
+ }
7
+ export interface PaginateBatchOptions {
8
+ paginateApprovedReviews?: boolean;
9
+ }
10
+ /**
11
+ * Fetch remaining connection pages with a slim `@include` query. Independent
12
+ * connections that still have cursors are combined into one request per round.
13
+ */
14
+ export declare function paginateBatchConnections(pr: number, repo: RepoInfo, raw: RawPr, opts: PaginateBatchOptions, initialRateLimit?: RateLimitInfo): Promise<PaginatedBatchNodes>;
@@ -0,0 +1,62 @@
1
+ import { graphqlWithRateLimit } from "./client.mjs";
2
+ import { GitHubRequestError } from "./errors.mjs";
3
+ import { BATCH_PR_PAGE_QUERY } from "./queries.mjs";
4
+ import { requireContextNodes } from "./batch-response.mjs";
5
+ import { applyIncludedPage, backwardCursor, forwardCursor, } from "./batch-page-helpers.mjs";
6
+ /**
7
+ * Fetch remaining connection pages with a slim `@include` query. Independent
8
+ * connections that still have cursors are combined into one request per round.
9
+ */
10
+ export async function paginateBatchConnections(pr, repo, raw, opts, initialRateLimit) {
11
+ const dest = {
12
+ threads: [...raw.reviewThreads.nodes],
13
+ comments: [...raw.comments.nodes],
14
+ changesRequested: [...raw.changesRequestedReviews.nodes],
15
+ reviewSummaries: [...raw.reviewSummaries.nodes],
16
+ approvedReviews: [...raw.approvedReviews.nodes],
17
+ checks: [
18
+ ...requireContextNodes(raw.commits.nodes[0]?.commit.statusCheckRollup?.contexts.nodes ?? []),
19
+ ],
20
+ };
21
+ const firstOid = raw.commits.nodes[0]?.commit.oid;
22
+ let cursors = {
23
+ threads: backwardCursor(raw.reviewThreads),
24
+ comments: backwardCursor(raw.comments),
25
+ changesRequested: backwardCursor(raw.changesRequestedReviews),
26
+ reviewSummaries: backwardCursor(raw.reviewSummaries),
27
+ approvedReviews: opts.paginateApprovedReviews === true ? backwardCursor(raw.approvedReviews) : undefined,
28
+ checks: forwardCursor(raw.commits.nodes[0]?.commit.statusCheckRollup?.contexts),
29
+ };
30
+ let rateLimit = initialRateLimit;
31
+ let pageCount = 0;
32
+ while (Object.values(cursors).some((cursor) => cursor !== undefined)) {
33
+ if (rateLimit?.remaining === 0) {
34
+ throw new GitHubRequestError("GitHub GraphQL rate limit remaining is 0; pagination incomplete", { status: 403, rateLimit });
35
+ }
36
+ const res = await graphqlWithRateLimit(BATCH_PR_PAGE_QUERY, {
37
+ owner: repo.owner,
38
+ repo: repo.name,
39
+ pr,
40
+ includeThreads: cursors.threads !== undefined,
41
+ threadsCursor: cursors.threads ?? null,
42
+ includeComments: cursors.comments !== undefined,
43
+ commentsCursor: cursors.comments ?? null,
44
+ includeChangesRequested: cursors.changesRequested !== undefined,
45
+ changesRequestedCursor: cursors.changesRequested ?? null,
46
+ includeReviewSummaries: cursors.reviewSummaries !== undefined,
47
+ reviewSummariesCursor: cursors.reviewSummaries ?? null,
48
+ includeApprovedReviews: cursors.approvedReviews !== undefined,
49
+ approvedReviewsCursor: cursors.approvedReviews ?? null,
50
+ includeChecks: cursors.checks !== undefined,
51
+ checksCursor: cursors.checks ?? null,
52
+ });
53
+ rateLimit = res.rateLimit ?? rateLimit;
54
+ const pr2 = res.data.repository.pullRequest;
55
+ if (!pr2)
56
+ throw new Error(`PR #${pr} not found`);
57
+ const applied = applyIncludedPage(dest, pr2, cursors, firstOid, pageCount, pr);
58
+ cursors = applied.cursors;
59
+ pageCount = applied.pageCount;
60
+ }
61
+ return { ...dest, rateLimit };
62
+ }
@@ -0,0 +1,4 @@
1
+ import type { CheckRun } from "../types.mts";
2
+ import type { RawPr } from "./batch-raw-types.mts";
3
+ export declare function parseCheckSuitesComplete(raw: RawPr): boolean;
4
+ export declare function parseSuiteStartupFailures(raw: RawPr): CheckRun[];
@@ -0,0 +1,25 @@
1
+ export function parseCheckSuitesComplete(raw) {
2
+ return raw.commits.nodes[0]?.commit.checkSuites?.pageInfo.hasNextPage === false;
3
+ }
4
+ export function parseSuiteStartupFailures(raw) {
5
+ const suites = raw.commits.nodes[0]?.commit.checkSuites;
6
+ if (!suites)
7
+ return [];
8
+ return suites.nodes.flatMap((node) => {
9
+ if (node.conclusion !== "STARTUP_FAILURE" || node.workflowRun == null)
10
+ return [];
11
+ const runId = node.workflowRun.databaseId != null ? String(node.workflowRun.databaseId) : null;
12
+ const name = node.workflowRun.workflow?.name?.trim() || (runId ? `workflow run ${runId}` : "workflow run");
13
+ return [
14
+ {
15
+ name,
16
+ status: "COMPLETED",
17
+ conclusion: "STARTUP_FAILURE",
18
+ source: "startup_failure",
19
+ detailsUrl: node.workflowRun.url ?? "",
20
+ event: node.workflowRun.event,
21
+ runId,
22
+ },
23
+ ];
24
+ });
25
+ }
@@ -0,0 +1,6 @@
1
+ import type { BranchRules, MergeQueueEntryStatus, StackStatus } from "../types.mts";
2
+ import type { RawBaseRef, RawPrMergeFields } from "./batch-raw-rules.mts";
3
+ export declare const EMPTY_BRANCH_RULES: BranchRules;
4
+ export declare function parseBranchRules(baseRef: RawBaseRef | null | undefined): BranchRules;
5
+ export declare function parseMergeQueueEntry(raw: RawPrMergeFields["mergeQueueEntry"]): MergeQueueEntryStatus | null;
6
+ export declare function parseStack(raw: RawPrMergeFields): StackStatus | null;
@@ -0,0 +1,122 @@
1
+ export const EMPTY_BRANCH_RULES = {
2
+ requiredApprovingReviewCount: 0,
3
+ requiresConversationResolution: false,
4
+ requiresCodeOwnerReviews: false,
5
+ requiresLastPushApproval: false,
6
+ requiresCommitSignatures: false,
7
+ requiresLinearHistory: false,
8
+ requiresStrictStatusChecks: false,
9
+ requiredStatusCheckContexts: [],
10
+ requiredDeploymentEnvironments: [],
11
+ requiresMergeQueue: false,
12
+ requiresWorkflows: false,
13
+ requiresCodeScanning: false,
14
+ };
15
+ export function parseBranchRules(baseRef) {
16
+ const rules = {
17
+ ...EMPTY_BRANCH_RULES,
18
+ requiredStatusCheckContexts: [],
19
+ requiredDeploymentEnvironments: [],
20
+ };
21
+ if (!baseRef)
22
+ return rules;
23
+ foldClassicProtection(rules, baseRef.branchProtectionRule);
24
+ for (const node of baseRef.rules?.nodes ?? [])
25
+ foldRulesetRule(rules, node);
26
+ return rules;
27
+ }
28
+ export function parseMergeQueueEntry(raw) {
29
+ if (!raw)
30
+ return null;
31
+ return {
32
+ position: raw.position,
33
+ state: raw.state,
34
+ estimatedTimeToMerge: raw.estimatedTimeToMerge,
35
+ };
36
+ }
37
+ export function parseStack(raw) {
38
+ if (!raw.stack)
39
+ return null;
40
+ return {
41
+ number: raw.stack.number,
42
+ size: raw.stack.size,
43
+ position: raw.stackEntry?.position ?? 0,
44
+ baseRefName: raw.stack.baseRefName,
45
+ };
46
+ }
47
+ function foldClassicProtection(rules, bp) {
48
+ if (!bp)
49
+ return;
50
+ if (bp.requiresApprovingReviews) {
51
+ rules.requiredApprovingReviewCount = Math.max(rules.requiredApprovingReviewCount, bp.requiredApprovingReviewCount ?? 0);
52
+ }
53
+ if (bp.requiresConversationResolution)
54
+ rules.requiresConversationResolution = true;
55
+ if (bp.requiresCodeOwnerReviews)
56
+ rules.requiresCodeOwnerReviews = true;
57
+ if (bp.requireLastPushApproval)
58
+ rules.requiresLastPushApproval = true;
59
+ if (bp.requiresCommitSignatures)
60
+ rules.requiresCommitSignatures = true;
61
+ if (bp.requiresLinearHistory)
62
+ rules.requiresLinearHistory = true;
63
+ if (bp.requiresStrictStatusChecks)
64
+ rules.requiresStrictStatusChecks = true;
65
+ if (bp.requiresStatusChecks) {
66
+ mergeUnique(rules.requiredStatusCheckContexts, bp.requiredStatusCheckContexts ?? []);
67
+ }
68
+ if (bp.requiresDeployments) {
69
+ mergeUnique(rules.requiredDeploymentEnvironments, bp.requiredDeploymentEnvironments ?? []);
70
+ }
71
+ }
72
+ function foldRulesetRule(rules, node) {
73
+ const p = node.parameters;
74
+ switch (node.type) {
75
+ case "PULL_REQUEST":
76
+ if (p?.requiredApprovingReviewCount != null) {
77
+ rules.requiredApprovingReviewCount = Math.max(rules.requiredApprovingReviewCount, p.requiredApprovingReviewCount);
78
+ }
79
+ if (p?.requiredReviewThreadResolution)
80
+ rules.requiresConversationResolution = true;
81
+ if (p?.requireCodeOwnerReview)
82
+ rules.requiresCodeOwnerReviews = true;
83
+ if (p?.requireLastPushApproval)
84
+ rules.requiresLastPushApproval = true;
85
+ break;
86
+ case "REQUIRED_REVIEW_THREAD_RESOLUTION":
87
+ rules.requiresConversationResolution = true;
88
+ break;
89
+ case "REQUIRED_STATUS_CHECKS":
90
+ if (p?.strictRequiredStatusChecksPolicy)
91
+ rules.requiresStrictStatusChecks = true;
92
+ mergeUnique(rules.requiredStatusCheckContexts, (p?.requiredStatusChecks ?? []).map((c) => c.context));
93
+ break;
94
+ case "REQUIRED_SIGNATURES":
95
+ rules.requiresCommitSignatures = true;
96
+ break;
97
+ case "REQUIRED_LINEAR_HISTORY":
98
+ rules.requiresLinearHistory = true;
99
+ break;
100
+ case "REQUIRED_DEPLOYMENTS":
101
+ mergeUnique(rules.requiredDeploymentEnvironments, p?.requiredDeploymentEnvironments ?? []);
102
+ break;
103
+ case "MERGE_QUEUE":
104
+ rules.requiresMergeQueue = true;
105
+ break;
106
+ case "WORKFLOWS":
107
+ case "REQUIRED_WORKFLOW_STATUS_CHECKS":
108
+ rules.requiresWorkflows = true;
109
+ break;
110
+ case "CODE_SCANNING":
111
+ rules.requiresCodeScanning = true;
112
+ break;
113
+ default:
114
+ break;
115
+ }
116
+ }
117
+ function mergeUnique(target, values) {
118
+ for (const value of values) {
119
+ if (value && !target.includes(value))
120
+ target.push(value);
121
+ }
122
+ }