pr-shepherd 0.52.1 → 0.53.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 +15 -13
- package/bin/cli/iterate-instructions.mjs +2 -21
- package/bin/cli/poll-summary-formatter.mjs +1 -2
- package/bin/commands/iterate/api-usage.mjs +2 -2
- package/bin/commands/iterate/index.mjs +3 -12
- package/bin/commands/iterate/parent-first.d.mts +6 -23
- package/bin/commands/iterate/parent-first.mjs +7 -64
- package/bin/commands/poll-quota.mjs +2 -2
- package/bin/commands/poll-summary-instructions.mjs +26 -41
- package/bin/commands/poll.mjs +1 -3
- package/bin/commands/stack-drain.d.mts +5 -5
- package/bin/commands/stack-drain.mjs +39 -30
- package/bin/commands/stack-layer-readiness.d.mts +2 -3
- package/bin/commands/stack-layer-readiness.mjs +4 -5
- package/bin/commands/stack-stall.mjs +0 -1
- package/bin/commands/stack-work.d.mts +4 -4
- package/bin/commands/stack-work.mjs +5 -6
- package/bin/github/api-telemetry-aggregate.d.mts +1 -0
- package/bin/github/api-telemetry-aggregate.mjs +8 -2
- package/bin/github/api-telemetry.d.mts +4 -0
- package/bin/github/api-telemetry.mjs +12 -0
- package/bin/github/graphql-http.mjs +6 -0
- package/bin/github/http-auth.d.mts +3 -0
- package/bin/github/http-auth.mjs +6 -0
- package/bin/github/http-intermediate.d.mts +1 -0
- package/bin/github/http-intermediate.mjs +3 -0
- package/bin/state/graphql-quota-policy.d.mts +7 -1
- package/bin/state/graphql-quota-policy.mjs +41 -14
- package/bin/state/graphql-quota-warnings.mjs +2 -0
- package/bin/types/api-usage.d.mts +2 -0
- package/bin/types/merge-requirements.d.mts +3 -13
- package/bin/types/poll-summary.d.mts +0 -2
- 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/README.md
CHANGED
|
@@ -147,22 +147,24 @@ needed, every selected PR is complete, the bounded timeout expires, or `--until-
|
|
|
147
147
|
configured GraphQL quota-warning band. Explicit PR sets give each actionable row an exact single-PR
|
|
148
148
|
`pollCommand`, so independent rows can proceed before the next aggregate poll.
|
|
149
149
|
|
|
150
|
-
Native-stack rows are ordered bottom-to-top. `--stack` never performs a mutation itself
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
queued stack, or one whose remaining layers can only wait, returns `WAIT`; an idle `WAIT` that stays unchanged past the stall timeout returns `ESCALATE` with `stall-timeout`. A terminal READY or fully merged stack returns `CANCEL`. Closed or unverified
|
|
150
|
+
Native-stack rows are ordered bottom-to-top. `--stack` never performs a mutation itself. Every
|
|
151
|
+
layer that still has work gets its own one-PR session on the same tick, including a clean draft
|
|
152
|
+
whose session marks it ready. Layers do not wait for a lower layer's READY receipt, so their
|
|
153
|
+
ready-delays overlap. With automatic mark-ready disabled, the instructions ask the agent to mark
|
|
154
|
+
a clean draft ready after its probe. A queued stack, or one whose remaining layers can only wait,
|
|
155
|
+
returns `WAIT`; an idle `WAIT` that stays unchanged past the stall timeout returns `ESCALATE` with
|
|
156
|
+
`stall-timeout`. A terminal READY or fully merged stack returns `CANCEL`. Closed or unverified
|
|
158
157
|
topology returns `ESCALATE` for human direction after any other shepherdable PRs are handled;
|
|
159
158
|
until then, `SHEPHERD` remains the immediate action and lists the human blockers too.
|
|
160
159
|
|
|
161
|
-
With `--stack --merge`,
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
160
|
+
With `--stack --merge`, the highest open layer whose open lower layers all have current READY
|
|
161
|
+
receipts, and whose bottom open layer GitHub has retargeted onto the stack base, returns `MERGE`
|
|
162
|
+
with `gh stack merge <that PR number> --yes --squash`. That lands the named layer and every
|
|
163
|
+
unmerged layer below it. When the base uses a merge queue, the same command queues the prefix
|
|
164
|
+
together and GitHub evaluates each layer from the bottom; a failure ejects that layer and those
|
|
165
|
+
above it. Layers above the prefix keep their one-PR sessions. After the merge, GitHub retargets
|
|
166
|
+
the next layer, so the rerun continues until the stack returns `CANCEL`. API and MCP aggregate
|
|
167
|
+
calls perform one summary tick and leave recurrence to the caller.
|
|
166
168
|
|
|
167
169
|
Polling defaults can be set under `poll` in `.pr-shepherdrc.yml`: `intervalSeconds`, `timeoutSeconds`, `debounceSeconds`, and `quietStatus`. Explicit flags override configuration, including `--no-quiet-status` when a shared config enables quiet output. Quiet status remains off by default.
|
|
168
170
|
|
|
@@ -4,18 +4,6 @@ import { buildQuotaAwareContinuation } from "../quota-warning.mjs";
|
|
|
4
4
|
import { formatPrUrl } from "../pr-reference.mjs";
|
|
5
5
|
import { AUTO_MARK_READY_DISABLED_HOLD } from "../commands/stack-work.mjs";
|
|
6
6
|
import { buildPrShepherdCommand } from "./runner.mjs";
|
|
7
|
-
const STACK_LAYER_BLOCK_REASONS = {
|
|
8
|
-
closed: "was closed without merging",
|
|
9
|
-
draft: "is still a draft",
|
|
10
|
-
conflicting: "has merge conflicts",
|
|
11
|
-
"queue-removal": "has an unacknowledged merge-queue removal",
|
|
12
|
-
"failing-checks": "has failing checks",
|
|
13
|
-
"review-work": "has unresolved review work",
|
|
14
|
-
"checks-in-progress": "has checks in progress",
|
|
15
|
-
"merge-state": "is not in a mergeable state",
|
|
16
|
-
"no-ready-receipt": "has no current Shepherd READY receipt",
|
|
17
|
-
"stale-ancestry": "is not rebased onto its parent layer's current head",
|
|
18
|
-
};
|
|
19
7
|
export function buildSimpleIterateInstructions(result) {
|
|
20
8
|
switch (result.action) {
|
|
21
9
|
case "wait":
|
|
@@ -76,20 +64,13 @@ function buildStackDraftHoldInstruction(result, hold) {
|
|
|
76
64
|
formatPrUrl(result.repo, result.pr),
|
|
77
65
|
"--until-terminal",
|
|
78
66
|
]).text;
|
|
79
|
-
const lowerLayer = hold.kind === "lower-layer-not-ready" ? hold.lowerLayer : undefined;
|
|
80
67
|
const handoff = `a \`--stack\` selector listed this session, finish that selector's remaining steps and rerun it with its original flags; otherwise run ${inlineCode(stackCommand)}, adding \`--merge\` when merging was requested.`;
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
: `PR #${result.pr} stays in draft because ${holdReason(hold)}, so repeating this one-PR session cannot advance it. If ${handoff}`;
|
|
68
|
+
const reason = hold.kind === "auto-mark-ready-disabled" ? AUTO_MARK_READY_DISABLED_HOLD : hold.kind;
|
|
69
|
+
const instruction = `PR #${result.pr} stays in draft because ${reason}, so repeating this one-PR session cannot advance it. If ${handoff}`;
|
|
84
70
|
return result.quotaWarning
|
|
85
71
|
? buildQuotaAwareContinuation(result.quotaWarning, instruction)
|
|
86
72
|
: instruction;
|
|
87
73
|
}
|
|
88
|
-
function holdReason(hold) {
|
|
89
|
-
return hold.kind === "auto-mark-ready-disabled"
|
|
90
|
-
? AUTO_MARK_READY_DISABLED_HOLD
|
|
91
|
-
: "its lower stack layers could not be verified";
|
|
92
|
-
}
|
|
93
74
|
export function adaptIterateLog(log) {
|
|
94
75
|
return log.replace(/\s+—\s+\d+s until auto-cancel/g, "");
|
|
95
76
|
}
|
|
@@ -43,12 +43,11 @@ function formatItem(item) {
|
|
|
43
43
|
: "";
|
|
44
44
|
const readyDelay = item.remainingSeconds !== undefined ? ` · ready delay \`${item.remainingSeconds}s\`` : "";
|
|
45
45
|
const readyReceipt = item.readyReceipt ? " · Shepherd READY completion `verified`" : "";
|
|
46
|
-
const blockedBy = item.blockedByPr ? ` · stack blocked by PR #${item.blockedByPr}` : "";
|
|
47
46
|
const checks = item.checks;
|
|
48
47
|
const review = item.review;
|
|
49
48
|
return [
|
|
50
49
|
`- [PR #${item.pr}: ${escapeMarkdownText(item.title)}](${item.url}) [${item.action.toUpperCase()}]`,
|
|
51
|
-
` - state \`${item.state}\` · mergeable \`${item.mergeable}\` · merge \`${item.mergeStateStatus}\`${reviewDecision}${stateFlags}${blockingReviewer}${readyDelay}${readyReceipt}${
|
|
50
|
+
` - state \`${item.state}\` · mergeable \`${item.mergeable}\` · merge \`${item.mergeStateStatus}\`${reviewDecision}${stateFlags}${blockingReviewer}${readyDelay}${readyReceipt}${stack}`,
|
|
52
51
|
` - head \`${item.headRefName}\` at \`${item.headRefOid}\` · base \`${item.baseRefName}\``,
|
|
53
52
|
...(checks
|
|
54
53
|
? [` - checks: ${formatCounts(checks, checks.incomplete ? ", incomplete" : "")}`]
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { loadConfig } from "../../config/load.mjs";
|
|
2
|
-
import { summarizeApiTelemetry } from "../../github/api-telemetry.mjs";
|
|
2
|
+
import { summarizeApiTelemetry, withGraphqlCredentialFingerprint, } from "../../github/api-telemetry.mjs";
|
|
3
3
|
import { evaluateWorktreeGraphqlQuotaWarning } from "../../state/graphql-quota-warnings.mjs";
|
|
4
4
|
import { buildQuotaAwareContinuation } from "../../quota-warning.mjs";
|
|
5
5
|
function shouldWarn(result) {
|
|
@@ -17,7 +17,7 @@ export async function attachApiUsage(result, persistWarning, preservePersistedWa
|
|
|
17
17
|
...band,
|
|
18
18
|
pollIntervalMinutes: Math.max(band.pollIntervalMinutes, minimumPollIntervalMinutes),
|
|
19
19
|
}));
|
|
20
|
-
quotaWarning = await evaluateWorktreeGraphqlQuotaWarning({ owner, repo }, bands, apiUsage.graphql, persistWarning);
|
|
20
|
+
quotaWarning = await evaluateWorktreeGraphqlQuotaWarning({ owner, repo }, bands, withGraphqlCredentialFingerprint(apiUsage.graphql), persistWarning);
|
|
21
21
|
}
|
|
22
22
|
}
|
|
23
23
|
const { quotaWarning: _deferredWarning, ...baseResult } = result;
|
|
@@ -22,7 +22,7 @@ import { fingerprintRawSummaryPr } from "../../github/poll-summary-fingerprint.m
|
|
|
22
22
|
import { currentQueueRemovalEvent } from "../../github/poll-summary-queue-removal.mjs";
|
|
23
23
|
import { isCurrentSummaryReady } from "../../github/poll-summary-readiness.mjs";
|
|
24
24
|
import { clearReadyReceipt, isReadyReceiptCurrent, readReadyReceipt, writeReadyReceipt, } from "../../state/ready-receipts.mjs";
|
|
25
|
-
import {
|
|
25
|
+
import { stackDraftHold } from "./parent-first.mjs";
|
|
26
26
|
import { findStaleNativeStackAncestry } from "./stale-ancestry.mjs";
|
|
27
27
|
export function runIterate(opts) {
|
|
28
28
|
return withIterateApiUsage(opts, () => runIterateCore(opts));
|
|
@@ -175,11 +175,8 @@ async function runIterateCore(opts) {
|
|
|
175
175
|
const canMarkReady = report.status === "READY" &&
|
|
176
176
|
report.mergeStatus.isDraft &&
|
|
177
177
|
!report.mergeStatus.blockingBotReviewInProgress;
|
|
178
|
-
const parentBlock = canMarkReady
|
|
179
|
-
? await findParentMarkReadyBlock(report, { owner: repoOwner, name: repoName })
|
|
180
|
-
: undefined;
|
|
181
178
|
const autoMarkReady = !opts.noAutoMarkReady && config.actions.autoMarkReady;
|
|
182
|
-
const markReadyResult = await markReadyIfAuthorized(canMarkReady &&
|
|
179
|
+
const markReadyResult = await markReadyIfAuthorized(canMarkReady && autoMarkReady, base, report);
|
|
183
180
|
if (markReadyResult)
|
|
184
181
|
return markReadyResult;
|
|
185
182
|
if (readyState.shouldCancel && !report.mergeStatus.isDraft) {
|
|
@@ -209,19 +206,13 @@ async function runIterateCore(opts) {
|
|
|
209
206
|
log: `CANCEL: PR #${base.pr} ${cancelNote} — ready-delay elapsed, stopping`,
|
|
210
207
|
};
|
|
211
208
|
}
|
|
212
|
-
const hold = stackDraftHold(report, autoMarkReady
|
|
209
|
+
const hold = stackDraftHold(report, autoMarkReady);
|
|
213
210
|
const wait = {
|
|
214
211
|
...base,
|
|
215
212
|
action: "wait",
|
|
216
213
|
log: buildWaitLog(base),
|
|
217
214
|
...(hold && { stackDraftHold: hold }),
|
|
218
215
|
};
|
|
219
|
-
// The lower layer's own session owns progress here, so this draft cannot
|
|
220
|
-
// stall; its stall clock restarts once that layer releases it.
|
|
221
|
-
if (heldByLowerLayer(wait)) {
|
|
222
|
-
await clearStallState(stallKey);
|
|
223
|
-
return wait;
|
|
224
|
-
}
|
|
225
216
|
return applyStallGuard(stallKey, stallTimeoutSeconds, headSha, base, prNumber, wait, report, reviewSummaryIds);
|
|
226
217
|
}
|
|
227
218
|
async function recordReadyReceipt(key, report) {
|
|
@@ -1,25 +1,8 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import type { IterateResult, ShepherdReport, StackDraftHold, StackLowerLayerBlock } from "../../types.mts";
|
|
1
|
+
import type { ShepherdReport, StackDraftHold } from "../../types.mts";
|
|
3
2
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* A native stack draft this one-PR session cannot promote because it is ready
|
|
4
|
+
* and automatic mark-ready is off. A draft that is not ready yet keeps the
|
|
5
|
+
* ordinary wait. A clean draft is marked ready on its own, without waiting
|
|
6
|
+
* for lower layers.
|
|
6
7
|
*/
|
|
7
|
-
export
|
|
8
|
-
/**
|
|
9
|
-
* Draft children may only be converted after their immediate parent has
|
|
10
|
-
* independently completed a one-PR ready-delay and the stack boundary is
|
|
11
|
-
* still linear. A failed or incomplete stack read blocks this mutation but
|
|
12
|
-
* does not block ordinary review/CI work in the caller.
|
|
13
|
-
*/
|
|
14
|
-
export declare function findParentMarkReadyBlock(report: ShepherdReport, repo: RepoInfo): Promise<ParentMarkReadyBlock | undefined>;
|
|
15
|
-
/**
|
|
16
|
-
* A native stack draft this one-PR session cannot promote: a lower layer blocks it, or
|
|
17
|
-
* automatic mark-ready is off. Undefined when the session can still advance the PR by
|
|
18
|
-
* iterating.
|
|
19
|
-
*/
|
|
20
|
-
export declare function stackDraftHold(report: ShepherdReport, autoMarkReady: boolean, parentBlock: ParentMarkReadyBlock | undefined): StackDraftHold | undefined;
|
|
21
|
-
/**
|
|
22
|
-
* The lower layer a held draft waits on. That layer's own session owns this draft's
|
|
23
|
-
* progress, so the draft neither stalls nor keeps polling while it waits.
|
|
24
|
-
*/
|
|
25
|
-
export declare function heldByLowerLayer(result: IterateResult): StackLowerLayerBlock | undefined;
|
|
8
|
+
export declare function stackDraftHold(report: ShepherdReport, autoMarkReady: boolean): StackDraftHold | undefined;
|
|
@@ -1,70 +1,13 @@
|
|
|
1
|
-
import { fetchPollSummary } from "../../github/poll-summary.mjs";
|
|
2
|
-
import { stackLayerBlockReason } from "../stack-layer-readiness.mjs";
|
|
3
1
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
2
|
+
* A native stack draft this one-PR session cannot promote because it is ready
|
|
3
|
+
* and automatic mark-ready is off. A draft that is not ready yet keeps the
|
|
4
|
+
* ordinary wait. A clean draft is marked ready on its own, without waiting
|
|
5
|
+
* for lower layers.
|
|
8
6
|
*/
|
|
9
|
-
export
|
|
10
|
-
const stack = report.mergeStatus.mergeRequirements?.stack;
|
|
11
|
-
if (!stack || stack.position === 1)
|
|
12
|
-
return undefined;
|
|
13
|
-
if (stack.position < 1)
|
|
14
|
-
return "unverifiable";
|
|
15
|
-
try {
|
|
16
|
-
const summary = await fetchPollSummary({ stackPrNumber: report.pr }, repo);
|
|
17
|
-
const child = summary.prs.find((item) => item.pr === report.pr);
|
|
18
|
-
const lowerLayers = summary.prs
|
|
19
|
-
.filter((item) => (item.stack?.position ?? Number.MAX_SAFE_INTEGER) < stack.position)
|
|
20
|
-
.sort((left, right) => (left.stack?.position ?? Number.MAX_SAFE_INTEGER) -
|
|
21
|
-
(right.stack?.position ?? Number.MAX_SAFE_INTEGER));
|
|
22
|
-
if (!child || child.state !== "OPEN" || lowerLayers.length !== stack.position - 1)
|
|
23
|
-
return "unverifiable";
|
|
24
|
-
// A stale boundary means that layer is no longer based on the parent it was
|
|
25
|
-
// reviewed against. Gaps above this child do not affect its promotion boundary.
|
|
26
|
-
const staleChildren = new Set(summary.stackAncestry?.map((gap) => gap.childPr));
|
|
27
|
-
for (const layer of lowerLayers) {
|
|
28
|
-
// A merged layer is already satisfied; GitHub may have retargeted the
|
|
29
|
-
// layer above it to the trunk as part of the merge.
|
|
30
|
-
if (layer.state === "MERGED")
|
|
31
|
-
continue;
|
|
32
|
-
const reason = staleChildren.has(layer.pr) ? "stale-ancestry" : stackLayerBlockReason(layer);
|
|
33
|
-
if (reason)
|
|
34
|
-
return { pr: layer.pr, reason };
|
|
35
|
-
}
|
|
36
|
-
// This layer's own stale boundary belongs to its own session's repair; the
|
|
37
|
-
// earlier stale-ancestry read missed it, so this snapshot is unsettled.
|
|
38
|
-
return staleChildren.has(report.pr) ? "unverifiable" : undefined;
|
|
39
|
-
}
|
|
40
|
-
catch {
|
|
41
|
-
// Never convert a child draft based on an unverifiable parent.
|
|
42
|
-
return "unverifiable";
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
/**
|
|
46
|
-
* A native stack draft this one-PR session cannot promote: a lower layer blocks it, or
|
|
47
|
-
* automatic mark-ready is off. Undefined when the session can still advance the PR by
|
|
48
|
-
* iterating.
|
|
49
|
-
*/
|
|
50
|
-
export function stackDraftHold(report, autoMarkReady, parentBlock) {
|
|
7
|
+
export function stackDraftHold(report, autoMarkReady) {
|
|
51
8
|
if (!report.mergeStatus.mergeRequirements?.stack || !report.mergeStatus.isDraft)
|
|
52
9
|
return undefined;
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
// lower-layer read that failed must not hide behind the flag.
|
|
56
|
-
if (parentBlock === "unverifiable")
|
|
57
|
-
return { kind: "lower-layer-not-ready" };
|
|
58
|
-
if (parentBlock)
|
|
59
|
-
return { kind: "lower-layer-not-ready", lowerLayer: parentBlock };
|
|
10
|
+
if (report.status !== "READY" || report.mergeStatus.blockingBotReviewInProgress)
|
|
11
|
+
return undefined;
|
|
60
12
|
return autoMarkReady ? undefined : { kind: "auto-mark-ready-disabled" };
|
|
61
13
|
}
|
|
62
|
-
/**
|
|
63
|
-
* The lower layer a held draft waits on. That layer's own session owns this draft's
|
|
64
|
-
* progress, so the draft neither stalls nor keeps polling while it waits.
|
|
65
|
-
*/
|
|
66
|
-
export function heldByLowerLayer(result) {
|
|
67
|
-
return result.action === "wait" && result.stackDraftHold?.kind === "lower-layer-not-ready"
|
|
68
|
-
? result.stackDraftHold.lowerLayer
|
|
69
|
-
: undefined;
|
|
70
|
-
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { evaluateWorktreeGraphqlQuotaWarning } from "../state/graphql-quota-warnings.mjs";
|
|
2
|
-
import { summarizeApiTelemetry } from "../github/api-telemetry.mjs";
|
|
2
|
+
import { summarizeApiTelemetry, withGraphqlCredentialFingerprint, } from "../github/api-telemetry.mjs";
|
|
3
3
|
import { GitHubRequestError } from "../github/errors.mjs";
|
|
4
4
|
import { isRateLimitMessage } from "../comments/rate-limit.mjs";
|
|
5
5
|
const GRAPHQL_RETRY_AFTER_DEFAULT_MS = 60_000;
|
|
@@ -44,5 +44,5 @@ export async function aggregateQuotaWarning(result, bands, intervalSeconds) {
|
|
|
44
44
|
return evaluateWorktreeGraphqlQuotaWarning({ owner, repo }, bands.map((band) => ({
|
|
45
45
|
...band,
|
|
46
46
|
pollIntervalMinutes: Math.max(band.pollIntervalMinutes, intervalSeconds / 60),
|
|
47
|
-
})), usage, true);
|
|
47
|
+
})), withGraphqlCredentialFingerprint(usage), true);
|
|
48
48
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/* eslint-disable max-lines */
|
|
2
2
|
import { buildQuotaAwareContinuation } from "../quota-warning.mjs";
|
|
3
3
|
import { explicitInstructions } from "./poll-summary-explicit-instructions.mjs";
|
|
4
|
-
import { appendAutonomousInstructions, appendHumanHandoffInstructions, findHumanHandoffs, idleWaitPlan, isStackLayerReady,
|
|
4
|
+
import { appendAutonomousInstructions, appendHumanHandoffInstructions, findHumanHandoffs, idleWaitPlan, isStackLayerReady, planPrefixDrain, retargetWaitPlan, stackPosition, } from "./stack-drain.mjs";
|
|
5
5
|
import { appendMarkReadyInstructions, splitStackWork } from "./stack-work.mjs";
|
|
6
6
|
/** Keep aggregate JSON, Markdown, and MCP instructions on one projection. */
|
|
7
7
|
export function withPollSummaryInstructions(result, mergeRequested) {
|
|
@@ -14,59 +14,44 @@ export function planPollSummary(result, mergeRequested) {
|
|
|
14
14
|
}
|
|
15
15
|
const prs = [...result.prs].sort((left, right) => stackPosition(left) - stackPosition(right));
|
|
16
16
|
const staleChildren = new Set(result.stackAncestry?.map((gap) => gap.childPr) ?? []);
|
|
17
|
-
const firstUnready = prs.find((item) => item.state === "OPEN" && (!isStackLayerReady(item) || staleChildren.has(item.pr)));
|
|
18
|
-
const blocked = prs.map((item) => firstUnready && item.state === "OPEN" && stackPosition(item) > stackPosition(firstUnready)
|
|
19
|
-
? {
|
|
20
|
-
...item,
|
|
21
|
-
...(["cancel", "mark_ready", "merge"].includes(item.action) && {
|
|
22
|
-
action: "wait",
|
|
23
|
-
reasons: [...item.reasons, "lower-layer-not-ready"],
|
|
24
|
-
}),
|
|
25
|
-
blockedByPr: firstUnready.pr,
|
|
26
|
-
...(item.isDraft &&
|
|
27
|
-
item.pollCommand && {
|
|
28
|
-
pollCommand: item.pollCommand.replace(" --until-terminal", " --timeout 1s --debounce 0s") +
|
|
29
|
-
(item.pollCommand.includes("--no-auto-mark-ready") ? "" : " --no-auto-mark-ready"),
|
|
30
|
-
pollProbe: true,
|
|
31
|
-
}),
|
|
32
|
-
}
|
|
33
|
-
: item.state === "OPEN" &&
|
|
34
|
-
(!isStackLayerReady(item) || staleChildren.has(item.pr)) &&
|
|
35
|
-
["cancel", "merge"].includes(item.action)
|
|
36
|
-
? {
|
|
37
|
-
...item,
|
|
38
|
-
action: "fix_code",
|
|
39
|
-
reasons: [
|
|
40
|
-
...item.reasons,
|
|
41
|
-
staleChildren.has(item.pr) ? "stale-ancestry" : "ready-receipt-required",
|
|
42
|
-
],
|
|
43
|
-
}
|
|
44
|
-
: item);
|
|
45
17
|
const projected = {
|
|
46
18
|
...result,
|
|
47
|
-
prs:
|
|
48
|
-
|
|
49
|
-
|
|
19
|
+
prs: prs.map((item) => {
|
|
20
|
+
const needsOwnSession = item.state === "OPEN" &&
|
|
21
|
+
(!isStackLayerReady(item) || staleChildren.has(item.pr)) &&
|
|
22
|
+
["cancel", "merge"].includes(item.action);
|
|
23
|
+
const layer = needsOwnSession
|
|
24
|
+
? {
|
|
50
25
|
...item,
|
|
26
|
+
action: "fix_code",
|
|
27
|
+
reasons: [
|
|
28
|
+
...item.reasons,
|
|
29
|
+
staleChildren.has(item.pr) ? "stale-ancestry" : "ready-receipt-required",
|
|
30
|
+
],
|
|
31
|
+
}
|
|
32
|
+
: item;
|
|
33
|
+
if (layer.state === "OPEN" && layer.isInMergeQueue && layer.action === "cancel") {
|
|
34
|
+
return {
|
|
35
|
+
...layer,
|
|
51
36
|
action: "wait",
|
|
52
|
-
reasons: [...
|
|
37
|
+
reasons: [...layer.reasons, "already-in-merge-queue"],
|
|
53
38
|
};
|
|
54
39
|
}
|
|
55
|
-
if (
|
|
40
|
+
if (layer.state === "CLOSED" && closedDependency(prs, layer.pr)) {
|
|
56
41
|
return {
|
|
57
|
-
...
|
|
42
|
+
...layer,
|
|
58
43
|
action: "escalate",
|
|
59
|
-
reasons: [...
|
|
44
|
+
reasons: [...layer.reasons, "closed-unmerged-dependency"],
|
|
60
45
|
};
|
|
61
46
|
}
|
|
62
|
-
if (
|
|
47
|
+
if (layer.state !== "OPEN" && layer.state !== "MERGED") {
|
|
63
48
|
return {
|
|
64
|
-
...
|
|
49
|
+
...layer,
|
|
65
50
|
action: "escalate",
|
|
66
|
-
reasons: [...
|
|
51
|
+
reasons: [...layer.reasons, "unverified-stack-state"],
|
|
67
52
|
};
|
|
68
53
|
}
|
|
69
|
-
return
|
|
54
|
+
return layer;
|
|
70
55
|
}),
|
|
71
56
|
};
|
|
72
57
|
const planned = planStack(projected, mergeRequested);
|
|
@@ -100,7 +85,7 @@ function planStack(result, mergeRequested) {
|
|
|
100
85
|
const runnableCandidates = work.sessions.filter((item) => item.pollCommand);
|
|
101
86
|
const missingCommands = work.sessions.filter((item) => !item.pollCommand);
|
|
102
87
|
const agentWork = runnableCandidates.length > 0 || work.markReady.length > 0;
|
|
103
|
-
const drain =
|
|
88
|
+
const drain = planPrefixDrain(result, mergeRequested);
|
|
104
89
|
if (drain)
|
|
105
90
|
return drain;
|
|
106
91
|
const handoffs = findHumanHandoffs(result);
|
package/bin/commands/poll.mjs
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { runIterate } from "./iterate/index.mjs";
|
|
2
|
-
import { heldByLowerLayer } from "./iterate/parent-first.mjs";
|
|
3
2
|
import { sleep } from "../util/sleep.mjs";
|
|
4
3
|
import { withPollApiUsage } from "./poll-run.mjs";
|
|
5
4
|
import { loadConfig } from "../config/load.mjs";
|
|
@@ -98,8 +97,7 @@ async function runPollCore(opts) {
|
|
|
98
97
|
}
|
|
99
98
|
break;
|
|
100
99
|
}
|
|
101
|
-
|
|
102
|
-
if (lastResult.action === "wait" && !pastDebounce && !heldByLowerLayer(lastResult)) {
|
|
100
|
+
if (lastResult.action === "wait" && !pastDebounce) {
|
|
103
101
|
if (pendingQuotaWarning === undefined)
|
|
104
102
|
debounceUntil = null;
|
|
105
103
|
const elapsedMs = Date.now() - start;
|
|
@@ -8,17 +8,17 @@ export interface StackPlan {
|
|
|
8
8
|
instructions: string[];
|
|
9
9
|
}
|
|
10
10
|
/**
|
|
11
|
-
* Merge the ready
|
|
12
|
-
* number first, but stack numbers come from the repository's issue and
|
|
13
|
-
* request sequence, so a PR number never names a stack.
|
|
11
|
+
* Merge the ready prefix by its highest PR number. `gh stack merge <n>` tries a
|
|
12
|
+
* stack number first, but stack numbers come from the repository's issue and
|
|
13
|
+
* pull request sequence, so a PR number never names a stack.
|
|
14
14
|
*/
|
|
15
|
-
export declare function
|
|
15
|
+
export declare function planPrefixDrain(result: PollSummaryResult, mergeRequested: boolean): StackPlan | undefined;
|
|
16
16
|
/**
|
|
17
17
|
* A merge-requested, fully ready stack whose bottom open layer still targets
|
|
18
18
|
* the merged layer below it until GitHub retargets it onto the stack base.
|
|
19
19
|
*/
|
|
20
20
|
export declare function retargetWaitPlan(first: PollSummaryItem): StackPlan;
|
|
21
|
-
/** Every remaining layer only waits
|
|
21
|
+
/** Every remaining layer only waits on CI or merge state. */
|
|
22
22
|
export declare function idleWaitPlan(idle: PollSummaryItem[]): StackPlan;
|
|
23
23
|
export declare function describeIdleLayers(idle: PollSummaryItem[]): string;
|
|
24
24
|
export declare function appendAutonomousInstructions(instructions: string[], candidates: PollSummaryItem[]): void;
|
|
@@ -1,51 +1,63 @@
|
|
|
1
1
|
import { stackLayerBlockReason } from "./stack-layer-readiness.mjs";
|
|
2
2
|
import { appendMarkReadyInstructions, splitStackWork } from "./stack-work.mjs";
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Highest open layer such that it and every open layer below it are ready to
|
|
5
|
+
* merge together. `gh stack merge <PR>` lands that PR and every unmerged layer
|
|
6
|
+
* below it. A merge queue accepts the same prefix and evaluates each layer
|
|
7
|
+
* from the bottom; a failure ejects that layer and those above it.
|
|
7
8
|
*/
|
|
8
|
-
function
|
|
9
|
+
function readyPrefixTop(result) {
|
|
9
10
|
const layers = [...result.prs].sort((left, right) => stackPosition(left) - stackPosition(right));
|
|
10
|
-
|
|
11
|
-
if (!bottom || !isStackLayer(bottom) || bottom.state !== "OPEN")
|
|
11
|
+
if (layers.some((item) => item.state === "OPEN" && item.isInMergeQueue))
|
|
12
12
|
return undefined;
|
|
13
|
-
|
|
13
|
+
const open = layers.filter((item) => item.state === "OPEN");
|
|
14
|
+
const bottom = open[0];
|
|
15
|
+
if (!bottom || !isStackLayer(bottom) || bottom.state !== "OPEN")
|
|
14
16
|
return undefined;
|
|
15
17
|
if (bottom.baseRefName !== bottom.stack.baseRefName)
|
|
16
18
|
return undefined;
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
const stale = new Set((result.stackAncestry ?? []).map((gap) => gap.childPr));
|
|
20
|
+
let top;
|
|
21
|
+
for (const item of open) {
|
|
22
|
+
if (!isStackLayer(item))
|
|
23
|
+
break;
|
|
24
|
+
if (item.action === "escalate" || !isStackLayerReady(item) || stale.has(item.pr))
|
|
25
|
+
break;
|
|
26
|
+
if (layers.some((layer) => layer.state === "CLOSED" && stackPosition(layer) < stackPosition(item)))
|
|
27
|
+
break;
|
|
28
|
+
top = item;
|
|
29
|
+
}
|
|
30
|
+
return top;
|
|
22
31
|
}
|
|
23
32
|
/**
|
|
24
|
-
* Merge the ready
|
|
25
|
-
* number first, but stack numbers come from the repository's issue and
|
|
26
|
-
* request sequence, so a PR number never names a stack.
|
|
33
|
+
* Merge the ready prefix by its highest PR number. `gh stack merge <n>` tries a
|
|
34
|
+
* stack number first, but stack numbers come from the repository's issue and
|
|
35
|
+
* pull request sequence, so a PR number never names a stack.
|
|
27
36
|
*/
|
|
28
|
-
export function
|
|
37
|
+
export function planPrefixDrain(result, mergeRequested) {
|
|
29
38
|
if (!mergeRequested)
|
|
30
39
|
return undefined;
|
|
31
|
-
const
|
|
32
|
-
if (!
|
|
40
|
+
const top = readyPrefixTop(result);
|
|
41
|
+
if (!top)
|
|
33
42
|
return undefined;
|
|
34
43
|
const gaps = result.stackAncestry ?? [];
|
|
35
44
|
const staleChildren = new Set(gaps.map((gap) => gap.childPr));
|
|
36
|
-
const open = result.prs
|
|
37
|
-
|
|
45
|
+
const open = result.prs
|
|
46
|
+
.filter((item) => item.state === "OPEN")
|
|
47
|
+
.sort((left, right) => stackPosition(left) - stackPosition(right));
|
|
48
|
+
const above = splitStackWork(open.filter((item) => stackPosition(item) > stackPosition(top) &&
|
|
38
49
|
item.action !== "escalate" &&
|
|
39
50
|
(!isStackLayerReady(item) || staleChildren.has(item.pr))), staleChildren);
|
|
51
|
+
const span = open[0]?.pr === top.pr ? "that layer alone" : `PR #${top.pr} and every unmerged layer below it`;
|
|
40
52
|
const instructions = [
|
|
41
|
-
`1. PR #${
|
|
53
|
+
`1. PR #${top.pr} is the highest open layer of stack #${top.stack.number} in \`${result.repo}\` whose open lower layers are all ready. Run \`GH_REPO=${result.repo} gh stack merge ${top.pr} --yes --squash\` to merge ${span}. When the base uses a merge queue, the same command queues that prefix together and GitHub evaluates each layer from the bottom; a failure ejects that layer and the layers above it. If \`gh stack\` is an unknown command, run \`gh extension install github/gh-stack\` first.`,
|
|
42
54
|
];
|
|
43
|
-
appendAutonomousInstructions(instructions,
|
|
44
|
-
appendMarkReadyInstructions(instructions,
|
|
55
|
+
appendAutonomousInstructions(instructions, above.sessions);
|
|
56
|
+
appendMarkReadyInstructions(instructions, above.markReady);
|
|
45
57
|
const handoffs = findHumanHandoffs(result);
|
|
46
58
|
if (handoffs)
|
|
47
59
|
appendHumanHandoffInstructions(instructions, handoffs, false);
|
|
48
|
-
instructions.push(`${instructions.length + 1}. After the merge attempt, rerun this same \`--stack --merge\` selector; GitHub retargets the next layer onto \`${
|
|
60
|
+
instructions.push(`${instructions.length + 1}. After the merge attempt, rerun this same \`--stack --merge\` selector; GitHub retargets the next layer onto \`${top.stack.baseRefName}\`. Shepherd any layer that GitHub rejects or ejects.`);
|
|
49
61
|
return {
|
|
50
62
|
action: "merge",
|
|
51
63
|
stackMergeable: gaps.length === 0 && open.every(isStackLayerReady),
|
|
@@ -66,7 +78,7 @@ export function retargetWaitPlan(first) {
|
|
|
66
78
|
],
|
|
67
79
|
};
|
|
68
80
|
}
|
|
69
|
-
/** Every remaining layer only waits
|
|
81
|
+
/** Every remaining layer only waits on CI or merge state. */
|
|
70
82
|
export function idleWaitPlan(idle) {
|
|
71
83
|
return {
|
|
72
84
|
action: "wait",
|
|
@@ -79,9 +91,7 @@ export function idleWaitPlan(idle) {
|
|
|
79
91
|
};
|
|
80
92
|
}
|
|
81
93
|
export function describeIdleLayers(idle) {
|
|
82
|
-
return idle
|
|
83
|
-
.map((item) => `PR #${item.pr} (${item.blockedByPr ? `stack-blocked by PR #${item.blockedByPr}` : item.reasons.join(", ")})`)
|
|
84
|
-
.join("; ");
|
|
94
|
+
return idle.map((item) => `PR #${item.pr} (${item.reasons.join(", ")})`).join("; ");
|
|
85
95
|
}
|
|
86
96
|
export function appendAutonomousInstructions(instructions, candidates) {
|
|
87
97
|
if (candidates.length === 0)
|
|
@@ -89,10 +99,9 @@ export function appendAutonomousInstructions(instructions, candidates) {
|
|
|
89
99
|
instructions.push(`${instructions.length + 1}. Start or delegate the relevant one-PR sessions below; review and CI work on separate layers can proceed concurrently.`);
|
|
90
100
|
for (const item of candidates) {
|
|
91
101
|
instructions.push(item.pollCommand
|
|
92
|
-
? `${instructions.length + 1}. Run \`${item.pollCommand}\` for PR #${item.pr}${item.
|
|
102
|
+
? `${instructions.length + 1}. Run \`${item.pollCommand}\` for PR #${item.pr}${item.queueRemoval ? `; GitHub removed it from the merge queue (${item.queueRemoval.reason ?? "unknown reason"})` : ""}.`
|
|
93
103
|
: `${instructions.length + 1}. PR #${item.pr} needs a one-PR Shepherd session, but no command was available.`);
|
|
94
104
|
}
|
|
95
|
-
instructions.push(`${instructions.length + 1}. Keep upper draft PRs in draft until every lower layer has completed Shepherd READY.`);
|
|
96
105
|
}
|
|
97
106
|
export function findHumanHandoffs(result) {
|
|
98
107
|
const lastOpen = result.prs.filter((item) => item.state === "OPEN").at(-1);
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import type { PollSummaryItem, StackLayerBlockReason } from "../types.mts";
|
|
2
2
|
/**
|
|
3
|
-
* Why a native stack layer
|
|
4
|
-
*
|
|
5
|
-
* share this predicate so both always name the same blocking layer.
|
|
3
|
+
* Why a native stack layer is not ready to merge, or undefined when it is.
|
|
4
|
+
* A draft is marked ready by its own session; this predicate does not gate that.
|
|
6
5
|
*/
|
|
7
6
|
export declare function stackLayerBlockReason(item: PollSummaryItem): StackLayerBlockReason | undefined;
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Why a native stack layer
|
|
3
|
-
*
|
|
4
|
-
* share this predicate so both always name the same blocking layer.
|
|
2
|
+
* Why a native stack layer is not ready to merge, or undefined when it is.
|
|
3
|
+
* A draft is marked ready by its own session; this predicate does not gate that.
|
|
5
4
|
*/
|
|
6
5
|
export function stackLayerBlockReason(item) {
|
|
7
6
|
if (item.state !== "OPEN")
|
|
@@ -13,7 +12,7 @@ export function stackLayerBlockReason(item) {
|
|
|
13
12
|
// A receipt only establishes readiness after a merge-queue removal once
|
|
14
13
|
// the one-PR session has observed and acknowledged that exact removal.
|
|
15
14
|
// The aggregate projection preserves an unacknowledged removal here, so
|
|
16
|
-
//
|
|
15
|
+
// the layer is not ready to merge.
|
|
17
16
|
if (item.queueRemoval)
|
|
18
17
|
return "queue-removal";
|
|
19
18
|
if ((item.checks?.failing ?? 0) > 0)
|
|
@@ -30,6 +29,6 @@ export function stackLayerBlockReason(item) {
|
|
|
30
29
|
return "merge-state";
|
|
31
30
|
}
|
|
32
31
|
// A layer that looks ready but has not completed its own one-PR receipt
|
|
33
|
-
// is not
|
|
32
|
+
// is not ready to merge.
|
|
34
33
|
return item.readyReceipt === true ? undefined : "no-ready-receipt";
|
|
35
34
|
}
|
|
@@ -19,14 +19,14 @@ export interface StackWork {
|
|
|
19
19
|
}
|
|
20
20
|
/**
|
|
21
21
|
* Split autonomous stack candidates by what the agent can do for each. A bounded probe cannot
|
|
22
|
-
* mark a draft ready, so rerunning it for a waiting layer cannot change the stack. A
|
|
23
|
-
*
|
|
24
|
-
* child keeps its session, which returns the ancestry repair.
|
|
22
|
+
* mark a draft ready, so rerunning it for a waiting layer cannot change the stack. A probed wait
|
|
23
|
+
* kept in draft only by the disabled mark-ready setting is the agent's ready-for-review step.
|
|
24
|
+
* A stale-ancestry child keeps its session, which returns the ancestry repair.
|
|
25
25
|
*/
|
|
26
26
|
export declare function splitStackWork(candidates: PollSummaryItem[], staleChildren: ReadonlySet<number>): StackWork;
|
|
27
27
|
/**
|
|
28
28
|
* The poll loop leaves a disabled draft's ready transition to the agent. The bounded probe runs
|
|
29
|
-
* first because only the one-PR session reads the full review context
|
|
29
|
+
* first because only the one-PR session reads the full review context.
|
|
30
30
|
*/
|
|
31
31
|
export declare function appendMarkReadyInstructions(instructions: string[], layers: ProbedLayer[]): void;
|
|
32
32
|
export {};
|
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
export const AUTO_MARK_READY_DISABLED_HOLD = "automatic mark-ready is disabled for this session";
|
|
6
6
|
/**
|
|
7
7
|
* Split autonomous stack candidates by what the agent can do for each. A bounded probe cannot
|
|
8
|
-
* mark a draft ready, so rerunning it for a waiting layer cannot change the stack. A
|
|
9
|
-
*
|
|
10
|
-
* child keeps its session, which returns the ancestry repair.
|
|
8
|
+
* mark a draft ready, so rerunning it for a waiting layer cannot change the stack. A probed wait
|
|
9
|
+
* kept in draft only by the disabled mark-ready setting is the agent's ready-for-review step.
|
|
10
|
+
* A stale-ancestry child keeps its session, which returns the ancestry repair.
|
|
11
11
|
*/
|
|
12
12
|
export function splitStackWork(candidates, staleChildren) {
|
|
13
13
|
const work = { sessions: [], markReady: [], idle: [] };
|
|
@@ -15,8 +15,7 @@ export function splitStackWork(candidates, staleChildren) {
|
|
|
15
15
|
if (!isProbed(item) || item.action !== "wait" || staleChildren.has(item.pr)) {
|
|
16
16
|
work.sessions.push(item);
|
|
17
17
|
}
|
|
18
|
-
else if (item.
|
|
19
|
-
item.reasons.includes("draft-auto-mark-ready-disabled")) {
|
|
18
|
+
else if (item.reasons.includes("draft-auto-mark-ready-disabled")) {
|
|
20
19
|
work.markReady.push(item);
|
|
21
20
|
}
|
|
22
21
|
else {
|
|
@@ -27,7 +26,7 @@ export function splitStackWork(candidates, staleChildren) {
|
|
|
27
26
|
}
|
|
28
27
|
/**
|
|
29
28
|
* The poll loop leaves a disabled draft's ready transition to the agent. The bounded probe runs
|
|
30
|
-
* first because only the one-PR session reads the full review context
|
|
29
|
+
* first because only the one-PR session reads the full review context.
|
|
31
30
|
*/
|
|
32
31
|
export function appendMarkReadyInstructions(instructions, layers) {
|
|
33
32
|
for (const item of layers) {
|
|
@@ -21,7 +21,7 @@ export function aggregateEvents(events) {
|
|
|
21
21
|
aggregate.graphql.measuredQueryCost += event.rateLimit.cost;
|
|
22
22
|
aggregate.graphql.nodeCount += event.rateLimit?.nodeCount ?? 0;
|
|
23
23
|
if (event.rateLimit !== undefined) {
|
|
24
|
-
aggregate.graphql
|
|
24
|
+
adoptGraphqlRateLimit(aggregate.graphql, event.rateLimit, event.credentialFingerprint);
|
|
25
25
|
}
|
|
26
26
|
continue;
|
|
27
27
|
}
|
|
@@ -48,7 +48,7 @@ export function mergeAggregate(target, source) {
|
|
|
48
48
|
target.graphql.unmeasuredRequestCount += source.graphql.unmeasuredRequestCount;
|
|
49
49
|
target.graphql.nodeCount += source.graphql.nodeCount;
|
|
50
50
|
if (source.graphql.rateLimit !== undefined) {
|
|
51
|
-
target.graphql
|
|
51
|
+
adoptGraphqlRateLimit(target.graphql, source.graphql.rateLimit, source.graphql.credentialFingerprint);
|
|
52
52
|
}
|
|
53
53
|
for (const [resource, sourceGroup] of source.rest) {
|
|
54
54
|
const targetGroup = target.rest.get(resource) ?? { requestCount: 0 };
|
|
@@ -65,6 +65,12 @@ export function aggregateStore(active) {
|
|
|
65
65
|
mergeAggregate(all, aggregateEvents(active.events));
|
|
66
66
|
return all;
|
|
67
67
|
}
|
|
68
|
+
function adoptGraphqlRateLimit(graphql, candidate, fingerprint) {
|
|
69
|
+
const next = selectAuthoritativeRateLimit(graphql.rateLimit, candidate);
|
|
70
|
+
if (next !== graphql.rateLimit)
|
|
71
|
+
graphql.credentialFingerprint = fingerprint;
|
|
72
|
+
graphql.rateLimit = next;
|
|
73
|
+
}
|
|
68
74
|
function selectAuthoritativeRateLimit(current, candidate) {
|
|
69
75
|
if (current === undefined)
|
|
70
76
|
return { ...candidate };
|
|
@@ -4,10 +4,14 @@ export interface ApiTelemetryEvent {
|
|
|
4
4
|
kind: "GraphQL" | "REST";
|
|
5
5
|
method: string;
|
|
6
6
|
authSource: string;
|
|
7
|
+
/** Truncated SHA-256 of the credential that produced this request. */
|
|
8
|
+
credentialFingerprint?: string;
|
|
7
9
|
rateLimit?: RateLimitInfo;
|
|
8
10
|
}
|
|
9
11
|
/** Isolates a top-level CLI/MCP command while allowing nested iterate ticks to aggregate. */
|
|
10
12
|
export declare function withApiTelemetryScope<T>(fn: () => Promise<T>): Promise<T>;
|
|
11
13
|
export declare function recordApiTelemetry(event: ApiTelemetryEvent): void;
|
|
12
14
|
export declare function mergeGraphqlRateLimit(headerRateLimit: RateLimitInfo | null, data: unknown): RateLimitInfo | null;
|
|
15
|
+
/** Attach the credential fingerprint without adding it when this command has none. */
|
|
16
|
+
export declare function withGraphqlCredentialFingerprint<T extends object>(sample: T): T;
|
|
13
17
|
export declare function summarizeApiTelemetry(): ApiUsage | undefined;
|
|
@@ -65,6 +65,18 @@ export function mergeGraphqlRateLimit(headerRateLimit, data) {
|
|
|
65
65
|
...(payload !== null && Number.isFinite(payload.nodeCount) && { nodeCount: payload.nodeCount }),
|
|
66
66
|
};
|
|
67
67
|
}
|
|
68
|
+
/** Fingerprint of the credential that owns the authoritative GraphQL rate-limit sample. */
|
|
69
|
+
function graphqlQuotaCredentialFingerprint() {
|
|
70
|
+
const active = store();
|
|
71
|
+
if (active === undefined)
|
|
72
|
+
return undefined;
|
|
73
|
+
return aggregateStore(active).graphql.credentialFingerprint;
|
|
74
|
+
}
|
|
75
|
+
/** Attach the credential fingerprint without adding it when this command has none. */
|
|
76
|
+
export function withGraphqlCredentialFingerprint(sample) {
|
|
77
|
+
const credentialFingerprint = graphqlQuotaCredentialFingerprint();
|
|
78
|
+
return credentialFingerprint === undefined ? sample : { ...sample, credentialFingerprint };
|
|
79
|
+
}
|
|
68
80
|
export function summarizeApiTelemetry() {
|
|
69
81
|
const active = store();
|
|
70
82
|
if (active === undefined)
|
|
@@ -22,9 +22,11 @@ async function graphqlInner(query, vars, opts) {
|
|
|
22
22
|
}));
|
|
23
23
|
const t0 = performance.now();
|
|
24
24
|
let authSource = "unknown";
|
|
25
|
+
let credentialFingerprint;
|
|
25
26
|
const { res, attempt, retryT0 } = await requestWithTokenRetry(async () => {
|
|
26
27
|
const auth = await makeAuthHeaders();
|
|
27
28
|
authSource = auth.source;
|
|
29
|
+
credentialFingerprint = auth.fingerprint;
|
|
28
30
|
return fetch(url, {
|
|
29
31
|
method: "POST",
|
|
30
32
|
headers: auth.headers,
|
|
@@ -38,6 +40,7 @@ async function graphqlInner(query, vars, opts) {
|
|
|
38
40
|
response,
|
|
39
41
|
durationMs,
|
|
40
42
|
authSource,
|
|
43
|
+
credentialFingerprint,
|
|
41
44
|
}));
|
|
42
45
|
const durationMs = Math.round(performance.now() - retryT0);
|
|
43
46
|
const headerRateLimit = parseRateLimit(res.headers);
|
|
@@ -61,6 +64,7 @@ async function graphqlInner(query, vars, opts) {
|
|
|
61
64
|
kind: "GraphQL",
|
|
62
65
|
method: "POST",
|
|
63
66
|
authSource,
|
|
67
|
+
credentialFingerprint,
|
|
64
68
|
rateLimit: headerRateLimit ?? undefined,
|
|
65
69
|
});
|
|
66
70
|
throw new GitHubRequestError(`GitHub GraphQL request failed: ${res.status} ${sanitizeBody(body)}`, {
|
|
@@ -93,6 +97,7 @@ async function graphqlInner(query, vars, opts) {
|
|
|
93
97
|
kind: "GraphQL",
|
|
94
98
|
method: "POST",
|
|
95
99
|
authSource,
|
|
100
|
+
credentialFingerprint,
|
|
96
101
|
rateLimit: headerRateLimit ?? undefined,
|
|
97
102
|
});
|
|
98
103
|
throw new GitHubRequestError(`GitHub GraphQL response was not valid JSON${detail}`, {
|
|
@@ -123,6 +128,7 @@ async function graphqlInner(query, vars, opts) {
|
|
|
123
128
|
kind: "GraphQL",
|
|
124
129
|
method: "POST",
|
|
125
130
|
authSource,
|
|
131
|
+
credentialFingerprint,
|
|
126
132
|
rateLimit: rateLimit ?? undefined,
|
|
127
133
|
});
|
|
128
134
|
const payload = parseGraphQlPayload(parsed, res.status, rateLimit, retryAfterSeconds);
|
|
@@ -2,6 +2,8 @@ export type AuthSource = "GH_TOKEN" | "GITHUB_TOKEN" | "gh auth token" | "GITHUB
|
|
|
2
2
|
export declare function _resetTokenCache(): void;
|
|
3
3
|
export declare function hasCachedToken(): boolean;
|
|
4
4
|
export declare function clearTokenCache(): void;
|
|
5
|
+
/** Truncated SHA-256. Callers persist this instead of the token or its full hash. */
|
|
6
|
+
export declare function credentialFingerprint(token: string): string;
|
|
5
7
|
/**
|
|
6
8
|
* `extra` lets callers layer additional headers (e.g. `If-None-Match` for
|
|
7
9
|
* conditional REST requests) on top of the standard auth/version headers.
|
|
@@ -9,4 +11,5 @@ export declare function clearTokenCache(): void;
|
|
|
9
11
|
export declare function makeAuthHeaders(extra?: Record<string, string>): Promise<{
|
|
10
12
|
headers: Record<string, string>;
|
|
11
13
|
source: AuthSource;
|
|
14
|
+
fingerprint: string;
|
|
12
15
|
}>;
|
package/bin/github/http-auth.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import { execFile as execFileCb } from "node:child_process";
|
|
2
3
|
import { promisify } from "node:util";
|
|
3
4
|
import { EXIT, ShepherdError } from "../exit-codes.mjs";
|
|
@@ -15,6 +16,10 @@ export function clearTokenCache() {
|
|
|
15
16
|
_token = undefined;
|
|
16
17
|
_tokenSource = undefined;
|
|
17
18
|
}
|
|
19
|
+
/** Truncated SHA-256. Callers persist this instead of the token or its full hash. */
|
|
20
|
+
export function credentialFingerprint(token) {
|
|
21
|
+
return createHash("sha256").update(token).digest("hex").slice(0, 16);
|
|
22
|
+
}
|
|
18
23
|
async function resolveToken() {
|
|
19
24
|
if (_token && _tokenSource)
|
|
20
25
|
return { token: _token, source: _tokenSource };
|
|
@@ -58,6 +63,7 @@ export async function makeAuthHeaders(extra) {
|
|
|
58
63
|
const { token, source } = await resolveToken();
|
|
59
64
|
return {
|
|
60
65
|
source,
|
|
66
|
+
fingerprint: credentialFingerprint(token),
|
|
61
67
|
headers: {
|
|
62
68
|
Authorization: `Bearer ${token}`,
|
|
63
69
|
Accept: "application/vnd.github+json",
|
|
@@ -15,6 +15,9 @@ export function recordIntermediateResponse(opts) {
|
|
|
15
15
|
kind: opts.kind === "GraphQL" ? "GraphQL" : "REST",
|
|
16
16
|
method: opts.method,
|
|
17
17
|
authSource: opts.authSource,
|
|
18
|
+
...(opts.credentialFingerprint !== undefined && {
|
|
19
|
+
credentialFingerprint: opts.credentialFingerprint,
|
|
20
|
+
}),
|
|
18
21
|
rateLimit,
|
|
19
22
|
});
|
|
20
23
|
}
|
|
@@ -7,11 +7,17 @@ export interface GraphqlQuotaWarningState {
|
|
|
7
7
|
lastRemaining: number;
|
|
8
8
|
resetAt: number;
|
|
9
9
|
warnedThresholds: number[];
|
|
10
|
+
/** Truncated SHA-256 of the credential. Absent in state written before fingerprints. */
|
|
11
|
+
credentialFingerprint?: string;
|
|
10
12
|
rearmEpoch?: number;
|
|
11
13
|
}
|
|
12
|
-
|
|
14
|
+
type GraphqlQuotaSample = Pick<GraphqlApiUsage, "resource" | "limit" | "used" | "remaining" | "resetAt"> & {
|
|
15
|
+
credentialFingerprint?: string;
|
|
16
|
+
};
|
|
17
|
+
export declare function evaluateGraphqlQuotaWarning(bands: GraphqlQuotaWarningBand[], sample: GraphqlQuotaSample, previous: GraphqlQuotaWarningState | null, observedAt?: number): {
|
|
13
18
|
warning?: GraphqlQuotaWarning;
|
|
14
19
|
state: GraphqlQuotaWarningState & {
|
|
15
20
|
rearmEpoch: number;
|
|
16
21
|
};
|
|
17
22
|
};
|
|
23
|
+
export {};
|
|
@@ -4,25 +4,28 @@ export function evaluateGraphqlQuotaWarning(bands, sample, previous, observedAt
|
|
|
4
4
|
previous.resource !== sample.resource ||
|
|
5
5
|
previous.limit !== sample.limit ||
|
|
6
6
|
windowRolled ||
|
|
7
|
-
(
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
fingerprintChanged(previous, sample);
|
|
8
|
+
const effective = !rearm && previous !== null && usageRegressed(previous, sample)
|
|
9
|
+
? newerSavedSample(previous)
|
|
10
|
+
: sample;
|
|
11
|
+
const warned = new Set(rearm || previous === null ? [] : previous.warnedThresholds);
|
|
11
12
|
const rearmEpoch = rearm ? (previous?.rearmEpoch ?? 0) + 1 : (previous?.rearmEpoch ?? 0);
|
|
12
|
-
const crossed = bands.filter((band) =>
|
|
13
|
+
const crossed = bands.filter((band) => effective.remaining * 100 <= effective.limit * band.remainingPercent);
|
|
13
14
|
const newCrossed = crossed.filter((band) => !warned.has(band.remainingPercent));
|
|
14
15
|
for (const band of crossed)
|
|
15
16
|
warned.add(band.remainingPercent);
|
|
16
17
|
const active = newCrossed.at(-1);
|
|
18
|
+
const credentialFingerprint = effective.credentialFingerprint ?? previous?.credentialFingerprint;
|
|
17
19
|
const state = {
|
|
18
|
-
resource:
|
|
19
|
-
limit:
|
|
20
|
-
...(
|
|
21
|
-
lastRemaining:
|
|
22
|
-
resetAt:
|
|
20
|
+
resource: effective.resource,
|
|
21
|
+
limit: effective.limit,
|
|
22
|
+
...(effective.used !== undefined && { lastUsed: effective.used }),
|
|
23
|
+
lastRemaining: effective.remaining,
|
|
24
|
+
resetAt: effective.resetAt,
|
|
23
25
|
warnedThresholds: bands
|
|
24
26
|
.map((band) => band.remainingPercent)
|
|
25
27
|
.filter((threshold) => warned.has(threshold)),
|
|
28
|
+
...(credentialFingerprint !== undefined && { credentialFingerprint }),
|
|
26
29
|
rearmEpoch,
|
|
27
30
|
};
|
|
28
31
|
if (active === undefined)
|
|
@@ -31,13 +34,37 @@ export function evaluateGraphqlQuotaWarning(bands, sample, previous, observedAt
|
|
|
31
34
|
warning: {
|
|
32
35
|
resource: "graphql",
|
|
33
36
|
thresholdPercent: active.remainingPercent,
|
|
34
|
-
remaining:
|
|
35
|
-
limit:
|
|
36
|
-
...(
|
|
37
|
-
resetAt:
|
|
37
|
+
remaining: effective.remaining,
|
|
38
|
+
limit: effective.limit,
|
|
39
|
+
...(effective.used !== undefined && { used: effective.used }),
|
|
40
|
+
resetAt: effective.resetAt,
|
|
38
41
|
pollIntervalMinutes: active.pollIntervalMinutes,
|
|
39
42
|
pollTimeoutMinutes: active.pollIntervalMinutes * 2,
|
|
40
43
|
},
|
|
41
44
|
state,
|
|
42
45
|
};
|
|
43
46
|
}
|
|
47
|
+
function fingerprintChanged(previous, sample) {
|
|
48
|
+
return (previous.credentialFingerprint !== undefined &&
|
|
49
|
+
sample.credentialFingerprint !== undefined &&
|
|
50
|
+
previous.credentialFingerprint !== sample.credentialFingerprint);
|
|
51
|
+
}
|
|
52
|
+
/** An older observation of the same window: lower used, or higher remaining when used is absent. */
|
|
53
|
+
function usageRegressed(previous, sample) {
|
|
54
|
+
if (sample.used !== undefined && previous.lastUsed !== undefined) {
|
|
55
|
+
return sample.used < previous.lastUsed;
|
|
56
|
+
}
|
|
57
|
+
return sample.remaining > previous.lastRemaining;
|
|
58
|
+
}
|
|
59
|
+
function newerSavedSample(previous) {
|
|
60
|
+
return {
|
|
61
|
+
resource: previous.resource,
|
|
62
|
+
limit: previous.limit,
|
|
63
|
+
...(previous.lastUsed !== undefined && { used: previous.lastUsed }),
|
|
64
|
+
remaining: previous.lastRemaining,
|
|
65
|
+
resetAt: previous.resetAt,
|
|
66
|
+
...(previous.credentialFingerprint !== undefined && {
|
|
67
|
+
credentialFingerprint: previous.credentialFingerprint,
|
|
68
|
+
}),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
@@ -13,6 +13,8 @@ export interface GraphqlApiUsage extends ApiResourceUsage {
|
|
|
13
13
|
unmeasuredRequestCount: number;
|
|
14
14
|
/** Exact sum reported by rateLimit.nodeCount for measured GraphQL queries. */
|
|
15
15
|
nodeCount: number;
|
|
16
|
+
/** Truncated SHA-256 of the credential. Quota state only; summarized usage omits it. */
|
|
17
|
+
credentialFingerprint?: string;
|
|
16
18
|
}
|
|
17
19
|
export interface ApiUsage {
|
|
18
20
|
credentialSources: string[];
|
|
@@ -39,23 +39,13 @@ export interface StackStatus {
|
|
|
39
39
|
position: number;
|
|
40
40
|
baseRefName: string;
|
|
41
41
|
}
|
|
42
|
-
/** Why an open native stack layer
|
|
42
|
+
/** Why an open native stack layer is not ready to merge. */
|
|
43
43
|
export type StackLayerBlockReason = "closed" | "draft" | "conflicting" | "queue-removal" | "failing-checks" | "review-work" | "checks-in-progress" | "merge-state" | "no-ready-receipt" | "stale-ancestry";
|
|
44
|
-
/** The lowest native stack layer that keeps an upper draft from being marked ready. */
|
|
45
|
-
export interface StackLowerLayerBlock {
|
|
46
|
-
pr: number;
|
|
47
|
-
reason: StackLayerBlockReason;
|
|
48
|
-
}
|
|
49
44
|
/**
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
* `lowerLayer` is the layer to advance first; without one, the lower layers could not
|
|
53
|
-
* be verified.
|
|
45
|
+
* A native stack draft stays in draft on a WAIT tick because this session will not
|
|
46
|
+
* mark it ready. The caller returns to the stack selector.
|
|
54
47
|
*/
|
|
55
48
|
export type StackDraftHold = {
|
|
56
|
-
kind: "lower-layer-not-ready";
|
|
57
|
-
lowerLayer?: StackLowerLayerBlock;
|
|
58
|
-
} | {
|
|
59
49
|
kind: "auto-mark-ready-disabled";
|
|
60
50
|
};
|
|
61
51
|
/** Extra batch-PR fields for merge-queue, stacks, and folded branch rules. */
|
|
@@ -65,8 +65,6 @@ export interface PollSummaryItem {
|
|
|
65
65
|
pollProbe?: true;
|
|
66
66
|
/** A current one-PR READY-after-delay completion was verified. */
|
|
67
67
|
readyReceipt?: true;
|
|
68
|
-
/** Lowest unready ancestor that prevents this layer from being stack-mergeable. */
|
|
69
|
-
blockedByPr?: number;
|
|
70
68
|
}
|
|
71
69
|
export type PollSummarySelection = {
|
|
72
70
|
kind: "prs";
|
package/package.json
CHANGED