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