pr-shepherd 0.55.2 → 0.56.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 +6 -2
- package/bin/api.d.mts +5 -0
- package/bin/api.mjs +12 -1
- package/bin/checks/job-log.d.mts +9 -0
- package/bin/checks/job-log.mjs +30 -0
- package/bin/checks/jobs-types.d.mts +14 -0
- package/bin/checks/jobs-types.mjs +7 -0
- package/bin/checks/related-jobs.d.mts +11 -0
- package/bin/checks/related-jobs.mjs +32 -0
- package/bin/checks/triage-budget.d.mts +17 -0
- package/bin/checks/triage-budget.mjs +49 -0
- package/bin/checks/triage.d.mts +6 -3
- package/bin/checks/triage.mjs +89 -61
- package/bin/cli/api-usage-formatter.mjs +2 -2
- package/bin/cli/fix-formatter.mjs +2 -0
- package/bin/cli/iterate-checks-formatter.mjs +2 -0
- package/bin/cli/related-jobs-format.d.mts +3 -0
- package/bin/cli/related-jobs-format.mjs +16 -0
- package/bin/commands/check-execution-context.d.mts +12 -0
- package/bin/commands/check-execution-context.mjs +38 -0
- package/bin/commands/check-fingerprint.mjs +14 -8
- package/bin/commands/check-unreported.d.mts +3 -2
- package/bin/commands/check-unreported.mjs +4 -4
- package/bin/commands/check.d.mts +2 -1
- package/bin/commands/check.mjs +21 -6
- package/bin/commands/iterate/check-instructions.d.mts +6 -3
- package/bin/commands/iterate/check-instructions.mjs +6 -5
- package/bin/commands/iterate/escalate.mjs +3 -0
- package/bin/commands/iterate/fix-code.mjs +6 -2
- package/bin/commands/iterate/helpers.mjs +1 -0
- package/bin/commands/iterate/index.mjs +20 -8
- package/bin/commands/iterate/render.mjs +11 -3
- package/bin/commands/iterate/stale-ancestry.d.mts +2 -1
- package/bin/commands/iterate/stale-ancestry.mjs +3 -2
- package/bin/commands/poll-quota.d.mts +2 -0
- package/bin/commands/poll-quota.mjs +18 -25
- package/bin/commands/poll-rate-limit-wait.mjs +13 -8
- package/bin/commands/ready-delay.d.mts +2 -0
- package/bin/commands/ready-delay.mjs +18 -0
- package/bin/commands/resolve-mutate.mjs +8 -9
- package/bin/github/batch-raw-types.d.mts +2 -0
- package/bin/github/batch-receipt-evidence.d.mts +5 -0
- package/bin/github/batch-receipt-evidence.mjs +61 -0
- package/bin/github/batch.d.mts +4 -0
- package/bin/github/batch.mjs +42 -8
- package/bin/github/errors.d.mts +3 -0
- package/bin/github/errors.mjs +15 -6
- package/bin/github/gql/batch-pr-page.gql +1 -0
- package/bin/github/gql/batch-pr.gql +1 -0
- package/bin/github/gql/poll-summary-annotation-probe.gql +8 -0
- package/bin/github/gql/reply-thread-comments.gql +25 -0
- package/bin/github/gql/reply-thread-transcripts.gql +31 -0
- package/bin/github/merge-queue-checks.d.mts +2 -1
- package/bin/github/merge-queue-checks.mjs +18 -8
- package/bin/github/merge-target-rules.d.mts +2 -1
- package/bin/github/merge-target-rules.mjs +4 -4
- package/bin/github/poll-summary-annotation-probe.d.mts +2 -0
- package/bin/github/poll-summary-annotation-probe.mjs +7 -0
- package/bin/github/queries.d.mts +5 -0
- package/bin/github/queries.mjs +5 -0
- package/bin/github/rate-limit-kind.d.mts +13 -0
- package/bin/github/rate-limit-kind.mjs +25 -0
- package/bin/github/reply-thread-transcripts.d.mts +3 -0
- package/bin/github/reply-thread-transcripts.mjs +89 -0
- package/bin/github/rest-http.mjs +1 -0
- package/bin/github/rest-text.d.mts +2 -1
- package/bin/github/rest-text.mjs +5 -2
- package/bin/github/thread-comments.d.mts +5 -1
- package/bin/github/thread-comments.mjs +30 -6
- package/bin/mcp/server.mjs +32 -5
- package/bin/quota-warning.mjs +2 -2
- package/bin/reporters/agent.mjs +1 -0
- package/bin/threads/transcript.d.mts +1 -0
- package/bin/threads/transcript.mjs +4 -1
- package/bin/types/check-classification.d.mts +10 -0
- package/bin/types/report.d.mts +4 -1
- 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
- package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +3 -1
- package/plugins/pr-shepherd/skills/pr-shepherd/references/ci-failure-triage.md +2 -1
|
@@ -31,20 +31,26 @@ export async function tryReuseFingerprintReport(prNumber, repo, stateKey, config
|
|
|
31
31
|
}
|
|
32
32
|
if (!reportAllowsFingerprintSkip(cached.report))
|
|
33
33
|
return null;
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
if (cached.fingerprint.isInMergeQueue)
|
|
35
|
+
return null;
|
|
36
|
+
if (!cached.fingerprint.checkSuitesComplete)
|
|
37
|
+
return null;
|
|
38
|
+
if (cached.fingerprint.commentCount > 100)
|
|
36
39
|
return null;
|
|
37
|
-
if (
|
|
40
|
+
if (cached.fingerprint.reviewCount > 100)
|
|
38
41
|
return null;
|
|
39
|
-
if (
|
|
42
|
+
if (cached.fingerprint.threadCount > 20)
|
|
40
43
|
return null;
|
|
41
|
-
if (
|
|
44
|
+
if (cached.fingerprint.hasMultiCommentThreads)
|
|
42
45
|
return null;
|
|
43
|
-
if (
|
|
46
|
+
if (!cached.fingerprint.rulesComplete)
|
|
47
|
+
return null;
|
|
48
|
+
const live = await fetchPrFingerprint(prNumber, repo);
|
|
49
|
+
if (live.isInMergeQueue || !live.checkSuitesComplete)
|
|
44
50
|
return null;
|
|
45
|
-
if (live.
|
|
51
|
+
if (live.commentCount > 100 || live.reviewCount > 100 || live.threadCount > 20)
|
|
46
52
|
return null;
|
|
47
|
-
if (
|
|
53
|
+
if (live.hasMultiCommentThreads || !live.rulesComplete)
|
|
48
54
|
return null;
|
|
49
55
|
if (!fingerprintsEqual(cached.fingerprint, live))
|
|
50
56
|
return null;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type WorkflowSuiteSnapshot } from "../checks/unreported-required.mts";
|
|
2
2
|
import type { BatchPrData, CheckRun, ShepherdReport } from "../types.mts";
|
|
3
3
|
import type { RepoInfo } from "../github/client.mts";
|
|
4
|
+
import type { CheckExecutionContext } from "./check-execution-context.mts";
|
|
4
5
|
export interface UnreportedRequiredFields {
|
|
5
6
|
unreportedRequiredChecks?: string[];
|
|
6
7
|
trunkBehindBy?: number;
|
|
@@ -20,9 +21,9 @@ export declare function collectUnreportedRequired(input: {
|
|
|
20
21
|
name: string;
|
|
21
22
|
pr: number;
|
|
22
23
|
relevantEvents: readonly string[];
|
|
23
|
-
}): Promise<UnreportedRequiredFields>;
|
|
24
|
+
}, context?: CheckExecutionContext): Promise<UnreportedRequiredFields>;
|
|
24
25
|
/**
|
|
25
26
|
* A fingerprint hit can keep a stale trunk `behindBy`. Refresh the trunk compare
|
|
26
27
|
* and the required-context diff without refetching the whole batch.
|
|
27
28
|
*/
|
|
28
|
-
export declare function refreshCachedUnreported(report: ShepherdReport, repo: RepoInfo): Promise<ShepherdReport>;
|
|
29
|
+
export declare function refreshCachedUnreported(report: ShepherdReport, repo: RepoInfo, context?: CheckExecutionContext): Promise<ShepherdReport>;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { actionsWorkflowInProgress, reportedCheckNames, unreportedRequiredContexts, } from "../checks/unreported-required.mjs";
|
|
2
2
|
import { loadBaseBehindBy, loadMergeTargetStatus } from "../github/merge-target-rules.mjs";
|
|
3
3
|
/** Load trunk rules for a stack and diff them against the head's check names. */
|
|
4
|
-
export async function collectUnreportedRequired(input) {
|
|
4
|
+
export async function collectUnreportedRequired(input, context) {
|
|
5
5
|
const localContexts = input.batchData.branchRules?.requiredStatusCheckContexts ??
|
|
6
6
|
input.batchData.branchProtection?.requiredStatusCheckContexts ??
|
|
7
7
|
[];
|
|
@@ -13,7 +13,7 @@ export async function collectUnreportedRequired(input) {
|
|
|
13
13
|
headRefName: input.batchData.headRefName,
|
|
14
14
|
localContexts,
|
|
15
15
|
stack: input.batchData.stack,
|
|
16
|
-
});
|
|
16
|
+
}, context);
|
|
17
17
|
const unreported = unreportedRequiredContexts(target.contexts, reportedCheckNames(input.checks));
|
|
18
18
|
const actionsRunning = actionsWorkflowInProgress(input.suites, new Set(input.relevantEvents));
|
|
19
19
|
const baseBehindBy = unreported.length > 0 && !input.batchData.stack
|
|
@@ -34,7 +34,7 @@ export async function collectUnreportedRequired(input) {
|
|
|
34
34
|
* A fingerprint hit can keep a stale trunk `behindBy`. Refresh the trunk compare
|
|
35
35
|
* and the required-context diff without refetching the whole batch.
|
|
36
36
|
*/
|
|
37
|
-
export async function refreshCachedUnreported(report, repo) {
|
|
37
|
+
export async function refreshCachedUnreported(report, repo, context) {
|
|
38
38
|
const stack = report.mergeStatus?.mergeRequirements?.stack;
|
|
39
39
|
if (!report.headRefName)
|
|
40
40
|
return report;
|
|
@@ -48,7 +48,7 @@ export async function refreshCachedUnreported(report, repo) {
|
|
|
48
48
|
headRefName: report.headRefName,
|
|
49
49
|
localContexts: report.mergeStatus.mergeRequirements?.requiredStatusChecks?.contexts ?? [],
|
|
50
50
|
stack,
|
|
51
|
-
});
|
|
51
|
+
}, context);
|
|
52
52
|
const unreported = unreportedRequiredContexts(target.contexts, reportedCheckNames(reportChecks(report)));
|
|
53
53
|
const next = { ...report };
|
|
54
54
|
if (unreported.length > 0)
|
package/bin/commands/check.d.mts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { CheckExecutionContext } from "./check-execution-context.mts";
|
|
1
2
|
import type { GlobalOptions, ShepherdReport } from "../types.mts";
|
|
2
3
|
export declare function runCheck(opts: GlobalOptions & {
|
|
3
4
|
autoResolve?: boolean;
|
|
@@ -6,4 +7,4 @@ export declare function runCheck(opts: GlobalOptions & {
|
|
|
6
7
|
persistSeen?: boolean;
|
|
7
8
|
fingerprintCache?: boolean;
|
|
8
9
|
merge?: boolean;
|
|
9
|
-
}): Promise<ShepherdReport>;
|
|
10
|
+
}, context?: CheckExecutionContext): Promise<ShepherdReport>;
|
package/bin/commands/check.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import { getRepoInfo, getCurrentPrNumber } from "../github/client.mjs";
|
|
|
7
7
|
import { classifyChecks, getCiVerdict } from "../checks/classify.mjs";
|
|
8
8
|
import { mergeStartupFailureChecks } from "../checks/startup-failures.mjs";
|
|
9
9
|
import { fetchStartupFailureChecks, triageFailingChecks } from "../checks/triage.mjs";
|
|
10
|
+
import { TriageBudget } from "../checks/triage-budget.mjs";
|
|
10
11
|
import { deriveMergeStatus } from "../merge-status/derive.mjs";
|
|
11
12
|
import { loadConfig } from "../config/load.mjs";
|
|
12
13
|
import { classifyVisibleComments } from "../comments/visible-comments.mjs";
|
|
@@ -26,7 +27,7 @@ import { discoverRuleFiles, loadRules } from "../classify/loader.mjs";
|
|
|
26
27
|
import { buildClassifyIndex, partitionBatch } from "../classify/apply.mjs";
|
|
27
28
|
import { EXIT, ShepherdError } from "../exit-codes.mjs";
|
|
28
29
|
import { getEffectiveCwd } from "../execution-context.mjs";
|
|
29
|
-
export async function runCheck(opts) {
|
|
30
|
+
export async function runCheck(opts, context) {
|
|
30
31
|
const repo = opts.targetRepository ?? (await getRepoInfo());
|
|
31
32
|
const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
|
|
32
33
|
if (prNumber === null) {
|
|
@@ -38,10 +39,15 @@ export async function runCheck(opts) {
|
|
|
38
39
|
if (reuseFingerprint) {
|
|
39
40
|
const cached = await tryReuseFingerprintReport(prNumber, repo, stateKey, config);
|
|
40
41
|
if (cached)
|
|
41
|
-
return refreshCachedUnreported(cached, repo);
|
|
42
|
+
return refreshCachedUnreported(cached, repo, context);
|
|
42
43
|
}
|
|
43
44
|
const paginateApprovedReviews = config.iterate.minimizeApprovals;
|
|
44
|
-
const
|
|
45
|
+
const includeReceiptSummary = (await context?.wantsReceiptSummary(prNumber, repo)) ?? false;
|
|
46
|
+
const result = await fetchPrBatch(prNumber, repo, {
|
|
47
|
+
paginateApprovedReviews,
|
|
48
|
+
...(includeReceiptSummary && { includeReceiptSummary: true }),
|
|
49
|
+
});
|
|
50
|
+
context?.setReceiptSummary(result.receiptSummary ?? null);
|
|
45
51
|
let batchData = result.data;
|
|
46
52
|
const unknownRefresh = await refreshUnknownMergeability(prNumber, repo, batchData);
|
|
47
53
|
batchData = unknownRefresh.batchData;
|
|
@@ -55,9 +61,10 @@ export async function runCheck(opts) {
|
|
|
55
61
|
return terminal;
|
|
56
62
|
}
|
|
57
63
|
const startupFailuresNeedAttempt = batchData.checks.some((check) => check.source === "startup_failure" && check.runAttempt === undefined);
|
|
64
|
+
const triageBudget = new TriageBudget();
|
|
58
65
|
const startupFailureChecks = result.checkSuitesComplete && !startupFailuresNeedAttempt
|
|
59
66
|
? []
|
|
60
|
-
: await fetchStartupFailureChecks(repo, batchData.headRefOid, prNumber, stateKey);
|
|
67
|
+
: await fetchStartupFailureChecks(repo, batchData.headRefOid, prNumber, stateKey, triageBudget);
|
|
61
68
|
const allChecks = mergeStartupFailureChecks(batchData.checks, startupFailureChecks);
|
|
62
69
|
const classifiedPrChecks = classifyChecks(allChecks);
|
|
63
70
|
const latestRemoval = batchData.latestMergeQueueRemoval;
|
|
@@ -103,8 +110,10 @@ export async function runCheck(opts) {
|
|
|
103
110
|
const filtered = classifiedChecks.filter((c) => c.category === "filtered");
|
|
104
111
|
const ignored = classifiedChecks.filter((c) => c.category === "ignored");
|
|
105
112
|
const triagedBase = failing.length > 0 && !opts.skipTriage
|
|
106
|
-
? await triageFailingChecks(failing, repo, stateKey)
|
|
113
|
+
? await triageFailingChecks(failing, repo, stateKey, triageBudget, classifiedChecks.filter((c) => c.category !== "failing"))
|
|
107
114
|
: failing;
|
|
115
|
+
triageBudget.throwIfSecondary();
|
|
116
|
+
triageBudget.reportOmissionIfNeeded();
|
|
108
117
|
const seenMap = await loadSeenMap(stateKey);
|
|
109
118
|
const botUsernames = normalizeBotUsernames(config.botUsernames);
|
|
110
119
|
const ruleSet = await loadRules(discoverRuleFiles(getEffectiveCwd()));
|
|
@@ -168,7 +177,7 @@ export async function runCheck(opts) {
|
|
|
168
177
|
name: repo.name,
|
|
169
178
|
pr: prNumber,
|
|
170
179
|
relevantEvents: config.checks.ciTriggerEvents,
|
|
171
|
-
});
|
|
180
|
+
}, context);
|
|
172
181
|
let status = computeStatus(verdict, threadVisibility.activeThreads.length + threadVisibility.resolutionOnlyThreads.length, visibleCommentClassification.actionable.length, mergeStatus, changesRequestedReviewCount, unreported.hasUnreportedRequired);
|
|
173
182
|
// Resolve any pending mergeability refresh (and the resulting MERGED/CLOSED short-circuit)
|
|
174
183
|
// before deciding what to persist below — deferWhileQueued must see the same final,
|
|
@@ -251,6 +260,12 @@ export async function runCheck(opts) {
|
|
|
251
260
|
ruleAutoResolveCommentIds: partition.ruleAutoResolveCommentIds.filter((id) => batchData.comments.find((comment) => comment.id === id)?.viewerCanMinimize === true),
|
|
252
261
|
ruleAutoResolveReviewSummaryIds: partition.ruleAutoResolveReviewSummaryIds.filter((id) => batchData.reviewSummaries.find((review) => review.id === id)?.viewerCanMinimize === true),
|
|
253
262
|
};
|
|
263
|
+
if (opts.autoMinimizeSuppressed === true &&
|
|
264
|
+
(authorizedPartition.ruleAutoResolveThreadIds.length > 0 ||
|
|
265
|
+
authorizedPartition.ruleAutoResolveCommentIds.length > 0 ||
|
|
266
|
+
authorizedPartition.ruleAutoResolveReviewSummaryIds.length > 0)) {
|
|
267
|
+
context?.invalidateReceiptSummary();
|
|
268
|
+
}
|
|
254
269
|
const { threadIds: authorizedRuleAutoResolveThreadIds, commentIds: ruleAutoResolveCommentIds, reviewSummaryIds: ruleAutoResolveReviewSummaryIds, autoResolved, autoMinimized, autoResolveErrors, errorReasons, } = await applySuppressedRuleAutoResolve({
|
|
255
270
|
enabled: opts.autoMinimizeSuppressed === true,
|
|
256
271
|
partition: authorizedPartition,
|
|
@@ -2,15 +2,18 @@ import type { AgentCheck, ResolveCommand, Review } from "../../types.mts";
|
|
|
2
2
|
/** Build the stale-CR clause appended to the `## Changes-requested reviews` instruction. */
|
|
3
3
|
export declare function buildCrStaleClause(reviews: Review[]): string;
|
|
4
4
|
/**
|
|
5
|
-
* Build the optional
|
|
6
|
-
* and the user configured a non-blank `iterate.behindBaseHint` — the CLI never prescribes
|
|
5
|
+
* Build the optional branch-update hint. Empty unless the branch is behind or conflicts with its
|
|
6
|
+
* base and the user configured a non-blank `iterate.behindBaseHint` — the CLI never prescribes
|
|
7
7
|
* rebase/merge mechanics itself (see "Keep skills and loop prompts minimal" in AGENTS.md); this
|
|
8
8
|
* only echoes back the caller's own configured pointer. `hint` is trimmed and type-checked at the
|
|
9
9
|
* point of use (rather than at config load) so a malformed rc file value (non-string, or
|
|
10
10
|
* whitespace-only) degrades to "no hint" instead of rendering garbage into agent-facing text or
|
|
11
11
|
* discarding the rest of the user's config.
|
|
12
12
|
*/
|
|
13
|
-
export declare function buildBehindBaseHintInstruction(baseBranch: string, hint: string,
|
|
13
|
+
export declare function buildBehindBaseHintInstruction(baseBranch: string, hint: string, branch: {
|
|
14
|
+
isBehind: boolean;
|
|
15
|
+
hasConflicts: boolean;
|
|
16
|
+
}): string[];
|
|
14
17
|
/**
|
|
15
18
|
* Give one branch-refresh recovery path after Shepherd's single workflow rerun has failed.
|
|
16
19
|
* The fetched PR base branch is raw context; the caller still owns repository-specific git
|
|
@@ -8,19 +8,20 @@ export function buildCrStaleClause(reviews) {
|
|
|
8
8
|
return human;
|
|
9
9
|
}
|
|
10
10
|
/**
|
|
11
|
-
* Build the optional
|
|
12
|
-
* and the user configured a non-blank `iterate.behindBaseHint` — the CLI never prescribes
|
|
11
|
+
* Build the optional branch-update hint. Empty unless the branch is behind or conflicts with its
|
|
12
|
+
* base and the user configured a non-blank `iterate.behindBaseHint` — the CLI never prescribes
|
|
13
13
|
* rebase/merge mechanics itself (see "Keep skills and loop prompts minimal" in AGENTS.md); this
|
|
14
14
|
* only echoes back the caller's own configured pointer. `hint` is trimmed and type-checked at the
|
|
15
15
|
* point of use (rather than at config load) so a malformed rc file value (non-string, or
|
|
16
16
|
* whitespace-only) degrades to "no hint" instead of rendering garbage into agent-facing text or
|
|
17
17
|
* discarding the rest of the user's config.
|
|
18
18
|
*/
|
|
19
|
-
export function buildBehindBaseHintInstruction(baseBranch, hint,
|
|
19
|
+
export function buildBehindBaseHintInstruction(baseBranch, hint, branch) {
|
|
20
20
|
const trimmedHint = typeof hint === "string" ? hint.trim() : "";
|
|
21
|
-
if (!isBehind || trimmedHint === "")
|
|
21
|
+
if ((!branch.isBehind && !branch.hasConflicts) || trimmedHint === "")
|
|
22
22
|
return [];
|
|
23
|
-
|
|
23
|
+
const state = branch.hasConflicts ? "conflicts with" : "is behind";
|
|
24
|
+
return [`The branch ${state} PR base branch \`${baseBranch}\`. ${trimmedHint} before pushing.`];
|
|
24
25
|
}
|
|
25
26
|
/**
|
|
26
27
|
* Give one branch-refresh recovery path after Shepherd's single workflow rerun has failed.
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
/* eslint-disable max-lines */
|
|
2
|
+
import { renderRelatedJobLines } from "../../cli/related-jobs-format.mjs";
|
|
1
3
|
import { loadConfig } from "../../config/load.mjs";
|
|
2
4
|
import { renderResolveCommand } from "./render.mjs";
|
|
3
5
|
function renderEscalateAuthor(item) {
|
|
@@ -52,6 +54,7 @@ function renderEscalateCheck(check) {
|
|
|
52
54
|
lines.push(` > ${check.summary}`);
|
|
53
55
|
if (check.logExcerpt)
|
|
54
56
|
lines.push(...renderBlockquoteLines(check.logExcerpt, " "));
|
|
57
|
+
lines.push(...renderRelatedJobLines(check.relatedJobs));
|
|
55
58
|
if (check.rerunCommand)
|
|
56
59
|
lines.push(` rerun: \`${check.rerunCommand}\``);
|
|
57
60
|
for (const annotation of check.annotations ?? []) {
|
|
@@ -19,6 +19,10 @@ import { canRerunWorkflows } from "../../checks/conclusions.mjs";
|
|
|
19
19
|
import { loadConfig } from "../../config/load.mjs";
|
|
20
20
|
import { formatPrUrl } from "../../pr-reference.mjs";
|
|
21
21
|
const EMPTY_RELEASED = new Set();
|
|
22
|
+
function hasLogEvidence(check) {
|
|
23
|
+
return (Boolean(check.logExcerpt?.trim()) ||
|
|
24
|
+
(check.relatedJobs ?? []).some((job) => Boolean(job.logExcerpt?.trim())));
|
|
25
|
+
}
|
|
22
26
|
function checkRequiresHumanFollowUp(check) {
|
|
23
27
|
if (check.rerunCommand)
|
|
24
28
|
return false;
|
|
@@ -30,13 +34,13 @@ function checkRequiresHumanFollowUp(check) {
|
|
|
30
34
|
// identify a code or configuration fix for the agent. Only a later failure with
|
|
31
35
|
// no actionable evidence needs a human handoff.
|
|
32
36
|
if (check.runAttempt !== undefined && check.runAttempt > 1)
|
|
33
|
-
return !check
|
|
37
|
+
return !hasLogEvidence(check);
|
|
34
38
|
// An external check's direct URL is actionable evidence: the agent can inspect the
|
|
35
39
|
// provider and/or reproduce the reported failure locally. Only a truly bare check
|
|
36
40
|
// has no autonomous investigation path.
|
|
37
41
|
if (check.runId === null)
|
|
38
42
|
return !check.detailsUrl?.trim();
|
|
39
|
-
return !check
|
|
43
|
+
return !hasLogEvidence(check);
|
|
40
44
|
}
|
|
41
45
|
function nextFixAttempts(stored, threads, countAttempt) {
|
|
42
46
|
const threadAttempts = stored ? { ...stored.threadAttempts } : {};
|
|
@@ -80,6 +80,7 @@ export function buildRelevantChecks(report) {
|
|
|
80
80
|
...(c.failedStep !== undefined && { failedStep: c.failedStep }),
|
|
81
81
|
...(c.summary !== undefined && { summary: c.summary }),
|
|
82
82
|
...(c.logExcerpt !== undefined && { logExcerpt: c.logExcerpt }),
|
|
83
|
+
...(c.relatedJobs !== undefined && { relatedJobs: c.relatedJobs }),
|
|
83
84
|
...(c.annotations !== undefined && { annotations: c.annotations }),
|
|
84
85
|
...(c.scope !== undefined && { scope: c.scope }),
|
|
85
86
|
...(c.commitOid !== undefined && { commitOid: c.commitOid }),
|
|
@@ -27,6 +27,7 @@ import { stackDraftHold } from "./parent-first.mjs";
|
|
|
27
27
|
import { findStaleNativeStackAncestry } from "./stale-ancestry.mjs";
|
|
28
28
|
import { annotateBlockedWait, resolveCheckBlockerGate } from "./check-blocker-gate.mjs";
|
|
29
29
|
import { buildUnreportedFixResult, planUnreportedRequired } from "./unreported-required.mjs";
|
|
30
|
+
import { createCheckExecutionContext } from "../check-execution-context.mjs";
|
|
30
31
|
export function runIterate(opts) {
|
|
31
32
|
return withIterateApiUsage(opts, () => runIterateCore(opts));
|
|
32
33
|
}
|
|
@@ -40,6 +41,7 @@ async function runIterateCore(opts) {
|
|
|
40
41
|
throw new ShepherdError("No open PR found for current branch. Pass a PR number explicitly.", EXIT.UNAVAILABLE);
|
|
41
42
|
}
|
|
42
43
|
const neverCancelRuns = opts.neverCancelRuns ?? config.actions.neverCancelRuns;
|
|
44
|
+
const checkContext = createCheckExecutionContext(readyDelaySeconds);
|
|
43
45
|
const report = await runCheck({
|
|
44
46
|
...opts,
|
|
45
47
|
prNumber,
|
|
@@ -48,7 +50,7 @@ async function runIterateCore(opts) {
|
|
|
48
50
|
// compatibility key in the config loader.
|
|
49
51
|
autoResolve: true,
|
|
50
52
|
autoMinimizeSuppressed: config.actions.autoMinimizeSuppressed,
|
|
51
|
-
});
|
|
53
|
+
}, checkContext);
|
|
52
54
|
const [repoOwner, repoName] = report.repo.split("/");
|
|
53
55
|
if (!repoOwner || !repoName) {
|
|
54
56
|
throw new ShepherdError(`Unexpected repo format: "${report.repo}" (expected "owner/name")`, EXIT.DATAERR);
|
|
@@ -84,6 +86,7 @@ async function runIterateCore(opts) {
|
|
|
84
86
|
// the apply command remains a working fallback instead of silently dropping it.
|
|
85
87
|
let reviewSummaryIds = minimizeIds;
|
|
86
88
|
if (selfMinimizeIds.length > 0) {
|
|
89
|
+
checkContext.invalidateReceiptSummary();
|
|
87
90
|
const { minimized } = await autoMinimizeComments(selfMinimizeIds);
|
|
88
91
|
const minimizedIds = new Set(minimized);
|
|
89
92
|
const unminimized = selfMinimizeIds.filter((id) => !minimizedIds.has(id));
|
|
@@ -110,13 +113,13 @@ async function runIterateCore(opts) {
|
|
|
110
113
|
const staleAncestry = await findStaleNativeStackAncestry(report, {
|
|
111
114
|
owner: repoOwner,
|
|
112
115
|
name: repoName,
|
|
113
|
-
});
|
|
116
|
+
}, checkContext);
|
|
114
117
|
const isCleanReadyState = report.status === "READY" &&
|
|
115
118
|
!report.mergeStatus.isDraft &&
|
|
116
119
|
!hasReadinessWork &&
|
|
117
120
|
!activeMerge &&
|
|
118
121
|
staleAncestry === null;
|
|
119
|
-
const receiptCurrent = await revalidateReadyReceipt(receiptKey, report, hasReadinessWork);
|
|
122
|
+
const receiptCurrent = await revalidateReadyReceipt(receiptKey, report, hasReadinessWork, checkContext);
|
|
120
123
|
const headSha = report.headSha ?? "unknown";
|
|
121
124
|
// The elapsed marker survives a hidden-comment acknowledgement tick and is
|
|
122
125
|
// consumed only when this tick cancels or merges. A receipt that is still
|
|
@@ -233,7 +236,7 @@ async function runIterateCore(opts) {
|
|
|
233
236
|
if (markReadyResult)
|
|
234
237
|
return markReadyResult;
|
|
235
238
|
if (readyState.shouldCancel && !report.mergeStatus.isDraft) {
|
|
236
|
-
const receiptWritten = receiptCurrent || (await recordReadyReceipt(receiptKey, report));
|
|
239
|
+
const receiptWritten = receiptCurrent || (await recordReadyReceipt(receiptKey, report, checkContext));
|
|
237
240
|
// Aggregate --stack routing trusts only a layer's receipt, so an unwritten
|
|
238
241
|
// one keeps the elapsed marker and retries next tick. A one-PR receipt
|
|
239
242
|
// only lets a rerun skip the wait, so its failure never changes the action.
|
|
@@ -279,7 +282,7 @@ async function runIterateCore(opts) {
|
|
|
279
282
|
};
|
|
280
283
|
return annotateBlockedWait(await applyStallGuard(stallKey, stallTimeoutSeconds, headSha, base, prNumber, wait, reportForWork, reviewSummaryIds), blockerGate);
|
|
281
284
|
}
|
|
282
|
-
async function recordReadyReceipt(key, report) {
|
|
285
|
+
async function recordReadyReceipt(key, report, context) {
|
|
283
286
|
if (report.status !== "READY" ||
|
|
284
287
|
report.mergeStatus.state !== "OPEN" ||
|
|
285
288
|
report.mergeStatus.isDraft ||
|
|
@@ -287,7 +290,7 @@ async function recordReadyReceipt(key, report) {
|
|
|
287
290
|
!report.baseRefOid)
|
|
288
291
|
return false;
|
|
289
292
|
try {
|
|
290
|
-
const raw = await
|
|
293
|
+
const raw = await receiptSummaryForReport(key, report, context);
|
|
291
294
|
if (fingerprintRawSummaryPr(raw) === null ||
|
|
292
295
|
raw.state !== "OPEN" ||
|
|
293
296
|
raw.isDraft ||
|
|
@@ -327,7 +330,7 @@ async function recordReadyReceipt(key, report) {
|
|
|
327
330
|
}
|
|
328
331
|
}
|
|
329
332
|
/** Clear a stale READY receipt; return whether a current receipt remains. */
|
|
330
|
-
async function revalidateReadyReceipt(key, report, hasReadinessWork) {
|
|
333
|
+
async function revalidateReadyReceipt(key, report, hasReadinessWork, context) {
|
|
331
334
|
const receipt = await readReadyReceipt(key);
|
|
332
335
|
if (!receipt)
|
|
333
336
|
return false;
|
|
@@ -344,7 +347,7 @@ async function revalidateReadyReceipt(key, report, hasReadinessWork) {
|
|
|
344
347
|
return false;
|
|
345
348
|
}
|
|
346
349
|
try {
|
|
347
|
-
const raw = await
|
|
350
|
+
const raw = await receiptSummaryForReport(key, report, context);
|
|
348
351
|
const summary = await summarizePollSummaryPr(raw, { owner: key.owner, name: key.repo }, { stackPrNumber: report.pr });
|
|
349
352
|
if (annotationProbeUnavailable(raw)) {
|
|
350
353
|
const sameHead = raw.headRefOid === report.headSha;
|
|
@@ -385,3 +388,12 @@ async function revalidateReadyReceipt(key, report, hasReadinessWork) {
|
|
|
385
388
|
return false;
|
|
386
389
|
}
|
|
387
390
|
}
|
|
391
|
+
/** A mismatched or invalidated same-request hint falls back to the original fresh read. */
|
|
392
|
+
async function receiptSummaryForReport(key, report, context) {
|
|
393
|
+
const candidate = context?.getReceiptSummary();
|
|
394
|
+
if (candidate?.number === report.pr &&
|
|
395
|
+
candidate.headRefOid === report.headSha &&
|
|
396
|
+
candidate.baseRefOid === report.baseRefOid)
|
|
397
|
+
return candidate;
|
|
398
|
+
return fetchRawSummaryPr(report.pr, { owner: key.owner, name: key.repo });
|
|
399
|
+
}
|
|
@@ -38,8 +38,15 @@ isBehind = false, viewerCanUpdate = false, hasExhaustedWorkflowRerun = false, st
|
|
|
38
38
|
const sectionRef = actionableSections.length > 0 ? `under ${actionableSections.join(", ")}` : "above";
|
|
39
39
|
instructions.push(`Review each item ${sectionRef} and decide whether it needs a code change.`);
|
|
40
40
|
}
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
// A conflicting native stack layer follows the printed gh-stack route, so it omits the hint.
|
|
42
|
+
const branchUpdateHint = buildBehindBaseHintInstruction(baseBranch, behindBaseHint, {
|
|
43
|
+
isBehind,
|
|
44
|
+
hasConflicts: hasConflicts && !stackRebase,
|
|
45
|
+
});
|
|
46
|
+
// The conflict hint belongs with the conflict step; otherwise it precedes the push step.
|
|
47
|
+
const hintWithConflictStep = hasConflicts && !hasRepeatedWorkflowBranchRecovery;
|
|
48
|
+
if (hintWithConflictStep) {
|
|
49
|
+
instructions.push(buildConflictInstruction(stackRebase), ...branchUpdateHint);
|
|
43
50
|
}
|
|
44
51
|
const firstLookTotal = firstLookThreads.length + firstLookComments.length;
|
|
45
52
|
if (firstLookTotal > 0) {
|
|
@@ -77,7 +84,8 @@ isBehind = false, viewerCanUpdate = false, hasExhaustedWorkflowRerun = false, st
|
|
|
77
84
|
const staleClause = buildCrStaleClause(changesRequestedReviews);
|
|
78
85
|
instructions.push(`Read every body under \`## Changes-requested reviews\` and apply any warranted change.${staleClause}`);
|
|
79
86
|
}
|
|
80
|
-
|
|
87
|
+
if (!hintWithConflictStep)
|
|
88
|
+
instructions.push(...branchUpdateHint);
|
|
81
89
|
const hasReviewMutations = resolveCommand.hasMutations || resolveOnlyCommand?.hasMutations === true;
|
|
82
90
|
const mutationSuffix = hasReviewMutations ? " before review mutations" : "";
|
|
83
91
|
if (hasConflicts || hasRepeatedWorkflowBranchRecovery) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { RepoInfo } from "../../github/client.mts";
|
|
2
2
|
import type { PollSummaryStackAncestry } from "../../types.mts";
|
|
3
3
|
import type { ShepherdReport } from "../../types/report.mts";
|
|
4
|
+
import type { CheckExecutionContext } from "../check-execution-context.mts";
|
|
4
5
|
/**
|
|
5
6
|
* A verified stale boundary for the PR being shepherded.
|
|
6
7
|
*
|
|
@@ -19,4 +20,4 @@ export interface StaleNativeStackAncestry extends PollSummaryStackAncestry {
|
|
|
19
20
|
* could not be verified. Callers must not emit a repair command from an
|
|
20
21
|
* unverified snapshot.
|
|
21
22
|
*/
|
|
22
|
-
export declare function findStaleNativeStackAncestry(report: Pick<ShepherdReport, "pr" | "mergeStatus">, repo: RepoInfo): Promise<StaleNativeStackAncestry | null>;
|
|
23
|
+
export declare function findStaleNativeStackAncestry(report: Pick<ShepherdReport, "pr" | "mergeStatus">, repo: RepoInfo, context?: CheckExecutionContext): Promise<StaleNativeStackAncestry | null>;
|
|
@@ -8,12 +8,13 @@ import { buildNativeStackRebaseInstruction } from "./native-stack-rebase.mjs";
|
|
|
8
8
|
* could not be verified. Callers must not emit a repair command from an
|
|
9
9
|
* unverified snapshot.
|
|
10
10
|
*/
|
|
11
|
-
export async function findStaleNativeStackAncestry(report, repo) {
|
|
11
|
+
export async function findStaleNativeStackAncestry(report, repo, context) {
|
|
12
12
|
const stack = report.mergeStatus.mergeRequirements?.stack;
|
|
13
13
|
if (!stack || stack.position <= 1)
|
|
14
14
|
return null;
|
|
15
15
|
try {
|
|
16
|
-
const topology = await readStackTopology(report.pr, repo)
|
|
16
|
+
const topology = await (context?.readStackTopology(report.pr, repo) ??
|
|
17
|
+
readStackTopology(report.pr, repo));
|
|
17
18
|
const ancestry = stackAncestryGaps(topology.ordered).find((gap) => gap.childPr === report.pr);
|
|
18
19
|
if (!ancestry)
|
|
19
20
|
return null;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { GraphqlQuotaWarningBand } from "../config/load.mts";
|
|
2
|
+
import { type RateLimitKind } from "../github/rate-limit-kind.mts";
|
|
2
3
|
import type { ApiResourceUsage, GraphqlApiUsage, PollSummaryResult } from "../types.mts";
|
|
3
4
|
/** Slow the poll for whichever of GraphQL or REST core is in a tighter band. */
|
|
4
5
|
export declare function quotaPollIntervalMs(bands: GraphqlQuotaWarningBand[], usage: {
|
|
@@ -8,6 +9,7 @@ export declare function quotaPollIntervalMs(bands: GraphqlQuotaWarningBand[], us
|
|
|
8
9
|
export interface RateLimitRetry {
|
|
9
10
|
ms: number;
|
|
10
11
|
resource: string;
|
|
12
|
+
kind: RateLimitKind;
|
|
11
13
|
remaining?: number;
|
|
12
14
|
limit?: number;
|
|
13
15
|
resetAt?: number;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { summarizeApiTelemetry } from "../github/api-telemetry.mjs";
|
|
2
2
|
import { GitHubRequestError } from "../github/errors.mjs";
|
|
3
|
-
import {
|
|
3
|
+
import { rateLimitKind } from "../github/rate-limit-kind.mjs";
|
|
4
4
|
import { exhaustedPrimaryLimitDelayMs } from "./poll-rate-limit-delay.mjs";
|
|
5
5
|
import { selectQuotaWarning } from "./quota-selection.mjs";
|
|
6
6
|
const GRAPHQL_RETRY_AFTER_DEFAULT_MS = 60_000;
|
|
@@ -28,36 +28,29 @@ export function quotaPollIntervalMs(bands, usage, fallbackMs, maxMs) {
|
|
|
28
28
|
export function pollRateLimitRetryAfterMs(err) {
|
|
29
29
|
if (!(err instanceof GitHubRequestError))
|
|
30
30
|
return null;
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
const exhausted = err.rateLimit !== undefined && err.rateLimit.remaining <= 0;
|
|
34
|
-
const retryable = err.status === 429 || err.retryAfterSeconds !== undefined || rateLimitMessage || exhausted;
|
|
35
|
-
if (!retryable)
|
|
31
|
+
const kind = rateLimitKind(err);
|
|
32
|
+
if (kind === null)
|
|
36
33
|
return null;
|
|
37
|
-
const resource =
|
|
34
|
+
const resource = kind === "secondary" ? "secondary" : (err.rateLimit?.resource ?? "graphql");
|
|
38
35
|
const rateLimit = err.rateLimit;
|
|
39
36
|
const details = {
|
|
37
|
+
kind,
|
|
40
38
|
resource,
|
|
41
|
-
...(
|
|
42
|
-
|
|
43
|
-
...(rateLimit?.
|
|
39
|
+
...(kind === "primary" &&
|
|
40
|
+
rateLimit?.remaining !== undefined && { remaining: rateLimit.remaining }),
|
|
41
|
+
...(kind === "primary" && rateLimit?.limit !== undefined && { limit: rateLimit.limit }),
|
|
42
|
+
...(kind === "primary" && rateLimit?.resetAt !== undefined && { resetAt: rateLimit.resetAt }),
|
|
44
43
|
};
|
|
45
|
-
if (
|
|
46
|
-
return {
|
|
44
|
+
if (kind === "primary" && rateLimit !== undefined) {
|
|
45
|
+
return {
|
|
46
|
+
...details,
|
|
47
|
+
ms: Math.max(exhaustedPrimaryLimitDelayMs(rateLimit.resetAt, Date.now()), Math.max(err.retryAfterSeconds ?? 0, 0) * 1000),
|
|
48
|
+
};
|
|
47
49
|
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
}
|
|
53
|
-
function retryResource(err) {
|
|
54
|
-
if (err.rateLimit?.resource)
|
|
55
|
-
return err.rateLimit.resource;
|
|
56
|
-
const secondary = err.status === 429 ||
|
|
57
|
-
err.retryAfterSeconds !== undefined ||
|
|
58
|
-
/secondary/i.test(err.message) ||
|
|
59
|
-
(err.graphqlErrors?.some((error) => /secondary/i.test(error.message)) ?? false);
|
|
60
|
-
return secondary ? "secondary" : "graphql";
|
|
50
|
+
return {
|
|
51
|
+
...details,
|
|
52
|
+
ms: Math.max(GRAPHQL_RETRY_AFTER_DEFAULT_MS, Math.max(err.retryAfterSeconds ?? 0, 0) * 1000),
|
|
53
|
+
};
|
|
61
54
|
}
|
|
62
55
|
function rateLimitResourceLabel(resource) {
|
|
63
56
|
if (resource === "graphql")
|
|
@@ -2,7 +2,8 @@ import { rest } from "../github/rest-http.mjs";
|
|
|
2
2
|
import { sleep } from "../util/sleep.mjs";
|
|
3
3
|
import { formatRateLimitGiveUpLine, formatRateLimitRetryLine, pollRateLimitRetryAfterMs, } from "./poll-quota.mjs";
|
|
4
4
|
const MAX_NO_PROGRESS_ATTEMPTS = 5;
|
|
5
|
-
const
|
|
5
|
+
const PRIMARY_NO_PROGRESS_BACKOFF_MS = [15_000, 30_000, 60_000];
|
|
6
|
+
const SECONDARY_NO_PROGRESS_BACKOFF_MS = [60_000, 120_000, 240_000, 480_000, 960_000];
|
|
6
7
|
const MERGE_STATES = new Set([
|
|
7
8
|
"BEHIND",
|
|
8
9
|
"BLOCKED",
|
|
@@ -26,8 +27,10 @@ export function createUntilTerminalRateLimitRetry() {
|
|
|
26
27
|
},
|
|
27
28
|
};
|
|
28
29
|
}
|
|
29
|
-
function backoffMs(attempt) {
|
|
30
|
-
|
|
30
|
+
function backoffMs(attempt, kind) {
|
|
31
|
+
const backoff = kind === "secondary" ? SECONDARY_NO_PROGRESS_BACKOFF_MS : PRIMARY_NO_PROGRESS_BACKOFF_MS;
|
|
32
|
+
const index = kind === "secondary" ? attempt : attempt - 1;
|
|
33
|
+
return backoff[Math.min(index, backoff.length - 1)] ?? backoff.at(-1);
|
|
31
34
|
}
|
|
32
35
|
async function waitForRateLimit(state, err, opts) {
|
|
33
36
|
const retry = opts.untilTerminal ? pollRateLimitRetryAfterMs(err) : null;
|
|
@@ -35,14 +38,16 @@ async function waitForRateLimit(state, err, opts) {
|
|
|
35
38
|
throw err;
|
|
36
39
|
const elapsed = Math.round((Date.now() - opts.startedAt) / 1000);
|
|
37
40
|
const progressed = state.armed &&
|
|
41
|
+
retry.kind === "primary" &&
|
|
38
42
|
retry.resetAt !== undefined &&
|
|
39
43
|
(state.lastResetAt === undefined || retry.resetAt > state.lastResetAt);
|
|
40
44
|
let sleepMs = retry.ms;
|
|
41
45
|
if (!state.armed || progressed) {
|
|
42
46
|
state.armed = true;
|
|
43
47
|
state.noProgress = 0;
|
|
44
|
-
if (retry.resetAt !== undefined)
|
|
48
|
+
if (retry.kind === "primary" && retry.resetAt !== undefined) {
|
|
45
49
|
state.lastResetAt = retry.resetAt;
|
|
50
|
+
}
|
|
46
51
|
}
|
|
47
52
|
else {
|
|
48
53
|
state.noProgress += 1;
|
|
@@ -52,13 +57,13 @@ async function waitForRateLimit(state, err, opts) {
|
|
|
52
57
|
}
|
|
53
58
|
// Never sleep less than GitHub asked for. A short no-progress backoff must
|
|
54
59
|
// not undercut an explicit Retry-After or the secondary-limit default.
|
|
55
|
-
sleepMs = Math.max(retry.ms, backoffMs(state.noProgress));
|
|
60
|
+
sleepMs = Math.max(retry.ms, backoffMs(state.noProgress, retry.kind));
|
|
56
61
|
}
|
|
57
62
|
process.stderr.write(formatRateLimitRetryLine(opts.tickLabel, elapsed, { ...retry, ms: sleepMs }));
|
|
58
|
-
return sleepRateLimit(sleepMs, opts, retry.resource);
|
|
63
|
+
return sleepRateLimit(sleepMs, opts, retry.kind, retry.resource);
|
|
59
64
|
}
|
|
60
|
-
async function sleepRateLimit(ms, opts, resource) {
|
|
61
|
-
const canProbe = resource
|
|
65
|
+
async function sleepRateLimit(ms, opts, kind, resource) {
|
|
66
|
+
const canProbe = kind === "primary" && resource === "graphql" && opts.targets.length > 0;
|
|
62
67
|
if (ms <= opts.intervalMs || opts.intervalMs <= 0) {
|
|
63
68
|
await sleep(ms);
|
|
64
69
|
return undefined;
|
|
@@ -33,4 +33,6 @@ export declare function updateReadyDelay(prNumber: number, isReady: boolean, rea
|
|
|
33
33
|
}): Promise<ReadyDelayState>;
|
|
34
34
|
/** Delete the ready-delay marker once an elapsed delay has been consumed. */
|
|
35
35
|
export declare function clearReadyDelay(prNumber: number, owner: string, repo: string): Promise<void>;
|
|
36
|
+
/** Read-only hint for including compact receipt evidence in the next full PR snapshot. */
|
|
37
|
+
export declare function readyDelayElapsed(prNumber: number, owner: string, repo: string, readyDelaySeconds: number): Promise<boolean>;
|
|
36
38
|
export {};
|
|
@@ -54,6 +54,24 @@ export async function updateReadyDelay(prNumber, isReady, readyDelaySeconds, own
|
|
|
54
54
|
export async function clearReadyDelay(prNumber, owner, repo) {
|
|
55
55
|
await safeUnlink(readySincePath(prNumber, owner, repo));
|
|
56
56
|
}
|
|
57
|
+
/** Read-only hint for including compact receipt evidence in the next full PR snapshot. */
|
|
58
|
+
export async function readyDelayElapsed(prNumber, owner, repo, readyDelaySeconds) {
|
|
59
|
+
const markerPath = readySincePath(prNumber, owner, repo);
|
|
60
|
+
let raw;
|
|
61
|
+
try {
|
|
62
|
+
raw = await readFile(markerPath, "utf8");
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
const [since, head] = raw.trim().split(" ");
|
|
68
|
+
const readySince = Number(since);
|
|
69
|
+
const now = Math.floor(Date.now() / 1000);
|
|
70
|
+
return Boolean(head &&
|
|
71
|
+
Number.isSafeInteger(readySince) &&
|
|
72
|
+
readySince <= now &&
|
|
73
|
+
now - readySince >= readyDelaySeconds);
|
|
74
|
+
}
|
|
57
75
|
// ---------------------------------------------------------------------------
|
|
58
76
|
// Helpers
|
|
59
77
|
// ---------------------------------------------------------------------------
|