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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +32 -12
- package/bin/checks/triage.mjs +11 -3
- package/bin/cli/args.mjs +1 -0
- package/bin/cli/default-poll.mjs +1 -0
- package/bin/cli/help-command-pages.d.mts +1 -1
- package/bin/cli/help-iterate-poll-pages.d.mts +1 -1
- package/bin/cli/help-iterate-poll-pages.mjs +10 -5
- package/bin/cli/help-top-page.d.mts +1 -1
- package/bin/cli/help-top-page.mjs +2 -1
- package/bin/cli/help.d.mts +2 -2
- package/bin/cli/iterate-formatter.mjs +11 -2
- package/bin/cli/iterate-lean.mjs +1 -0
- package/bin/cli/poll-handler.mjs +7 -0
- package/bin/commands/check.d.mts +1 -0
- package/bin/commands/check.mjs +26 -22
- package/bin/commands/commit-suggestion.mjs +11 -5
- package/bin/commands/iterate/fix-code.mjs +1 -1
- package/bin/commands/iterate/helpers.d.mts +1 -0
- package/bin/commands/iterate/helpers.mjs +15 -6
- package/bin/commands/iterate/index.mjs +3 -10
- package/bin/commands/poll.d.mts +2 -0
- package/bin/commands/poll.mjs +51 -17
- package/bin/commands/ready-delay.mjs +3 -13
- package/bin/config/load.mjs +67 -25
- package/bin/github/batch-page-helpers.d.mts +45 -0
- package/bin/github/batch-page-helpers.mjs +63 -0
- package/bin/github/batch-page.d.mts +14 -0
- package/bin/github/batch-page.mjs +62 -0
- package/bin/github/batch-parse-suites.d.mts +4 -0
- package/bin/github/batch-parse-suites.mjs +25 -0
- package/bin/github/batch-parsers-rules.d.mts +6 -0
- package/bin/github/batch-parsers-rules.mjs +122 -0
- package/bin/github/batch-parsers.mjs +6 -0
- package/bin/github/batch-raw-rules.d.mts +59 -0
- package/bin/github/batch-raw-rules.mjs +1 -0
- package/bin/github/batch-raw-types.d.mts +20 -11
- package/bin/github/batch.d.mts +2 -0
- package/bin/github/batch.mjs +14 -120
- package/bin/github/gql/batch-pr-page.gql +189 -0
- package/bin/github/gql/batch-pr.gql +74 -21
- package/bin/github/gql/commit-suggestion-thread.gql +40 -0
- package/bin/github/http.d.mts +2 -1
- package/bin/github/http.mjs +2 -1
- package/bin/github/pagination.d.mts +3 -2
- package/bin/github/pagination.mjs +3 -2
- package/bin/github/queries.d.mts +4 -0
- package/bin/github/queries.mjs +4 -0
- package/bin/github/rest-http.d.mts +6 -1
- package/bin/github/rest-http.mjs +25 -86
- package/bin/github/rest-text.d.mts +1 -0
- package/bin/github/rest-text.mjs +88 -0
- package/bin/github/suggestion-thread.d.mts +9 -0
- package/bin/github/suggestion-thread.mjs +45 -0
- package/bin/github/thread-comments.mjs +12 -8
- package/bin/merge-status/derive.mjs +2 -0
- package/bin/merge-status/requirements-format.d.mts +3 -0
- package/bin/merge-status/requirements-format.mjs +88 -0
- package/bin/merge-status/requirements.d.mts +2 -0
- package/bin/merge-status/requirements.mjs +51 -0
- package/bin/state/base.d.mts +9 -0
- package/bin/state/base.mjs +23 -0
- package/bin/state/bot-cr-seen.mjs +3 -13
- package/bin/state/fix-attempts.mjs +3 -13
- package/bin/state/iterate-stall.mjs +3 -13
- package/bin/state/seen-comments.mjs +6 -13
- package/bin/types/github.d.mts +4 -7
- package/bin/types/iterate.d.mts +6 -7
- package/bin/types/merge-requirements.d.mts +82 -0
- package/bin/types/merge-requirements.mjs +2 -0
- package/bin/types.d.mts +1 -0
- package/bin/types.mjs +1 -0
- package/bin/util/path-segment.d.mts +2 -0
- package/bin/util/path-segment.mjs +2 -0
- package/bin/util/pool.d.mts +2 -0
- package/bin/util/pool.mjs +18 -0
- package/package.json +1 -1
- package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
- package/plugins/pr-shepherd/.codex.mcp.json +1 -1
- 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
|
-
|
|
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",
|
package/bin/commands/poll.d.mts
CHANGED
|
@@ -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
|
}
|
package/bin/commands/poll.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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:
|
|
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
|
-
|
|
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 {
|
|
10
|
-
import {
|
|
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
|
-
|
|
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 {
|
package/bin/config/load.mjs
CHANGED
|
@@ -1,24 +1,40 @@
|
|
|
1
1
|
/* eslint-disable max-lines */
|
|
2
2
|
import { readFileSync, statSync } from "node:fs";
|
|
3
|
-
import { join,
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
25
|
+
seen.add(candidate);
|
|
26
|
+
files.push(candidate);
|
|
17
27
|
}
|
|
18
|
-
|
|
19
|
-
|
|
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
|
|
133
|
-
if (
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
151
|
-
|
|
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 ${
|
|
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,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
|
+
}
|