pr-shepherd 0.53.1 → 0.54.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 +1 -1
- package/bin/cli/args.mjs +3 -0
- package/bin/cli/check-blocker-handler.d.mts +2 -0
- package/bin/cli/check-blocker-handler.mjs +82 -0
- package/bin/cli/help-command-pages.d.mts +22 -3
- package/bin/cli/help-command-pages.mjs +21 -2
- package/bin/cli/help-iterate-poll-pages.d.mts +1 -1
- package/bin/cli/help-iterate-poll-pages.mjs +1 -1
- package/bin/cli/help-top-page.d.mts +1 -1
- package/bin/cli/help-top-page.mjs +3 -1
- package/bin/cli/help.d.mts +23 -4
- package/bin/cli/help.mjs +2 -0
- package/bin/cli/poll-handler.mjs +5 -1
- package/bin/cli-parser.mjs +4 -0
- package/bin/commands/apply-check-blocker.d.mts +22 -0
- package/bin/commands/apply-check-blocker.mjs +36 -0
- package/bin/commands/check-annotations.d.mts +1 -1
- package/bin/commands/check-annotations.mjs +27 -17
- package/bin/commands/check-blocker-ref.d.mts +3 -0
- package/bin/commands/check-blocker-ref.mjs +50 -0
- package/bin/commands/iterate/check-blocker-gate.d.mts +18 -0
- package/bin/commands/iterate/check-blocker-gate.mjs +115 -0
- package/bin/commands/iterate/check-instructions.d.mts +2 -0
- package/bin/commands/iterate/check-instructions.mjs +4 -0
- package/bin/commands/iterate/fix-code.d.mts +2 -0
- package/bin/commands/iterate/fix-code.mjs +31 -15
- package/bin/commands/iterate/index.mjs +22 -8
- package/bin/commands/poll-quota.d.mts +2 -0
- package/bin/commands/poll-quota.mjs +25 -12
- package/bin/commands/poll-rate-limit-cancel.d.mts +35 -0
- package/bin/commands/poll-rate-limit-cancel.mjs +101 -0
- package/bin/commands/poll-rate-limit-delay.d.mts +1 -0
- package/bin/commands/poll-rate-limit-delay.mjs +7 -0
- package/bin/commands/poll-rate-limit-wait.d.mts +14 -0
- package/bin/commands/poll-rate-limit-wait.mjs +125 -0
- package/bin/commands/poll-summary.mjs +21 -9
- package/bin/commands/poll.mjs +22 -17
- package/bin/config/load.d.mts +2 -0
- package/bin/config/load.mjs +24 -2
- package/bin/config.json +1 -0
- package/bin/github/check-annotation-cache.d.mts +8 -0
- package/bin/github/check-annotation-cache.mjs +23 -0
- package/bin/github/check-annotation-pages.d.mts +11 -0
- package/bin/github/check-annotation-pages.mjs +36 -0
- package/bin/github/check-annotation-shape.d.mts +21 -0
- package/bin/github/check-annotation-shape.mjs +50 -0
- package/bin/github/check-annotations-batch.d.mts +17 -0
- package/bin/github/check-annotations-batch.mjs +92 -0
- package/bin/github/check-annotations.d.mts +8 -11
- package/bin/github/check-annotations.mjs +14 -103
- package/bin/github/gql/batch-pr.gql +1 -1
- package/bin/github/gql/check-run-annotations-batch.gql +41 -0
- package/bin/github/pagination.d.mts +1 -1
- package/bin/github/pagination.mjs +1 -1
- package/bin/github/poll-summary-check-blockers.d.mts +16 -0
- package/bin/github/poll-summary-check-blockers.mjs +25 -0
- package/bin/github/poll-summary-checks.d.mts +2 -0
- package/bin/github/poll-summary-checks.mjs +35 -20
- package/bin/github/poll-summary-projector.mjs +5 -0
- package/bin/github/queries.d.mts +6 -0
- package/bin/github/queries.mjs +6 -0
- package/bin/state/check-blockers.d.mts +30 -0
- package/bin/state/check-blockers.mjs +88 -0
- package/package.json +1 -1
- package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
- package/plugins/pr-shepherd/.codex.mcp.json +1 -1
- package/plugins/pr-shepherd/.mcp.json +1 -1
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
const NAME = /^[A-Za-z0-9_.-]+$/;
|
|
2
|
+
const SHORTHAND = /^(?:(issue):)?([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)#([1-9]\d*)$/;
|
|
3
|
+
/** Parse `--blocked-by`. Returns null for anything other than the four accepted forms. */
|
|
4
|
+
export function parseBlockedByRef(raw) {
|
|
5
|
+
const text = raw.trim();
|
|
6
|
+
const short = SHORTHAND.exec(text);
|
|
7
|
+
if (short?.[2] && short[3] && short[4]) {
|
|
8
|
+
const number = Number(short[4]);
|
|
9
|
+
if (!Number.isSafeInteger(number))
|
|
10
|
+
return null;
|
|
11
|
+
return {
|
|
12
|
+
owner: short[2],
|
|
13
|
+
name: short[3],
|
|
14
|
+
number,
|
|
15
|
+
kind: short[1] === "issue" ? "issue" : "pull",
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
return parseUrl(text);
|
|
19
|
+
}
|
|
20
|
+
function parseUrl(text) {
|
|
21
|
+
let url;
|
|
22
|
+
try {
|
|
23
|
+
url = new URL(text);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
if (url.protocol !== "https:" && url.protocol !== "http:")
|
|
29
|
+
return null;
|
|
30
|
+
if (url.hostname !== "github.com" && url.hostname !== "www.github.com")
|
|
31
|
+
return null;
|
|
32
|
+
if (url.search !== "" || url.hash !== "")
|
|
33
|
+
return null;
|
|
34
|
+
const parts = url.pathname.split("/").filter(Boolean);
|
|
35
|
+
if (parts.length !== 4)
|
|
36
|
+
return null;
|
|
37
|
+
const [owner, name, type, num] = parts;
|
|
38
|
+
if (!owner || !name || !num || !NAME.test(owner) || !NAME.test(name))
|
|
39
|
+
return null;
|
|
40
|
+
if (!/^[1-9]\d*$/.test(num))
|
|
41
|
+
return null;
|
|
42
|
+
const number = Number(num);
|
|
43
|
+
if (!Number.isSafeInteger(number))
|
|
44
|
+
return null;
|
|
45
|
+
if (type === "pull")
|
|
46
|
+
return { owner, name, number, kind: "pull" };
|
|
47
|
+
if (type === "issues")
|
|
48
|
+
return { owner, name, number, kind: "issue" };
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { IterateResult } from "../../types.mts";
|
|
2
|
+
export interface CheckBlockerGate {
|
|
3
|
+
deferredNames: ReadonlySet<string>;
|
|
4
|
+
releasedNames: ReadonlySet<string>;
|
|
5
|
+
/** `owner/name#number` for blockers that are still open. */
|
|
6
|
+
openBlockers: readonly string[];
|
|
7
|
+
}
|
|
8
|
+
interface StateKey {
|
|
9
|
+
owner: string;
|
|
10
|
+
repo: string;
|
|
11
|
+
pr: number;
|
|
12
|
+
}
|
|
13
|
+
export declare function resolveCheckBlockerGate(key: StateKey, failing: readonly {
|
|
14
|
+
name: string;
|
|
15
|
+
}[], mergeStatus?: string): Promise<CheckBlockerGate | null>;
|
|
16
|
+
/** Append the open blocker to a WAIT log. Other actions are unchanged. */
|
|
17
|
+
export declare function annotateBlockedWait(result: IterateResult, gate: CheckBlockerGate | null): IterateResult;
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { graphql } from "../../github/client.mjs";
|
|
2
|
+
import { pollRateLimitRetryAfterMs } from "../poll-quota.mjs";
|
|
3
|
+
import { clearCheckBlocker, formatCheckBlockerRef, readCheckBlockers, } from "../../state/check-blockers.mjs";
|
|
4
|
+
const RATE_LIMIT = "cost limit nodeCount remaining resetAt used";
|
|
5
|
+
const PULL_QUERY = `query CheckBlockerPull($owner: String!, $name: String!, $number: Int!) {
|
|
6
|
+
_shepherdRateLimit: rateLimit { ${RATE_LIMIT} }
|
|
7
|
+
repository(owner: $owner, name: $name) {
|
|
8
|
+
pullRequest(number: $number) { state merged }
|
|
9
|
+
}
|
|
10
|
+
}`;
|
|
11
|
+
const ISSUE_QUERY = `query CheckBlockerIssue($owner: String!, $name: String!, $number: Int!) {
|
|
12
|
+
_shepherdRateLimit: rateLimit { ${RATE_LIMIT} }
|
|
13
|
+
repository(owner: $owner, name: $name) {
|
|
14
|
+
issue(number: $number) { state }
|
|
15
|
+
}
|
|
16
|
+
}`;
|
|
17
|
+
/**
|
|
18
|
+
* Match failing checks to stored blockers. No records, or none of them name a
|
|
19
|
+
* failing check, skips GitHub. Rate-limit errors propagate; other lookup
|
|
20
|
+
* failures leave that check as a normal failure.
|
|
21
|
+
*/
|
|
22
|
+
/** Behind or conflicting branches still need the update-branch instruction. */
|
|
23
|
+
const KEEP_RELEASED_STATUS = new Set(["BEHIND", "CONFLICTS", "DIRTY", "UNKNOWN"]);
|
|
24
|
+
export async function resolveCheckBlockerGate(key, failing, mergeStatus) {
|
|
25
|
+
const records = await readCheckBlockers(key);
|
|
26
|
+
if (records.length === 0 || failing.length === 0)
|
|
27
|
+
return null;
|
|
28
|
+
const failingNames = new Set(failing.map((check) => check.name));
|
|
29
|
+
const matched = records.filter((record) => failingNames.has(record.checkName));
|
|
30
|
+
if (matched.length === 0)
|
|
31
|
+
return null;
|
|
32
|
+
const lookups = new Map();
|
|
33
|
+
for (const record of matched) {
|
|
34
|
+
const id = blockerKey(record.blocker);
|
|
35
|
+
if (lookups.has(id))
|
|
36
|
+
continue;
|
|
37
|
+
lookups.set(id, await lookupBlocker(record.blocker));
|
|
38
|
+
}
|
|
39
|
+
const deferredNames = new Set();
|
|
40
|
+
const releasedNames = new Set();
|
|
41
|
+
const openBlockers = [];
|
|
42
|
+
const seen = new Set();
|
|
43
|
+
for (const record of matched) {
|
|
44
|
+
const status = lookups.get(blockerKey(record.blocker));
|
|
45
|
+
if (status === "deferred") {
|
|
46
|
+
deferredNames.add(record.checkName);
|
|
47
|
+
const label = formatCheckBlockerRef(record.blocker);
|
|
48
|
+
if (!seen.has(label)) {
|
|
49
|
+
seen.add(label);
|
|
50
|
+
openBlockers.push(label);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
else if (status === "released") {
|
|
54
|
+
if (mergeStatus !== undefined && !KEEP_RELEASED_STATUS.has(mergeStatus)) {
|
|
55
|
+
// The branch is already current, so update-branch would no-op and a
|
|
56
|
+
// later unrelated failure must be a normal check again.
|
|
57
|
+
await clearCheckBlocker(key, record.checkName);
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
releasedNames.add(record.checkName);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (deferredNames.size === 0 && releasedNames.size === 0)
|
|
65
|
+
return null;
|
|
66
|
+
return { deferredNames, releasedNames, openBlockers };
|
|
67
|
+
}
|
|
68
|
+
/** Append the open blocker to a WAIT log. Other actions are unchanged. */
|
|
69
|
+
export function annotateBlockedWait(result, gate) {
|
|
70
|
+
if (result.action !== "wait" || gate === null || gate.openBlockers.length === 0)
|
|
71
|
+
return result;
|
|
72
|
+
const note = `blocked by ${gate.openBlockers.join(", ")}`;
|
|
73
|
+
if (result.log.includes(note))
|
|
74
|
+
return result;
|
|
75
|
+
return { ...result, log: `${result.log} — ${note}` };
|
|
76
|
+
}
|
|
77
|
+
async function lookupBlocker(blocker) {
|
|
78
|
+
try {
|
|
79
|
+
const vars = { owner: blocker.owner, name: blocker.name, number: blocker.number };
|
|
80
|
+
if (blocker.kind === "issue") {
|
|
81
|
+
const { data } = await graphql(ISSUE_QUERY, vars);
|
|
82
|
+
const issue = data.repository?.issue;
|
|
83
|
+
if (!issue)
|
|
84
|
+
return ignore(blocker, "not found");
|
|
85
|
+
if (issue.state === "OPEN")
|
|
86
|
+
return "deferred";
|
|
87
|
+
if (issue.state === "CLOSED")
|
|
88
|
+
return "released";
|
|
89
|
+
return ignore(blocker, `unexpected state ${issue.state}`);
|
|
90
|
+
}
|
|
91
|
+
const { data } = await graphql(PULL_QUERY, vars);
|
|
92
|
+
const pull = data.repository?.pullRequest;
|
|
93
|
+
if (!pull)
|
|
94
|
+
return ignore(blocker, "not found");
|
|
95
|
+
if (pull.state === "OPEN" && pull.merged !== true)
|
|
96
|
+
return "deferred";
|
|
97
|
+
if (pull.state === "MERGED" || pull.state === "CLOSED" || pull.merged === true) {
|
|
98
|
+
return "released";
|
|
99
|
+
}
|
|
100
|
+
return ignore(blocker, `unexpected state ${pull.state}`);
|
|
101
|
+
}
|
|
102
|
+
catch (err) {
|
|
103
|
+
if (pollRateLimitRetryAfterMs(err) !== null)
|
|
104
|
+
throw err;
|
|
105
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
106
|
+
return ignore(blocker, message);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
function ignore(blocker, message) {
|
|
110
|
+
process.stderr.write(`pr-shepherd: check blocker lookup failed for ${formatCheckBlockerRef(blocker)} (ignored): ${message}\n`);
|
|
111
|
+
return "ignored";
|
|
112
|
+
}
|
|
113
|
+
function blockerKey(blocker) {
|
|
114
|
+
return `${blocker.kind}:${formatCheckBlockerRef(blocker)}`;
|
|
115
|
+
}
|
|
@@ -33,4 +33,6 @@ export declare function buildRepeatedWorkflowBranchRecoveryInstructions(baseBran
|
|
|
33
33
|
export declare function buildResolveCommandInstruction(resolveCommand: ResolveCommand): string[];
|
|
34
34
|
/** Build the CI-triage pointer; the skill limits follow-up actions to included evidence. */
|
|
35
35
|
export declare function buildFailingCheckInstructions(checks: AgentCheck[]): string[];
|
|
36
|
+
/** Update the PR branch after an external blocker merges or closes. Never rerun that job. */
|
|
37
|
+
export declare function buildReleasedBlockerInstruction(prNumber: number): string;
|
|
36
38
|
export declare function buildFixCompletionInstruction(checks: AgentCheck[], hasConflicts?: boolean, hasShaGatedReviewMutations?: boolean, pushesRewrittenStack?: boolean): string;
|
|
@@ -80,6 +80,10 @@ export function buildFailingCheckInstructions(checks) {
|
|
|
80
80
|
}
|
|
81
81
|
return instructions;
|
|
82
82
|
}
|
|
83
|
+
/** Update the PR branch after an external blocker merges or closes. Never rerun that job. */
|
|
84
|
+
export function buildReleasedBlockerInstruction(prNumber) {
|
|
85
|
+
return `Update this PR branch from its base with \`gh pr update-branch ${prNumber} --rebase\`. Do not rerun the job; a rerun retests the old merge ref.`;
|
|
86
|
+
}
|
|
83
87
|
export function buildFixCompletionInstruction(checks, hasConflicts = false, hasShaGatedReviewMutations = false, pushesRewrittenStack = false) {
|
|
84
88
|
const push = pushesRewrittenStack
|
|
85
89
|
? "push the rewritten stack with `gh stack push`"
|
|
@@ -19,6 +19,8 @@ interface HandleFixCodeContext {
|
|
|
19
19
|
editedSummaries: Review[];
|
|
20
20
|
surfacedApprovals: Review[];
|
|
21
21
|
botUsernames: NormalizedBotUsernames;
|
|
22
|
+
/** Failing checks whose external blocker has merged or closed. */
|
|
23
|
+
releasedCheckNames?: ReadonlySet<string>;
|
|
22
24
|
ruleAutoResolveThreadIds?: string[];
|
|
23
25
|
/** Verified stack-repair guidance, when ancestry is stale. */
|
|
24
26
|
repairInstructions?: string[];
|
|
@@ -6,6 +6,7 @@ import { checkEscalateTriggers, validateBaseBranch, buildEscalateSuggestion, bui
|
|
|
6
6
|
import { buildResolveCommand } from "./classify.mjs";
|
|
7
7
|
import { buildThreadMutationRouting, threadHasAuthorizedMutation, } from "./thread-mutation-routing.mjs";
|
|
8
8
|
import { buildFixInstructions } from "./render.mjs";
|
|
9
|
+
import { buildReleasedBlockerInstruction } from "./check-instructions.mjs";
|
|
9
10
|
import { buildNativeStackLayerRebase } from "./native-stack-rebase.mjs";
|
|
10
11
|
import { applyStallGuard } from "./stall.mjs";
|
|
11
12
|
import { annotationMarkerBody, checksWithActionableAnnotations } from "../check-annotations.mjs";
|
|
@@ -14,6 +15,7 @@ import { isHumanAuthor, isConfiguredBotAuthor } from "../../comments/authors.mjs
|
|
|
14
15
|
import { canRerunWorkflows } from "../../checks/conclusions.mjs";
|
|
15
16
|
import { loadConfig } from "../../config/load.mjs";
|
|
16
17
|
import { formatPrUrl } from "../../pr-reference.mjs";
|
|
18
|
+
const EMPTY_RELEASED = new Set();
|
|
17
19
|
function checkRequiresHumanFollowUp(check) {
|
|
18
20
|
if (check.rerunCommand)
|
|
19
21
|
return false;
|
|
@@ -68,7 +70,7 @@ function pendingReviewCommands(resolveCommand, resolveOnlyCommand) {
|
|
|
68
70
|
return Object.keys(pending).length > 0 ? pending : undefined;
|
|
69
71
|
}
|
|
70
72
|
export async function handleFixCode(ctx) {
|
|
71
|
-
const { base, report, opts, headSha, stallKey, prNumber, stallTimeoutSeconds, repoOwner, repoName, reviewSummaryIds, firstLookSummaries, editedSummaries, surfacedApprovals, botUsernames, ruleAutoResolveThreadIds, repairInstructions, } = ctx;
|
|
73
|
+
const { base, report, opts, headSha, stallKey, prNumber, stallTimeoutSeconds, repoOwner, repoName, reviewSummaryIds, firstLookSummaries, editedSummaries, surfacedApprovals, botUsernames, releasedCheckNames = EMPTY_RELEASED, ruleAutoResolveThreadIds, repairInstructions, } = ctx;
|
|
72
74
|
const prReference = formatPrUrl(report.repo, prNumber);
|
|
73
75
|
const failingChecks = report.checks.failing;
|
|
74
76
|
const annotatedExtra = checksWithActionableAnnotations(report).filter((c) => c.category !== "failing");
|
|
@@ -158,18 +160,22 @@ export async function handleFixCode(ctx) {
|
|
|
158
160
|
? [c.runId]
|
|
159
161
|
: []));
|
|
160
162
|
const initialAttemptRunIds = new Set(failingChecks.flatMap((c) => (c.runId !== null && c.runAttempt === 1 ? [c.runId] : [])));
|
|
161
|
-
const failingAgentChecks = toAgentChecks(failingChecks).map((c) =>
|
|
162
|
-
c.
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
163
|
+
const failingAgentChecks = toAgentChecks(failingChecks).map((c) => {
|
|
164
|
+
if (releasedCheckNames.has(c.name))
|
|
165
|
+
return c;
|
|
166
|
+
return rerunAuthorized &&
|
|
167
|
+
c.runId &&
|
|
168
|
+
actionsRunIds.has(c.runId) &&
|
|
169
|
+
// GitHub increments run_attempt after every rerun. Recommend at most one rerun by limiting
|
|
170
|
+
// the command to the original attempt; missing attempt metadata is denied conservatively.
|
|
171
|
+
initialAttemptRunIds.has(c.runId) &&
|
|
172
|
+
// ACTION_REQUIRED means the run is paused pending manual workflow approval; rerunning does
|
|
173
|
+
// not grant that approval, so no rerun command applies.
|
|
174
|
+
c.conclusion !== "ACTION_REQUIRED" &&
|
|
175
|
+
!inProgressWorkflowRunIds.has(c.runId)
|
|
176
|
+
? { ...c, rerunCommand: `gh run rerun ${c.runId} -R ${report.repo}` }
|
|
177
|
+
: c;
|
|
178
|
+
});
|
|
173
179
|
const checks = [
|
|
174
180
|
...failingAgentChecks,
|
|
175
181
|
...toAgentChecks(annotatedExtra).map((c) => ({ ...c, annotationOnly: true })),
|
|
@@ -184,7 +190,9 @@ export async function handleFixCode(ctx) {
|
|
|
184
190
|
const inProgressRunIds = [];
|
|
185
191
|
const commentMinimizeIds = report.comments.minimizeIds ?? actionableComments.map((c) => c.id);
|
|
186
192
|
const belongsToActiveWorkflowRun = (check) => check.runId !== null && inProgressWorkflowRunIds.has(check.runId);
|
|
187
|
-
const manualFollowUpChecks = failingAgentChecks.filter((check) => !
|
|
193
|
+
const manualFollowUpChecks = failingAgentChecks.filter((check) => !releasedCheckNames.has(check.name) &&
|
|
194
|
+
!belongsToActiveWorkflowRun(check) &&
|
|
195
|
+
checkRequiresHumanFollowUp(check));
|
|
188
196
|
const exhaustedAttempts = failingAgentChecks.filter((check) => check.runAttempt !== undefined && check.runAttempt > 1);
|
|
189
197
|
const manualExhaustedAttempts = exhaustedAttempts.filter((check) => manualFollowUpChecks.includes(check));
|
|
190
198
|
const hasBehindBaseRecovery = isBehind && manualExhaustedAttempts.length > 0;
|
|
@@ -201,7 +209,9 @@ export async function handleFixCode(ctx) {
|
|
|
201
209
|
report.threads.firstLook.length > 0 ||
|
|
202
210
|
report.comments.firstLook.length > 0 ||
|
|
203
211
|
checks.some((check) => (check.annotations?.length ?? 0) > 0) ||
|
|
204
|
-
failingAgentChecks.some((check) =>
|
|
212
|
+
failingAgentChecks.some((check) => releasedCheckNames.has(check.name) ||
|
|
213
|
+
belongsToActiveWorkflowRun(check) ||
|
|
214
|
+
!checkRequiresHumanFollowUp(check));
|
|
205
215
|
if (manualFollowUpChecks.length > 0 && !hasAutonomousWork) {
|
|
206
216
|
const { resolveCommand, resolveOnlyCommand } = buildReviewCommands(failingAgentChecks);
|
|
207
217
|
const pending = pendingReviewCommands(resolveCommand, resolveOnlyCommand);
|
|
@@ -277,6 +287,12 @@ export async function handleFixCode(ctx) {
|
|
|
277
287
|
? buildNativeStackLayerRebase(report.repo, { number: prNumber, baseBranch: baseLookup.branch }, report.mergeStatus.mergeRequirements?.stack)
|
|
278
288
|
: undefined;
|
|
279
289
|
const instructions = buildFixInstructions(threads, actionableComments, checks, changesRequestedReviews, baseLookup.branch, resolveCommand, hasConflicts, prReference, cancelled.length, firstLookThreads, firstLookComments, firstLookSummaries, editedSummaries, inProgressRunIds, resolutionOnlyThreads, resolveOnlyCommand, behindBaseHint, isBehind, report.viewerAuthorization?.viewerCanUpdate === true, exhaustedAttempts.length > 0, stackRebase);
|
|
290
|
+
if (failingAgentChecks.some((check) => releasedCheckNames.has(check.name))) {
|
|
291
|
+
const completion = instructions.pop();
|
|
292
|
+
instructions.push(buildReleasedBlockerInstruction(prNumber));
|
|
293
|
+
if (completion !== undefined)
|
|
294
|
+
instructions.push(completion);
|
|
295
|
+
}
|
|
280
296
|
if (repairInstructions && repairInstructions.length > 0) {
|
|
281
297
|
instructions.unshift(...repairInstructions);
|
|
282
298
|
}
|
|
@@ -24,6 +24,7 @@ import { isCurrentSummaryReady } from "../../github/poll-summary-readiness.mjs";
|
|
|
24
24
|
import { clearReadyReceipt, isReadyReceiptCurrent, readReadyReceipt, writeReadyReceipt, } from "../../state/ready-receipts.mjs";
|
|
25
25
|
import { stackDraftHold } from "./parent-first.mjs";
|
|
26
26
|
import { findStaleNativeStackAncestry } from "./stale-ancestry.mjs";
|
|
27
|
+
import { annotateBlockedWait, resolveCheckBlockerGate } from "./check-blocker-gate.mjs";
|
|
27
28
|
export function runIterate(opts) {
|
|
28
29
|
return withIterateApiUsage(opts, () => runIterateCore(opts));
|
|
29
30
|
}
|
|
@@ -58,6 +59,17 @@ async function runIterateCore(opts) {
|
|
|
58
59
|
await clearStallState(stallKey);
|
|
59
60
|
return buildTerminalCancelResult(report);
|
|
60
61
|
}
|
|
62
|
+
const blockerGate = await resolveCheckBlockerGate({ owner: repoOwner, repo: repoName, pr: prNumber }, report.checks.failing, report.mergeStatus.status);
|
|
63
|
+
const deferredNames = blockerGate?.deferredNames;
|
|
64
|
+
const reportForWork = deferredNames === undefined || deferredNames.size === 0
|
|
65
|
+
? report
|
|
66
|
+
: {
|
|
67
|
+
...report,
|
|
68
|
+
checks: {
|
|
69
|
+
...report.checks,
|
|
70
|
+
failing: report.checks.failing.filter((check) => !deferredNames.has(check.name)),
|
|
71
|
+
},
|
|
72
|
+
};
|
|
61
73
|
const { minimizeIds, selfMinimizeIds, firstLookSummaries, editedSummaries, surfacedApprovals } = classifyReviewSummaries({
|
|
62
74
|
firstLook: report.firstLookSummaries,
|
|
63
75
|
seen: report.reviewSummaries,
|
|
@@ -86,7 +98,7 @@ async function runIterateCore(opts) {
|
|
|
86
98
|
report.comments.actionable.length > 0 ||
|
|
87
99
|
(report.comments.minimizeIds?.length ?? 0) > 0 ||
|
|
88
100
|
report.changesRequestedReviews.length > 0 ||
|
|
89
|
-
hasCheckDrivenActionableWork(
|
|
101
|
+
hasCheckDrivenActionableWork(reportForWork.checks, report.mergeStatus.status) ||
|
|
90
102
|
reviewSummaryIds.length > 0 ||
|
|
91
103
|
firstLookSummaries.length > 0 ||
|
|
92
104
|
editedSummaries.length > 0 ||
|
|
@@ -109,20 +121,20 @@ async function runIterateCore(opts) {
|
|
|
109
121
|
// current already proves the delay elapsed for this exact head, base, and
|
|
110
122
|
// readiness evidence, so a rerun (e.g. with --merge) does not wait again.
|
|
111
123
|
const readyState = await updateReadyDelay(report.pr, isCleanReadyState, readyDelaySeconds, repoOwner, repoName, { headSha, alreadyElapsed: receiptCurrent });
|
|
112
|
-
const base = buildIterateBase(
|
|
124
|
+
const base = buildIterateBase(reportForWork, readyState);
|
|
113
125
|
// Checks (including merge-queue synthetic-commit checks) and hard conflicts are signals
|
|
114
126
|
// GitHub itself is already acting on — the queue will eject the PR for these regardless of
|
|
115
127
|
// what Shepherd does, so they always surface immediately. Only review threads/comments/
|
|
116
128
|
// changes-requested reviews/review summaries — the categories that would otherwise cause a
|
|
117
129
|
// Shepherd-initiated push while the PR sits safely in the queue — are eligible for deferral.
|
|
118
|
-
const checkDrivenActionableWork = hasCheckDrivenActionableWork(
|
|
130
|
+
const checkDrivenActionableWork = hasCheckDrivenActionableWork(reportForWork.checks, report.mergeStatus.status);
|
|
119
131
|
const deferWhileQueued = opts.merge === true &&
|
|
120
132
|
report.mergeQueue?.inQueue === true &&
|
|
121
133
|
config.actions.workWhileQueued !== true;
|
|
122
134
|
if (hasActionableWork && !(deferWhileQueued && !checkDrivenActionableWork)) {
|
|
123
135
|
return handleFixCode({
|
|
124
136
|
base,
|
|
125
|
-
report,
|
|
137
|
+
report: reportForWork,
|
|
126
138
|
opts: { ...opts, prNumber, neverCancelRuns },
|
|
127
139
|
headSha,
|
|
128
140
|
stallKey,
|
|
@@ -135,6 +147,7 @@ async function runIterateCore(opts) {
|
|
|
135
147
|
editedSummaries,
|
|
136
148
|
surfacedApprovals,
|
|
137
149
|
botUsernames,
|
|
150
|
+
releasedCheckNames: blockerGate?.releasedNames,
|
|
138
151
|
ruleAutoResolveThreadIds: report.threads.ruleAutoResolveIds,
|
|
139
152
|
});
|
|
140
153
|
}
|
|
@@ -151,11 +164,11 @@ async function runIterateCore(opts) {
|
|
|
151
164
|
minimizeApprovals: config.iterate.minimizeApprovals,
|
|
152
165
|
});
|
|
153
166
|
if (mergeStateResult)
|
|
154
|
-
return mergeStateResult;
|
|
167
|
+
return annotateBlockedWait(mergeStateResult, blockerGate);
|
|
155
168
|
if (staleAncestry) {
|
|
156
169
|
return handleFixCode({
|
|
157
170
|
base,
|
|
158
|
-
report,
|
|
171
|
+
report: reportForWork,
|
|
159
172
|
opts: { ...opts, prNumber, neverCancelRuns },
|
|
160
173
|
headSha,
|
|
161
174
|
stallKey,
|
|
@@ -168,6 +181,7 @@ async function runIterateCore(opts) {
|
|
|
168
181
|
editedSummaries,
|
|
169
182
|
surfacedApprovals,
|
|
170
183
|
botUsernames,
|
|
184
|
+
releasedCheckNames: blockerGate?.releasedNames,
|
|
171
185
|
repairInstructions: staleAncestry.instructions,
|
|
172
186
|
ruleAutoResolveThreadIds: report.threads.ruleAutoResolveIds,
|
|
173
187
|
});
|
|
@@ -191,7 +205,7 @@ async function runIterateCore(opts) {
|
|
|
191
205
|
shouldCancel: false,
|
|
192
206
|
log: `WAIT: PR #${base.pr} reached ready-delay but its stack readiness receipt could not be persisted`,
|
|
193
207
|
};
|
|
194
|
-
return applyStallGuard(stallKey, stallTimeoutSeconds, headSha, base, prNumber, receiptWait,
|
|
208
|
+
return annotateBlockedWait(await applyStallGuard(stallKey, stallTimeoutSeconds, headSha, base, prNumber, receiptWait, reportForWork, reviewSummaryIds), blockerGate);
|
|
195
209
|
}
|
|
196
210
|
await clearReadyDelay(report.pr, repoOwner, repoName);
|
|
197
211
|
await clearStallState(stallKey);
|
|
@@ -213,7 +227,7 @@ async function runIterateCore(opts) {
|
|
|
213
227
|
log: buildWaitLog(base),
|
|
214
228
|
...(hold && { stackDraftHold: hold }),
|
|
215
229
|
};
|
|
216
|
-
return applyStallGuard(stallKey, stallTimeoutSeconds, headSha, base, prNumber, wait,
|
|
230
|
+
return annotateBlockedWait(await applyStallGuard(stallKey, stallTimeoutSeconds, headSha, base, prNumber, wait, reportForWork, reviewSummaryIds), blockerGate);
|
|
217
231
|
}
|
|
218
232
|
async function recordReadyReceipt(key, report) {
|
|
219
233
|
if (report.status !== "READY" ||
|
|
@@ -19,4 +19,6 @@ export interface RateLimitRetry {
|
|
|
19
19
|
export declare function pollRateLimitRetryAfterMs(err: unknown): RateLimitRetry | null;
|
|
20
20
|
/** Stderr line naming the exhausted budget and when the sleep ends. */
|
|
21
21
|
export declare function formatRateLimitRetryLine(tickLabel: string, elapsedSeconds: number, retry: RateLimitRetry): string;
|
|
22
|
+
/** One stderr line before `--until-terminal` gives up and exits 75. */
|
|
23
|
+
export declare function formatRateLimitGiveUpLine(tickLabel: string, elapsedSeconds: number, retry: RateLimitRetry): string;
|
|
22
24
|
export declare function aggregateQuotaWarning(result: PollSummaryResult, bands: GraphqlQuotaWarningBand[], intervalSeconds: number): Promise<PollSummaryResult["quotaWarning"]>;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { summarizeApiTelemetry } from "../github/api-telemetry.mjs";
|
|
2
2
|
import { GitHubRequestError } from "../github/errors.mjs";
|
|
3
3
|
import { isRateLimitMessage } from "../comments/rate-limit.mjs";
|
|
4
|
+
import { exhaustedPrimaryLimitDelayMs } from "./poll-rate-limit-delay.mjs";
|
|
4
5
|
import { selectQuotaWarning } from "./quota-selection.mjs";
|
|
5
6
|
const GRAPHQL_RETRY_AFTER_DEFAULT_MS = 60_000;
|
|
6
7
|
/** Sleep at least `--interval`, and at least the active crossed quota band. */
|
|
@@ -45,7 +46,7 @@ export function pollRateLimitRetryAfterMs(err) {
|
|
|
45
46
|
return { ...details, ms: Math.max(err.retryAfterSeconds, 0) * 1000 };
|
|
46
47
|
}
|
|
47
48
|
if (exhausted && rateLimit !== undefined) {
|
|
48
|
-
return { ...details, ms:
|
|
49
|
+
return { ...details, ms: exhaustedPrimaryLimitDelayMs(rateLimit.resetAt, Date.now()) };
|
|
49
50
|
}
|
|
50
51
|
return { ...details, ms: GRAPHQL_RETRY_AFTER_DEFAULT_MS };
|
|
51
52
|
}
|
|
@@ -58,19 +59,31 @@ function retryResource(err) {
|
|
|
58
59
|
(err.graphqlErrors?.some((error) => /secondary/i.test(error.message)) ?? false);
|
|
59
60
|
return secondary ? "secondary" : "graphql";
|
|
60
61
|
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
const counts = retry.remaining !== undefined && retry.limit !== undefined
|
|
62
|
+
function rateLimitResourceLabel(resource) {
|
|
63
|
+
if (resource === "graphql")
|
|
64
|
+
return "GitHub GraphQL rate limit";
|
|
65
|
+
if (resource === "secondary")
|
|
66
|
+
return "GitHub secondary rate limit";
|
|
67
|
+
return `GitHub REST ${resource} rate limit`;
|
|
68
|
+
}
|
|
69
|
+
function rateLimitCounts(retry) {
|
|
70
|
+
return retry.remaining !== undefined && retry.limit !== undefined
|
|
71
71
|
? ` (${retry.remaining}/${retry.limit})`
|
|
72
72
|
: "";
|
|
73
|
-
|
|
73
|
+
}
|
|
74
|
+
function rateLimitClock(retry) {
|
|
75
|
+
const resetAt = retry.resetAt ?? Math.ceil((Date.now() + retry.ms) / 1000);
|
|
76
|
+
return `${new Date(resetAt * 1000).toISOString().slice(11, 19)}Z`;
|
|
77
|
+
}
|
|
78
|
+
/** Stderr line naming the exhausted budget and when the sleep ends. */
|
|
79
|
+
export function formatRateLimitRetryLine(tickLabel, elapsedSeconds, retry) {
|
|
80
|
+
const clock = rateLimitClock(retry);
|
|
81
|
+
return `[${tickLabel} / +${elapsedSeconds}s] ${rateLimitResourceLabel(retry.resource)}${rateLimitCounts(retry)} — retrying at ${clock} (in ${Math.round(retry.ms / 1000)}s)\n`;
|
|
82
|
+
}
|
|
83
|
+
/** One stderr line before `--until-terminal` gives up and exits 75. */
|
|
84
|
+
export function formatRateLimitGiveUpLine(tickLabel, elapsedSeconds, retry) {
|
|
85
|
+
const clock = rateLimitClock(retry);
|
|
86
|
+
return `[${tickLabel} / +${elapsedSeconds}s] ${rateLimitResourceLabel(retry.resource)}${rateLimitCounts(retry)} still exhausted at ${clock} after 5 attempts with no progress\n`;
|
|
74
87
|
}
|
|
75
88
|
export async function aggregateQuotaWarning(result, bands, intervalSeconds) {
|
|
76
89
|
const usage = summarizeApiTelemetry();
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { IterateResult, MergeStateStatus, MergeableState, PollSummaryResult } from "../types.mts";
|
|
2
|
+
export interface RateLimitPullTarget {
|
|
3
|
+
owner: string;
|
|
4
|
+
repo: string;
|
|
5
|
+
pr: number;
|
|
6
|
+
}
|
|
7
|
+
export interface RateLimitProbePull {
|
|
8
|
+
pr: number;
|
|
9
|
+
state: "MERGED" | "CLOSED";
|
|
10
|
+
title: string;
|
|
11
|
+
url: string;
|
|
12
|
+
draft: boolean;
|
|
13
|
+
mergeable: MergeableState;
|
|
14
|
+
mergeStateStatus: MergeStateStatus;
|
|
15
|
+
baseRefName: string;
|
|
16
|
+
headRefName: string;
|
|
17
|
+
headRefOid: string;
|
|
18
|
+
}
|
|
19
|
+
export declare function onePrRateLimitTargets(explicit: {
|
|
20
|
+
owner: string;
|
|
21
|
+
name: string;
|
|
22
|
+
} | undefined, pr: number | undefined, repoSlug: string | undefined): RateLimitPullTarget[];
|
|
23
|
+
/** Same fields as the merged/closed iterate cancel path, filled from the REST pull. */
|
|
24
|
+
export declare function onePrCancelFromPulls(explicit: {
|
|
25
|
+
owner: string;
|
|
26
|
+
name: string;
|
|
27
|
+
} | undefined, repoSlug: string | undefined, pulls: RateLimitProbePull[]): IterateResult | undefined;
|
|
28
|
+
export declare function aggregateRateLimitTargets(last: PollSummaryResult | undefined, repo: {
|
|
29
|
+
owner: string;
|
|
30
|
+
name: string;
|
|
31
|
+
} | undefined, prNumbers: number[] | undefined): RateLimitPullTarget[];
|
|
32
|
+
export declare function aggregateCancelFromPulls(last: PollSummaryResult | undefined, repo: {
|
|
33
|
+
owner: string;
|
|
34
|
+
name: string;
|
|
35
|
+
} | undefined, pulls: RateLimitProbePull[]): PollSummaryResult | undefined;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
export function onePrRateLimitTargets(explicit, pr, repoSlug) {
|
|
2
|
+
if (pr === undefined)
|
|
3
|
+
return [];
|
|
4
|
+
const [slugOwner, slugName] = repoSlug?.split("/") ?? [];
|
|
5
|
+
const owner = explicit?.owner ?? slugOwner;
|
|
6
|
+
const repo = explicit?.name ?? slugName;
|
|
7
|
+
if (!owner || !repo)
|
|
8
|
+
return [];
|
|
9
|
+
return [{ owner, repo, pr }];
|
|
10
|
+
}
|
|
11
|
+
/** Same fields as the merged/closed iterate cancel path, filled from the REST pull. */
|
|
12
|
+
export function onePrCancelFromPulls(explicit, repoSlug, pulls) {
|
|
13
|
+
const pull = pulls[0];
|
|
14
|
+
const slug = explicit ? `${explicit.owner}/${explicit.name}` : repoSlug;
|
|
15
|
+
if (!pull || !slug)
|
|
16
|
+
return undefined;
|
|
17
|
+
const state = pull.state;
|
|
18
|
+
return {
|
|
19
|
+
pr: pull.pr,
|
|
20
|
+
repo: slug,
|
|
21
|
+
status: state,
|
|
22
|
+
state,
|
|
23
|
+
mergeStateStatus: pull.mergeStateStatus,
|
|
24
|
+
mergeStatus: "UNKNOWN",
|
|
25
|
+
reviewDecision: null,
|
|
26
|
+
blockingBotReviewInProgress: false,
|
|
27
|
+
isDraft: pull.draft,
|
|
28
|
+
shouldCancel: true,
|
|
29
|
+
remainingSeconds: 0,
|
|
30
|
+
summary: { passing: 0, skipped: 0, filtered: 0, inProgress: 0, superseded: 0 },
|
|
31
|
+
baseBranch: pull.baseRefName,
|
|
32
|
+
branchProtection: null,
|
|
33
|
+
checks: [],
|
|
34
|
+
inProgressChecks: [],
|
|
35
|
+
action: "cancel",
|
|
36
|
+
reason: state === "MERGED" ? "merged" : "closed",
|
|
37
|
+
log: `CANCEL: PR #${pull.pr} is ${state.toLowerCase()} — stopping`,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
export function aggregateRateLimitTargets(last, repo, prNumbers) {
|
|
41
|
+
if (last) {
|
|
42
|
+
return last.prs.flatMap((item) => {
|
|
43
|
+
if (item.state !== "OPEN")
|
|
44
|
+
return [];
|
|
45
|
+
const [owner, name] = item.repo.split("/");
|
|
46
|
+
if (!owner || !name)
|
|
47
|
+
return [];
|
|
48
|
+
return [{ owner, repo: name, pr: item.pr }];
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
if (!repo || !prNumbers?.length)
|
|
52
|
+
return [];
|
|
53
|
+
return prNumbers.map((pr) => ({ owner: repo.owner, repo: repo.name, pr }));
|
|
54
|
+
}
|
|
55
|
+
export function aggregateCancelFromPulls(last, repo, pulls) {
|
|
56
|
+
if (last) {
|
|
57
|
+
const byPr = new Map(pulls.map((pull) => [pull.pr, pull]));
|
|
58
|
+
const prs = last.prs.map((item) => applyProbe(item, byPr.get(item.pr)));
|
|
59
|
+
if (prs.some((item) => item.state !== "MERGED" && item.state !== "CLOSED"))
|
|
60
|
+
return undefined;
|
|
61
|
+
return { ...last, prs, reason: "all_terminal" };
|
|
62
|
+
}
|
|
63
|
+
if (!repo)
|
|
64
|
+
return undefined;
|
|
65
|
+
const slug = `${repo.owner}/${repo.name}`;
|
|
66
|
+
const prs = pulls.map((pull) => syntheticItem(slug, pull));
|
|
67
|
+
return {
|
|
68
|
+
mode: "summary",
|
|
69
|
+
repo: slug,
|
|
70
|
+
selection: { kind: "prs", requested: pulls.map((pull) => pull.pr) },
|
|
71
|
+
reason: "all_terminal",
|
|
72
|
+
prs,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
function applyProbe(item, pull) {
|
|
76
|
+
if (!pull)
|
|
77
|
+
return item;
|
|
78
|
+
return {
|
|
79
|
+
...item,
|
|
80
|
+
action: "cancel",
|
|
81
|
+
state: pull.state,
|
|
82
|
+
reasons: [pull.state === "MERGED" ? "merged" : "closed"],
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
function syntheticItem(repo, pull) {
|
|
86
|
+
return {
|
|
87
|
+
pr: pull.pr,
|
|
88
|
+
repo,
|
|
89
|
+
title: pull.title || `PR ${pull.pr}`,
|
|
90
|
+
url: pull.url || `https://github.com/${repo}/pull/${pull.pr}`,
|
|
91
|
+
action: "cancel",
|
|
92
|
+
reasons: [pull.state === "MERGED" ? "merged" : "closed"],
|
|
93
|
+
state: pull.state,
|
|
94
|
+
mergeable: pull.mergeable,
|
|
95
|
+
mergeStateStatus: pull.mergeStateStatus,
|
|
96
|
+
headRefName: pull.headRefName,
|
|
97
|
+
headRefOid: pull.headRefOid,
|
|
98
|
+
baseRefName: pull.baseRefName,
|
|
99
|
+
...(pull.draft ? { isDraft: true } : {}),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function exhaustedPrimaryLimitDelayMs(resetAtSeconds: number, nowMs: number): number;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Exhausted primary limit: time until resetAt, plus 5s, plus `resetAt % 5` seconds. */
|
|
2
|
+
const EXHAUSTED_LIMIT_MARGIN_MS = 5_000;
|
|
3
|
+
export function exhaustedPrimaryLimitDelayMs(resetAtSeconds, nowMs) {
|
|
4
|
+
return (Math.max(resetAtSeconds * 1000 - nowMs, 0) +
|
|
5
|
+
EXHAUSTED_LIMIT_MARGIN_MS +
|
|
6
|
+
(resetAtSeconds % 5) * 1000);
|
|
7
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { RateLimitProbePull, RateLimitPullTarget } from "./poll-rate-limit-cancel.mts";
|
|
2
|
+
interface WaitOptions<T> {
|
|
3
|
+
untilTerminal: boolean;
|
|
4
|
+
intervalMs: number;
|
|
5
|
+
tickLabel: string;
|
|
6
|
+
startedAt: number;
|
|
7
|
+
targets: RateLimitPullTarget[];
|
|
8
|
+
onAllTerminal?: (pulls: RateLimitProbePull[]) => T | undefined;
|
|
9
|
+
}
|
|
10
|
+
export declare function createUntilTerminalRateLimitRetry(): {
|
|
11
|
+
reset(): void;
|
|
12
|
+
wait<T>(err: unknown, opts: WaitOptions<T>): Promise<T | undefined>;
|
|
13
|
+
};
|
|
14
|
+
export {};
|