pr-shepherd 0.59.0 → 0.59.2
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 +38 -45
- package/bin/cli/help-command-pages.d.mts +2 -2
- package/bin/cli/help-iterate-poll-pages.d.mts +2 -2
- package/bin/cli/help-iterate-poll-pages.mjs +5 -5
- package/bin/cli/help-top-page.d.mts +1 -1
- package/bin/cli/help-top-page.mjs +2 -2
- package/bin/cli/help.d.mts +3 -3
- package/bin/cli/iterate-instructions.mjs +1 -31
- package/bin/cli/iterate-lean.mjs +0 -1
- package/bin/commands/event-mode.mjs +3 -7
- package/bin/commands/iterate/index.mjs +23 -10
- package/bin/commands/next-check.d.mts +0 -2
- package/bin/commands/next-check.mjs +1 -1
- package/bin/commands/poll-summary-explicit-instructions.d.mts +2 -0
- package/bin/commands/poll-summary-explicit-instructions.mjs +18 -2
- package/bin/commands/poll-summary-instructions.mjs +33 -11
- package/bin/commands/stack-drain.mjs +1 -2
- package/bin/commands/stack-work.d.mts +8 -21
- package/bin/commands/stack-work.mjs +14 -38
- package/bin/exit-codes.d.mts +1 -1
- package/bin/exit-codes.mjs +2 -1
- package/bin/github/poll-summary-projector.mjs +3 -1
- package/bin/github/poll-summary-route.mjs +6 -7
- package/bin/journal/journal-container.d.mts +6 -0
- package/bin/journal/journal-container.mjs +33 -0
- package/bin/journal/markdown-details.d.mts +15 -0
- package/bin/journal/markdown-details.mjs +92 -0
- package/bin/journal/markdown-line.d.mts +2 -0
- package/bin/journal/markdown-line.mjs +3 -3
- package/bin/journal/reconcile.mjs +14 -31
- package/bin/types/iterate.d.mts +2 -3
- package/bin/types/merge-requirements.d.mts +0 -7
- package/package.json +1 -1
- package/plugins/pr-shepherd/.claude-plugin/plugin.json +1 -1
- package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
- package/plugins/pr-shepherd/.codex.mcp.json +1 -1
- package/plugins/pr-shepherd/.mcp.json +1 -1
- package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +8 -6
- package/plugins/pr-shepherd/skills/pr-shepherd/references/cloud-event-loop.md +1 -1
- package/plugins/pr-shepherd/skills/pr-shepherd/references/draft-handoff.md +38 -0
- package/bin/commands/iterate/parent-first.d.mts +0 -8
- package/bin/commands/iterate/parent-first.mjs +0 -13
|
@@ -1,12 +1,24 @@
|
|
|
1
1
|
import { buildQuotaAwareContinuation } from "../quota-warning.mjs";
|
|
2
|
+
import { isDraftReadinessVerification } from "./stack-work.mjs";
|
|
2
3
|
/** Every selected row already returned `CANCEL` or `ESCALATE`: no autonomous work remains. */
|
|
3
4
|
export function isHandedOffSelection(result) {
|
|
4
5
|
return (result.prs.length > 0 &&
|
|
5
6
|
result.prs.every((item) => item.action === "cancel" || item.action === "escalate"));
|
|
6
7
|
}
|
|
8
|
+
/** A final full-review probe can finish this selection without another aggregate read. */
|
|
9
|
+
export function onlyDraftVerificationSelection(result) {
|
|
10
|
+
return (result.prs.some(isDraftReadinessVerification) &&
|
|
11
|
+
result.prs.every((item) => item.action === "cancel" ||
|
|
12
|
+
item.action === "escalate" ||
|
|
13
|
+
isDraftReadinessVerification(item)));
|
|
14
|
+
}
|
|
7
15
|
export function explicitInstructions(result) {
|
|
8
16
|
if (result.reason === "all_terminal")
|
|
9
|
-
return [
|
|
17
|
+
return [
|
|
18
|
+
result.prs.some((item) => item.reasons.includes("draft-auto-mark-ready-disabled"))
|
|
19
|
+
? "1. Stop — every selected PR is complete for this Shepherd run."
|
|
20
|
+
: "1. Stop — every selected PR is terminal.",
|
|
21
|
+
];
|
|
10
22
|
if (result.quotaWarning && result.reason !== "actionable") {
|
|
11
23
|
return [
|
|
12
24
|
buildQuotaAwareContinuation(result.quotaWarning, "1. This aggregate selection is non-terminal. Before continuing,"),
|
|
@@ -20,7 +32,11 @@ export function explicitInstructions(result) {
|
|
|
20
32
|
"2. Follow each selected one-PR poll's `## Instructions` until it returns `CANCEL` or `ESCALATE`.",
|
|
21
33
|
"3. Run this aggregate poll again after selected work completes; one row's `ESCALATE` does not stop work on other rows.",
|
|
22
34
|
];
|
|
23
|
-
if (
|
|
35
|
+
if (onlyDraftVerificationSelection(result)) {
|
|
36
|
+
instructions[2] =
|
|
37
|
+
"3. If every draft verification probe returns `[CANCEL]` with reason `auto-mark-ready-disabled`, stop this selection and report any `ESCALATE` rows; leave the drafts unchanged. If any probe returns `[ESCALATE]`, report that handoff and stop after the other probes finish. Otherwise follow the probes' instructions and rerun this aggregate selector when that work completes.";
|
|
38
|
+
}
|
|
39
|
+
else if (isHandedOffSelection(result)) {
|
|
24
40
|
instructions[2] =
|
|
25
41
|
"3. Stop after reporting each `ESCALATE` handoff; every selected row is `CANCEL` or `ESCALATE`, so do not run this aggregate poll again.";
|
|
26
42
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { buildQuotaAwareContinuation } from "../quota-warning.mjs";
|
|
3
3
|
import { explicitInstructions } from "./poll-summary-explicit-instructions.mjs";
|
|
4
4
|
import { appendAutonomousInstructions, appendHumanHandoffInstructions, findHumanHandoffs, idleWaitPlan, isStackLayerReady, planPrefixDrain, retargetWaitPlan, stackPosition, } from "./stack-drain.mjs";
|
|
5
|
-
import {
|
|
5
|
+
import { appendStackRerunInstruction, onlyDraftReadinessVerification, splitStackWork, } from "./stack-work.mjs";
|
|
6
6
|
/** Keep aggregate JSON, Markdown, and MCP instructions on one projection. */
|
|
7
7
|
export function withPollSummaryInstructions(result, mergeRequested) {
|
|
8
8
|
return planPollSummary(result, mergeRequested).result;
|
|
@@ -19,7 +19,8 @@ export function planPollSummary(result, mergeRequested) {
|
|
|
19
19
|
prs: prs.map((item) => {
|
|
20
20
|
const needsOwnSession = item.state === "OPEN" &&
|
|
21
21
|
(!isStackLayerReady(item) || staleChildren.has(item.pr)) &&
|
|
22
|
-
["cancel", "merge"].includes(item.action)
|
|
22
|
+
["cancel", "merge"].includes(item.action) &&
|
|
23
|
+
(!item.reasons.includes("draft-auto-mark-ready-disabled") || staleChildren.has(item.pr));
|
|
23
24
|
const layer = needsOwnSession
|
|
24
25
|
? {
|
|
25
26
|
...item,
|
|
@@ -51,6 +52,11 @@ export function planPollSummary(result, mergeRequested) {
|
|
|
51
52
|
reasons: [...layer.reasons, "unverified-stack-state"],
|
|
52
53
|
};
|
|
53
54
|
}
|
|
55
|
+
if (layer.reasons.includes("draft-auto-mark-ready-disabled") &&
|
|
56
|
+
!staleChildren.has(layer.pr)) {
|
|
57
|
+
const { pollCommand: _pollCommand, pollProbe: _pollProbe, ...held } = layer;
|
|
58
|
+
return held;
|
|
59
|
+
}
|
|
54
60
|
return layer;
|
|
55
61
|
}),
|
|
56
62
|
};
|
|
@@ -84,9 +90,9 @@ function planStack(result, mergeRequested) {
|
|
|
84
90
|
const work = splitStackWork(open.filter((item) => (!isStackLayerReady(item) || staleChildren.has(item.pr)) && item.action !== "escalate"), staleChildren);
|
|
85
91
|
const runnableCandidates = work.sessions.filter((item) => item.pollCommand);
|
|
86
92
|
const missingCommands = work.sessions.filter((item) => !item.pollCommand);
|
|
87
|
-
const agentWork = runnableCandidates.length > 0
|
|
93
|
+
const agentWork = runnableCandidates.length > 0;
|
|
88
94
|
// Only owned work defers a human handoff: rerunning cannot advance another author's layer.
|
|
89
|
-
const ownedWork =
|
|
95
|
+
const ownedWork = runnableCandidates.some((item) => item.owned);
|
|
90
96
|
const drain = planPrefixDrain(result, mergeRequested);
|
|
91
97
|
if (drain)
|
|
92
98
|
return drain;
|
|
@@ -94,14 +100,17 @@ function planStack(result, mergeRequested) {
|
|
|
94
100
|
if (handoffs) {
|
|
95
101
|
const instructions = [];
|
|
96
102
|
appendAutonomousInstructions(instructions, runnableCandidates);
|
|
97
|
-
appendMarkReadyInstructions(instructions, work.markReady);
|
|
98
103
|
const stop = !ownedWork;
|
|
99
104
|
appendHumanHandoffInstructions(instructions, handoffs, stop);
|
|
100
105
|
for (const item of missingCommands) {
|
|
101
106
|
instructions.push(`${instructions.length + 1}. PR #${item.pr} needs a one-PR session, but Shepherd could not produce its command. Ask for direction.`);
|
|
102
107
|
}
|
|
103
108
|
if (ownedWork) {
|
|
104
|
-
|
|
109
|
+
const rerun = onlyDraftReadinessVerification(work.sessions, work.idle) &&
|
|
110
|
+
work.sessions.every((item) => item.owned)
|
|
111
|
+
? "If every listed one-PR probe returns `[CANCEL]` with reason `auto-mark-ready-disabled`, report the human handoff above and stop this selector. If any probe returns `[ESCALATE]`, report that handoff and stop after the other probes finish. Otherwise follow each probe's instructions and rerun this same `--stack` selector when that work completes."
|
|
112
|
+
: "After the listed one-PR sessions, rerun this same `--stack` selector. Stop for the human handoff only when no autonomous shepherding remains.";
|
|
113
|
+
appendStackRerunInstruction(instructions, runnableCandidates, rerun);
|
|
105
114
|
}
|
|
106
115
|
return { action: stop ? "escalate" : "shepherd", stackMergeable: false, instructions };
|
|
107
116
|
}
|
|
@@ -116,21 +125,34 @@ function planStack(result, mergeRequested) {
|
|
|
116
125
|
if (missingCommands.length > 0) {
|
|
117
126
|
const instructions = [];
|
|
118
127
|
appendAutonomousInstructions(instructions, runnableCandidates);
|
|
119
|
-
appendMarkReadyInstructions(instructions, work.markReady);
|
|
120
128
|
for (const item of missingCommands) {
|
|
121
129
|
instructions.push(`${instructions.length + 1}. PR #${item.pr} needs a one-PR session, but Shepherd could not produce its command. ${ownedWork ? "After autonomous shepherding, ask" : "Stop and ask"} for direction.`);
|
|
122
130
|
}
|
|
123
131
|
if (ownedWork) {
|
|
124
|
-
appendStackRerunInstruction(instructions,
|
|
132
|
+
appendStackRerunInstruction(instructions, runnableCandidates, "After the listed one-PR sessions, rerun this same `--stack` selector. Stop for the human handoff only when no autonomous shepherding remains.");
|
|
125
133
|
}
|
|
126
134
|
return { action: ownedWork ? "shepherd" : "escalate", stackMergeable: false, instructions };
|
|
127
135
|
}
|
|
128
|
-
if (!agentWork)
|
|
136
|
+
if (!agentWork) {
|
|
137
|
+
if (work.held.length > 0 && work.idle.length === 0) {
|
|
138
|
+
return {
|
|
139
|
+
action: "cancel",
|
|
140
|
+
stackMergeable: false,
|
|
141
|
+
instructions: [
|
|
142
|
+
`1. Stop shepherding this stack. Automatic mark-ready is disabled and ${work.held.map((item) => `PR #${item.pr}`).join(", ")} ${work.held.length === 1 ? "is" : "are"} clean but still in draft.`,
|
|
143
|
+
],
|
|
144
|
+
};
|
|
145
|
+
}
|
|
129
146
|
return idleWaitPlan(work.idle);
|
|
147
|
+
}
|
|
130
148
|
const instructions = [];
|
|
131
149
|
appendAutonomousInstructions(instructions, work.sessions);
|
|
132
|
-
|
|
133
|
-
|
|
150
|
+
if (onlyDraftReadinessVerification(work.sessions, work.idle) &&
|
|
151
|
+
work.sessions.every((item) => item.owned)) {
|
|
152
|
+
instructions.push(`${instructions.length + 1}. If every listed one-PR probe returns \`[CANCEL]\` with reason \`auto-mark-ready-disabled\`, stop shepherding this stack and leave those drafts unchanged. If any probe returns \`[ESCALATE]\`, report that handoff and stop after the other probes finish. Otherwise follow each probe's instructions and rerun this same \`--stack\` selector when that work completes.`);
|
|
153
|
+
return { action: "shepherd", stackMergeable: false, instructions };
|
|
154
|
+
}
|
|
155
|
+
const action = appendStackRerunInstruction(instructions, work.sessions, "After the selected one-PR sessions, rerun this same `--stack` selector.");
|
|
134
156
|
return { action, stackMergeable: false, instructions };
|
|
135
157
|
}
|
|
136
158
|
if (open.some((item) => item.isInMergeQueue)) {
|
|
@@ -2,7 +2,7 @@ import { findHumanHandoffs, appendHumanHandoffInstructions } from "./stack-hando
|
|
|
2
2
|
export { findHumanHandoffs, appendHumanHandoffInstructions } from "./stack-handoffs.mjs";
|
|
3
3
|
import { stackMergeFlag } from "./stack-merge-flag.mjs";
|
|
4
4
|
import { stackLayerBlockReason } from "./stack-layer-readiness.mjs";
|
|
5
|
-
import {
|
|
5
|
+
import { splitStackWork } from "./stack-work.mjs";
|
|
6
6
|
import { getGithubTransport } from "../github/transport.mjs";
|
|
7
7
|
import { buildMergeCommandPlan, renderMergeCommand } from "./iterate/merge.mjs";
|
|
8
8
|
function readyPrefixTop(result) {
|
|
@@ -89,7 +89,6 @@ export function planPrefixDrain(result, mergeRequested) {
|
|
|
89
89
|
instructions[0] = `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 ${method.flag}\` to merge ${span}. If \`gh stack\` is an unknown command, run \`gh extension install github/gh-stack\`, then rerun that merge command. Do not rebase, push, or run \`gh stack push\`.`;
|
|
90
90
|
}
|
|
91
91
|
appendAutonomousInstructions(instructions, above.sessions);
|
|
92
|
-
appendMarkReadyInstructions(instructions, above.markReady);
|
|
93
92
|
const handoffs = findHumanHandoffs(result);
|
|
94
93
|
if (handoffs)
|
|
95
94
|
appendHumanHandoffInstructions(instructions, handoffs, false);
|
|
@@ -1,37 +1,24 @@
|
|
|
1
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
2
|
/** How the stack selector routes the autonomous layers that still need work. */
|
|
12
3
|
export interface StackWork {
|
|
13
4
|
/** Layers that need a one-PR Shepherd session, with or without a known command. */
|
|
14
5
|
sessions: PollSummaryItem[];
|
|
15
|
-
/** Clean
|
|
16
|
-
|
|
6
|
+
/** Clean drafts held by the disabled ready transition. No more work is scheduled for them. */
|
|
7
|
+
held: PollSummaryItem[];
|
|
17
8
|
/** Layers whose bounded probe could only report that they are still waiting. */
|
|
18
|
-
idle:
|
|
9
|
+
idle: PollSummaryItem[];
|
|
19
10
|
}
|
|
11
|
+
export declare function isDraftReadinessVerification(item: PollSummaryItem): boolean;
|
|
12
|
+
/** A full one-PR check is the final task when the compact review sample was incomplete. */
|
|
13
|
+
export declare function onlyDraftReadinessVerification(sessions: PollSummaryItem[], idle: PollSummaryItem[]): boolean;
|
|
20
14
|
/**
|
|
21
|
-
* Split autonomous stack candidates by what the agent can do for each. A
|
|
22
|
-
* mark
|
|
23
|
-
* kept in draft only by the disabled mark-ready setting is the agent's ready-for-review step.
|
|
15
|
+
* Split autonomous stack candidates by what the agent can do for each. A clean draft held by
|
|
16
|
+
* disabled mark-ready is complete for this run. Other bounded waits stay idle.
|
|
24
17
|
* A stale-ancestry child keeps its session, which returns the ancestry repair.
|
|
25
18
|
*/
|
|
26
19
|
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.
|
|
30
|
-
*/
|
|
31
|
-
export declare function appendMarkReadyInstructions(instructions: string[], layers: ProbedLayer[]): void;
|
|
32
20
|
/**
|
|
33
21
|
* The closing step after routed sessions: rerun the selector, or stop when nothing is owned.
|
|
34
22
|
* Returns the plan action: a stop is a handoff, so no rerun, quota cadence, or wake-up follows it.
|
|
35
23
|
*/
|
|
36
24
|
export declare function appendStackRerunInstruction(instructions: string[], layers: PollSummaryItem[], rerun: string): "shepherd" | "escalate";
|
|
37
|
-
export {};
|
|
@@ -1,24 +1,23 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
1
|
+
export function isDraftReadinessVerification(item) {
|
|
2
|
+
return item.reasons?.includes("draft-readiness-verification-required") === true;
|
|
3
|
+
}
|
|
4
|
+
/** A full one-PR check is the final task when the compact review sample was incomplete. */
|
|
5
|
+
export function onlyDraftReadinessVerification(sessions, idle) {
|
|
6
|
+
return sessions.length > 0 && idle.length === 0 && sessions.every(isDraftReadinessVerification);
|
|
7
|
+
}
|
|
8
8
|
/**
|
|
9
|
-
* Split autonomous stack candidates by what the agent can do for each. A
|
|
10
|
-
* mark
|
|
11
|
-
* kept in draft only by the disabled mark-ready setting is the agent's ready-for-review step.
|
|
9
|
+
* Split autonomous stack candidates by what the agent can do for each. A clean draft held by
|
|
10
|
+
* disabled mark-ready is complete for this run. Other bounded waits stay idle.
|
|
12
11
|
* A stale-ancestry child keeps its session, which returns the ancestry repair.
|
|
13
12
|
*/
|
|
14
13
|
export function splitStackWork(candidates, staleChildren) {
|
|
15
|
-
const work = { sessions: [],
|
|
14
|
+
const work = { sessions: [], held: [], idle: [] };
|
|
16
15
|
for (const item of candidates) {
|
|
17
|
-
if (
|
|
18
|
-
work.
|
|
16
|
+
if (item.reasons.includes("draft-auto-mark-ready-disabled") && !staleChildren.has(item.pr)) {
|
|
17
|
+
work.held.push(item);
|
|
19
18
|
}
|
|
20
|
-
else if (item.
|
|
21
|
-
work.
|
|
19
|
+
else if (!isProbed(item) || item.action !== "wait" || staleChildren.has(item.pr)) {
|
|
20
|
+
work.sessions.push(item);
|
|
22
21
|
}
|
|
23
22
|
else {
|
|
24
23
|
work.idle.push(item);
|
|
@@ -26,29 +25,6 @@ export function splitStackWork(candidates, staleChildren) {
|
|
|
26
25
|
}
|
|
27
26
|
return work;
|
|
28
27
|
}
|
|
29
|
-
/**
|
|
30
|
-
* The poll loop leaves a disabled draft's ready transition to the agent. The bounded probe runs
|
|
31
|
-
* first because only the one-PR session reads the full review context.
|
|
32
|
-
*/
|
|
33
|
-
export function appendMarkReadyInstructions(instructions, layers) {
|
|
34
|
-
for (const item of layers) {
|
|
35
|
-
if (!item.owned) {
|
|
36
|
-
instructions.push(`${instructions.length + 1}. PR #${item.pr} is not owned. Leave its draft state unchanged.`);
|
|
37
|
-
continue;
|
|
38
|
-
}
|
|
39
|
-
if (item.transport === "rest") {
|
|
40
|
-
const command = buildPrShepherdCommand([
|
|
41
|
-
"iterate",
|
|
42
|
-
formatPrUrl(item.repo, item.pr),
|
|
43
|
-
"--transport",
|
|
44
|
-
"rest",
|
|
45
|
-
]).text;
|
|
46
|
-
instructions.push(`${instructions.length + 1}. Run \`${item.pollCommand}\` first for PR #${item.pr}. If it returns \`[WAIT]\` saying the draft is held only because automatic mark-ready is disabled, run \`${command}\` to authorize the CCR ready transition; otherwise follow the probe's instructions.`);
|
|
47
|
-
continue;
|
|
48
|
-
}
|
|
49
|
-
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.`);
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
28
|
function isProbed(item) {
|
|
53
29
|
return item.pollProbe === true && item.pollCommand !== undefined;
|
|
54
30
|
}
|
package/bin/exit-codes.d.mts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import type { IterateResult } from "./types.mts";
|
|
13
13
|
import type { ResolveResult } from "./comments/resolve.mts";
|
|
14
14
|
export declare const EXIT: Readonly<{
|
|
15
|
-
/**
|
|
15
|
+
/** Clean terminal stop, including a draft held by disabled automatic mark-ready. */
|
|
16
16
|
readonly OK: 0;
|
|
17
17
|
/** Nothing to do yet, or a clean PR whose ready-delay is still counting. */
|
|
18
18
|
readonly WAIT: 10;
|
package/bin/exit-codes.mjs
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* ran to completion and is reporting PR state. See docs/exit-codes.md.
|
|
11
11
|
*/
|
|
12
12
|
export const EXIT = Object.freeze({
|
|
13
|
-
/**
|
|
13
|
+
/** Clean terminal stop, including a draft held by disabled automatic mark-ready. */
|
|
14
14
|
OK: 0,
|
|
15
15
|
/** Nothing to do yet, or a clean PR whose ready-delay is still counting. */
|
|
16
16
|
WAIT: 10,
|
|
@@ -55,6 +55,7 @@ export class ShepherdError extends Error {
|
|
|
55
55
|
const CANCEL_REASON_EXIT_CODE = {
|
|
56
56
|
merged: EXIT.OK,
|
|
57
57
|
"ready-delay-elapsed": EXIT.OK,
|
|
58
|
+
"auto-mark-ready-disabled": EXIT.OK,
|
|
58
59
|
closed: EXIT.CLOSED,
|
|
59
60
|
};
|
|
60
61
|
export function iterateResultToExitCode(result) {
|
|
@@ -28,7 +28,9 @@ export async function summarizePollSummaryPr(raw, repo, opts, viewerCanAdministe
|
|
|
28
28
|
let remainingSeconds;
|
|
29
29
|
if (raw.isDraft &&
|
|
30
30
|
blockingReviewerInProgress &&
|
|
31
|
-
(action === "mark_ready" ||
|
|
31
|
+
(action === "mark_ready" ||
|
|
32
|
+
reasons.includes("draft-auto-mark-ready-disabled") ||
|
|
33
|
+
reasons.includes("draft-readiness-verification-required"))) {
|
|
32
34
|
action = "wait";
|
|
33
35
|
reasons = ["blocking-reviewer-in-progress"];
|
|
34
36
|
}
|
|
@@ -40,18 +40,17 @@ export function routePollSummary(raw, checks, review, opts) {
|
|
|
40
40
|
}
|
|
41
41
|
if (raw.isDraft) {
|
|
42
42
|
const autoMarkReadyDisabled = opts.noAutoMarkReady || actions.autoMarkReady === false;
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
if (
|
|
46
|
-
|
|
43
|
+
if (autoMarkReadyDisabled && (checks.incomplete || review.incomplete))
|
|
44
|
+
return { action: "fix_code", reasons: ["draft-readiness-verification-required"] };
|
|
45
|
+
if (autoMarkReadyDisabled)
|
|
46
|
+
return { action: "cancel", reasons: ["draft-auto-mark-ready-disabled"] };
|
|
47
|
+
if (!canGenerateGithubMutation(raw.viewerCanUpdate, "ready")) {
|
|
47
48
|
const unsupported = getGithubTransport() === "rest" && raw.viewerCanUpdate !== false && !isCcrTransport();
|
|
48
49
|
if (unsupported)
|
|
49
50
|
return { action: "escalate", reasons: ["transport-unsupported"] };
|
|
50
51
|
return { action: "escalate", reasons: ["mark-ready-authorization-required"] };
|
|
51
52
|
}
|
|
52
|
-
return
|
|
53
|
-
? { action: "wait", reasons: ["draft-auto-mark-ready-disabled"] }
|
|
54
|
-
: { action: "mark_ready", reasons: ["draft-appears-ready"] };
|
|
53
|
+
return { action: "mark_ready", reasons: ["draft-appears-ready"] };
|
|
55
54
|
}
|
|
56
55
|
if (opts.merge && (raw.isInMergeQueue || raw.autoMergeRequest)) {
|
|
57
56
|
return {
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { scanMarkdownLines } from "./markdown-line.mts";
|
|
2
|
+
export declare const LEGACY: RegExp;
|
|
3
|
+
export declare const JOURNAL_SUMMARY: RegExp;
|
|
4
|
+
export declare const CLOSE = "</details>";
|
|
5
|
+
/** Return the line of the canonical `</details>` closing a journal container, or null. */
|
|
6
|
+
export declare function closeJournalContainer(lines: string[], syntax: ReturnType<typeof scanMarkdownLines>, start: number): number | null;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { detailsTags, rawDetailsTags, rawHtmlState } from "./markdown-details.mjs";
|
|
2
|
+
export const LEGACY = /^ {0,3}##[ \t]+Shepherd[ \t]+Journal(?:[ \t]+#+)?[ \t]*$/;
|
|
3
|
+
export const JOURNAL_SUMMARY = /^<summary>\s*Shepherd\s+Journal\b/i;
|
|
4
|
+
export const CLOSE = "</details>";
|
|
5
|
+
/** Return the line of the canonical `</details>` closing a journal container, or null. */
|
|
6
|
+
export function closeJournalContainer(lines, syntax, start) {
|
|
7
|
+
let depth = 1;
|
|
8
|
+
let html = rawHtmlState();
|
|
9
|
+
for (let i = start; i < lines.length; i++) {
|
|
10
|
+
if (syntax[i].nested)
|
|
11
|
+
continue;
|
|
12
|
+
if (syntax[i].rawHtml) {
|
|
13
|
+
for (const tag of rawDetailsTags(lines[i], html))
|
|
14
|
+
if ((depth += tag) === 0)
|
|
15
|
+
return null;
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
html = rawHtmlState();
|
|
19
|
+
if (syntax[i].ignored)
|
|
20
|
+
continue;
|
|
21
|
+
const visible = syntax[i].visiblePrefix;
|
|
22
|
+
if (JOURNAL_SUMMARY.test(visible.trimStart()) || LEGACY.test(visible))
|
|
23
|
+
return null;
|
|
24
|
+
depth += detailsTags(visible, false);
|
|
25
|
+
const closes = detailsTags(visible, true);
|
|
26
|
+
if (closes) {
|
|
27
|
+
depth -= closes;
|
|
28
|
+
if (depth <= 0)
|
|
29
|
+
return depth === 0 && lines[i] === CLOSE ? i : null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export declare function detailsTags(line: string, closing: boolean): number;
|
|
2
|
+
/** Tokenizer state carried across the lines of one raw HTML block. */
|
|
3
|
+
export type RawHtmlState = {
|
|
4
|
+
mode: "comment" | "data" | "raw-text" | "tag";
|
|
5
|
+
quote: '"' | "'" | null;
|
|
6
|
+
rawTag: string | null;
|
|
7
|
+
terminator: RegExp;
|
|
8
|
+
};
|
|
9
|
+
export declare const rawHtmlState: () => RawHtmlState;
|
|
10
|
+
/**
|
|
11
|
+
* Return `1` per `<details>` opener and `-1` per closer that the browser
|
|
12
|
+
* creates from a raw HTML block line. Tags inside comments, CDATA, raw-text
|
|
13
|
+
* elements, and (possibly multiline) attribute values do not count.
|
|
14
|
+
*/
|
|
15
|
+
export declare function rawDetailsTags(line: string, state: RawHtmlState): Array<1 | -1>;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { inQuotedHtmlAttribute } from "./markdown-html.mjs";
|
|
2
|
+
import { structuralDetailsStart } from "./markdown-structure.mjs";
|
|
3
|
+
export function detailsTags(line, closing) {
|
|
4
|
+
if (structuralDetailsStart(line) === null)
|
|
5
|
+
return 0;
|
|
6
|
+
const expression = closing ? /<\/details>/gi : /<details(?:\s+[^>]*)?\/?>/gi;
|
|
7
|
+
return [...line.matchAll(expression)].filter((match) => !inQuotedHtmlAttribute(line, match.index)).length;
|
|
8
|
+
}
|
|
9
|
+
const RAW_TEXT = new Set([
|
|
10
|
+
"iframe",
|
|
11
|
+
"noembed",
|
|
12
|
+
"noframes",
|
|
13
|
+
"script",
|
|
14
|
+
"style",
|
|
15
|
+
"textarea",
|
|
16
|
+
"title",
|
|
17
|
+
"xmp",
|
|
18
|
+
]);
|
|
19
|
+
export const rawHtmlState = () => ({
|
|
20
|
+
mode: "data",
|
|
21
|
+
quote: null,
|
|
22
|
+
rawTag: null,
|
|
23
|
+
terminator: /--!?>/,
|
|
24
|
+
});
|
|
25
|
+
// Comments, CDATA sections, and bogus comments such as `<!x>`, `<?x>`, and `</ x>`.
|
|
26
|
+
const COMMENTS = [
|
|
27
|
+
[/^<!--/, /--!?>/],
|
|
28
|
+
[/^<!\[CDATA\[/, /\]\]>/],
|
|
29
|
+
[/^<(?:[!?]|\/[^A-Za-z>])/, />/],
|
|
30
|
+
];
|
|
31
|
+
/**
|
|
32
|
+
* Return `1` per `<details>` opener and `-1` per closer that the browser
|
|
33
|
+
* creates from a raw HTML block line. Tags inside comments, CDATA, raw-text
|
|
34
|
+
* elements, and (possibly multiline) attribute values do not count.
|
|
35
|
+
*/
|
|
36
|
+
export function rawDetailsTags(line, state) {
|
|
37
|
+
const tags = [];
|
|
38
|
+
let i = 0;
|
|
39
|
+
while (i < line.length) {
|
|
40
|
+
if (state.mode === "comment") {
|
|
41
|
+
const end = state.terminator.exec(line.slice(i));
|
|
42
|
+
if (!end)
|
|
43
|
+
break;
|
|
44
|
+
state.mode = "data";
|
|
45
|
+
i += end.index + end[0].length;
|
|
46
|
+
}
|
|
47
|
+
else if (state.mode === "raw-text") {
|
|
48
|
+
const end = new RegExp(`</${state.rawTag}(?=[\\s/>]|$)`, "i").exec(line.slice(i));
|
|
49
|
+
if (!end)
|
|
50
|
+
break;
|
|
51
|
+
state.mode = "tag";
|
|
52
|
+
state.rawTag = null;
|
|
53
|
+
i += end.index + end[0].length;
|
|
54
|
+
}
|
|
55
|
+
else if (state.mode === "tag") {
|
|
56
|
+
const char = line[i++];
|
|
57
|
+
if (state.quote) {
|
|
58
|
+
if (char === state.quote)
|
|
59
|
+
state.quote = null;
|
|
60
|
+
}
|
|
61
|
+
else if (char === '"' || char === "'")
|
|
62
|
+
state.quote = char;
|
|
63
|
+
else if (char === ">")
|
|
64
|
+
state.mode = state.rawTag ? "raw-text" : "data";
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
const open = line.indexOf("<", i);
|
|
68
|
+
if (open === -1)
|
|
69
|
+
break;
|
|
70
|
+
const comment = COMMENTS.map(([start, end]) => [start.exec(line.slice(open)), end]).find(([start]) => start);
|
|
71
|
+
if (comment) {
|
|
72
|
+
state.mode = "comment";
|
|
73
|
+
state.terminator = comment[1];
|
|
74
|
+
i = open + comment[0][0].length;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
// Tag names run until whitespace, `/`, or `>`, so `details:x` is not `details`.
|
|
78
|
+
const tag = /^<(\/?)([A-Za-z][^\s/>]*)/.exec(line.slice(open));
|
|
79
|
+
if (!tag) {
|
|
80
|
+
i = open + 1;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const name = tag[2].toLowerCase();
|
|
84
|
+
if (name === "details")
|
|
85
|
+
tags.push(tag[1] ? -1 : 1);
|
|
86
|
+
state.mode = "tag";
|
|
87
|
+
state.rawTag = !tag[1] && RAW_TEXT.has(name) ? name : null;
|
|
88
|
+
i = open + tag[0].length;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return tags;
|
|
92
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
/** `rawHtml` marks lines inside a blank-line-terminated raw HTML block, whose tags still render. */
|
|
1
2
|
type MarkdownLine = {
|
|
2
3
|
ignored: boolean;
|
|
3
4
|
nested: boolean;
|
|
5
|
+
rawHtml: boolean;
|
|
4
6
|
visiblePrefix: string;
|
|
5
7
|
};
|
|
6
8
|
/** Return whether a Markdown line starts a block that interrupts inline content. */
|
|
@@ -35,7 +35,7 @@ function scanMarkdown(lines) {
|
|
|
35
35
|
const container = resolveMarkdownContainer(line, activeContainer);
|
|
36
36
|
activeContainer = container.tokens;
|
|
37
37
|
const nested = container.tokens.length > 0;
|
|
38
|
-
const push = (ignored, visiblePrefix) => result.push({ ignored, nested, visiblePrefix });
|
|
38
|
+
const push = (ignored, visiblePrefix, rawHtml = false) => result.push({ ignored, nested, rawHtml, visiblePrefix });
|
|
39
39
|
if (indentedCodeContainer) {
|
|
40
40
|
if (stripMarkdownContainer(line, indentedCodeContainer) !== null) {
|
|
41
41
|
push(true, "");
|
|
@@ -53,7 +53,7 @@ function scanMarkdown(lines) {
|
|
|
53
53
|
else {
|
|
54
54
|
const end = rawHtmlEnd(html, content);
|
|
55
55
|
if (end === null) {
|
|
56
|
-
push(true, "");
|
|
56
|
+
push(true, "", html.kind === "blank-line");
|
|
57
57
|
continue;
|
|
58
58
|
}
|
|
59
59
|
html = null;
|
|
@@ -100,7 +100,7 @@ function scanMarkdown(lines) {
|
|
|
100
100
|
const end = rawHtmlEnd(openingHtml, content);
|
|
101
101
|
if (end === null) {
|
|
102
102
|
html = openingHtml;
|
|
103
|
-
push(true, "");
|
|
103
|
+
push(true, "", openingHtml.kind === "blank-line");
|
|
104
104
|
continue;
|
|
105
105
|
}
|
|
106
106
|
allowCodeOpeners = false;
|
|
@@ -1,42 +1,16 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { detailsTags, rawDetailsTags, rawHtmlState } from "./markdown-details.mjs";
|
|
2
|
+
import { CLOSE, closeJournalContainer, JOURNAL_SUMMARY, LEGACY } from "./journal-container.mjs";
|
|
2
3
|
import { isSafeMarkdownInsertionPoint, scanMarkdownLines } from "./markdown-line.mjs";
|
|
3
4
|
import { setextParagraphStart } from "./markdown-setext.mjs";
|
|
4
|
-
import { structuralDetailsStart } from "./markdown-structure.mjs";
|
|
5
|
-
const LEGACY = /^ {0,3}##[ \t]+Shepherd[ \t]+Journal(?:[ \t]+#+)?[ \t]*$/;
|
|
6
|
-
const JOURNAL_SUMMARY = /^<summary>\s*Shepherd\s+Journal\b/i;
|
|
7
5
|
const SETEXT = /^ {0,3}(?:=+|-+)[ \t]*$/;
|
|
8
|
-
const CLOSE = "</details>";
|
|
9
6
|
const OPEN = "<details>";
|
|
10
7
|
const SUMMARY = "<summary>Shepherd Journal</summary>";
|
|
11
8
|
const stripCr = (s) => s.replace(/\r$/, "");
|
|
12
|
-
function detailsTags(line, closing) {
|
|
13
|
-
if (structuralDetailsStart(line) === null)
|
|
14
|
-
return 0;
|
|
15
|
-
const expression = closing ? /<\/details>/gi : /<details(?:\s+[^>]*)?\/?>/gi;
|
|
16
|
-
return [...line.matchAll(expression)].filter((match) => !inQuotedHtmlAttribute(line, match.index)).length;
|
|
17
|
-
}
|
|
18
|
-
function close(lines, syntax, start) {
|
|
19
|
-
let depth = 1;
|
|
20
|
-
for (let i = start; i < lines.length; i++) {
|
|
21
|
-
if (syntax[i].ignored || syntax[i].nested)
|
|
22
|
-
continue;
|
|
23
|
-
const visible = syntax[i].visiblePrefix;
|
|
24
|
-
if (JOURNAL_SUMMARY.test(visible.trimStart()) || LEGACY.test(visible))
|
|
25
|
-
return null;
|
|
26
|
-
depth += detailsTags(visible, false);
|
|
27
|
-
const closes = detailsTags(visible, true);
|
|
28
|
-
if (closes) {
|
|
29
|
-
depth -= closes;
|
|
30
|
-
if (depth <= 0)
|
|
31
|
-
return depth === 0 && lines[i] === CLOSE ? i : null;
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
return null;
|
|
35
|
-
}
|
|
36
9
|
export function scanShepherdJournal(lines) {
|
|
37
10
|
const syntax = scanMarkdownLines(lines);
|
|
38
11
|
const found = [];
|
|
39
12
|
let detailsDepth = 0;
|
|
13
|
+
let html = rawHtmlState();
|
|
40
14
|
let legacy = null;
|
|
41
15
|
for (let i = 0; i < lines.length; i++) {
|
|
42
16
|
if (!syntax[i].ignored &&
|
|
@@ -48,7 +22,16 @@ export function scanShepherdJournal(lines) {
|
|
|
48
22
|
legacy.contentEnd = legacy.end = i;
|
|
49
23
|
continue;
|
|
50
24
|
}
|
|
51
|
-
if (syntax[i].
|
|
25
|
+
if (syntax[i].nested)
|
|
26
|
+
continue;
|
|
27
|
+
if (syntax[i].rawHtml) {
|
|
28
|
+
// Raw-HTML details stay open past the block; stray closers are ignored, as in the browser.
|
|
29
|
+
for (const tag of rawDetailsTags(lines[i], html))
|
|
30
|
+
detailsDepth = Math.max(0, detailsDepth + tag);
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
html = rawHtmlState();
|
|
34
|
+
if (syntax[i].ignored)
|
|
52
35
|
continue;
|
|
53
36
|
const visible = syntax[i].visiblePrefix;
|
|
54
37
|
detailsDepth += detailsTags(visible, false);
|
|
@@ -66,7 +49,7 @@ export function scanShepherdJournal(lines) {
|
|
|
66
49
|
lines[i - 1] !== OPEN ||
|
|
67
50
|
lines[i + 1] !== "")
|
|
68
51
|
return "error";
|
|
69
|
-
const end =
|
|
52
|
+
const end = closeJournalContainer(lines, syntax, i + 2);
|
|
70
53
|
if (end === null)
|
|
71
54
|
return "error";
|
|
72
55
|
detailsDepth--;
|
package/bin/types/iterate.d.mts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { AgentThread, AgentComment, AgentCheck, GlobalOptions, RelevantCheck, ShepherdStatus, FirstLookThread, FirstLookComment, RuleAutoResolveReport } from "./report.mts";
|
|
2
2
|
import type { ActiveCheck, PrActivitySummary } from "./activity.mts";
|
|
3
3
|
import type { BranchProtection, MergeStateStatus, MergedBasePullRequest, Review, ReviewDecision, ReviewThread, ShepherdMergeStatus } from "./github.mts";
|
|
4
|
-
import type { MergeRequirements
|
|
4
|
+
import type { MergeRequirements } from "./merge-requirements.mts";
|
|
5
5
|
import type { EscalateDetails } from "./escalate.mts";
|
|
6
6
|
import type { MergeCommandPlan } from "./merge-action.mts";
|
|
7
7
|
import type { ProtectedRun } from "./protected-run.mts";
|
|
@@ -75,14 +75,13 @@ interface IterateResultWait extends IterateResultBase {
|
|
|
75
75
|
action: "wait";
|
|
76
76
|
log: string;
|
|
77
77
|
deferredWork?: import("./merge-queue.mts").IterateDeferredWork;
|
|
78
|
-
stackDraftHold?: StackDraftHold;
|
|
79
78
|
}
|
|
80
79
|
/** Clean PR whose ready-delay is still counting. `--until-terminal` returns this instead of sleeping. */
|
|
81
80
|
interface IterateResultReady extends IterateResultBase {
|
|
82
81
|
action: "ready";
|
|
83
82
|
log: string;
|
|
84
83
|
}
|
|
85
|
-
export type CancelReason = "merged" | "closed" | "ready-delay-elapsed";
|
|
84
|
+
export type CancelReason = "merged" | "closed" | "ready-delay-elapsed" | "auto-mark-ready-disabled";
|
|
86
85
|
interface IterateResultCancel extends IterateResultBase {
|
|
87
86
|
action: "cancel";
|
|
88
87
|
reason: CancelReason;
|