pr-shepherd 0.17.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +6 -12
- package/bin/cli/args.mjs +2 -0
- package/bin/cli/default-iterate.mjs +1 -1
- package/bin/cli/duration-flag.mjs +22 -0
- package/bin/cli/exit-codes.mjs +14 -0
- package/bin/cli/fix-formatter.mjs +1 -2
- package/bin/cli/handlers.mjs +16 -36
- package/bin/cli/iterate-emitter.mjs +19 -0
- package/bin/cli/iterate-flags.mjs +21 -0
- package/bin/cli/iterate-formatter.mjs +50 -13
- package/bin/cli/iterate-instructions.mjs +10 -16
- package/bin/cli/iterate-lean.mjs +9 -12
- package/bin/cli/poll-handler.mjs +42 -0
- package/bin/cli-parser.iterate-fix.test-support.mjs +0 -4
- package/bin/cli-parser.iterate-fixtures.mjs +4 -1
- package/bin/cli-parser.iterate.test-support.mjs +0 -4
- package/bin/cli-parser.mjs +8 -1
- package/bin/commands/check-terminal-report.mjs +1 -0
- package/bin/commands/check.mjs +1 -0
- package/bin/commands/check.test-support.mjs +1 -0
- package/bin/commands/commit-suggestion.apply.test-support.mjs +1 -0
- package/bin/commands/commit-suggestion.test-support.mjs +1 -0
- package/bin/commands/iterate/check-instructions.mjs +19 -17
- package/bin/commands/iterate/escalate.mjs +17 -21
- package/bin/commands/iterate/fix-code.mjs +22 -11
- package/bin/commands/iterate/index.mjs +2 -0
- package/bin/commands/iterate/render.mjs +50 -45
- package/bin/commands/iterate-test-support.mjs +1 -0
- package/bin/commands/poll.mjs +32 -0
- package/bin/commands/poll.test-support.mjs +77 -0
- package/bin/commands/resolve-instructions.mjs +2 -5
- package/bin/github/batch-parser-helpers.mjs +38 -0
- package/bin/github/batch-parsers.mjs +12 -38
- package/bin/github/gql/batch-pr.gql +9 -0
- package/bin/state/seen-comments.mjs +29 -15
- package/package.json +1 -1
- package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
- package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +13 -2
- package/bin/agent-runtime.mjs +0 -7
package/bin/commands/check.mjs
CHANGED
|
@@ -1,24 +1,26 @@
|
|
|
1
1
|
export function buildFailingCheckInstructions(checks) {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
const
|
|
5
|
-
const
|
|
6
|
-
const
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
|
|
2
|
+
if (checks.length === 0)
|
|
3
|
+
return [];
|
|
4
|
+
const hasRunId = checks.some((c) => c.runId && c.conclusion !== "CANCELLED" && c.conclusion !== "STARTUP_FAILURE");
|
|
5
|
+
const hasCancelled = checks.some((c) => c.runId && c.conclusion === "CANCELLED");
|
|
6
|
+
const hasStartupFailure = checks.some((c) => c.runId && c.conclusion === "STARTUP_FAILURE");
|
|
7
|
+
const hasExternal = checks.some((c) => !c.runId && c.detailsUrl);
|
|
8
|
+
const hasBare = checks.some((c) => !c.runId && !c.detailsUrl);
|
|
9
|
+
const parts = [];
|
|
10
|
+
if (hasRunId) {
|
|
11
|
+
parts.push("fetch the log with `gh run view <runId> --log-failed` and decide: rerun with `gh run rerun <runId> --failed` for transient infrastructure failures (network timeout, OOM kill, runner crash), or apply a code fix for real test/build failures");
|
|
10
12
|
}
|
|
11
|
-
if (
|
|
12
|
-
|
|
13
|
+
if (hasCancelled) {
|
|
14
|
+
parts.push("for `[conclusion: CANCELLED]` entries: rerun with `gh run rerun <runId>` if the cancellation looks unintended (not superseded by a newer push or concurrency-group eviction); otherwise treat as resolved — do NOT confuse with IDs under `## Cancelled runs`");
|
|
13
15
|
}
|
|
14
|
-
if (
|
|
15
|
-
|
|
16
|
+
if (hasStartupFailure) {
|
|
17
|
+
parts.push("for `[conclusion: STARTUP_FAILURE]` entries: inspect with `gh run view <runId>` and rerun with `gh run rerun <runId>` if the workflow should be retried");
|
|
16
18
|
}
|
|
17
|
-
if (
|
|
18
|
-
|
|
19
|
+
if (hasExternal) {
|
|
20
|
+
parts.push("for `external` entries (no run ID, has URL): open the URL to inspect the failure");
|
|
19
21
|
}
|
|
20
|
-
if (
|
|
21
|
-
|
|
22
|
+
if (hasBare) {
|
|
23
|
+
parts.push("for `(no runId)` entries: no log or URL is available — escalate to a human for manual investigation");
|
|
22
24
|
}
|
|
23
|
-
return
|
|
25
|
+
return [`For each failing check under \`## Failing checks\`: ${parts.join("; ")}.`];
|
|
24
26
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { loadConfig } from "../../config/load.mjs";
|
|
2
|
-
export function checkEscalateTriggers(actionableThreads,
|
|
2
|
+
export function checkEscalateTriggers(actionableThreads, threadAttempts) {
|
|
3
3
|
const triggers = [];
|
|
4
4
|
const maxAttempts = loadConfig().iterate.fixAttemptsPerThread;
|
|
5
5
|
// Trigger 1: fix thrash — same thread dispatched too many times without resolving.
|
|
@@ -7,17 +7,7 @@ export function checkEscalateTriggers(actionableThreads, resolutionOnlyThreads,
|
|
|
7
7
|
if (thrashThreads.length > 0) {
|
|
8
8
|
triggers.push("fix-thrash");
|
|
9
9
|
}
|
|
10
|
-
// Trigger 2:
|
|
11
|
-
// Skip when there are merge conflicts — fix_code handles conflict resolution, not escalation.
|
|
12
|
-
if (changesRequestedReviews.length > 0 &&
|
|
13
|
-
actionableThreads.length === 0 &&
|
|
14
|
-
resolutionOnlyThreads.length === 0 &&
|
|
15
|
-
actionableComments.length === 0 &&
|
|
16
|
-
failingChecks.length === 0 &&
|
|
17
|
-
!hasConflicts) {
|
|
18
|
-
triggers.push("pr-level-changes-requested");
|
|
19
|
-
}
|
|
20
|
-
// Trigger 3: actionable thread has no file/line — cannot locate code to edit.
|
|
10
|
+
// Trigger 2: actionable thread has no file/line — cannot locate code to edit.
|
|
21
11
|
const unlocatable = actionableThreads.filter((t) => t.path === null || t.line === null);
|
|
22
12
|
if (unlocatable.length > 0) {
|
|
23
13
|
triggers.push("thread-missing-location");
|
|
@@ -69,16 +59,25 @@ export function buildEscalateHumanMessage(escalate, pr) {
|
|
|
69
59
|
lines.push("");
|
|
70
60
|
for (const t of escalate.unresolvedThreads) {
|
|
71
61
|
const loc = t.path ? `\`${t.path}:${t.line ?? "?"}\`` : "(no location)";
|
|
72
|
-
|
|
73
|
-
lines.push(
|
|
62
|
+
lines.push(`- thread \`${t.id}\` — ${loc} (@${t.author}):`);
|
|
63
|
+
lines.push("");
|
|
64
|
+
for (const bodyLine of t.body.split("\n"))
|
|
65
|
+
lines.push(` > ${bodyLine}`);
|
|
66
|
+
lines.push("");
|
|
74
67
|
}
|
|
75
68
|
for (const r of escalate.changesRequestedReviews) {
|
|
76
|
-
|
|
77
|
-
lines.push(
|
|
69
|
+
lines.push(`- review \`${r.id}\` (@${r.author}):`);
|
|
70
|
+
lines.push("");
|
|
71
|
+
for (const bodyLine of r.body.split("\n"))
|
|
72
|
+
lines.push(` > ${bodyLine}`);
|
|
73
|
+
lines.push("");
|
|
78
74
|
}
|
|
79
75
|
for (const c of escalate.ambiguousComments) {
|
|
80
|
-
|
|
81
|
-
lines.push(
|
|
76
|
+
lines.push(`- comment \`${c.id}\` (@${c.author}):`);
|
|
77
|
+
lines.push("");
|
|
78
|
+
for (const bodyLine of c.body.split("\n"))
|
|
79
|
+
lines.push(` > ${bodyLine}`);
|
|
80
|
+
lines.push("");
|
|
82
81
|
}
|
|
83
82
|
}
|
|
84
83
|
if (escalate.thrashHistory && escalate.thrashHistory.length > 0) {
|
|
@@ -107,9 +106,6 @@ export function buildEscalateSuggestion(triggers, detail) {
|
|
|
107
106
|
if (triggers.includes("fix-thrash")) {
|
|
108
107
|
return "Same thread(s) reached the automated attempt limit — treat this as a manual handoff. Apply the fix by hand.";
|
|
109
108
|
}
|
|
110
|
-
if (triggers.includes("pr-level-changes-requested")) {
|
|
111
|
-
return "Reviewer requested changes but left no inline comments — read the review and act manually.";
|
|
112
|
-
}
|
|
113
109
|
if (triggers.includes("thread-missing-location")) {
|
|
114
110
|
return "Review thread has no file/line reference — automated location routing failed and manual handling is required.";
|
|
115
111
|
}
|
|
@@ -17,7 +17,7 @@ export async function handleFixCode(ctx) {
|
|
|
17
17
|
currentAttempts[t.id] = (currentAttempts[t.id] ?? 0) + 1;
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
|
-
const escalateTriggers = checkEscalateTriggers(report.threads.actionable,
|
|
20
|
+
const escalateTriggers = checkEscalateTriggers(report.threads.actionable, currentAttempts);
|
|
21
21
|
if (escalateTriggers.triggers.length > 0) {
|
|
22
22
|
const escalateBase = {
|
|
23
23
|
triggers: escalateTriggers.triggers,
|
|
@@ -53,14 +53,15 @@ export async function handleFixCode(ctx) {
|
|
|
53
53
|
const checks = toAgentChecks(failingChecks);
|
|
54
54
|
const { changesRequestedReviews } = report;
|
|
55
55
|
const hasConflicts = report.mergeStatus.status === "CONFLICTS";
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
56
|
+
// Only surface in-progress runs when a push is plausible — resolution-only and
|
|
57
|
+
// summary-only iterations have no path to a push, so listing runs would prompt
|
|
58
|
+
// unnecessary cancellation.
|
|
59
|
+
const pushLikely = threads.length > 0 ||
|
|
60
|
+
checks.length > 0 ||
|
|
61
|
+
hasConflicts ||
|
|
62
|
+
changesRequestedReviews.length > 0 ||
|
|
63
|
+
actionableComments.length > 0;
|
|
64
|
+
const inProgressRunIds = pushLikely ? buildInProgressRunIds(report, cancelledSet) : [];
|
|
64
65
|
const commentMinimizeIds = report.comments.minimizeIds ?? actionableComments.map((c) => c.id);
|
|
65
66
|
const allCommentIds = [...commentMinimizeIds, ...reviewSummaryIds];
|
|
66
67
|
const resolveCommand = buildResolveCommand(threads, resolutionOnlyThreads, allCommentIds, changesRequestedReviews, checks, prNumber, cliRunner);
|
|
@@ -70,7 +71,17 @@ export async function handleFixCode(ctx) {
|
|
|
70
71
|
`review IDs were also in minimize/comment IDs and were dropped from --dismiss-review-ids: ` +
|
|
71
72
|
`${overlappingReviewIds.join(", ")}\n`);
|
|
72
73
|
}
|
|
73
|
-
if
|
|
74
|
+
// Safety: if the base branch is unknown, escalate when a push is plausible — the agent
|
|
75
|
+
// would need the correct base to rebase safely. This is a conservative guard, not a
|
|
76
|
+
// prediction that the agent *will* push. Intentionally broader than `pushLikely` above:
|
|
77
|
+
// resolution-only threads also need a known base in case the agent does push.
|
|
78
|
+
const pushIsPlausible = threads.length > 0 ||
|
|
79
|
+
checks.length > 0 ||
|
|
80
|
+
hasConflicts ||
|
|
81
|
+
changesRequestedReviews.length > 0 ||
|
|
82
|
+
actionableComments.length > 0 ||
|
|
83
|
+
resolutionOnlyThreads.length > 0;
|
|
84
|
+
if (baseLookup.isFallback && pushIsPlausible) {
|
|
74
85
|
const fallbackEscalateBase = {
|
|
75
86
|
triggers: ["base-branch-unknown"],
|
|
76
87
|
unresolvedThreads: [...threads, ...resolutionOnlyThreads.map(toAgentThread)],
|
|
@@ -89,7 +100,7 @@ export async function handleFixCode(ctx) {
|
|
|
89
100
|
}
|
|
90
101
|
const firstLookThreads = report.threads.firstLook;
|
|
91
102
|
const firstLookComments = report.comments.firstLook;
|
|
92
|
-
const instructions = buildFixInstructions(threads, actionableComments, checks, changesRequestedReviews, baseLookup.branch, resolveCommand, hasConflicts, prNumber, cancelled.length, firstLookThreads, firstLookComments, firstLookSummaries, editedSummaries, inProgressRunIds, resolutionOnlyThreads, cliRunner
|
|
103
|
+
const instructions = buildFixInstructions(threads, actionableComments, checks, changesRequestedReviews, baseLookup.branch, resolveCommand, hasConflicts, prNumber, cancelled.length, firstLookThreads, firstLookComments, firstLookSummaries, editedSummaries, inProgressRunIds, resolutionOnlyThreads, cliRunner);
|
|
93
104
|
return applyStallGuard(stallKey, stallTimeoutSeconds, headSha, base, prNumber, {
|
|
94
105
|
...base,
|
|
95
106
|
baseBranch: baseLookup.branch,
|
|
@@ -45,6 +45,7 @@ export async function runIterate(opts) {
|
|
|
45
45
|
state: report.mergeStatus.state,
|
|
46
46
|
summary: buildSummary(report),
|
|
47
47
|
baseBranch: report.baseBranch,
|
|
48
|
+
branchProtection: report.branchProtection,
|
|
48
49
|
checks: buildRelevantChecks(report),
|
|
49
50
|
action: "cancel",
|
|
50
51
|
reason: report.mergeStatus.state === "MERGED" ? "merged" : "closed",
|
|
@@ -84,6 +85,7 @@ export async function runIterate(opts) {
|
|
|
84
85
|
remainingSeconds: readyState.remainingSeconds,
|
|
85
86
|
summary: buildSummary(report),
|
|
86
87
|
baseBranch: report.baseBranch,
|
|
88
|
+
branchProtection: report.branchProtection,
|
|
87
89
|
checks: buildRelevantChecks(report),
|
|
88
90
|
};
|
|
89
91
|
if (readyState.shouldCancel) {
|
|
@@ -2,12 +2,11 @@ import { renderShellCommand } from "../../cli/runner.mjs";
|
|
|
2
2
|
import { buildFailingCheckInstructions } from "./check-instructions.mjs";
|
|
3
3
|
import { SHEPHERD_JOURNAL_FIRST_LOOK_GUIDANCE, SHEPHERD_JOURNAL_REFERENCE_GUIDANCE_THREADS_AND_COMMENTS_IN_ITEM_HEADINGS, buildShepherdJournalInstruction, } from "../shepherd-journal.mjs";
|
|
4
4
|
import { buildCommitSuggestionInstruction } from "../commit-suggestion-instruction.mjs";
|
|
5
|
-
export const
|
|
6
|
-
export const FIX_INSTRUCTION_STOP_BEFORE_NEXT_TICK = "Stop this iteration before the next tick.";
|
|
5
|
+
export const FIX_INSTRUCTION_STOP = "Stop this iteration — if you pushed new commits, CI needs time before the next tick; otherwise stop before the next tick.";
|
|
7
6
|
/**
|
|
8
7
|
* Render a resolve command as a shell snippet. Wraps `$DISMISS_MESSAGE`, `$HEAD_SHA`, and
|
|
9
|
-
* whitespace-bearing argv entries for placeholder substitution. `$HEAD_SHA` is
|
|
10
|
-
* when `requiresHeadSha` is set.
|
|
8
|
+
* whitespace-bearing argv entries for placeholder substitution. `$HEAD_SHA` is never in `argv` —
|
|
9
|
+
* `renderResolveCommand` appends `--require-sha "$HEAD_SHA"` when `requiresHeadSha` is set.
|
|
11
10
|
*/
|
|
12
11
|
export function renderResolveCommand(rc) {
|
|
13
12
|
const parts = [...rc.argv];
|
|
@@ -16,12 +15,41 @@ export function renderResolveCommand(rc) {
|
|
|
16
15
|
}
|
|
17
16
|
return renderShellCommand(parts);
|
|
18
17
|
}
|
|
19
|
-
export function buildFixInstructions(threads, actionableComments, checks, changesRequestedReviews, baseBranch, resolveCommand, hasConflicts, prNumber, cancelledCount, firstLookThreads = [], firstLookComments = [], firstLookSummaries = [], editedSummaries = [], inProgressRunIds = [], resolutionOnlyThreads = [], runner
|
|
18
|
+
export function buildFixInstructions(threads, actionableComments, checks, changesRequestedReviews, baseBranch, resolveCommand, hasConflicts, prNumber, cancelledCount, firstLookThreads = [], firstLookComments = [], firstLookSummaries = [], editedSummaries = [], inProgressRunIds = [], resolutionOnlyThreads = [], runner) {
|
|
20
19
|
const instructions = [];
|
|
21
|
-
const
|
|
22
|
-
|
|
20
|
+
const hasNonConflictHints = threads.length > 0 ||
|
|
21
|
+
checks.length > 0 ||
|
|
22
|
+
changesRequestedReviews.length > 0 ||
|
|
23
|
+
actionableComments.length > 0;
|
|
24
|
+
// Leading decision or mandatory instruction depending on what actionable items exist.
|
|
25
|
+
if (hasNonConflictHints) {
|
|
26
|
+
const actionableSections = [];
|
|
27
|
+
if (threads.length > 0)
|
|
28
|
+
actionableSections.push("`## Review threads`");
|
|
29
|
+
if (actionableComments.length > 0)
|
|
30
|
+
actionableSections.push("`## Actionable comments`");
|
|
31
|
+
if (checks.length > 0)
|
|
32
|
+
actionableSections.push("`## Failing checks`");
|
|
33
|
+
if (changesRequestedReviews.length > 0)
|
|
34
|
+
actionableSections.push("`## Changes-requested reviews`");
|
|
35
|
+
const sectionRef = actionableSections.length > 0 ? `under ${actionableSections.join(", ")}` : "above";
|
|
36
|
+
const resolveClause = resolveCommand.hasMutations ? ", then run the `resolve:` command" : "";
|
|
37
|
+
if (hasConflicts) {
|
|
38
|
+
// Conflicts make push mandatory regardless of whether code edits are needed.
|
|
39
|
+
instructions.push(`The branch has merge conflicts that require rebase before merging. Apply any code edits for items ${sectionRef}, commit if edits were made, rebase onto \`origin/${baseBranch}\` per your repository's conventions, push${resolveClause}.`);
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
const skipClause = resolveCommand.hasMutations
|
|
43
|
+
? "skip cancellation/commit/push and run the `resolve:` command"
|
|
44
|
+
: "no push is needed";
|
|
45
|
+
instructions.push(`Decide for each item ${sectionRef} whether a code change is warranted. **If any code changes are needed:** cancel in-progress runs first, apply edits, commit, rebase, push${resolveClause}. **If no code changes are needed:** ${skipClause}.`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
else if (hasConflicts) {
|
|
49
|
+
instructions.push(`The branch has merge conflicts — rebase onto \`origin/${baseBranch}\` per your repository's conventions to resolve them, then push.`);
|
|
50
|
+
}
|
|
23
51
|
if (inProgressRunIds.length > 0) {
|
|
24
|
-
instructions.push(`
|
|
52
|
+
instructions.push(`If you decide to push new commits: cancel each in-progress run listed under \`## In-progress runs\` before applying code fixes (e.g. \`gh run cancel <id>\`). Runs may complete between the tick and your action; treat cancellation errors on already-finished runs as non-fatal. Skip this step if you are only resolving threads without pushing — the existing runs remain relevant.`);
|
|
25
53
|
}
|
|
26
54
|
const hasSuggestions = threads.some((t) => t.suggestion);
|
|
27
55
|
if (hasSuggestions) {
|
|
@@ -40,25 +68,22 @@ export function buildFixInstructions(threads, actionableComments, checks, change
|
|
|
40
68
|
if (changesRequestedReviews.length > 0) {
|
|
41
69
|
instructions.push(`For each bullet under \`## Changes-requested reviews\` above: read the review body and apply the requested changes.`);
|
|
42
70
|
}
|
|
43
|
-
if (
|
|
44
|
-
instructions.push(`
|
|
45
|
-
}
|
|
46
|
-
if (changesRequestedReviews.length > 0) {
|
|
47
|
-
instructions.push(`Keep the PR title and description current: if the changes alter the PR's scope or intent, run \`gh pr edit ${prNumber} --title "<new title>" --body "<new body>"\` to reflect them. Skip if the existing title/body still accurately describe the PR.`);
|
|
48
|
-
}
|
|
49
|
-
if (!needsPush && resolveCommand.requiresHeadSha) {
|
|
50
|
-
instructions.push("Capture the current HEAD SHA before resolving with: `HEAD_SHA=$(git rev-parse HEAD)`.");
|
|
71
|
+
if (hasNonConflictHints) {
|
|
72
|
+
instructions.push(`If you applied code edits: commit them with a descriptive message, then rebase onto \`origin/${baseBranch}\` per your repository's conventions before pushing.`);
|
|
51
73
|
}
|
|
52
|
-
if (
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
if (hasConflicts) {
|
|
57
|
-
instructions.push(`Rebase with conflict resolution: run \`git fetch origin && git rebase origin/${baseBranch}\`. If the rebase halts with conflicts, edit the conflicted files to resolve them, \`git add <files>\`, then \`git rebase --continue\`. Repeat until the rebase completes, then \`git push --force-with-lease\`${captureHint}.`);
|
|
74
|
+
if (resolveCommand.hasMutations) {
|
|
75
|
+
const substituteParts = [];
|
|
76
|
+
if (resolveCommand.requiresHeadSha) {
|
|
77
|
+
substituteParts.push(`\`$HEAD_SHA\` with the pushed commit SHA (or \`$(git rev-parse HEAD)\` if you did not push)`);
|
|
58
78
|
}
|
|
59
|
-
|
|
60
|
-
|
|
79
|
+
if (resolveCommand.requiresDismissMessage) {
|
|
80
|
+
substituteParts.push(`\`$DISMISS_MESSAGE\` with a one-sentence description of what you changed`);
|
|
61
81
|
}
|
|
82
|
+
const substituteHint = substituteParts.length > 0 ? `, substituting ${substituteParts.join(" and ")}` : "";
|
|
83
|
+
instructions.push(`Run the \`resolve:\` command shown above${substituteHint}.`);
|
|
84
|
+
}
|
|
85
|
+
if (cancelledCount > 0) {
|
|
86
|
+
instructions.push(`Do not re-run \`gh run cancel\` on the IDs listed under \`## Cancelled runs\` — those runs were already cancelled by the CLI before this turn.`);
|
|
62
87
|
}
|
|
63
88
|
const firstLookTotal = firstLookThreads.length + firstLookComments.length;
|
|
64
89
|
if (firstLookTotal > 0) {
|
|
@@ -73,29 +98,9 @@ export function buildFixInstructions(threads, actionableComments, checks, change
|
|
|
73
98
|
if (editedTotal > 0) {
|
|
74
99
|
instructions.push(`Items under \`## Review summaries (edited since first look)\` and any first-look bullet tagged \`, edited\` were updated by their author after you previously acknowledged them. Read the updated body before deciding whether any matching \`## Review threads to resolve\` item should be resolved.`);
|
|
75
100
|
}
|
|
76
|
-
if (resolveCommand.hasMutations) {
|
|
77
|
-
const substituteParts = [];
|
|
78
|
-
if (resolveCommand.requiresHeadSha) {
|
|
79
|
-
const shaSource = needsPush ? "pushed commit SHA" : "current HEAD SHA";
|
|
80
|
-
substituteParts.push(`"$HEAD_SHA" with the ${shaSource}`);
|
|
81
|
-
}
|
|
82
|
-
if (resolveCommand.requiresDismissMessage) {
|
|
83
|
-
substituteParts.push(`$DISMISS_MESSAGE with a one-sentence description of what you changed`);
|
|
84
|
-
}
|
|
85
|
-
const substituteHint = substituteParts.length > 0 ? `, substituting ${substituteParts.join(" and ")}` : "";
|
|
86
|
-
instructions.push(`Run the \`resolve:\` command shown above${substituteHint}.`);
|
|
87
|
-
}
|
|
88
|
-
if (needsPush && cancelledCount > 0) {
|
|
89
|
-
instructions.push(`Do not re-run \`gh run cancel\` on the IDs listed under \`## Cancelled runs\` — the CLI cancelled those runs before your push, and your push has already triggered new runs with different IDs.`);
|
|
90
|
-
}
|
|
91
101
|
if (resolveCommand.hasMutations) {
|
|
92
102
|
instructions.push(buildShepherdJournalInstruction(prNumber, SHEPHERD_JOURNAL_REFERENCE_GUIDANCE_THREADS_AND_COMMENTS_IN_ITEM_HEADINGS));
|
|
93
103
|
}
|
|
94
|
-
|
|
95
|
-
instructions.push(FIX_INSTRUCTION_STOP_AFTER_PUSH);
|
|
96
|
-
}
|
|
97
|
-
else {
|
|
98
|
-
instructions.push(FIX_INSTRUCTION_STOP_BEFORE_NEXT_TICK);
|
|
99
|
-
}
|
|
104
|
+
instructions.push(FIX_INSTRUCTION_STOP);
|
|
100
105
|
return instructions;
|
|
101
106
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { runIterate } from "./iterate/index.mjs";
|
|
2
|
+
function sleep(ms) {
|
|
3
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
4
|
+
}
|
|
5
|
+
function writeTickProgress(tick, elapsedSeconds, sleepSeconds, verbose) {
|
|
6
|
+
if (process.stderr.isTTY || verbose) {
|
|
7
|
+
process.stderr.write(`[poll tick ${tick} / +${elapsedSeconds}s] WAIT — sleeping ${sleepSeconds}s\n`);
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
const MAX_TIMER_MS = 2 ** 31 - 1;
|
|
11
|
+
export async function runPoll(opts) {
|
|
12
|
+
const { intervalSeconds, timeoutSeconds, ...iterateOpts } = opts;
|
|
13
|
+
const intervalMs = Math.min(intervalSeconds * 1000, MAX_TIMER_MS);
|
|
14
|
+
const timeoutMs = Math.min(timeoutSeconds * 1000, MAX_TIMER_MS);
|
|
15
|
+
const start = Date.now();
|
|
16
|
+
let tick = 0;
|
|
17
|
+
let lastResult;
|
|
18
|
+
const verbose = opts.verbose === true;
|
|
19
|
+
while (true) {
|
|
20
|
+
tick += 1;
|
|
21
|
+
lastResult = await runIterate(iterateOpts);
|
|
22
|
+
if (lastResult.action !== "wait")
|
|
23
|
+
return lastResult;
|
|
24
|
+
const elapsedMs = Date.now() - start;
|
|
25
|
+
const remainingMs = timeoutMs - elapsedMs;
|
|
26
|
+
if (remainingMs <= 0)
|
|
27
|
+
return lastResult;
|
|
28
|
+
const nextSleepMs = Math.min(intervalMs, remainingMs);
|
|
29
|
+
writeTickProgress(tick, Math.round(elapsedMs / 1000), Math.round(nextSleepMs / 1000), verbose);
|
|
30
|
+
await sleep(nextSleepMs);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
vi.mock("./iterate/index.mts", () => ({ runIterate: vi.fn() }));
|
|
3
|
+
import { runIterate } from "./iterate/index.mjs";
|
|
4
|
+
const mockRunIterate = vi.mocked(runIterate);
|
|
5
|
+
function makeWaitResult(overrides = {}) {
|
|
6
|
+
return {
|
|
7
|
+
action: "wait",
|
|
8
|
+
pr: 42,
|
|
9
|
+
repo: "owner/repo",
|
|
10
|
+
status: "IN_PROGRESS",
|
|
11
|
+
state: "OPEN",
|
|
12
|
+
mergeStateStatus: "BLOCKED",
|
|
13
|
+
mergeStatus: "BLOCKED",
|
|
14
|
+
reviewDecision: "REVIEW_REQUIRED",
|
|
15
|
+
blockingBotReviewInProgress: false,
|
|
16
|
+
isDraft: false,
|
|
17
|
+
shouldCancel: false,
|
|
18
|
+
remainingSeconds: 0,
|
|
19
|
+
summary: { passing: 2, failing: 0, inProgress: 1, skipped: 0, filtered: 0 },
|
|
20
|
+
baseBranch: "main",
|
|
21
|
+
checks: [],
|
|
22
|
+
log: "WAIT: 2 passing, 1 in-progress",
|
|
23
|
+
...overrides,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
function makeCancelResult() {
|
|
27
|
+
return {
|
|
28
|
+
action: "cancel",
|
|
29
|
+
pr: 42,
|
|
30
|
+
repo: "owner/repo",
|
|
31
|
+
status: "READY",
|
|
32
|
+
state: "MERGED",
|
|
33
|
+
mergeStateStatus: "CLEAN",
|
|
34
|
+
mergeStatus: "CLEAN",
|
|
35
|
+
reviewDecision: "APPROVED",
|
|
36
|
+
blockingBotReviewInProgress: false,
|
|
37
|
+
isDraft: false,
|
|
38
|
+
shouldCancel: true,
|
|
39
|
+
remainingSeconds: 0,
|
|
40
|
+
summary: { passing: 3, failing: 0, inProgress: 0, skipped: 0, filtered: 0 },
|
|
41
|
+
baseBranch: "main",
|
|
42
|
+
checks: [],
|
|
43
|
+
reason: "merged",
|
|
44
|
+
log: "CANCEL: PR #42 is merged — stopping",
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
function makeMarkReadyResult() {
|
|
48
|
+
return {
|
|
49
|
+
action: "mark_ready",
|
|
50
|
+
pr: 42,
|
|
51
|
+
repo: "owner/repo",
|
|
52
|
+
status: "READY",
|
|
53
|
+
state: "OPEN",
|
|
54
|
+
mergeStateStatus: "CLEAN",
|
|
55
|
+
mergeStatus: "CLEAN",
|
|
56
|
+
reviewDecision: "APPROVED",
|
|
57
|
+
blockingBotReviewInProgress: false,
|
|
58
|
+
isDraft: false,
|
|
59
|
+
shouldCancel: false,
|
|
60
|
+
remainingSeconds: 0,
|
|
61
|
+
summary: { passing: 3, failing: 0, inProgress: 0, skipped: 0, filtered: 0 },
|
|
62
|
+
baseBranch: "main",
|
|
63
|
+
checks: [],
|
|
64
|
+
markedReady: true,
|
|
65
|
+
log: "MARKED READY: PR #42 converted from draft to ready for review",
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
function registerPollHooks() {
|
|
69
|
+
beforeEach(() => {
|
|
70
|
+
vi.clearAllMocks();
|
|
71
|
+
vi.useFakeTimers();
|
|
72
|
+
});
|
|
73
|
+
afterEach(() => {
|
|
74
|
+
vi.useRealTimers();
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
export { mockRunIterate, makeWaitResult, makeCancelResult, makeMarkReadyResult, registerPollHooks };
|
|
@@ -37,16 +37,13 @@ export function buildFetchInstructions(prNumber, result, runner) {
|
|
|
37
37
|
}
|
|
38
38
|
if (hasCodeItems) {
|
|
39
39
|
instructions.push(`Read and edit each file referenced under \`## Actionable Review Threads\`, \`## Actionable PR Comments\`, and \`## Pending CHANGES_REQUESTED reviews\` above. Reclassify each fixed item as Fixed. If an item is too complex to address, leave it as Actionable for the final report.`);
|
|
40
|
-
instructions.push(`
|
|
41
|
-
instructions.push(`Keep the PR title and description current: if the fixes alter the PR's scope or intent, run \`gh pr edit ${prNumber} --title "<new title>" --body "<new body>"\` to reflect them. Skip if the existing title/body still accurately describe the PR.`);
|
|
42
|
-
instructions.push(`Rebase and push: \`BASE_BRANCH=$(gh pr view ${prNumber} --json baseRefName --jq .baseRefName) && git fetch origin && git rebase "origin/$BASE_BRANCH" && git push --force-with-lease\`.`);
|
|
43
|
-
instructions.push(`Cancel stale in-progress runs: \`BRANCH=$(git rev-parse --abbrev-ref HEAD) && CURRENT_SHA=$(git rev-parse HEAD) && gh run list --branch "$BRANCH" --status in_progress --json databaseId,headSha --jq ".[] | select(.headSha != \\"$CURRENT_SHA\\") | .databaseId" | xargs -I{} gh run cancel {}\`.`);
|
|
40
|
+
instructions.push(`If you applied code edits: commit them with a descriptive message, cancel any stale in-progress runs, then rebase and push per your repository's conventions.`);
|
|
44
41
|
}
|
|
45
42
|
if (resolutionOnlyThreads.length > 0) {
|
|
46
43
|
instructions.push(`Resolve each thread under \`## Review threads to resolve\` with \`--resolve-thread-ids\`. These threads are already outdated or minimized, so no code edit is required for them unless their body reveals separate work you choose to do.`);
|
|
47
44
|
}
|
|
48
45
|
const requireShaHint = hasCodeItems
|
|
49
|
-
? ` Include \`--require-sha $(git rev-parse HEAD)\` only when
|
|
46
|
+
? ` Include \`--require-sha $(git rev-parse HEAD)\` only when you pushed new commits.`
|
|
50
47
|
: "";
|
|
51
48
|
const dismissNote = changesRequestedReviews.length > 0
|
|
52
49
|
? ` For \`--dismiss-review-ids\`: \`--message\` is required with one specific sentence describing the fix or the reason for not acting (no boilerplate like "address review comments"); omit \`--message\` when not dismissing. Review-summary IDs (\`PRR_…\` from \`## Review summaries\`) go into \`--minimize-comment-ids\`, never \`--dismiss-review-ids\`.`
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export function mapAuthorType(typeName) {
|
|
2
|
+
if (typeName === "User" || typeName === "Bot")
|
|
3
|
+
return typeName;
|
|
4
|
+
return "Unknown";
|
|
5
|
+
}
|
|
6
|
+
export function parseCreatedAt(iso) {
|
|
7
|
+
const ms = new Date(iso).getTime();
|
|
8
|
+
return Number.isFinite(ms) ? Math.floor(ms / 1000) : 0;
|
|
9
|
+
}
|
|
10
|
+
export function extractRunId(url) {
|
|
11
|
+
if (!url)
|
|
12
|
+
return null;
|
|
13
|
+
const m = /\/runs\/(\d+)/.exec(url);
|
|
14
|
+
return m ? (m[1] ?? null) : null;
|
|
15
|
+
}
|
|
16
|
+
export function extractCheckRunSummary(title, summary) {
|
|
17
|
+
const t = title?.trim();
|
|
18
|
+
if (t)
|
|
19
|
+
return t;
|
|
20
|
+
const firstLine = summary
|
|
21
|
+
?.split("\n")
|
|
22
|
+
?.find((l) => l.trim() !== "")
|
|
23
|
+
?.trim();
|
|
24
|
+
return firstLine || undefined;
|
|
25
|
+
}
|
|
26
|
+
export function mapStatusContextState(state) {
|
|
27
|
+
switch (state) {
|
|
28
|
+
case "SUCCESS":
|
|
29
|
+
return { status: "COMPLETED", conclusion: "SUCCESS" };
|
|
30
|
+
case "FAILURE":
|
|
31
|
+
case "ERROR":
|
|
32
|
+
return { status: "COMPLETED", conclusion: "FAILURE" };
|
|
33
|
+
case "PENDING":
|
|
34
|
+
case "EXPECTED":
|
|
35
|
+
default:
|
|
36
|
+
return { status: "IN_PROGRESS", conclusion: null };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { mapAuthorType, parseCreatedAt, extractRunId, extractCheckRunSummary, mapStatusContextState, } from "./batch-parser-helpers.mjs";
|
|
1
2
|
export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes, rawReviewSummaryNodes, rawApprovedReviewNodes, rawCheckNodes) {
|
|
2
3
|
const reviewRequests = (raw.reviewRequests?.nodes ?? []).flatMap((n) => {
|
|
3
4
|
const login = n.requestedReviewer?.login ?? n.requestedReviewer?.name;
|
|
@@ -92,6 +93,16 @@ export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes,
|
|
|
92
93
|
}
|
|
93
94
|
return [];
|
|
94
95
|
});
|
|
96
|
+
const rawProtection = raw.baseRef?.branchProtectionRule ?? null;
|
|
97
|
+
const branchProtection = rawProtection
|
|
98
|
+
? {
|
|
99
|
+
requiresApprovingReviews: rawProtection.requiresApprovingReviews,
|
|
100
|
+
requiredApprovingReviewCount: rawProtection.requiredApprovingReviewCount,
|
|
101
|
+
requiresConversationResolution: rawProtection.requiresConversationResolution,
|
|
102
|
+
requiresStatusChecks: rawProtection.requiresStatusChecks,
|
|
103
|
+
requiredStatusCheckContexts: rawProtection.requiredStatusCheckContexts ?? [],
|
|
104
|
+
}
|
|
105
|
+
: null;
|
|
95
106
|
return {
|
|
96
107
|
nodeId: raw.id,
|
|
97
108
|
number: raw.number,
|
|
@@ -112,43 +123,6 @@ export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes,
|
|
|
112
123
|
reviewSummaries,
|
|
113
124
|
approvedReviews,
|
|
114
125
|
checks,
|
|
126
|
+
branchProtection,
|
|
115
127
|
};
|
|
116
128
|
}
|
|
117
|
-
function mapAuthorType(typeName) {
|
|
118
|
-
if (typeName === "User" || typeName === "Bot")
|
|
119
|
-
return typeName;
|
|
120
|
-
return "Unknown";
|
|
121
|
-
}
|
|
122
|
-
function parseCreatedAt(iso) {
|
|
123
|
-
const ms = new Date(iso).getTime();
|
|
124
|
-
return Number.isFinite(ms) ? Math.floor(ms / 1000) : 0;
|
|
125
|
-
}
|
|
126
|
-
function extractRunId(url) {
|
|
127
|
-
if (!url)
|
|
128
|
-
return null;
|
|
129
|
-
const m = /\/runs\/(\d+)/.exec(url);
|
|
130
|
-
return m ? (m[1] ?? null) : null;
|
|
131
|
-
}
|
|
132
|
-
function extractCheckRunSummary(title, summary) {
|
|
133
|
-
const t = title?.trim();
|
|
134
|
-
if (t)
|
|
135
|
-
return t;
|
|
136
|
-
const firstLine = summary
|
|
137
|
-
?.split("\n")
|
|
138
|
-
?.find((l) => l.trim() !== "")
|
|
139
|
-
?.trim();
|
|
140
|
-
return firstLine || undefined;
|
|
141
|
-
}
|
|
142
|
-
function mapStatusContextState(state) {
|
|
143
|
-
switch (state) {
|
|
144
|
-
case "SUCCESS":
|
|
145
|
-
return { status: "COMPLETED", conclusion: "SUCCESS" };
|
|
146
|
-
case "FAILURE":
|
|
147
|
-
case "ERROR":
|
|
148
|
-
return { status: "COMPLETED", conclusion: "FAILURE" };
|
|
149
|
-
case "PENDING":
|
|
150
|
-
case "EXPECTED":
|
|
151
|
-
default:
|
|
152
|
-
return { status: "IN_PROGRESS", conclusion: null };
|
|
153
|
-
}
|
|
154
|
-
}
|
|
@@ -24,6 +24,15 @@ query BatchPr(
|
|
|
24
24
|
nameWithOwner
|
|
25
25
|
}
|
|
26
26
|
baseRefName
|
|
27
|
+
baseRef {
|
|
28
|
+
branchProtectionRule {
|
|
29
|
+
requiresApprovingReviews
|
|
30
|
+
requiredApprovingReviewCount
|
|
31
|
+
requiresConversationResolution
|
|
32
|
+
requiresStatusChecks
|
|
33
|
+
requiredStatusCheckContexts
|
|
34
|
+
}
|
|
35
|
+
}
|
|
27
36
|
# Not paginated — capped at 50; PRs with more pending reviewers truncate silently.
|
|
28
37
|
reviewRequests(last: 50) {
|
|
29
38
|
nodes {
|