pr-shepherd 0.32.5 → 0.34.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 +9 -4
- package/bin/cli/default-poll.mjs +2 -1
- package/bin/cli/duration-flag.mjs +5 -4
- package/bin/cli/{exit-codes.mjs → duration.mjs} +0 -26
- package/bin/cli/handlers.mjs +9 -8
- package/bin/cli/help-command-pages.mjs +17 -72
- package/bin/cli/help-iterate-poll-pages.mjs +72 -0
- package/bin/cli/help-top-page.mjs +8 -5
- package/bin/cli/iterate-emitter.mjs +2 -2
- package/bin/cli/iterate-flags.mjs +1 -1
- package/bin/cli/journal-handler.mjs +40 -7
- package/bin/cli/poll-handler.mjs +1 -1
- package/bin/cli/resolve-validators.mjs +3 -2
- package/bin/cli-parser.mjs +5 -4
- package/bin/commands/check.mjs +2 -1
- package/bin/commands/commit-suggestion.mjs +18 -17
- package/bin/commands/iterate/check-instructions.mjs +39 -5
- package/bin/commands/iterate/fix-code.mjs +4 -1
- package/bin/commands/iterate/index.mjs +5 -3
- package/bin/commands/iterate/render.mjs +9 -23
- package/bin/commands/mark-files-as-viewed.mjs +8 -8
- package/bin/commands/resolve-mutate.mjs +2 -1
- package/bin/comments/resolve.mjs +3 -1
- package/bin/config.json +2 -1
- package/bin/exit-codes.mjs +74 -0
- package/bin/github/batch-response.mjs +21 -0
- package/bin/github/batch.mjs +12 -22
- package/bin/github/errors.mjs +28 -2
- package/bin/github/graphql-http.mjs +45 -13
- package/bin/github/graphql-response.mjs +47 -0
- package/bin/github/http-auth.mjs +2 -1
- package/bin/github/rest-http.mjs +19 -5
- package/bin/index.mjs +2 -1
- package/package.json +3 -3
- package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
- package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +1 -1
|
@@ -5,50 +5,51 @@ import { getRepoInfo, getCurrentPrNumber, getCurrentBranch } from "../github/cli
|
|
|
5
5
|
import { fetchPrBatch } from "../github/batch.mjs";
|
|
6
6
|
import { parseSuggestion, isCommittableSuggestion } from "../suggestions/parse.mjs";
|
|
7
7
|
import { buildUnifiedDiff } from "../suggestions/patch.mjs";
|
|
8
|
+
import { EXIT, ShepherdError } from "../exit-codes.mjs";
|
|
8
9
|
import { buildPrShepherdCommand } from "../cli/runner.mjs";
|
|
9
10
|
const execFile = promisify(execFileCb);
|
|
10
11
|
export async function runCommitSuggestion(opts) {
|
|
11
12
|
if (!opts.threadId) {
|
|
12
|
-
throw new
|
|
13
|
+
throw new ShepherdError("--thread-id is required", EXIT.USAGE);
|
|
13
14
|
}
|
|
14
15
|
if (!opts.message || opts.message.trim() === "") {
|
|
15
|
-
throw new
|
|
16
|
+
throw new ShepherdError("--message is required and must be non-empty", EXIT.USAGE);
|
|
16
17
|
}
|
|
17
18
|
const repo = await getRepoInfo();
|
|
18
19
|
const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
|
|
19
20
|
if (prNumber === null) {
|
|
20
|
-
throw new
|
|
21
|
+
throw new ShepherdError("No open PR found for current branch. Pass a PR number explicitly.", EXIT.UNAVAILABLE);
|
|
21
22
|
}
|
|
22
23
|
const currentBranch = await getCurrentBranch();
|
|
23
24
|
const { stdout: localHeadOut } = await execFile("git", ["rev-parse", "HEAD"]);
|
|
24
25
|
const localHeadSha = localHeadOut.trim();
|
|
25
26
|
const { data } = await fetchPrBatch(prNumber, repo);
|
|
26
27
|
if (!data.headRepoWithOwner) {
|
|
27
|
-
throw new
|
|
28
|
+
throw new ShepherdError(`PR #${prNumber} head repository is unavailable (fork may have been deleted).`, EXIT.UNAVAILABLE);
|
|
28
29
|
}
|
|
29
30
|
if (currentBranch !== data.headRefName) {
|
|
30
|
-
throw new
|
|
31
|
-
`Check out "${data.headRefName}" before applying suggestions
|
|
31
|
+
throw new ShepherdError(`Current branch "${currentBranch}" does not match PR head branch "${data.headRefName}". ` +
|
|
32
|
+
`Check out "${data.headRefName}" before applying suggestions.`, EXIT.UNAVAILABLE);
|
|
32
33
|
}
|
|
33
34
|
if (localHeadSha !== data.headRefOid) {
|
|
34
|
-
throw new
|
|
35
|
-
`Pull/rebase "${data.headRefName}" to the latest PR head and try again
|
|
35
|
+
throw new ShepherdError(`Local HEAD ${localHeadSha} does not match PR head ${data.headRefOid}. ` +
|
|
36
|
+
`Pull/rebase "${data.headRefName}" to the latest PR head and try again.`, EXIT.UNAVAILABLE);
|
|
36
37
|
}
|
|
37
38
|
const thread = data.reviewThreads.find((t) => t.id === opts.threadId);
|
|
38
39
|
if (!thread) {
|
|
39
|
-
throw new
|
|
40
|
+
throw new ShepherdError(`Thread ${opts.threadId} not found on PR #${prNumber}.`, EXIT.UNAVAILABLE);
|
|
40
41
|
}
|
|
41
42
|
if (thread.isResolved) {
|
|
42
|
-
throw new
|
|
43
|
+
throw new ShepherdError(`Thread ${opts.threadId} is already resolved.`, EXIT.UNAVAILABLE);
|
|
43
44
|
}
|
|
44
45
|
if (thread.isOutdated) {
|
|
45
|
-
throw new
|
|
46
|
+
throw new ShepherdError(`Thread ${opts.threadId} is outdated.`, EXIT.UNAVAILABLE);
|
|
46
47
|
}
|
|
47
48
|
if (thread.isMinimized) {
|
|
48
|
-
throw new
|
|
49
|
+
throw new ShepherdError(`Thread ${opts.threadId} is minimized.`, EXIT.UNAVAILABLE);
|
|
49
50
|
}
|
|
50
51
|
if (!thread.path || thread.line === null) {
|
|
51
|
-
throw new
|
|
52
|
+
throw new ShepherdError(`Thread ${opts.threadId} has no file/line anchor.`, EXIT.UNAVAILABLE);
|
|
52
53
|
}
|
|
53
54
|
// Validate the target file is clean before generating the patch, so the emitted
|
|
54
55
|
// `git add -- <file>` instruction cannot accidentally stage unrelated local edits.
|
|
@@ -59,15 +60,15 @@ export async function runCommitSuggestion(opts) {
|
|
|
59
60
|
thread.path,
|
|
60
61
|
]);
|
|
61
62
|
if (fileStatus.trim() !== "") {
|
|
62
|
-
throw new
|
|
63
|
+
throw new ShepherdError(`${thread.path} has uncommitted changes. Commit or stash them before running commit-suggestion.`, EXIT.UNAVAILABLE);
|
|
63
64
|
}
|
|
64
65
|
const parsed = parseSuggestion(thread.body);
|
|
65
66
|
if (!parsed) {
|
|
66
|
-
throw new
|
|
67
|
+
throw new ShepherdError(`Thread ${opts.threadId} has no suggestion block in the comment body.`, EXIT.UNAVAILABLE);
|
|
67
68
|
}
|
|
68
69
|
if (!isCommittableSuggestion(parsed)) {
|
|
69
|
-
throw new
|
|
70
|
-
`3+ backtick fences — refusing to apply (could silently truncate)
|
|
70
|
+
throw new ShepherdError(`Thread ${opts.threadId}'s suggestion body contains nested suggestion fencing or unbalanced ` +
|
|
71
|
+
`3+ backtick fences — refusing to apply (could silently truncate).`, EXIT.UNAVAILABLE);
|
|
71
72
|
}
|
|
72
73
|
const startLine = thread.startLine ?? thread.line;
|
|
73
74
|
const endLine = thread.line;
|
|
@@ -8,6 +8,40 @@ export function buildCrStaleClause(reviews) {
|
|
|
8
8
|
: "";
|
|
9
9
|
return bot + human;
|
|
10
10
|
}
|
|
11
|
+
/**
|
|
12
|
+
* Build the optional behind-base push hint. Empty unless the branch is actually behind its base
|
|
13
|
+
* and the user configured a non-blank `iterate.behindBaseHint` — the CLI never prescribes
|
|
14
|
+
* rebase/merge mechanics itself (see "Keep skills and loop prompts minimal" in CLAUDE.md); this
|
|
15
|
+
* only echoes back the caller's own configured pointer. `hint` is trimmed and type-checked at the
|
|
16
|
+
* point of use (rather than at config load) so a malformed rc file value (non-string, or
|
|
17
|
+
* whitespace-only) degrades to "no hint" instead of rendering garbage into agent-facing text or
|
|
18
|
+
* discarding the rest of the user's config.
|
|
19
|
+
*/
|
|
20
|
+
export function buildBehindBaseHintInstruction(baseBranch, hint, isBehind) {
|
|
21
|
+
const trimmedHint = typeof hint === "string" ? hint.trim() : "";
|
|
22
|
+
if (!isBehind || trimmedHint === "")
|
|
23
|
+
return [];
|
|
24
|
+
return [`The branch is behind \`origin/${baseBranch}\` — ${trimmedHint} before pushing.`];
|
|
25
|
+
}
|
|
26
|
+
/** Build the `Run the resolve: command` instruction, including its optional substitution hint. */
|
|
27
|
+
export function buildResolveCommandInstruction(resolveCommand) {
|
|
28
|
+
if (!resolveCommand.hasMutations)
|
|
29
|
+
return [];
|
|
30
|
+
const instructions = [];
|
|
31
|
+
if ((resolveCommand.replyThreadIds?.length ?? 0) > 0) {
|
|
32
|
+
instructions.push(`Before running the \`resolve:\` command, remove any thread from \`--reply-thread-ids\` if the latest visible comment in that thread is your own prior Shepherd reply. Do not reply to your own comments.`);
|
|
33
|
+
}
|
|
34
|
+
const substituteParts = [];
|
|
35
|
+
if (resolveCommand.requiresHeadSha) {
|
|
36
|
+
substituteParts.push(`\`$HEAD_SHA\` with the pushed commit SHA (or \`$(git rev-parse HEAD)\` if you did not push)`);
|
|
37
|
+
}
|
|
38
|
+
if (resolveCommand.requiresDismissMessage) {
|
|
39
|
+
substituteParts.push(`\`$DISMISS_MESSAGE\` with a one-sentence reply/description of what you changed`);
|
|
40
|
+
}
|
|
41
|
+
const substituteHint = substituteParts.length > 0 ? `, substituting ${substituteParts.join(" and ")}` : "";
|
|
42
|
+
instructions.push(`Run the \`resolve:\` command shown above${substituteHint}.`);
|
|
43
|
+
return instructions;
|
|
44
|
+
}
|
|
11
45
|
export function buildFailingCheckInstructions(checks) {
|
|
12
46
|
if (checks.length === 0)
|
|
13
47
|
return [];
|
|
@@ -18,19 +52,19 @@ export function buildFailingCheckInstructions(checks) {
|
|
|
18
52
|
const hasBare = checks.some((c) => !c.runId && !c.detailsUrl);
|
|
19
53
|
const parts = [];
|
|
20
54
|
if (hasRunId) {
|
|
21
|
-
parts.push("read any included log excerpt first; fetch the full log with `gh run view <runId> --log-failed`
|
|
55
|
+
parts.push("read any included log excerpt first; fetch the full log with `gh run view <runId> --log-failed` if insufficient; rerun with `gh run rerun <runId> --failed` for transient infra failures, or apply a code fix for real test/build failures; if API/log output lacks detail, open the run URL in the GitHub UI");
|
|
22
56
|
}
|
|
23
57
|
if (hasCancelled) {
|
|
24
|
-
parts.push("for `[conclusion: CANCELLED]` entries
|
|
58
|
+
parts.push("for `[conclusion: CANCELLED]` entries (not concurrency-superseded — see `**superseded**`): rerun with `gh run rerun <runId>` unless already pushing new commits this tick, in which case the fresh run supersedes it; don't treat as resolved — distinct from `## Cancelled runs`");
|
|
25
59
|
}
|
|
26
60
|
if (hasStartupFailure) {
|
|
27
|
-
parts.push("for `[conclusion: STARTUP_FAILURE]` entries: inspect with `gh run view <runId
|
|
61
|
+
parts.push("for `[conclusion: STARTUP_FAILURE]` entries: inspect with `gh run view <runId>`, rerun with `gh run rerun <runId>` if warranted");
|
|
28
62
|
}
|
|
29
63
|
if (hasExternal) {
|
|
30
|
-
parts.push("for `external` entries
|
|
64
|
+
parts.push("for `external` entries: open the URL to inspect the failure");
|
|
31
65
|
}
|
|
32
66
|
if (hasBare) {
|
|
33
|
-
parts.push("for `(no runId)` entries: no log or URL
|
|
67
|
+
parts.push("for `(no runId)` entries: no log or URL available — escalate to a human");
|
|
34
68
|
}
|
|
35
69
|
return [`For each failing check under \`## Failing checks\`: ${parts.join("; ")}.`];
|
|
36
70
|
}
|
|
@@ -11,6 +11,7 @@ import { tryCancelRun, buildAutoCancelRunIdsWithOptions, buildInProgressRunIds,
|
|
|
11
11
|
import { annotationMarkerBody } from "../check-annotations.mjs";
|
|
12
12
|
import { threadTranscriptBody } from "../../threads/transcript.mjs";
|
|
13
13
|
import { isHumanAuthor, isConfiguredBotAuthor } from "../../comments/authors.mjs";
|
|
14
|
+
import { loadConfig } from "../../config/load.mjs";
|
|
14
15
|
function nextFixAttempts(stored, headSha, threads) {
|
|
15
16
|
const threadAttempts = stored ? { ...stored.threadAttempts } : {};
|
|
16
17
|
const threadBodyHashes = stored?.threadBodyHashes
|
|
@@ -91,6 +92,8 @@ export async function handleFixCode(ctx) {
|
|
|
91
92
|
const checks = toAgentChecks(failingChecks);
|
|
92
93
|
const { changesRequestedReviews } = report;
|
|
93
94
|
const hasConflicts = report.mergeStatus.status === "CONFLICTS";
|
|
95
|
+
const isBehind = report.mergeStatus.status === "BEHIND";
|
|
96
|
+
const { behindBaseHint } = loadConfig().iterate;
|
|
94
97
|
// Only surface in-progress runs when a push is plausible — resolution-only and
|
|
95
98
|
// summary-only iterations have no path to a push, so listing runs would prompt
|
|
96
99
|
// unnecessary cancellation.
|
|
@@ -137,7 +140,7 @@ export async function handleFixCode(ctx) {
|
|
|
137
140
|
}
|
|
138
141
|
const firstLookThreads = report.threads.firstLook;
|
|
139
142
|
const firstLookComments = report.comments.firstLook;
|
|
140
|
-
const instructions = buildFixInstructions(threads, actionableComments, checks, changesRequestedReviews, baseLookup.branch, resolveCommand, hasConflicts, prNumber, cancelled.length, firstLookThreads, firstLookComments, firstLookSummaries, editedSummaries, inProgressRunIds, resolutionOnlyThreads, resolveOnlyCommand);
|
|
143
|
+
const instructions = buildFixInstructions(threads, actionableComments, checks, changesRequestedReviews, baseLookup.branch, resolveCommand, hasConflicts, prNumber, cancelled.length, firstLookThreads, firstLookComments, firstLookSummaries, editedSummaries, inProgressRunIds, resolutionOnlyThreads, resolveOnlyCommand, behindBaseHint, isBehind);
|
|
141
144
|
const prospectiveResult = {
|
|
142
145
|
...base,
|
|
143
146
|
baseBranch: baseLookup.branch,
|
|
@@ -4,6 +4,7 @@ import { getCurrentPrNumber } from "../../github/client.mjs";
|
|
|
4
4
|
import { graphql } from "../../github/http.mjs";
|
|
5
5
|
import { MARK_PR_READY_MUTATION } from "../../github/queries.mjs";
|
|
6
6
|
import { loadConfig } from "../../config/load.mjs";
|
|
7
|
+
import { EXIT, ShepherdError } from "../../exit-codes.mjs";
|
|
7
8
|
import { getCurrentHeadSha, buildSummary, buildRelevantChecks, buildActiveChecks, buildWaitLog, buildSuppressedCheckFields, buildTerminalCancelResult, } from "./helpers.mjs";
|
|
8
9
|
import { classifyReviewSummaries } from "./classify.mjs";
|
|
9
10
|
import { applyStallGuard } from "./stall.mjs";
|
|
@@ -17,8 +18,9 @@ export async function runIterate(opts) {
|
|
|
17
18
|
const readyDelaySeconds = opts.readyDelaySeconds ?? config.watch.readyDelayMinutes * 60;
|
|
18
19
|
const stallTimeoutSeconds = opts.stallTimeoutSeconds ?? config.iterate.stallTimeoutMinutes * 60;
|
|
19
20
|
const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
|
|
20
|
-
if (prNumber === null)
|
|
21
|
-
throw new
|
|
21
|
+
if (prNumber === null) {
|
|
22
|
+
throw new ShepherdError("No open PR found for current branch. Pass a PR number explicitly.", EXIT.UNAVAILABLE);
|
|
23
|
+
}
|
|
22
24
|
const neverCancelRuns = opts.neverCancelRuns ?? config.actions.neverCancelRuns;
|
|
23
25
|
const report = await runCheck({
|
|
24
26
|
...opts,
|
|
@@ -28,7 +30,7 @@ export async function runIterate(opts) {
|
|
|
28
30
|
});
|
|
29
31
|
const [repoOwner, repoName] = report.repo.split("/");
|
|
30
32
|
if (!repoOwner || !repoName) {
|
|
31
|
-
throw new
|
|
33
|
+
throw new ShepherdError(`Unexpected repo format: "${report.repo}" (expected "owner/name")`, EXIT.DATAERR);
|
|
32
34
|
}
|
|
33
35
|
const stallKey = { owner: repoOwner, repo: repoName, pr: prNumber };
|
|
34
36
|
if (report.mergeStatus.state !== "OPEN") {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { renderShellCommand } from "../../cli/runner.mjs";
|
|
2
|
-
import { buildFailingCheckInstructions, buildCrStaleClause } from "./check-instructions.mjs";
|
|
2
|
+
import { buildFailingCheckInstructions, buildCrStaleClause, buildBehindBaseHintInstruction, buildResolveCommandInstruction, } 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
5
|
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.";
|
|
@@ -10,8 +10,8 @@ export function renderResolveCommand(rc) {
|
|
|
10
10
|
parts.push("--require-sha", "$HEAD_SHA");
|
|
11
11
|
return renderShellCommand(parts);
|
|
12
12
|
}
|
|
13
|
-
export function buildFixInstructions(threads, actionableComments, checks, changesRequestedReviews,
|
|
14
|
-
|
|
13
|
+
export function buildFixInstructions(threads, actionableComments, checks, changesRequestedReviews, baseBranch, resolveCommand, hasConflicts, prNumber, cancelledCount, firstLookThreads = [], firstLookComments = [], firstLookSummaries = [], editedSummaries = [], inProgressRunIds = [], resolutionOnlyThreads = [], resolveOnlyCommand, behindBaseHint = "", // iterate.behindBaseHint — see buildBehindBaseHintInstruction
|
|
14
|
+
isBehind = false) {
|
|
15
15
|
const instructions = [];
|
|
16
16
|
const hasNonConflictHints = threads.length > 0 ||
|
|
17
17
|
checks.length > 0 ||
|
|
@@ -47,6 +47,7 @@ resolveCommand, hasConflicts, prNumber, cancelledCount, firstLookThreads = [], f
|
|
|
47
47
|
else if (hasConflicts) {
|
|
48
48
|
instructions.push(`The branch has merge conflicts that must be resolved before merging (see \`**branch**\` above). Resolve them and push.`);
|
|
49
49
|
}
|
|
50
|
+
instructions.push(...buildBehindBaseHintInstruction(baseBranch, behindBaseHint, isBehind));
|
|
50
51
|
if (inProgressRunIds.length > 0) {
|
|
51
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.`);
|
|
52
53
|
}
|
|
@@ -54,15 +55,13 @@ resolveCommand, hasConflicts, prNumber, cancelledCount, firstLookThreads = [], f
|
|
|
54
55
|
if (hasSuggestions)
|
|
55
56
|
instructions.push(buildCommitSuggestionInstruction(prNumber, "## Review threads", false));
|
|
56
57
|
if (threads.length > 0 || actionableComments.length > 0) {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
if (actionableComments.length > 0)
|
|
61
|
-
fixSections.push("`## Actionable comments`");
|
|
58
|
+
// Actionable comments carry no file/line location (unlike threads), so "referenced above"
|
|
59
|
+
// is only accurate when threads are present.
|
|
60
|
+
const filesRef = threads.length > 0 ? "each file referenced above" : "the relevant files";
|
|
62
61
|
const suggestionFallback = hasSuggestions
|
|
63
62
|
? ` When applying a \`[suggestion]\` thread manually (e.g. after a failed \`commit-suggestion\` run), replace the exact line range shown in the heading (\`path:startLine-endLine\`) with the replacement shown in its \`Replaces lines …\` block verbatim — an empty replacement deletes those lines, a single blank line replaces the range with one blank line.`
|
|
64
63
|
: "";
|
|
65
|
-
instructions.push(`Apply code fixes: read and edit
|
|
64
|
+
instructions.push(`Apply code fixes: read and edit ${filesRef}.${suggestionFallback}`);
|
|
66
65
|
}
|
|
67
66
|
if (resolutionOnlyThreads.length > 0) {
|
|
68
67
|
instructions.push(`Review the threads under \`## Review threads to resolve\`. Human-authored threads are replied to by the \`resolve:\` command shown below; Shepherd does not resolve them. Bot/non-human threads are included in \`--resolve-thread-ids\`.`);
|
|
@@ -79,20 +78,7 @@ resolveCommand, hasConflicts, prNumber, cancelledCount, firstLookThreads = [], f
|
|
|
79
78
|
}
|
|
80
79
|
if (resolveOnlyCommand?.hasMutations)
|
|
81
80
|
instructions.push(`Run the \`resolve-only:\` command shown above — no substitutions needed.`);
|
|
82
|
-
|
|
83
|
-
if ((resolveCommand.replyThreadIds?.length ?? 0) > 0) {
|
|
84
|
-
instructions.push(`Before running the \`resolve:\` command, remove any thread from \`--reply-thread-ids\` if the latest visible comment in that thread is your own prior Shepherd reply. Do not reply to your own comments.`);
|
|
85
|
-
}
|
|
86
|
-
const substituteParts = [];
|
|
87
|
-
if (resolveCommand.requiresHeadSha) {
|
|
88
|
-
substituteParts.push(`\`$HEAD_SHA\` with the pushed commit SHA (or \`$(git rev-parse HEAD)\` if you did not push)`);
|
|
89
|
-
}
|
|
90
|
-
if (resolveCommand.requiresDismissMessage) {
|
|
91
|
-
substituteParts.push(`\`$DISMISS_MESSAGE\` with a one-sentence reply/description of what you changed`);
|
|
92
|
-
}
|
|
93
|
-
const substituteHint = substituteParts.length > 0 ? `, substituting ${substituteParts.join(" and ")}` : "";
|
|
94
|
-
instructions.push(`Run the \`resolve:\` command shown above${substituteHint}.`);
|
|
95
|
-
}
|
|
81
|
+
instructions.push(...buildResolveCommandInstruction(resolveCommand));
|
|
96
82
|
if (cancelledCount > 0) {
|
|
97
83
|
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.`);
|
|
98
84
|
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { graphql, graphqlWithRateLimit, getCurrentPrNumber, getRepoInfo, } from "../github/client.mjs";
|
|
3
3
|
import { paginateForward } from "../github/pagination.mjs";
|
|
4
4
|
import { isRateLimitMessage, rateLimitFromError, rateLimitFromGraphQlResult, } from "../comments/rate-limit.mjs";
|
|
5
|
+
import { EXIT, ShepherdError } from "../exit-codes.mjs";
|
|
5
6
|
const FILES_QUERY = `query PullRequestFiles($owner: String!, $repo: String!, $pr: Int!, $filesCursor: String) {
|
|
6
7
|
repository(owner: $owner, name: $repo) {
|
|
7
8
|
pullRequest(number: $pr) {
|
|
@@ -25,8 +26,9 @@ const BULK_CHUNK_SIZE = 10;
|
|
|
25
26
|
export async function runMarkFilesAsViewed(opts) {
|
|
26
27
|
const repo = await getRepoInfo();
|
|
27
28
|
const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
|
|
28
|
-
if (!prNumber)
|
|
29
|
-
throw new
|
|
29
|
+
if (!prNumber) {
|
|
30
|
+
throw new ShepherdError("No PR number provided and no current branch PR found", EXIT.UNAVAILABLE);
|
|
31
|
+
}
|
|
30
32
|
const matchPatterns = opts.matchPatterns ?? [];
|
|
31
33
|
const matchRegexes = matchPatterns.map((pattern) => compilePattern(pattern));
|
|
32
34
|
const fetched = await fetchPullRequestFiles(prNumber, repo);
|
|
@@ -61,7 +63,7 @@ async function fetchPullRequestFiles(pr, repo) {
|
|
|
61
63
|
});
|
|
62
64
|
const raw = first.data.repository?.pullRequest;
|
|
63
65
|
if (!raw)
|
|
64
|
-
throw new
|
|
66
|
+
throw new ShepherdError(`PR #${pr} not found`, EXIT.UNAVAILABLE);
|
|
65
67
|
let files = raw.files.nodes;
|
|
66
68
|
if (raw.files.pageInfo.hasNextPage && raw.files.pageInfo.endCursor) {
|
|
67
69
|
const extra = await paginateForward(async (cursor) => {
|
|
@@ -73,7 +75,7 @@ async function fetchPullRequestFiles(pr, repo) {
|
|
|
73
75
|
});
|
|
74
76
|
const pr2 = res.data.repository?.pullRequest;
|
|
75
77
|
if (!pr2)
|
|
76
|
-
throw new
|
|
78
|
+
throw new ShepherdError(`PR #${pr} not found`, EXIT.UNAVAILABLE);
|
|
77
79
|
return pr2.files;
|
|
78
80
|
}, raw.files.pageInfo.endCursor);
|
|
79
81
|
files = [...files, ...extra];
|
|
@@ -86,7 +88,7 @@ function compilePattern(pattern) {
|
|
|
86
88
|
}
|
|
87
89
|
catch (e) {
|
|
88
90
|
const msg = e instanceof Error ? e.message : String(e);
|
|
89
|
-
throw new
|
|
91
|
+
throw new ShepherdError(`Invalid --match regex ${JSON.stringify(pattern)}: ${msg}`, EXIT.USAGE);
|
|
90
92
|
}
|
|
91
93
|
}
|
|
92
94
|
function selectChangedFiles(changedFiles, opts) {
|
|
@@ -153,9 +155,7 @@ async function bulkMarkFilesAsViewedChunk(pullRequestId, paths, result, hasPendi
|
|
|
153
155
|
let suppressCurrentChunkErrors = false;
|
|
154
156
|
let rateLimitStop;
|
|
155
157
|
try {
|
|
156
|
-
const resp = await graphqlWithRateLimit(buildBulkMutation(paths), {
|
|
157
|
-
pullRequestId,
|
|
158
|
-
});
|
|
158
|
+
const resp = await graphqlWithRateLimit(buildBulkMutation(paths), { pullRequestId }, { allowPartialData: true });
|
|
159
159
|
data = resp.data;
|
|
160
160
|
graphQlErrors = (resp.errors ?? []);
|
|
161
161
|
const messages = graphQlErrors.map((e) => e.message);
|
|
@@ -6,11 +6,12 @@ import { isConfiguredBotAuthor, isHumanAuthor, normalizeBotUsernames, } from "..
|
|
|
6
6
|
import { markReplySeen } from "../state/seen-comments.mjs";
|
|
7
7
|
import { threadTranscriptBody } from "../threads/transcript.mjs";
|
|
8
8
|
import { addPrShepherdMarker } from "../comments/marker.mjs";
|
|
9
|
+
import { EXIT, ShepherdError } from "../exit-codes.mjs";
|
|
9
10
|
export async function runResolveMutate(opts) {
|
|
10
11
|
const repo = await getRepoInfo();
|
|
11
12
|
const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
|
|
12
13
|
if (prNumber === null) {
|
|
13
|
-
throw new
|
|
14
|
+
throw new ShepherdError("No open PR found for current branch. Pass a PR number explicitly.", EXIT.UNAVAILABLE);
|
|
14
15
|
}
|
|
15
16
|
const { data } = await fetchPrBatch(prNumber, repo, { paginateApprovedReviews: true });
|
|
16
17
|
const config = loadConfig();
|
package/bin/comments/resolve.mjs
CHANGED
|
@@ -128,7 +128,9 @@ async function bulkApplyChunk(resolveIds, replyIds, minimizeIds, dismissIds, dis
|
|
|
128
128
|
let rateLimitStop;
|
|
129
129
|
let suppressCurrentChunkErrors = false;
|
|
130
130
|
try {
|
|
131
|
-
const resp = await graphqlWithRateLimit(doc, {}
|
|
131
|
+
const resp = await graphqlWithRateLimit(doc, {}, {
|
|
132
|
+
allowPartialData: true,
|
|
133
|
+
});
|
|
132
134
|
data = resp.data;
|
|
133
135
|
graphQlErrors = (resp.errors ?? []);
|
|
134
136
|
const graphQlErrorMessages = graphQlErrors.map((e) => e.message);
|
package/bin/config.json
CHANGED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process exit codes for the pr-shepherd CLI.
|
|
3
|
+
*
|
|
4
|
+
* Three bands:
|
|
5
|
+
* 0 done — shepherd finished and the PR is in a good terminal state
|
|
6
|
+
* 10-19 shepherd RAN successfully; the code reports PR state
|
|
7
|
+
* 64-78 shepherd FAILED (BSD `sysexits.h` codes)
|
|
8
|
+
*
|
|
9
|
+
* Caller rule: `$? >= 64` means shepherd itself failed. `0` or `10-19` means it
|
|
10
|
+
* ran to completion and is reporting PR state. See docs/exit-codes.md.
|
|
11
|
+
*/
|
|
12
|
+
export const EXIT = Object.freeze({
|
|
13
|
+
/** `cancel` + `merged` or `ready-delay-elapsed` — shepherd finished cleanly. */
|
|
14
|
+
OK: 0,
|
|
15
|
+
/** Nothing to do yet; CI still in progress. */
|
|
16
|
+
WAIT: 10,
|
|
17
|
+
/** Draft PR converted to ready for review. */
|
|
18
|
+
MARK_READY: 11,
|
|
19
|
+
/** Agent work required. */
|
|
20
|
+
FIX_CODE: 12,
|
|
21
|
+
/** Human attention required. */
|
|
22
|
+
ESCALATE: 13,
|
|
23
|
+
/** `cancel` + `closed` — PR closed without merging. */
|
|
24
|
+
CLOSED: 14,
|
|
25
|
+
/** Bad/unknown flag, unknown subcommand, missing required arg, invalid duration. */
|
|
26
|
+
USAGE: 64,
|
|
27
|
+
/** Malformed caller data: bad `--require-sha`, `PRRC_*` IDs, bad repo string. */
|
|
28
|
+
DATAERR: 65,
|
|
29
|
+
/** Input file/stdin could not be read. */
|
|
30
|
+
NOINPUT: 66,
|
|
31
|
+
/** Precondition unmet: no open PR for branch, thread not eligible, unclassified 4xx. */
|
|
32
|
+
UNAVAILABLE: 69,
|
|
33
|
+
/** Unexpected/unclassified internal error — the fallback. */
|
|
34
|
+
SOFTWARE: 70,
|
|
35
|
+
/** Retryable GitHub failure: 429, 5xx, rate limit exhausted, `Retry-After` present. */
|
|
36
|
+
TEMPFAIL: 75,
|
|
37
|
+
/** GitHub 401/403 — missing token or insufficient PAT scopes. */
|
|
38
|
+
NOPERM: 77,
|
|
39
|
+
/** `.pr-shepherdrc.yml` validation failure. */
|
|
40
|
+
CONFIG: 78,
|
|
41
|
+
});
|
|
42
|
+
/** An error that carries its own exit code, so the top-level handler doesn't have to guess. */
|
|
43
|
+
export class ShepherdError extends Error {
|
|
44
|
+
exitCode;
|
|
45
|
+
constructor(message, exitCode, opts) {
|
|
46
|
+
super(message, opts);
|
|
47
|
+
this.name = "ShepherdError";
|
|
48
|
+
this.exitCode = exitCode;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const CANCEL_REASON_EXIT_CODE = {
|
|
52
|
+
merged: EXIT.OK,
|
|
53
|
+
"ready-delay-elapsed": EXIT.OK,
|
|
54
|
+
closed: EXIT.CLOSED,
|
|
55
|
+
};
|
|
56
|
+
export function iterateResultToExitCode(result) {
|
|
57
|
+
switch (result.action) {
|
|
58
|
+
case "cancel":
|
|
59
|
+
return CANCEL_REASON_EXIT_CODE[result.reason];
|
|
60
|
+
case "wait":
|
|
61
|
+
return EXIT.WAIT;
|
|
62
|
+
case "mark_ready":
|
|
63
|
+
return EXIT.MARK_READY;
|
|
64
|
+
case "fix_code":
|
|
65
|
+
return EXIT.FIX_CODE;
|
|
66
|
+
case "escalate":
|
|
67
|
+
return EXIT.ESCALATE;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
export function errorToExitCode(err) {
|
|
71
|
+
if (err instanceof ShepherdError)
|
|
72
|
+
return err.exitCode;
|
|
73
|
+
return EXIT.SOFTWARE;
|
|
74
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { EXIT, ShepherdError } from "../exit-codes.mjs";
|
|
2
|
+
import { GitHubRequestError } from "./errors.mjs";
|
|
3
|
+
export function requireRawPr(response, pr, repo) {
|
|
4
|
+
if (!response?.repository) {
|
|
5
|
+
throw new GitHubRequestError(`GitHub GraphQL response did not include repository ${repo.owner}/${repo.name} (not found or access denied)`, { status: 200 });
|
|
6
|
+
}
|
|
7
|
+
if (!response.repository.pullRequest) {
|
|
8
|
+
throw new ShepherdError(`PR #${pr} not found`, EXIT.UNAVAILABLE);
|
|
9
|
+
}
|
|
10
|
+
return response.repository.pullRequest;
|
|
11
|
+
}
|
|
12
|
+
export function requireContextNodes(nodes) {
|
|
13
|
+
const nullIndex = nodes.findIndex((node) => node === null);
|
|
14
|
+
if (nullIndex !== -1) {
|
|
15
|
+
// A null context node is an unexpected/malformed shape, not a precondition or
|
|
16
|
+
// permission problem — force EX_SOFTWARE rather than falling through to the
|
|
17
|
+
// (200-status-derived) EX_UNAVAILABLE default.
|
|
18
|
+
throw new GitHubRequestError(`Malformed GitHub GraphQL response: null check context at repository.pullRequest.commits.nodes.0.commit.statusCheckRollup.contexts.nodes.${nullIndex}`, { status: 200, exitCodeOverride: EXIT.SOFTWARE });
|
|
19
|
+
}
|
|
20
|
+
return nodes;
|
|
21
|
+
}
|
package/bin/github/batch.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import { paginateForward, paginateBackward } from "./pagination.mjs";
|
|
|
3
3
|
import { hydrateThreadCommentPages } from "./thread-comments.mjs";
|
|
4
4
|
import { BATCH_PR_QUERY } from "./queries.mjs";
|
|
5
5
|
import { parseRawPr } from "./batch-parsers.mjs";
|
|
6
|
+
import { requireContextNodes, requireRawPr } from "./batch-response.mjs";
|
|
6
7
|
/**
|
|
7
8
|
* Fetch all PR data needed for a `shepherd check` in one (or a few, if paginating) GraphQL requests.
|
|
8
9
|
*/
|
|
@@ -13,10 +14,7 @@ export async function fetchPrBatch(pr, repo, opts = {}) {
|
|
|
13
14
|
repo: repo.name,
|
|
14
15
|
pr,
|
|
15
16
|
});
|
|
16
|
-
const raw = result.data
|
|
17
|
-
if (!raw) {
|
|
18
|
-
throw new Error(`PR #${pr} not found`);
|
|
19
|
-
}
|
|
17
|
+
const raw = requireRawPr(result.data, pr, repo);
|
|
20
18
|
// Paginate reviewThreads backward if the first page is incomplete.
|
|
21
19
|
let rawThreadPages = raw.reviewThreads.nodes;
|
|
22
20
|
if (raw.reviewThreads.pageInfo.hasPreviousPage && raw.reviewThreads.pageInfo.startCursor) {
|
|
@@ -29,9 +27,7 @@ export async function fetchPrBatch(pr, repo, opts = {}) {
|
|
|
29
27
|
pr,
|
|
30
28
|
...(cursor ? { threadsCursor: cursor } : {}),
|
|
31
29
|
});
|
|
32
|
-
const pr2 = res.data
|
|
33
|
-
if (!pr2)
|
|
34
|
-
throw new Error(`PR #${pr} not found`);
|
|
30
|
+
const pr2 = requireRawPr(res.data, pr, repo);
|
|
35
31
|
return pr2.reviewThreads;
|
|
36
32
|
}, raw.reviewThreads.pageInfo.startCursor);
|
|
37
33
|
// extra contains pages before the first page.
|
|
@@ -48,9 +44,7 @@ export async function fetchPrBatch(pr, repo, opts = {}) {
|
|
|
48
44
|
pr,
|
|
49
45
|
...(cursor ? { commentsCursor: cursor } : {}),
|
|
50
46
|
});
|
|
51
|
-
const pr2 = res.data
|
|
52
|
-
if (!pr2)
|
|
53
|
-
throw new Error(`PR #${pr} not found`);
|
|
47
|
+
const pr2 = requireRawPr(res.data, pr, repo);
|
|
54
48
|
return pr2.comments;
|
|
55
49
|
}, raw.comments.pageInfo.startCursor);
|
|
56
50
|
rawCommentNodes = [...extra, ...rawCommentNodes];
|
|
@@ -66,9 +60,7 @@ export async function fetchPrBatch(pr, repo, opts = {}) {
|
|
|
66
60
|
pr,
|
|
67
61
|
...(cursor ? { changesRequestedCursor: cursor } : {}),
|
|
68
62
|
});
|
|
69
|
-
const pr2 = res.data
|
|
70
|
-
if (!pr2)
|
|
71
|
-
throw new Error(`PR #${pr} not found`);
|
|
63
|
+
const pr2 = requireRawPr(res.data, pr, repo);
|
|
72
64
|
return pr2.changesRequestedReviews;
|
|
73
65
|
}, raw.changesRequestedReviews.pageInfo.startCursor);
|
|
74
66
|
rawReviewNodes = [...extra, ...rawReviewNodes];
|
|
@@ -83,9 +75,7 @@ export async function fetchPrBatch(pr, repo, opts = {}) {
|
|
|
83
75
|
pr,
|
|
84
76
|
...(cursor ? { reviewSummariesCursor: cursor } : {}),
|
|
85
77
|
});
|
|
86
|
-
const pr2 = res.data
|
|
87
|
-
if (!pr2)
|
|
88
|
-
throw new Error(`PR #${pr} not found`);
|
|
78
|
+
const pr2 = requireRawPr(res.data, pr, repo);
|
|
89
79
|
return pr2.reviewSummaries;
|
|
90
80
|
}, raw.reviewSummaries.pageInfo.startCursor);
|
|
91
81
|
rawReviewSummaryNodes = [...extra, ...rawReviewSummaryNodes];
|
|
@@ -103,15 +93,13 @@ export async function fetchPrBatch(pr, repo, opts = {}) {
|
|
|
103
93
|
pr,
|
|
104
94
|
...(cursor ? { approvedReviewsCursor: cursor } : {}),
|
|
105
95
|
});
|
|
106
|
-
const pr2 = res.data
|
|
107
|
-
if (!pr2)
|
|
108
|
-
throw new Error(`PR #${pr} not found`);
|
|
96
|
+
const pr2 = requireRawPr(res.data, pr, repo);
|
|
109
97
|
return pr2.approvedReviews;
|
|
110
98
|
}, raw.approvedReviews.pageInfo.startCursor);
|
|
111
99
|
rawApprovedReviewNodes = [...extra, ...rawApprovedReviewNodes];
|
|
112
100
|
}
|
|
113
101
|
// Paginate check contexts forward if the first page is incomplete.
|
|
114
|
-
let rawCheckNodes = raw.commits.nodes[0]?.commit.statusCheckRollup?.contexts.nodes ?? [];
|
|
102
|
+
let rawCheckNodes = requireContextNodes(raw.commits.nodes[0]?.commit.statusCheckRollup?.contexts.nodes ?? []);
|
|
115
103
|
const checksPageInfo = raw.commits.nodes[0]?.commit.statusCheckRollup?.contexts.pageInfo;
|
|
116
104
|
const firstOid = raw.commits.nodes[0]?.commit.oid;
|
|
117
105
|
if (checksPageInfo?.hasNextPage && checksPageInfo.endCursor) {
|
|
@@ -125,7 +113,7 @@ export async function fetchPrBatch(pr, repo, opts = {}) {
|
|
|
125
113
|
pr,
|
|
126
114
|
...(cursor ? { checksCursor: cursor } : {}),
|
|
127
115
|
});
|
|
128
|
-
const pr2 = res.data
|
|
116
|
+
const pr2 = requireRawPr(res.data, pr, repo);
|
|
129
117
|
if (!pr2?.commits.nodes[0]?.commit.statusCheckRollup) {
|
|
130
118
|
throw new Error(`Check-context pagination interrupted: statusCheckRollup disappeared on page ${pageCount + 2} (possible force-push race). Retry after the push stabilizes.`);
|
|
131
119
|
}
|
|
@@ -135,7 +123,9 @@ export async function fetchPrBatch(pr, repo, opts = {}) {
|
|
|
135
123
|
}
|
|
136
124
|
pageCount++;
|
|
137
125
|
const ctxs = pr2.commits.nodes[0]?.commit.statusCheckRollup?.contexts;
|
|
138
|
-
|
|
126
|
+
if (!ctxs)
|
|
127
|
+
return { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] };
|
|
128
|
+
return { ...ctxs, nodes: requireContextNodes(ctxs.nodes) };
|
|
139
129
|
}, checksPageInfo.endCursor);
|
|
140
130
|
rawCheckNodes = [...rawCheckNodes, ...extra];
|
|
141
131
|
}
|
package/bin/github/errors.mjs
CHANGED
|
@@ -1,12 +1,38 @@
|
|
|
1
|
-
|
|
1
|
+
import { EXIT, ShepherdError } from "../exit-codes.mjs";
|
|
2
|
+
// GitHub's GraphQL API reports field-level permission failures (e.g. a fine-grained
|
|
3
|
+
// PAT missing a scope) as an `errors[].message` entry at HTTP 200, not as an HTTP
|
|
4
|
+
// 401/403 — the transport-level request succeeded even though one field could not
|
|
5
|
+
// be resolved. Status alone can't see this, so classification must also inspect the
|
|
6
|
+
// GraphQL error messages themselves.
|
|
7
|
+
const GRAPHQL_PERMISSION_ERROR = /resource not accessible/i;
|
|
8
|
+
function hasPermissionError(graphqlErrors) {
|
|
9
|
+
return graphqlErrors?.some((e) => GRAPHQL_PERMISSION_ERROR.test(e.message)) ?? false;
|
|
10
|
+
}
|
|
11
|
+
function classifyStatus(status, rateLimit, retryAfterSeconds, graphqlErrors) {
|
|
12
|
+
// Retry signals take priority over everything else: GitHub's secondary rate limit
|
|
13
|
+
// returns 403 with a Retry-After header, which is a transient throttle — not the
|
|
14
|
+
// permission-denied 403 a bad/missing token produces. Treat any retry signal as
|
|
15
|
+
// TEMPFAIL first so it isn't shadowed by the checks below.
|
|
16
|
+
const rateLimitExhausted = rateLimit !== undefined && rateLimit.remaining <= 0;
|
|
17
|
+
if (status === 429 || status >= 500 || retryAfterSeconds !== undefined || rateLimitExhausted) {
|
|
18
|
+
return EXIT.TEMPFAIL;
|
|
19
|
+
}
|
|
20
|
+
if (status === 401 || status === 403 || hasPermissionError(graphqlErrors))
|
|
21
|
+
return EXIT.NOPERM;
|
|
22
|
+
return EXIT.UNAVAILABLE;
|
|
23
|
+
}
|
|
24
|
+
export class GitHubRequestError extends ShepherdError {
|
|
2
25
|
status;
|
|
3
26
|
rateLimit;
|
|
4
27
|
retryAfterSeconds;
|
|
28
|
+
graphqlErrors;
|
|
5
29
|
constructor(message, opts) {
|
|
6
|
-
super(message
|
|
30
|
+
super(message, opts.exitCodeOverride ??
|
|
31
|
+
classifyStatus(opts.status, opts.rateLimit, opts.retryAfterSeconds, opts.graphqlErrors));
|
|
7
32
|
this.name = "GitHubRequestError";
|
|
8
33
|
this.status = opts.status;
|
|
9
34
|
this.rateLimit = opts.rateLimit;
|
|
10
35
|
this.retryAfterSeconds = opts.retryAfterSeconds;
|
|
36
|
+
this.graphqlErrors = opts.graphqlErrors;
|
|
11
37
|
}
|
|
12
38
|
}
|