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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +2 -0
- package/bin/api.d.mts +1 -1
- 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 +6 -5
- package/bin/cli/help-top-page.d.mts +1 -1
- package/bin/cli/help-top-page.mjs +5 -4
- package/bin/cli/help.d.mts +2 -2
- package/bin/cli/poll-handler.mjs +15 -7
- package/bin/commands/check-fingerprint.d.mts +8 -0
- package/bin/commands/check-fingerprint.mjs +69 -0
- package/bin/commands/check.d.mts +1 -0
- package/bin/commands/check.mjs +23 -3
- package/bin/commands/iterate/api-usage.d.mts +1 -1
- package/bin/commands/iterate/api-usage.mjs +6 -2
- package/bin/commands/iterate/base.mjs +1 -0
- package/bin/commands/iterate/mark-ready.mjs +8 -0
- package/bin/commands/iterate/run.mjs +1 -1
- package/bin/commands/poll-progress.d.mts +11 -0
- package/bin/commands/poll-progress.mjs +52 -0
- package/bin/commands/poll-quota.d.mts +9 -0
- package/bin/commands/poll-quota.mjs +36 -0
- package/bin/commands/poll-run.d.mts +1 -1
- package/bin/commands/poll-run.mjs +2 -2
- package/bin/commands/poll.mjs +66 -66
- package/bin/config/load.d.mts +7 -0
- package/bin/config/load.mjs +81 -20
- package/bin/config.json +9 -3
- package/bin/github/batch-raw-rules.d.mts +4 -1
- package/bin/github/batch-raw-types.d.mts +14 -0
- package/bin/github/batch.d.mts +2 -0
- package/bin/github/batch.mjs +2 -0
- package/bin/github/fingerprint-fields.d.mts +58 -0
- package/bin/github/fingerprint-fields.mjs +39 -0
- package/bin/github/fingerprint.d.mts +35 -0
- package/bin/github/fingerprint.mjs +67 -0
- package/bin/github/gql/batch-pr.gql +25 -120
- package/bin/github/gql/commit-check-suites.gql +19 -0
- package/bin/github/gql/pr-fingerprint.gql +75 -0
- package/bin/github/gql/pr-merge-policy.gql +49 -0
- package/bin/github/merge-queue-checks.mjs +57 -30
- package/bin/github/queries.d.mts +2 -0
- package/bin/github/queries.mjs +4 -1
- package/bin/quota-warning.mjs +1 -1
- package/bin/state/pr-fingerprint.d.mts +20 -0
- package/bin/state/pr-fingerprint.mjs +90 -0
- package/bin/types/iterate.d.mts +12 -3
- package/bin/types/report.d.mts +2 -0
- package/package.json +2 -2
- 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
package/bin/commands/poll.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
115
|
+
sleepMs,
|
|
126
116
|
result: lastResult,
|
|
127
117
|
quietStatus,
|
|
128
118
|
verbose,
|
|
129
119
|
lastWaitSignature,
|
|
130
120
|
});
|
|
131
|
-
await sleep(
|
|
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
|
-
|
|
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;
|
package/bin/config/load.d.mts
CHANGED
|
@@ -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[];
|
package/bin/config/load.mjs
CHANGED
|
@@ -137,7 +137,30 @@ function parseMergeCommandArgs(value) {
|
|
|
137
137
|
}
|
|
138
138
|
return strategies.length === 0 ? [...value, "--merge"] : [...value];
|
|
139
139
|
}
|
|
140
|
-
function
|
|
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 (
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
296
|
-
|
|
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, "
|
|
29
|
-
{ "remainingPercent": 20, "
|
|
30
|
-
{ "remainingPercent": 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;
|
package/bin/github/batch.d.mts
CHANGED
|
@@ -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;
|
package/bin/github/batch.mjs
CHANGED
|
@@ -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>;
|