pr-shepherd 0.51.1 → 0.52.1
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 +29 -18
- package/bin/cli/help-command-pages.d.mts +1 -1
- package/bin/cli/help-iterate-poll-pages.d.mts +1 -1
- package/bin/cli/help-iterate-poll-pages.mjs +4 -3
- package/bin/cli/help-top-page.d.mts +1 -1
- package/bin/cli/help-top-page.mjs +3 -2
- package/bin/cli/help.d.mts +2 -2
- package/bin/cli/iterate-instructions.mjs +41 -0
- package/bin/cli/iterate-lean.mjs +1 -3
- package/bin/cli/poll-summary-emitter.mjs +2 -3
- package/bin/cli/poll-summary-formatter.mjs +12 -3
- package/bin/cli/runner.d.mts +1 -0
- package/bin/cli/runner.mjs +3 -1
- package/bin/commands/check.mjs +14 -4
- package/bin/commands/clean.mjs +7 -13
- package/bin/commands/iterate/check-instructions.d.mts +2 -2
- package/bin/commands/iterate/check-instructions.mjs +11 -7
- package/bin/commands/iterate/escalate.mjs +4 -13
- package/bin/commands/iterate/fix-code.d.mts +2 -0
- package/bin/commands/iterate/fix-code.mjs +20 -43
- package/bin/commands/iterate/helpers.d.mts +0 -1
- package/bin/commands/iterate/helpers.mjs +0 -15
- package/bin/commands/iterate/index.mjs +178 -11
- package/bin/commands/iterate/merge-state.mjs +35 -18
- package/bin/commands/iterate/native-stack-rebase.d.mts +34 -0
- package/bin/commands/iterate/native-stack-rebase.mjs +43 -0
- package/bin/commands/iterate/parent-first.d.mts +25 -0
- package/bin/commands/iterate/parent-first.mjs +70 -0
- package/bin/commands/iterate/render.d.mts +1 -1
- package/bin/commands/iterate/render.mjs +7 -12
- package/bin/commands/iterate/stale-ancestry.d.mts +22 -0
- package/bin/commands/iterate/stale-ancestry.mjs +40 -0
- package/bin/commands/iterate/stall.mjs +40 -3
- package/bin/commands/poll-progress.d.mts +5 -1
- package/bin/commands/poll-progress.mjs +7 -1
- package/bin/commands/poll-summary-instructions.d.mts +6 -1
- package/bin/commands/poll-summary-instructions.mjs +151 -107
- package/bin/commands/poll-summary.mjs +32 -6
- package/bin/commands/poll.mjs +3 -1
- package/bin/commands/ready-delay.d.mts +9 -4
- package/bin/commands/ready-delay.mjs +34 -20
- package/bin/commands/shepherd-journal.mjs +4 -1
- package/bin/commands/stack-drain.d.mts +35 -0
- package/bin/commands/stack-drain.mjs +129 -0
- package/bin/commands/stack-layer-readiness.d.mts +7 -0
- package/bin/commands/stack-layer-readiness.mjs +35 -0
- package/bin/commands/stack-stall.d.mts +14 -0
- package/bin/commands/stack-stall.mjs +69 -0
- package/bin/commands/stack-work.d.mts +32 -0
- package/bin/commands/stack-work.mjs +39 -0
- package/bin/config/load.d.mts +2 -0
- package/bin/config/load.mjs +10 -0
- package/bin/config.json +1 -0
- package/bin/exit-codes.d.mts +2 -0
- package/bin/exit-codes.mjs +2 -0
- package/bin/github/batch-parsers.mjs +1 -0
- package/bin/github/batch-raw-types.d.mts +2 -0
- package/bin/github/errors.d.mts +5 -0
- package/bin/github/errors.mjs +4 -0
- package/bin/github/gql/batch-pr.gql +1 -0
- package/bin/github/gql/poll-stack-summary.gql +8 -2
- package/bin/github/gql/poll-stack-topology.gql +38 -0
- package/bin/github/gql/poll-summary-check-contexts.gql +34 -0
- package/bin/github/gql/poll-summary-check-page.gql +23 -0
- package/bin/github/gql/poll-summary-fragment.gql +53 -56
- package/bin/github/merge-queue-checks.mjs +10 -1
- package/bin/github/poll-summary-check-hydration.d.mts +12 -0
- package/bin/github/poll-summary-check-hydration.mjs +55 -0
- package/bin/github/poll-summary-fingerprint.d.mts +8 -0
- package/bin/github/poll-summary-fingerprint.mjs +48 -0
- package/bin/github/poll-summary-projector.mjs +70 -16
- package/bin/github/poll-summary-queue-removal.d.mts +5 -0
- package/bin/github/poll-summary-queue-removal.mjs +25 -0
- package/bin/github/poll-summary-raw.d.mts +52 -31
- package/bin/github/poll-summary-readiness.d.mts +6 -0
- package/bin/github/poll-summary-readiness.mjs +25 -0
- package/bin/github/poll-summary-route.mjs +8 -5
- package/bin/github/poll-summary.d.mts +3 -0
- package/bin/github/poll-summary.mjs +21 -73
- package/bin/github/queries.d.mts +7 -0
- package/bin/github/queries.mjs +9 -1
- package/bin/github/queue-removal-freshness.d.mts +16 -0
- package/bin/github/queue-removal-freshness.mjs +26 -0
- package/bin/github/stack-read.d.mts +34 -0
- package/bin/github/stack-read.mjs +92 -0
- package/bin/log/log-file.d.mts +1 -1
- package/bin/log/log-file.mjs +4 -17
- package/bin/state/base.d.mts +18 -1
- package/bin/state/base.mjs +65 -13
- package/bin/state/fix-attempts.d.mts +1 -1
- package/bin/state/fix-attempts.mjs +1 -1
- package/bin/state/graphql-quota-warnings.mjs +2 -6
- package/bin/state/iterate-stall.d.mts +7 -15
- package/bin/state/iterate-stall.mjs +6 -64
- package/bin/state/ready-receipts.d.mts +45 -0
- package/bin/state/ready-receipts.mjs +86 -0
- package/bin/state/rest-cache.d.mts +1 -1
- package/bin/state/rest-cache.mjs +1 -1
- package/bin/state/stack-stall.d.mts +16 -0
- package/bin/state/stack-stall.mjs +12 -0
- package/bin/state/stall-state-store.d.mts +37 -0
- package/bin/state/stall-state-store.mjs +74 -0
- package/bin/types/escalate.d.mts +2 -3
- package/bin/types/github.d.mts +2 -0
- package/bin/types/iterate.d.mts +2 -1
- package/bin/types/merge-requirements.d.mts +19 -0
- package/bin/types/poll-summary.d.mts +17 -2
- package/bin/types/report.d.mts +2 -0
- package/package.json +2 -2
- package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
- package/plugins/pr-shepherd/.codex.mcp.json +1 -1
- package/plugins/pr-shepherd/.mcp.json +1 -1
- package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +3 -3
- package/bin/state/bot-cr-seen.d.mts +0 -51
- package/bin/state/bot-cr-seen.mjs +0 -100
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
/* eslint-disable max-lines */
|
|
1
2
|
import { loadConfig } from "../config/load.mjs";
|
|
2
3
|
import { ShepherdError } from "../exit-codes.mjs";
|
|
3
4
|
import { getRepoInfo } from "../github/client.mjs";
|
|
@@ -5,8 +6,9 @@ import { withApiTelemetryScope, summarizeApiTelemetry } from "../github/api-tele
|
|
|
5
6
|
import { fetchPollSummary } from "../github/poll-summary.mjs";
|
|
6
7
|
import { sleep } from "../util/sleep.mjs";
|
|
7
8
|
import { aggregateQuotaWarning, graphqlQuotaPollIntervalMs, pollGraphQlRetryAfterMs, } from "./poll-quota.mjs";
|
|
8
|
-
import { withPollSummaryInstructions } from "./poll-summary-instructions.mjs";
|
|
9
|
+
import { planPollSummary, withPollSummaryInstructions } from "./poll-summary-instructions.mjs";
|
|
9
10
|
import { summaryStatusSignature } from "./poll-summary-signature.mjs";
|
|
11
|
+
import { applyStackStallGuard } from "./stack-stall.mjs";
|
|
10
12
|
const MAX_TIMER_MS = 2 ** 31 - 1;
|
|
11
13
|
const TIMER_DRIFT_TOLERANCE_MS = 500;
|
|
12
14
|
export function runPollSummary(opts) {
|
|
@@ -20,7 +22,7 @@ async function runPollSummaryCore(opts) {
|
|
|
20
22
|
const fetched = await fetchPollSummary(opts, repo);
|
|
21
23
|
const allTerminal = fetched.prs.every((item) => item.action === "cancel");
|
|
22
24
|
const actionable = fetched.prs.some((item) => item.action !== "wait" && item.action !== "cancel");
|
|
23
|
-
|
|
25
|
+
const planned = planPollSummary({
|
|
24
26
|
mode: "summary",
|
|
25
27
|
repo: `${repo.owner}/${repo.name}`,
|
|
26
28
|
selection: fetched.selection,
|
|
@@ -28,6 +30,7 @@ async function runPollSummaryCore(opts) {
|
|
|
28
30
|
prs: fetched.prs,
|
|
29
31
|
...(fetched.stackAncestry?.length && { stackAncestry: fetched.stackAncestry }),
|
|
30
32
|
}, opts.merge === true);
|
|
33
|
+
return applyStackStallGuard(planned, repo, opts.stallTimeoutSeconds ?? loadConfig().iterate.stallTimeoutMinutes * 60);
|
|
31
34
|
}
|
|
32
35
|
async function runAggregatePollCore(opts) {
|
|
33
36
|
const intervalMs = Math.min(opts.intervalSeconds * 1000, MAX_TIMER_MS);
|
|
@@ -54,16 +57,31 @@ async function runAggregatePollCore(opts) {
|
|
|
54
57
|
stackPrNumber: undefined,
|
|
55
58
|
prNumbers: last.prs.map((item) => item.pr),
|
|
56
59
|
});
|
|
57
|
-
if (explicit.prs.every((item) => item.
|
|
60
|
+
if (explicit.prs.every((item) => item.state === "MERGED")) {
|
|
58
61
|
const warning = await aggregateQuotaWarning(explicit, quotaBands, opts.intervalSeconds);
|
|
59
62
|
if (warning)
|
|
60
63
|
pendingQuotaWarning = warning;
|
|
61
64
|
return attachUsage({
|
|
62
65
|
...explicit,
|
|
66
|
+
selection: last.selection,
|
|
63
67
|
reason: "all_terminal",
|
|
68
|
+
nextAction: "cancel",
|
|
69
|
+
stackMergeable: true,
|
|
70
|
+
instructions: ["1. Stop — every stack layer is merged."],
|
|
64
71
|
...(pendingQuotaWarning && { quotaWarning: pendingQuotaWarning }),
|
|
65
72
|
}, opts.merge);
|
|
66
73
|
}
|
|
74
|
+
return attachUsage({
|
|
75
|
+
...explicit,
|
|
76
|
+
selection: last.selection,
|
|
77
|
+
reason: "actionable",
|
|
78
|
+
nextAction: "escalate",
|
|
79
|
+
stackMergeable: false,
|
|
80
|
+
instructions: [
|
|
81
|
+
`1. Native stack #${last.selection.stackNumber} disappeared before every tracked layer merged. Stop and ask the stack owner to reconcile the remaining PRs; do not claim the stack is complete.`,
|
|
82
|
+
],
|
|
83
|
+
...(pendingQuotaWarning && { quotaWarning: pendingQuotaWarning }),
|
|
84
|
+
}, opts.merge);
|
|
67
85
|
}
|
|
68
86
|
const retryMs = opts.untilTerminal ? pollGraphQlRetryAfterMs(error) : null;
|
|
69
87
|
if (retryMs === null || rateLimitRetries >= 1)
|
|
@@ -77,10 +95,11 @@ async function runAggregatePollCore(opts) {
|
|
|
77
95
|
? last.nextAction === "cancel"
|
|
78
96
|
: last.prs.every((item) => item.action === "cancel");
|
|
79
97
|
const immediate = last.selection.kind === "stack"
|
|
80
|
-
?
|
|
98
|
+
? (last.nextAction === "escalate" || last.nextAction === "merge") &&
|
|
99
|
+
last.reason !== "waiting"
|
|
81
100
|
: last.prs.some((item) => ["escalate", "merge", "mark_ready"].includes(item.action));
|
|
82
101
|
const hasFix = last.selection.kind === "stack"
|
|
83
|
-
? last.nextAction === "
|
|
102
|
+
? last.nextAction === "shepherd" && last.reason !== "waiting"
|
|
84
103
|
: last.prs.some((item) => item.action === "fix_code");
|
|
85
104
|
const warning = await aggregateQuotaWarning(last, quotaBands, opts.intervalSeconds);
|
|
86
105
|
if (warning)
|
|
@@ -145,5 +164,12 @@ function isMissingStack(error) {
|
|
|
145
164
|
}
|
|
146
165
|
function attachUsage(result, mergeRequested) {
|
|
147
166
|
const apiUsage = summarizeApiTelemetry();
|
|
148
|
-
|
|
167
|
+
const enriched = apiUsage ? { ...result, apiUsage } : result;
|
|
168
|
+
// A disappeared native stack is an aggregate-level terminal handoff. Keep
|
|
169
|
+
// that explicit escalation intact even when the one-PR fallback contains
|
|
170
|
+
// open orphan rows that would otherwise be re-planned as fix_code.
|
|
171
|
+
if (enriched.selection.kind === "stack" && enriched.nextAction === "escalate") {
|
|
172
|
+
return enriched;
|
|
173
|
+
}
|
|
174
|
+
return withPollSummaryInstructions(enriched, mergeRequested === true);
|
|
149
175
|
}
|
package/bin/commands/poll.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { runIterate } from "./iterate/index.mjs";
|
|
2
|
+
import { heldByLowerLayer } from "./iterate/parent-first.mjs";
|
|
2
3
|
import { sleep } from "../util/sleep.mjs";
|
|
3
4
|
import { withPollApiUsage } from "./poll-run.mjs";
|
|
4
5
|
import { loadConfig } from "../config/load.mjs";
|
|
@@ -97,7 +98,8 @@ async function runPollCore(opts) {
|
|
|
97
98
|
}
|
|
98
99
|
break;
|
|
99
100
|
}
|
|
100
|
-
|
|
101
|
+
// A held draft waits on another layer's session, so hand it straight back.
|
|
102
|
+
if (lastResult.action === "wait" && !pastDebounce && !heldByLowerLayer(lastResult)) {
|
|
101
103
|
if (pendingQuotaWarning === undefined)
|
|
102
104
|
debounceUntil = null;
|
|
103
105
|
const elapsedMs = Date.now() - start;
|
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
* Ready-delay state machine for the shepherd iterate loop.
|
|
3
3
|
*
|
|
4
4
|
* When all READY conditions hold, shepherd writes a `ready-since.txt` marker
|
|
5
|
-
* to the state dir. The loop continues until the PR has
|
|
6
|
-
* `readyDelaySeconds` consecutively. Any not-READY
|
|
5
|
+
* bound to the PR head to the state dir. The loop continues until the PR has
|
|
6
|
+
* been READY on that head for `readyDelaySeconds` consecutively. Any not-READY
|
|
7
|
+
* result, or a different head, resets the timer. An elapsed marker stays until
|
|
8
|
+
* the caller consumes it with `clearReadyDelay`.
|
|
7
9
|
*/
|
|
8
10
|
interface ReadyDelayState {
|
|
9
11
|
isReady: boolean;
|
|
@@ -25,7 +27,10 @@ interface ReadyDelayState {
|
|
|
25
27
|
* When `shouldCancel == true`, the formatter tells loop-capable agents to cancel
|
|
26
28
|
* the loop and tells one-shot agents to stop.
|
|
27
29
|
*/
|
|
28
|
-
export declare function updateReadyDelay(prNumber: number, isReady: boolean, readyDelaySeconds: number, owner: string, repo: string, options
|
|
29
|
-
|
|
30
|
+
export declare function updateReadyDelay(prNumber: number, isReady: boolean, readyDelaySeconds: number, owner: string, repo: string, options: {
|
|
31
|
+
headSha: string;
|
|
32
|
+
alreadyElapsed?: boolean;
|
|
30
33
|
}): Promise<ReadyDelayState>;
|
|
34
|
+
/** Delete the ready-delay marker once an elapsed delay has been consumed. */
|
|
35
|
+
export declare function clearReadyDelay(prNumber: number, owner: string, repo: string): Promise<void>;
|
|
31
36
|
export {};
|
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
* Ready-delay state machine for the shepherd iterate loop.
|
|
3
3
|
*
|
|
4
4
|
* When all READY conditions hold, shepherd writes a `ready-since.txt` marker
|
|
5
|
-
* to the state dir. The loop continues until the PR has
|
|
6
|
-
* `readyDelaySeconds` consecutively. Any not-READY
|
|
5
|
+
* bound to the PR head to the state dir. The loop continues until the PR has
|
|
6
|
+
* been READY on that head for `readyDelaySeconds` consecutively. Any not-READY
|
|
7
|
+
* result, or a different head, resets the timer. An elapsed marker stays until
|
|
8
|
+
* the caller consumes it with `clearReadyDelay`.
|
|
7
9
|
*/
|
|
8
10
|
import { readFile, writeFile, mkdir, unlink } from "node:fs/promises";
|
|
9
11
|
import { dirname } from "node:path";
|
|
@@ -18,47 +20,59 @@ import { resolvePrStatePath } from "../state/base.mjs";
|
|
|
18
20
|
* When `shouldCancel == true`, the formatter tells loop-capable agents to cancel
|
|
19
21
|
* the loop and tells one-shot agents to stop.
|
|
20
22
|
*/
|
|
21
|
-
export async function updateReadyDelay(prNumber, isReady, readyDelaySeconds, owner, repo, options
|
|
23
|
+
export async function updateReadyDelay(prNumber, isReady, readyDelaySeconds, owner, repo, options) {
|
|
22
24
|
const markerPath = readySincePath(prNumber, owner, repo);
|
|
23
25
|
if (!isReady) {
|
|
24
26
|
// Reset the timer.
|
|
25
27
|
await safeUnlink(markerPath);
|
|
26
28
|
return { isReady: false, shouldCancel: false, remainingSeconds: readyDelaySeconds };
|
|
27
29
|
}
|
|
30
|
+
// Durable evidence (a current READY receipt) already proves the delay
|
|
31
|
+
// elapsed for this exact state, so a re-poll must not start a fresh timer.
|
|
32
|
+
if (options.alreadyElapsed) {
|
|
33
|
+
return { isReady: true, shouldCancel: true, remainingSeconds: 0 };
|
|
34
|
+
}
|
|
28
35
|
// PR is READY — check or create the marker.
|
|
29
36
|
const now = Math.floor(Date.now() / 1000);
|
|
30
|
-
let readySince;
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
// negative elapsed value and an inflated remainingSeconds.
|
|
37
|
-
if (!Number.isFinite(readySince) || readySince > now) {
|
|
38
|
-
readySince = now;
|
|
39
|
-
await safeWriteFile(markerPath, String(now));
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
catch {
|
|
43
|
-
// Marker doesn't exist yet — create it.
|
|
37
|
+
let readySince = await readReadySince(markerPath, options.headSha);
|
|
38
|
+
// Reset if the marker is missing, names another head, is not finite, or is
|
|
39
|
+
// in the future (clock skew, corrupted file, or manual edit). A future
|
|
40
|
+
// timestamp would produce a negative elapsed value and an inflated
|
|
41
|
+
// remainingSeconds.
|
|
42
|
+
if (readySince === null || readySince > now) {
|
|
44
43
|
readySince = now;
|
|
45
|
-
await safeWriteFile(markerPath,
|
|
44
|
+
await safeWriteFile(markerPath, `${now} ${options.headSha}`);
|
|
46
45
|
}
|
|
47
46
|
const elapsed = now - readySince;
|
|
48
47
|
const remaining = readyDelaySeconds - elapsed;
|
|
49
48
|
if (remaining <= 0) {
|
|
50
|
-
if (!options.retainElapsed)
|
|
51
|
-
await safeUnlink(markerPath);
|
|
52
49
|
return { isReady: true, shouldCancel: true, remainingSeconds: 0 };
|
|
53
50
|
}
|
|
54
51
|
return { isReady: true, shouldCancel: false, remainingSeconds: remaining };
|
|
55
52
|
}
|
|
53
|
+
/** Delete the ready-delay marker once an elapsed delay has been consumed. */
|
|
54
|
+
export async function clearReadyDelay(prNumber, owner, repo) {
|
|
55
|
+
await safeUnlink(readySincePath(prNumber, owner, repo));
|
|
56
|
+
}
|
|
56
57
|
// ---------------------------------------------------------------------------
|
|
57
58
|
// Helpers
|
|
58
59
|
// ---------------------------------------------------------------------------
|
|
59
60
|
function readySincePath(pr, owner, repo) {
|
|
60
61
|
return resolvePrStatePath({ owner, repo, pr }, "ready-since.txt");
|
|
61
62
|
}
|
|
63
|
+
/** The marker's start time, or null when it is missing, malformed, or bound to another head. */
|
|
64
|
+
async function readReadySince(path, headSha) {
|
|
65
|
+
let raw;
|
|
66
|
+
try {
|
|
67
|
+
raw = await readFile(path, "utf8");
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
const [since, head] = raw.trim().split(" ");
|
|
73
|
+
const readySince = Number(since);
|
|
74
|
+
return head === headSha && Number.isSafeInteger(readySince) ? readySince : null;
|
|
75
|
+
}
|
|
62
76
|
async function safeUnlink(path) {
|
|
63
77
|
try {
|
|
64
78
|
await unlink(path);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { buildPrShepherdCommand } from "../cli/runner.mjs";
|
|
1
2
|
export const SHEPHERD_JOURNAL_SECTION = "Shepherd Journal";
|
|
2
3
|
export const SHEPHERD_JOURNAL_SECTION_PATTERN = /^##\s+Shepherd\s+Journal$/;
|
|
3
4
|
export const SHEPHERD_JOURNAL_DETAILS_OPEN = "<details>";
|
|
@@ -12,5 +13,7 @@ export const SHEPHERD_JOURNAL_FIRST_LOOK_GUIDANCE = "Review each body under `##
|
|
|
12
13
|
* of being re-emitted every tick (see CLAUDE.md "Keep skills and loop prompts minimal").
|
|
13
14
|
*/
|
|
14
15
|
export function buildShepherdJournalInstruction(prReference) {
|
|
15
|
-
|
|
16
|
+
// Single-quote the placeholder so a substituted decision stays literal in the shell.
|
|
17
|
+
const command = `${buildPrShepherdCommand(["apply", "journal", String(prReference)]).text} '- <decision>'`;
|
|
18
|
+
return `For any substantial decision or rejection, append \`- <decision>\` to Shepherd Journal with \`${command}\`. See "Shepherd Journal" in the pr-shepherd skill for citation conventions.`;
|
|
16
19
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { PollSummaryItem, PollSummaryResult, StackNextAction } from "../types.mts";
|
|
2
|
+
export interface StackPlan {
|
|
3
|
+
action: StackNextAction;
|
|
4
|
+
stackMergeable: boolean;
|
|
5
|
+
waiting?: boolean;
|
|
6
|
+
/** Set only by {@link idleWaitPlan}: the layers whose one-PR probes could only report waiting. */
|
|
7
|
+
idle?: PollSummaryItem[];
|
|
8
|
+
instructions: string[];
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Merge the ready bottom layer by PR number. `gh stack merge <n>` tries a stack
|
|
12
|
+
* number first, but stack numbers come from the repository's issue and pull
|
|
13
|
+
* request sequence, so a PR number never names a stack.
|
|
14
|
+
*/
|
|
15
|
+
export declare function planBottomDrain(result: PollSummaryResult, mergeRequested: boolean): StackPlan | undefined;
|
|
16
|
+
/**
|
|
17
|
+
* A merge-requested, fully ready stack whose bottom open layer still targets
|
|
18
|
+
* the merged layer below it until GitHub retargets it onto the stack base.
|
|
19
|
+
*/
|
|
20
|
+
export declare function retargetWaitPlan(first: PollSummaryItem): StackPlan;
|
|
21
|
+
/** Every remaining layer only waits: on CI or merge state, or on a lower layer that does. */
|
|
22
|
+
export declare function idleWaitPlan(idle: PollSummaryItem[]): StackPlan;
|
|
23
|
+
export declare function describeIdleLayers(idle: PollSummaryItem[]): string;
|
|
24
|
+
export declare function appendAutonomousInstructions(instructions: string[], candidates: PollSummaryItem[]): void;
|
|
25
|
+
/** Layers only a human can resolve: a closed dependency, an unverified state, or an escalation. */
|
|
26
|
+
export interface HumanHandoffs {
|
|
27
|
+
closedDependency?: PollSummaryItem;
|
|
28
|
+
unverified?: PollSummaryItem;
|
|
29
|
+
escalated: PollSummaryItem[];
|
|
30
|
+
}
|
|
31
|
+
export declare function findHumanHandoffs(result: PollSummaryResult): HumanHandoffs | undefined;
|
|
32
|
+
/** Name every human handoff; `stop` when no autonomous work precedes it. */
|
|
33
|
+
export declare function appendHumanHandoffInstructions(instructions: string[], { closedDependency, unverified, escalated }: HumanHandoffs, stop: boolean): void;
|
|
34
|
+
export declare function isStackLayerReady(item: PollSummaryItem): boolean;
|
|
35
|
+
export declare function stackPosition(item: PollSummaryItem): number;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { stackLayerBlockReason } from "./stack-layer-readiness.mjs";
|
|
2
|
+
import { appendMarkReadyInstructions, splitStackWork } from "./stack-work.mjs";
|
|
3
|
+
/**
|
|
4
|
+
* The lowest open layer when it can merge by itself: every layer below it
|
|
5
|
+
* merged, GitHub retargeted it onto the stack base, it holds a current READY
|
|
6
|
+
* receipt, and no layer is already in the merge queue.
|
|
7
|
+
*/
|
|
8
|
+
function drainableBottom(result) {
|
|
9
|
+
const layers = [...result.prs].sort((left, right) => stackPosition(left) - stackPosition(right));
|
|
10
|
+
const bottom = layers.find((item) => item.state !== "MERGED");
|
|
11
|
+
if (!bottom || !isStackLayer(bottom) || bottom.state !== "OPEN")
|
|
12
|
+
return undefined;
|
|
13
|
+
if (bottom.action === "escalate" || !isStackLayerReady(bottom))
|
|
14
|
+
return undefined;
|
|
15
|
+
if (bottom.baseRefName !== bottom.stack.baseRefName)
|
|
16
|
+
return undefined;
|
|
17
|
+
if (result.stackAncestry?.some((gap) => gap.childPr === bottom.pr))
|
|
18
|
+
return undefined;
|
|
19
|
+
if (layers.some((item) => item.state === "OPEN" && item.isInMergeQueue))
|
|
20
|
+
return undefined;
|
|
21
|
+
return bottom;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Merge the ready bottom layer by PR number. `gh stack merge <n>` tries a stack
|
|
25
|
+
* number first, but stack numbers come from the repository's issue and pull
|
|
26
|
+
* request sequence, so a PR number never names a stack.
|
|
27
|
+
*/
|
|
28
|
+
export function planBottomDrain(result, mergeRequested) {
|
|
29
|
+
if (!mergeRequested)
|
|
30
|
+
return undefined;
|
|
31
|
+
const bottom = drainableBottom(result);
|
|
32
|
+
if (!bottom)
|
|
33
|
+
return undefined;
|
|
34
|
+
const gaps = result.stackAncestry ?? [];
|
|
35
|
+
const staleChildren = new Set(gaps.map((gap) => gap.childPr));
|
|
36
|
+
const open = result.prs.filter((item) => item.state === "OPEN");
|
|
37
|
+
const uppers = splitStackWork(open.filter((item) => item.pr !== bottom.pr &&
|
|
38
|
+
item.action !== "escalate" &&
|
|
39
|
+
(!isStackLayerReady(item) || staleChildren.has(item.pr))), staleChildren);
|
|
40
|
+
const instructions = [
|
|
41
|
+
`1. PR #${bottom.pr} is the bottom open layer of stack #${bottom.stack.number} in \`${result.repo}\` and is ready. Run \`GH_REPO=${result.repo} gh stack merge ${bottom.pr} --yes --squash\` to merge that layer alone, or to enqueue it when the base uses a merge queue. If \`gh stack\` is an unknown command, run \`gh extension install github/gh-stack\` first.`,
|
|
42
|
+
];
|
|
43
|
+
appendAutonomousInstructions(instructions, uppers.sessions);
|
|
44
|
+
appendMarkReadyInstructions(instructions, uppers.markReady);
|
|
45
|
+
const handoffs = findHumanHandoffs(result);
|
|
46
|
+
if (handoffs)
|
|
47
|
+
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 \`${bottom.stack.baseRefName}\`. Shepherd any layer that GitHub rejects or ejects.`);
|
|
49
|
+
return {
|
|
50
|
+
action: "merge",
|
|
51
|
+
stackMergeable: gaps.length === 0 && open.every(isStackLayerReady),
|
|
52
|
+
instructions,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* A merge-requested, fully ready stack whose bottom open layer still targets
|
|
57
|
+
* the merged layer below it until GitHub retargets it onto the stack base.
|
|
58
|
+
*/
|
|
59
|
+
export function retargetWaitPlan(first) {
|
|
60
|
+
return {
|
|
61
|
+
action: "wait",
|
|
62
|
+
stackMergeable: true,
|
|
63
|
+
waiting: true,
|
|
64
|
+
instructions: [
|
|
65
|
+
`1. PR #${first.pr} still targets \`${first.baseRefName}\` rather than \`${first.stack?.baseRefName}\`; wait for GitHub to retarget it before merging. Recheck at the configured polling cadence.`,
|
|
66
|
+
],
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
/** Every remaining layer only waits: on CI or merge state, or on a lower layer that does. */
|
|
70
|
+
export function idleWaitPlan(idle) {
|
|
71
|
+
return {
|
|
72
|
+
action: "wait",
|
|
73
|
+
stackMergeable: false,
|
|
74
|
+
waiting: true,
|
|
75
|
+
idle,
|
|
76
|
+
instructions: [
|
|
77
|
+
`1. No one-PR session can advance the stack yet: ${describeIdleLayers(idle)}. Recheck at the configured polling cadence.`,
|
|
78
|
+
],
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
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("; ");
|
|
85
|
+
}
|
|
86
|
+
export function appendAutonomousInstructions(instructions, candidates) {
|
|
87
|
+
if (candidates.length === 0)
|
|
88
|
+
return;
|
|
89
|
+
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
|
+
for (const item of candidates) {
|
|
91
|
+
instructions.push(item.pollCommand
|
|
92
|
+
? `${instructions.length + 1}. Run \`${item.pollCommand}\` for PR #${item.pr}${item.blockedByPr ? ` (stack-blocked by PR #${item.blockedByPr})` : ""}${item.queueRemoval ? `; GitHub removed it from the merge queue (${item.queueRemoval.reason ?? "unknown reason"})` : ""}.`
|
|
93
|
+
: `${instructions.length + 1}. PR #${item.pr} needs a one-PR Shepherd session, but no command was available.`);
|
|
94
|
+
}
|
|
95
|
+
instructions.push(`${instructions.length + 1}. Keep upper draft PRs in draft until every lower layer has completed Shepherd READY.`);
|
|
96
|
+
}
|
|
97
|
+
export function findHumanHandoffs(result) {
|
|
98
|
+
const lastOpen = result.prs.filter((item) => item.state === "OPEN").at(-1);
|
|
99
|
+
const closedDependency = result.prs.find((item) => item.state === "CLOSED" && lastOpen && stackPosition(item) < stackPosition(lastOpen));
|
|
100
|
+
const unverified = result.prs.find((item) => item.state !== "OPEN" && item.state !== "MERGED");
|
|
101
|
+
const escalated = result.prs.filter((item) => item.action === "escalate");
|
|
102
|
+
return closedDependency || unverified || escalated.length > 0
|
|
103
|
+
? { closedDependency, unverified, escalated }
|
|
104
|
+
: undefined;
|
|
105
|
+
}
|
|
106
|
+
/** Name every human handoff; `stop` when no autonomous work precedes it. */
|
|
107
|
+
export function appendHumanHandoffInstructions(instructions, { closedDependency, unverified, escalated }, stop) {
|
|
108
|
+
const ask = stop ? "Stop and ask" : "After autonomous shepherding, ask";
|
|
109
|
+
if (closedDependency) {
|
|
110
|
+
instructions.push(`${instructions.length + 1}. PR #${closedDependency.pr} was closed without merging below an open layer. ${ask} the stack owner whether to restore that dependency or rebuild the upper branches.`);
|
|
111
|
+
}
|
|
112
|
+
if (unverified && unverified.pr !== closedDependency?.pr) {
|
|
113
|
+
instructions.push(`${instructions.length + 1}. PR #${unverified.pr} has state \`${unverified.state}\` rather than open or merged. ${ask} the stack owner to reconcile this layer before declaring the stack complete.`);
|
|
114
|
+
}
|
|
115
|
+
for (const item of escalated) {
|
|
116
|
+
if (item.pr === closedDependency?.pr || item.pr === unverified?.pr)
|
|
117
|
+
continue;
|
|
118
|
+
instructions.push(`${instructions.length + 1}. PR #${item.pr} requires human action (${item.reasons.join(", ")}). ${stop ? "Stop for that decision." : "Keep shepherding other PRs before the handoff."}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
export function isStackLayerReady(item) {
|
|
122
|
+
return item.stack !== undefined && stackLayerBlockReason(item) === undefined;
|
|
123
|
+
}
|
|
124
|
+
export function stackPosition(item) {
|
|
125
|
+
return item.stack?.position ?? Number.MAX_SAFE_INTEGER;
|
|
126
|
+
}
|
|
127
|
+
function isStackLayer(item) {
|
|
128
|
+
return item.stack !== undefined;
|
|
129
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { PollSummaryItem, StackLayerBlockReason } from "../types.mts";
|
|
2
|
+
/**
|
|
3
|
+
* Why a native stack layer does not yet let the layers above it advance, or undefined
|
|
4
|
+
* when it does. The stack selector and an upper draft's parent-first mark-ready check
|
|
5
|
+
* share this predicate so both always name the same blocking layer.
|
|
6
|
+
*/
|
|
7
|
+
export declare function stackLayerBlockReason(item: PollSummaryItem): StackLayerBlockReason | undefined;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Why a native stack layer does not yet let the layers above it advance, or undefined
|
|
3
|
+
* when it does. The stack selector and an upper draft's parent-first mark-ready check
|
|
4
|
+
* share this predicate so both always name the same blocking layer.
|
|
5
|
+
*/
|
|
6
|
+
export function stackLayerBlockReason(item) {
|
|
7
|
+
if (item.state !== "OPEN")
|
|
8
|
+
return "closed";
|
|
9
|
+
if (item.isDraft)
|
|
10
|
+
return "draft";
|
|
11
|
+
if (item.mergeable === "CONFLICTING" || item.mergeStateStatus === "DIRTY")
|
|
12
|
+
return "conflicting";
|
|
13
|
+
// A receipt only establishes readiness after a merge-queue removal once
|
|
14
|
+
// the one-PR session has observed and acknowledged that exact removal.
|
|
15
|
+
// The aggregate projection preserves an unacknowledged removal here, so
|
|
16
|
+
// do not let its otherwise-current receipt promote a child draft.
|
|
17
|
+
if (item.queueRemoval)
|
|
18
|
+
return "queue-removal";
|
|
19
|
+
if ((item.checks?.failing ?? 0) > 0)
|
|
20
|
+
return "failing-checks";
|
|
21
|
+
if ((item.review?.actionable ?? 0) > 0)
|
|
22
|
+
return "review-work";
|
|
23
|
+
// Merge-group checks and the queue's own merge state supersede the source
|
|
24
|
+
// PR's while it is queued.
|
|
25
|
+
if (!item.isInMergeQueue) {
|
|
26
|
+
if ((item.checks?.inProgress ?? 0) > 0)
|
|
27
|
+
return "checks-in-progress";
|
|
28
|
+
if (item.mergeable !== "MERGEABLE" ||
|
|
29
|
+
["BEHIND", "UNKNOWN", "BLOCKED", "HAS_HOOKS"].includes(item.mergeStateStatus))
|
|
30
|
+
return "merge-state";
|
|
31
|
+
}
|
|
32
|
+
// A layer that looks ready but has not completed its own one-PR receipt
|
|
33
|
+
// is not sufficient evidence for the layers above it.
|
|
34
|
+
return item.readyReceipt === true ? undefined : "no-ready-receipt";
|
|
35
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { PollSummaryItem, PollSummaryResult } from "../types.mts";
|
|
2
|
+
/**
|
|
3
|
+
* A `--stack` plan that can only wait never reruns its layers' one-PR sessions, so their own
|
|
4
|
+
* stall guards cannot fire. Escalate `stall-timeout` once that idle plan's fingerprint stays
|
|
5
|
+
* unchanged for `stallTimeoutSeconds`; any other plan, or `0`, clears the timer. When the enabled
|
|
6
|
+
* timer cannot be read or written, escalate `stall-state-unavailable` instead of restarting it.
|
|
7
|
+
*/
|
|
8
|
+
export declare function applyStackStallGuard(planned: {
|
|
9
|
+
result: PollSummaryResult;
|
|
10
|
+
idle?: PollSummaryItem[];
|
|
11
|
+
}, repo: {
|
|
12
|
+
owner: string;
|
|
13
|
+
name: string;
|
|
14
|
+
}, stallTimeoutSeconds: number): Promise<PollSummaryResult>;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { clearStackStallState, readStackStallState, writeStackStallState, } from "../state/stack-stall.mjs";
|
|
2
|
+
import { formatDurationApprox } from "./iterate/escalate.mjs";
|
|
3
|
+
import { summaryStatusSignature } from "./poll-summary-signature.mjs";
|
|
4
|
+
import { describeIdleLayers } from "./stack-drain.mjs";
|
|
5
|
+
/**
|
|
6
|
+
* A `--stack` plan that can only wait never reruns its layers' one-PR sessions, so their own
|
|
7
|
+
* stall guards cannot fire. Escalate `stall-timeout` once that idle plan's fingerprint stays
|
|
8
|
+
* unchanged for `stallTimeoutSeconds`; any other plan, or `0`, clears the timer. When the enabled
|
|
9
|
+
* timer cannot be read or written, escalate `stall-state-unavailable` instead of restarting it.
|
|
10
|
+
*/
|
|
11
|
+
export async function applyStackStallGuard(planned, repo, stallTimeoutSeconds) {
|
|
12
|
+
const { result, idle } = planned;
|
|
13
|
+
if (result.selection.kind !== "stack")
|
|
14
|
+
return result;
|
|
15
|
+
const key = { owner: repo.owner, repo: repo.name, stack: result.selection.stackNumber };
|
|
16
|
+
if (!idle || stallTimeoutSeconds <= 0) {
|
|
17
|
+
await clearStackStallState(key);
|
|
18
|
+
return result;
|
|
19
|
+
}
|
|
20
|
+
const fingerprint = stackStallFingerprint(result);
|
|
21
|
+
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
22
|
+
const read = await readStackStallState(key);
|
|
23
|
+
if (!read.ok)
|
|
24
|
+
return stackStateUnavailable(result, read.reason);
|
|
25
|
+
const stored = read.state;
|
|
26
|
+
if (!stored || stored.fingerprint !== fingerprint || stored.firstSeenAt > nowSeconds) {
|
|
27
|
+
const wrote = await writeStackStallState(key, { fingerprint, firstSeenAt: nowSeconds });
|
|
28
|
+
if (!wrote.ok)
|
|
29
|
+
return stackStateUnavailable(result, wrote.reason);
|
|
30
|
+
return result;
|
|
31
|
+
}
|
|
32
|
+
const ageSeconds = nowSeconds - stored.firstSeenAt;
|
|
33
|
+
if (ageSeconds < stallTimeoutSeconds)
|
|
34
|
+
return result;
|
|
35
|
+
return {
|
|
36
|
+
...result,
|
|
37
|
+
reason: "actionable",
|
|
38
|
+
nextAction: "escalate",
|
|
39
|
+
stackMergeable: false,
|
|
40
|
+
instructions: [
|
|
41
|
+
`1. \`stall-timeout\`: the stack has not changed for ${formatDurationApprox(ageSeconds)} while no one-PR session could advance it: ${describeIdleLayers(idle)}. Stop and ask a human to unblock the waiting layers.`,
|
|
42
|
+
],
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
function stackStateUnavailable(result, reason) {
|
|
46
|
+
return {
|
|
47
|
+
...result,
|
|
48
|
+
reason: "actionable",
|
|
49
|
+
nextAction: "escalate",
|
|
50
|
+
stackMergeable: false,
|
|
51
|
+
instructions: [
|
|
52
|
+
`1. \`stall-state-unavailable\`: the stack stall timer could not be saved (${reason}). Stop and ask a human to fix the state directory before resuming.`,
|
|
53
|
+
],
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/** The status signature plus the per-layer evidence it omits, so a push resets the timer. */
|
|
57
|
+
function stackStallFingerprint(result) {
|
|
58
|
+
return JSON.stringify({
|
|
59
|
+
status: summaryStatusSignature(result),
|
|
60
|
+
layers: result.prs.map((item) => ({
|
|
61
|
+
pr: item.pr,
|
|
62
|
+
headRefOid: item.headRefOid,
|
|
63
|
+
isDraft: item.isDraft,
|
|
64
|
+
readyReceipt: item.readyReceipt,
|
|
65
|
+
reasons: item.reasons,
|
|
66
|
+
blockedByPr: item.blockedByPr,
|
|
67
|
+
})),
|
|
68
|
+
});
|
|
69
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { PollSummaryItem } from "../types.mts";
|
|
2
|
+
type ProbedLayer = PollSummaryItem & {
|
|
3
|
+
pollCommand: string;
|
|
4
|
+
pollProbe: true;
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* The one-PR hold reason for a `READY` draft that only the disabled setting keeps in draft. The
|
|
8
|
+
* probe re-reads the draft, so this phrase, not a bare `[WAIT]`, proves it is still clean.
|
|
9
|
+
*/
|
|
10
|
+
export declare const AUTO_MARK_READY_DISABLED_HOLD = "automatic mark-ready is disabled for this session";
|
|
11
|
+
/** How the stack selector routes the autonomous layers that still need work. */
|
|
12
|
+
export interface StackWork {
|
|
13
|
+
/** Layers that need a one-PR Shepherd session, with or without a known command. */
|
|
14
|
+
sessions: PollSummaryItem[];
|
|
15
|
+
/** Clean, unblocked drafts that the agent marks ready because the poll loop will not. */
|
|
16
|
+
markReady: ProbedLayer[];
|
|
17
|
+
/** Layers whose bounded probe could only report that they are still waiting. */
|
|
18
|
+
idle: ProbedLayer[];
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
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 clean draft
|
|
23
|
+
* that no lower layer blocks becomes an agent ready-for-review step instead. A stale-ancestry
|
|
24
|
+
* child keeps its session, which returns the ancestry repair.
|
|
25
|
+
*/
|
|
26
|
+
export declare function splitStackWork(candidates: PollSummaryItem[], staleChildren: ReadonlySet<number>): StackWork;
|
|
27
|
+
/**
|
|
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 and current lower layers.
|
|
30
|
+
*/
|
|
31
|
+
export declare function appendMarkReadyInstructions(instructions: string[], layers: ProbedLayer[]): void;
|
|
32
|
+
export {};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one-PR hold reason for a `READY` draft that only the disabled setting keeps in draft. The
|
|
3
|
+
* probe re-reads the draft, so this phrase, not a bare `[WAIT]`, proves it is still clean.
|
|
4
|
+
*/
|
|
5
|
+
export const AUTO_MARK_READY_DISABLED_HOLD = "automatic mark-ready is disabled for this session";
|
|
6
|
+
/**
|
|
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 clean draft
|
|
9
|
+
* that no lower layer blocks becomes an agent ready-for-review step instead. A stale-ancestry
|
|
10
|
+
* child keeps its session, which returns the ancestry repair.
|
|
11
|
+
*/
|
|
12
|
+
export function splitStackWork(candidates, staleChildren) {
|
|
13
|
+
const work = { sessions: [], markReady: [], idle: [] };
|
|
14
|
+
for (const item of candidates) {
|
|
15
|
+
if (!isProbed(item) || item.action !== "wait" || staleChildren.has(item.pr)) {
|
|
16
|
+
work.sessions.push(item);
|
|
17
|
+
}
|
|
18
|
+
else if (item.blockedByPr === undefined &&
|
|
19
|
+
item.reasons.includes("draft-auto-mark-ready-disabled")) {
|
|
20
|
+
work.markReady.push(item);
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
work.idle.push(item);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return work;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* 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 and current lower layers.
|
|
31
|
+
*/
|
|
32
|
+
export function appendMarkReadyInstructions(instructions, layers) {
|
|
33
|
+
for (const item of layers) {
|
|
34
|
+
instructions.push(`${instructions.length + 1}. Automatic mark-ready is disabled, so marking PR #${item.pr} ready for review is your step. Run \`${item.pollCommand}\` first. If it returns \`[WAIT]\` saying PR #${item.pr} stays in draft because ${AUTO_MARK_READY_DISABLED_HOLD}, run \`gh pr ready ${item.pr} -R ${item.repo}\`; otherwise complete its instructions and leave PR #${item.pr} in draft this round.`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function isProbed(item) {
|
|
38
|
+
return item.pollProbe === true && item.pollCommand !== undefined;
|
|
39
|
+
}
|
package/bin/config/load.d.mts
CHANGED
|
@@ -15,6 +15,8 @@ interface PollConfig {
|
|
|
15
15
|
export interface PrShepherdConfig {
|
|
16
16
|
/** Optional user classification configuration; preserved for rule consumers. */
|
|
17
17
|
classify?: unknown;
|
|
18
|
+
/** Argv prefix for every pr-shepherd command Shepherd emits, e.g. `["pnpm", "exec", "pr-shepherd"]`. */
|
|
19
|
+
cliCommand: string[];
|
|
18
20
|
/** GitHub logins that should be treated as bots even when GitHub reports User/Unknown. */
|
|
19
21
|
botUsernames: string[];
|
|
20
22
|
/** Case-insensitive glob patterns for check/status context names Shepherd should ignore. */
|
package/bin/config/load.mjs
CHANGED
|
@@ -76,6 +76,14 @@ function parseResolveOtherHumanThreads(value) {
|
|
|
76
76
|
}
|
|
77
77
|
throw new Error(`Invalid config: iterate.resolveOtherHumanThreads must be one of "none", "outdated", or "always", got ${JSON.stringify(value)}`);
|
|
78
78
|
}
|
|
79
|
+
function parseCliCommand(value) {
|
|
80
|
+
if (!Array.isArray(value) ||
|
|
81
|
+
value.length === 0 ||
|
|
82
|
+
!value.every((arg) => typeof arg === "string" && arg.trim() !== "")) {
|
|
83
|
+
throw new Error("Invalid config: cliCommand must be a non-empty array of non-empty strings");
|
|
84
|
+
}
|
|
85
|
+
return value;
|
|
86
|
+
}
|
|
79
87
|
function parseBotUsernames(value) {
|
|
80
88
|
if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) {
|
|
81
89
|
throw new Error(`Invalid config: botUsernames must be an array of strings`);
|
|
@@ -223,6 +231,7 @@ function parseGraphqlQuotaWarnings(value, pollIntervalSeconds) {
|
|
|
223
231
|
}
|
|
224
232
|
const KNOWN_CONFIG_KEYS = new Set([
|
|
225
233
|
"classify",
|
|
234
|
+
"cliCommand",
|
|
226
235
|
"botUsernames",
|
|
227
236
|
"ignoreChecks",
|
|
228
237
|
"iterate",
|
|
@@ -284,6 +293,7 @@ function warnUnknownConfigKeys(config) {
|
|
|
284
293
|
const rawDefaults = builtins;
|
|
285
294
|
function parseConfig(value, normalizeMergeArgs = true) {
|
|
286
295
|
const config = value;
|
|
296
|
+
config.cliCommand = parseCliCommand(config.cliCommand);
|
|
287
297
|
config.botUsernames = parseBotUsernames(config.botUsernames);
|
|
288
298
|
config.ignoreChecks = parseIgnoreChecks(config.ignoreChecks);
|
|
289
299
|
config.actions.neverCancelRuns = parseNeverCancelRuns(config.actions.neverCancelRuns);
|