pr-shepherd 0.9.0 → 0.10.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 +2 -2
- package/bin/checks/triage.mjs +32 -40
- package/bin/cli/fence.mjs +4 -0
- package/bin/cli/fix-formatter.mjs +21 -24
- package/bin/cli/formatters.mjs +33 -51
- package/bin/cli/handlers.mjs +1 -1
- package/bin/cli/iterate-formatter.mjs +20 -35
- package/bin/cli/iterate-lean.mjs +0 -3
- package/bin/cli/list-formatters.mjs +32 -0
- package/bin/cli/suggestion-renderer.mjs +31 -0
- package/bin/cli-parser.iterate-fixtures.mjs +1 -2
- package/bin/cli-parser.mjs +27 -3
- package/bin/commands/check.mjs +1 -2
- package/bin/commands/commit-suggestion.mjs +12 -10
- package/bin/commands/iterate/classify.mjs +5 -29
- package/bin/commands/iterate/escalate.mjs +3 -12
- package/bin/commands/iterate/fix-code.mjs +4 -9
- package/bin/commands/iterate/render.mjs +9 -2
- package/bin/commands/log-file.mjs +7 -0
- package/bin/commands/monitor.mjs +7 -4
- package/bin/commands/ready-delay.mjs +2 -2
- package/bin/commands/resolve-instructions.mjs +5 -2
- package/bin/commands/resolve.mjs +1 -20
- package/bin/commands/status.mjs +40 -28
- package/bin/comments/resolve.mjs +75 -65
- package/bin/config.json +2 -2
- package/bin/github/batch-parsers.mjs +2 -0
- package/bin/github/client.mjs +11 -32
- package/bin/github/gql/batch-pr.gql +4 -0
- package/bin/github/gql/get-pr-head-sha.gql +7 -0
- package/bin/github/http.mjs +166 -11
- package/bin/github/queries.mjs +7 -11
- package/bin/log/log-file.mjs +88 -0
- package/bin/log/session.mjs +100 -0
- package/bin/log/setup.mjs +54 -0
- package/bin/reporters/agent.mjs +14 -1
- package/bin/reporters/text.mjs +81 -102
- package/bin/state/base.mjs +5 -0
- package/bin/state/fix-attempts.mjs +2 -2
- package/bin/state/iterate-stall.mjs +2 -2
- package/bin/state/seen-comments.mjs +2 -2
- package/bin/suggestions/extract.mjs +15 -0
- package/bin/suggestions/parse.mjs +1 -26
- package/bin/util/markdown.mjs +7 -0
- package/bin/util/worktree.mjs +23 -0
- package/package.json +2 -2
- package/bin/github/gql/dismiss-review.gql +0 -7
- package/bin/github/gql/minimize-comment.gql +0 -7
- package/bin/github/gql/multi-pr-status.gql +0 -32
- package/bin/github/gql/resolve-thread.gql +0 -7
|
@@ -3,7 +3,7 @@ import { readFile, writeFile, unlink } from "node:fs/promises";
|
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { promisify } from "node:util";
|
|
6
|
-
import { getRepoInfo, getCurrentPrNumber,
|
|
6
|
+
import { getRepoInfo, getCurrentPrNumber, getCurrentBranch } from "../github/client.mjs";
|
|
7
7
|
import { fetchPrBatch } from "../github/batch.mjs";
|
|
8
8
|
import { applyResolveOptions } from "../comments/resolve.mjs";
|
|
9
9
|
import { parseSuggestion, isCommittableSuggestion } from "../suggestions/parse.mjs";
|
|
@@ -25,19 +25,21 @@ export async function runCommitSuggestion(opts) {
|
|
|
25
25
|
if (prNumber === null) {
|
|
26
26
|
throw new Error("No open PR found for current branch. Pass a PR number explicitly.");
|
|
27
27
|
}
|
|
28
|
-
const head = await getPrHead(prNumber, repo.owner, repo.name);
|
|
29
28
|
const currentBranch = await getCurrentBranch();
|
|
30
|
-
if (currentBranch !== head.ref) {
|
|
31
|
-
throw new Error(`Current branch "${currentBranch}" does not match PR head branch "${head.ref}". ` +
|
|
32
|
-
`Check out "${head.ref}" before applying suggestions.`);
|
|
33
|
-
}
|
|
34
29
|
const { stdout: localHeadOut } = await execFile("git", ["rev-parse", "HEAD"]);
|
|
35
30
|
const localHeadSha = localHeadOut.trim();
|
|
36
|
-
if (localHeadSha !== head.sha) {
|
|
37
|
-
throw new Error(`Local HEAD ${localHeadSha} does not match PR head ${head.sha}. ` +
|
|
38
|
-
`Pull/rebase "${head.ref}" to the latest PR head and try again.`);
|
|
39
|
-
}
|
|
40
31
|
const { data } = await fetchPrBatch(prNumber, repo);
|
|
32
|
+
if (!data.headRepoWithOwner) {
|
|
33
|
+
throw new Error(`PR #${prNumber} head repository is unavailable (fork may have been deleted).`);
|
|
34
|
+
}
|
|
35
|
+
if (currentBranch !== data.headRefName) {
|
|
36
|
+
throw new Error(`Current branch "${currentBranch}" does not match PR head branch "${data.headRefName}". ` +
|
|
37
|
+
`Check out "${data.headRefName}" before applying suggestions.`);
|
|
38
|
+
}
|
|
39
|
+
if (localHeadSha !== data.headRefOid) {
|
|
40
|
+
throw new Error(`Local HEAD ${localHeadSha} does not match PR head ${data.headRefOid}. ` +
|
|
41
|
+
`Pull/rebase "${data.headRefName}" to the latest PR head and try again.`);
|
|
42
|
+
}
|
|
41
43
|
const thread = data.reviewThreads.find((t) => t.id === opts.threadId);
|
|
42
44
|
if (!thread) {
|
|
43
45
|
throw new Error(`Thread ${opts.threadId} not found on PR #${prNumber}.`);
|
|
@@ -7,32 +7,7 @@ export function classifyReviewSummaries(summaries, approvals, minimizeApprovals)
|
|
|
7
7
|
}
|
|
8
8
|
return { minimizeIds, surfacedApprovals: approvals };
|
|
9
9
|
}
|
|
10
|
-
|
|
11
|
-
// Conservative: only match explicit known patterns to avoid accidentally suppressing real reviews.
|
|
12
|
-
const NOISE_PATTERNS = [
|
|
13
|
-
/you have reached your daily quota/i,
|
|
14
|
-
/please wait up to \d+ hours?/i,
|
|
15
|
-
/rate[\s-]?limit(?:ed)?\s*[-—:]\s*try again/i,
|
|
16
|
-
/resuming (monitoring|watch|checking)/i,
|
|
17
|
-
/restarting (monitoring|watch)/i,
|
|
18
|
-
];
|
|
19
|
-
function isNoiseComment(comment) {
|
|
20
|
-
return NOISE_PATTERNS.some((p) => p.test(comment.body));
|
|
21
|
-
}
|
|
22
|
-
export function classifyComments(comments) {
|
|
23
|
-
const actionable = [];
|
|
24
|
-
const noiseIds = [];
|
|
25
|
-
for (const c of comments) {
|
|
26
|
-
if (isNoiseComment(c)) {
|
|
27
|
-
noiseIds.push(c.id);
|
|
28
|
-
}
|
|
29
|
-
else {
|
|
30
|
-
actionable.push(c);
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
return { actionable, noiseIds };
|
|
34
|
-
}
|
|
35
|
-
export function buildResolveCommand(threads, actionableComments, allCommentIds, reviews, checks, prNumber) {
|
|
10
|
+
export function buildResolveCommand(threads, allCommentIds, reviews, checks, prNumber) {
|
|
36
11
|
const argv = ["npx", "pr-shepherd", "resolve", String(prNumber)];
|
|
37
12
|
if (threads.length > 0) {
|
|
38
13
|
argv.push("--resolve-thread-ids", threads.map((t) => t.id).join(","));
|
|
@@ -45,9 +20,10 @@ export function buildResolveCommand(threads, actionableComments, allCommentIds,
|
|
|
45
20
|
argv.push("--dismiss-review-ids", reviews.map((r) => r.id).join(","));
|
|
46
21
|
argv.push("--message", "$DISMISS_MESSAGE");
|
|
47
22
|
}
|
|
48
|
-
// A push
|
|
49
|
-
//
|
|
50
|
-
|
|
23
|
+
// A push is required when threads, CI failures, or changes-requested reviews are present — the
|
|
24
|
+
// CLI knows those imply code edits. Comments are surfaced for the agent to evaluate; the CLI
|
|
25
|
+
// cannot know whether a given comment will require a push, so comments are excluded here.
|
|
26
|
+
const requiresHeadSha = threads.length > 0 || checks.length > 0 || reviews.length > 0;
|
|
51
27
|
// hasMutations = we appended at least one of --resolve-thread-ids,
|
|
52
28
|
// --minimize-comment-ids, or --dismiss-review-ids. Returned explicitly
|
|
53
29
|
// (rather than derived from argv.length) so callers don't couple to the
|
|
@@ -31,8 +31,8 @@ export function checkEscalateTriggers(actionableThreads, actionableComments, cha
|
|
|
31
31
|
/**
|
|
32
32
|
* Validate the base branch name from the GraphQL batch (`report.baseBranch`)
|
|
33
33
|
* and fall back safely if it's missing/unsafe. The branch is interpolated into
|
|
34
|
-
* shell commands by `
|
|
35
|
-
*
|
|
34
|
+
* shell commands by `buildFixInstructions`, so we reject anything outside
|
|
35
|
+
* `[A-Za-z0-9._/-]` to prevent shell injection.
|
|
36
36
|
*/
|
|
37
37
|
export function validateBaseBranch(raw) {
|
|
38
38
|
const trimmed = raw.trim();
|
|
@@ -52,18 +52,9 @@ export function validateBaseBranch(raw) {
|
|
|
52
52
|
}
|
|
53
53
|
return { branch: trimmed, isFallback: false };
|
|
54
54
|
}
|
|
55
|
-
export function buildRebaseShellScript(baseBranch) {
|
|
56
|
-
return [
|
|
57
|
-
`if ! git diff --quiet || ! git diff --cached --quiet; then`,
|
|
58
|
-
` echo "SKIP rebase: dirty worktree (uncommitted changes present)"`,
|
|
59
|
-
` exit 1`,
|
|
60
|
-
`fi`,
|
|
61
|
-
`git fetch origin && git rebase origin/${baseBranch} && git push --force-with-lease`,
|
|
62
|
-
].join("\n");
|
|
63
|
-
}
|
|
64
55
|
export function buildEscalateHumanMessage(escalate, pr) {
|
|
65
56
|
const lines = [];
|
|
66
|
-
lines.push("⚠️
|
|
57
|
+
lines.push("⚠️ /pr-shepherd:monitor paused — needs human direction");
|
|
67
58
|
lines.push("");
|
|
68
59
|
lines.push(`**Triggers:** ${escalate.triggers.map((t) => `\`${t}\``).join(", ")}`);
|
|
69
60
|
lines.push("");
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFixAttempts, writeFixAttempts } from "../../state/fix-attempts.mjs";
|
|
2
2
|
import { toAgentThread, toAgentComment, toAgentChecks } from "../../reporters/agent.mjs";
|
|
3
3
|
import { checkEscalateTriggers, validateBaseBranch, buildEscalateSuggestion, buildEscalateHumanMessage, } from "./escalate.mjs";
|
|
4
|
-
import {
|
|
4
|
+
import { buildResolveCommand } from "./classify.mjs";
|
|
5
5
|
import { buildFixInstructions } from "./render.mjs";
|
|
6
6
|
import { applyStallGuard } from "./stall.mjs";
|
|
7
7
|
import { tryCancelRun } from "./helpers.mjs";
|
|
@@ -48,16 +48,12 @@ export async function handleFixCode(ctx) {
|
|
|
48
48
|
}
|
|
49
49
|
const baseLookup = validateBaseBranch(report.baseBranch);
|
|
50
50
|
const threads = report.threads.actionable.map(toAgentThread);
|
|
51
|
-
const
|
|
51
|
+
const actionableComments = report.comments.actionable.map(toAgentComment);
|
|
52
52
|
const checks = toAgentChecks(failingChecks);
|
|
53
53
|
const { changesRequestedReviews } = report;
|
|
54
54
|
const hasConflicts = report.mergeStatus.status === "CONFLICTS";
|
|
55
|
-
const allCommentIds = [
|
|
56
|
-
|
|
57
|
-
...noiseCommentIds,
|
|
58
|
-
...reviewSummaryIds,
|
|
59
|
-
];
|
|
60
|
-
const resolveCommand = buildResolveCommand(threads, actionableComments, allCommentIds, changesRequestedReviews, checks, prNumber);
|
|
55
|
+
const allCommentIds = [...actionableComments.map((c) => c.id), ...reviewSummaryIds];
|
|
56
|
+
const resolveCommand = buildResolveCommand(threads, allCommentIds, changesRequestedReviews, checks, prNumber);
|
|
61
57
|
if (baseLookup.isFallback && (resolveCommand.requiresHeadSha || hasConflicts)) {
|
|
62
58
|
const fallbackEscalateBase = {
|
|
63
59
|
triggers: ["base-branch-unknown"],
|
|
@@ -86,7 +82,6 @@ export async function handleFixCode(ctx) {
|
|
|
86
82
|
mode: "rebase-and-push",
|
|
87
83
|
threads,
|
|
88
84
|
actionableComments,
|
|
89
|
-
noiseCommentIds,
|
|
90
85
|
reviewSummaryIds,
|
|
91
86
|
surfacedApprovals,
|
|
92
87
|
checks,
|
|
@@ -30,14 +30,21 @@ export function renderResolveCommand(rc) {
|
|
|
30
30
|
}
|
|
31
31
|
export function buildFixInstructions(threads, actionableComments, checks, reviews, baseBranch, resolveCommand, hasConflicts, prNumber, cancelledCount, firstLookThreads = [], firstLookComments = []) {
|
|
32
32
|
const instructions = [];
|
|
33
|
+
const hasSuggestions = threads.some((t) => t.suggestion);
|
|
34
|
+
if (hasSuggestions) {
|
|
35
|
+
instructions.push(`For each thread marked \`[suggestion]\` under \`## Review threads\`: run \`npx pr-shepherd commit-suggestion ${prNumber} --thread-id <id> --message "<one-sentence headline>" --format=json\`, one thread at a time. On \`applied: true\` the CLI already resolved the thread — remove its ID from \`--resolve-thread-ids\` in the \`resolve:\` command below. On \`applied: false\` read \`reason\` and \`patch\`, fall through to the manual-edit step, and do not retry the same command. Optionally pass \`--dry-run\` (omitting \`--message\`) to preview the patch without mutating the working tree.`);
|
|
36
|
+
}
|
|
33
37
|
if (threads.length > 0 || actionableComments.length > 0) {
|
|
34
|
-
|
|
38
|
+
const suggestionFallback = hasSuggestions
|
|
39
|
+
? ` 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.`
|
|
40
|
+
: "";
|
|
41
|
+
instructions.push(`Apply code fixes: read and edit each file referenced under \`## Review threads\` and \`## Actionable comments\` above.${suggestionFallback}`);
|
|
35
42
|
}
|
|
36
43
|
const checksWithRunId = checks.filter((c) => c.runId);
|
|
37
44
|
const externalChecks = checks.filter((c) => !c.runId && c.detailsUrl);
|
|
38
45
|
const bareChecks = checks.filter((c) => !c.runId && !c.detailsUrl);
|
|
39
46
|
if (checksWithRunId.length > 0) {
|
|
40
|
-
instructions.push(`For each failing check under \`## Failing checks\` with a run ID
|
|
47
|
+
instructions.push(`For each failing check under \`## Failing checks\` with a run ID, examine the log tail in the fenced block to decide what to do:\n - If the log tail shows a transient runner or infrastructure failure (network timeout, runner setup crash, OOM kill), run \`gh run rerun <runId> --failed\` and stop this iteration — CI will re-run automatically.\n - If the log tail shows a real test or build failure, apply a code fix.\n - If the fenced log block is absent, run \`gh run view <runId> --log-failed\` first to fetch it, then choose between rerun and fix above.`);
|
|
41
48
|
}
|
|
42
49
|
if (externalChecks.length > 0) {
|
|
43
50
|
instructions.push(`For each bullet in \`## Failing checks\` starting with \`external\` (external status check): open the linked URL in a browser to inspect the failure — log tails are not available for external checks.`);
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { getRepoInfo } from "../github/client.mjs";
|
|
2
|
+
import { resolveLogPath } from "../log/log-file.mjs";
|
|
3
|
+
export async function runLogFile() {
|
|
4
|
+
const { owner, name } = await getRepoInfo();
|
|
5
|
+
const path = await resolveLogPath({ owner, repo: name });
|
|
6
|
+
return { path };
|
|
7
|
+
}
|
package/bin/commands/monitor.mjs
CHANGED
|
@@ -16,7 +16,12 @@ export async function runMonitor(opts) {
|
|
|
16
16
|
if (!Number.isFinite(maxTurns) || maxTurns <= 0 || !Number.isInteger(maxTurns)) {
|
|
17
17
|
throw new Error(`Invalid config: watch.maxTurns must be a positive integer, got ${JSON.stringify(maxTurns)}`);
|
|
18
18
|
}
|
|
19
|
-
|
|
19
|
+
// No space after `#` — `# text` is a CommonMark ATX heading; `#text` is not.
|
|
20
|
+
// Trailing `:` prevents substring false positives: without it, the dedup grep
|
|
21
|
+
// for pr=1 would match a cron prompt for pr=135. Both the CronList check in
|
|
22
|
+
// step 1 of formatMonitorResult's ## Instructions and the in-prompt Self-dedup
|
|
23
|
+
// block depend on this exact string — don't change the format.
|
|
24
|
+
const loopTag = `#pr-shepherd-loop:pr=${prNumber}:`;
|
|
20
25
|
const loopPrompt = buildLoopPrompt(prNumber, loopTag, opts.readyDelaySuffix);
|
|
21
26
|
const loopArgs = `${interval} --max-turns ${maxTurns} --expires ${expiresHours}h`;
|
|
22
27
|
return { prNumber, loopTag, loopArgs, loopPrompt };
|
|
@@ -56,9 +61,7 @@ function validateReadyDelaySuffix(readyDelaySuffix) {
|
|
|
56
61
|
}
|
|
57
62
|
function buildLoopPrompt(prNumber, loopTag, readyDelaySuffix) {
|
|
58
63
|
const validatedDelay = validateReadyDelaySuffix(readyDelaySuffix);
|
|
59
|
-
const iterateCmd = validatedDelay
|
|
60
|
-
? `npx pr-shepherd iterate ${prNumber} --no-cache --ready-delay ${validatedDelay}`
|
|
61
|
-
: `npx pr-shepherd iterate ${prNumber} --no-cache`;
|
|
64
|
+
const iterateCmd = `npx pr-shepherd iterate ${prNumber}${validatedDelay ? ` --ready-delay ${validatedDelay}` : ""}`;
|
|
62
65
|
return [
|
|
63
66
|
loopTag,
|
|
64
67
|
"",
|
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { readFile, writeFile, mkdir, unlink } from "node:fs/promises";
|
|
9
9
|
import { join, dirname } from "node:path";
|
|
10
|
-
import { tmpdir } from "node:os";
|
|
11
10
|
import { SAFE_SEGMENT } from "../util/path-segment.mjs";
|
|
11
|
+
import { resolveStateBase } from "../state/base.mjs";
|
|
12
12
|
/**
|
|
13
13
|
* Update the ready-delay state machine and return the current decision.
|
|
14
14
|
*
|
|
@@ -64,7 +64,7 @@ function readySincePath(pr, owner, repo) {
|
|
|
64
64
|
throw new Error(`Invalid path segment "${field}": ${value}`);
|
|
65
65
|
}
|
|
66
66
|
}
|
|
67
|
-
const base =
|
|
67
|
+
const base = resolveStateBase();
|
|
68
68
|
return join(base, `${owner}-${repo}`, String(pr), "ready-since.txt");
|
|
69
69
|
}
|
|
70
70
|
async function safeUnlink(path) {
|
|
@@ -28,10 +28,13 @@ export function buildFetchInstructions(prNumber, result) {
|
|
|
28
28
|
}
|
|
29
29
|
if (hasCodeItems) {
|
|
30
30
|
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.`);
|
|
31
|
-
instructions.push(`Commit changed files: \`git add <files>\` (not \`git add -A\`) \`&& git commit -m "<descriptive message>"
|
|
31
|
+
instructions.push(`Commit changed files: \`git add <files>\` (not \`git add -A\`) \`&& git commit -m "<descriptive message>"\`.`);
|
|
32
|
+
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.`);
|
|
33
|
+
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\`.`);
|
|
34
|
+
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 {}\`.`);
|
|
32
35
|
}
|
|
33
36
|
const requireShaHint = hasCodeItems
|
|
34
|
-
? ` Include \`--require-sha $(git rev-parse HEAD)\` only when the
|
|
37
|
+
? ` Include \`--require-sha $(git rev-parse HEAD)\` only when the rebase-and-push step above ran.`
|
|
35
38
|
: "";
|
|
36
39
|
const dismissNote = changesRequestedReviews.length > 0
|
|
37
40
|
? ` 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\`.`
|
package/bin/commands/resolve.mjs
CHANGED
|
@@ -3,7 +3,7 @@ import { fetchPrBatch } from "../github/batch.mjs";
|
|
|
3
3
|
import { getOutdatedThreads } from "../comments/outdated.mjs";
|
|
4
4
|
import { autoResolveOutdated, applyResolveOptions } from "../comments/resolve.mjs";
|
|
5
5
|
import { loadConfig } from "../config/load.mjs";
|
|
6
|
-
import {
|
|
6
|
+
import { extractSuggestion } from "../suggestions/extract.mjs";
|
|
7
7
|
import { buildFetchInstructions } from "./resolve-instructions.mjs";
|
|
8
8
|
import { loadSeenSet, markSeen } from "../state/seen-comments.mjs";
|
|
9
9
|
/**
|
|
@@ -80,25 +80,6 @@ export async function runResolveFetch(opts) {
|
|
|
80
80
|
};
|
|
81
81
|
return { ...result, instructions: buildFetchInstructions(prNumber, result) };
|
|
82
82
|
}
|
|
83
|
-
/**
|
|
84
|
-
* Attach a parsed suggestion block to a thread if the comment body contains one
|
|
85
|
-
* and the thread has a resolvable line anchor. Threads without `path`/`line`
|
|
86
|
-
* (rare — usually file-level comments) can't accept a suggestion commit.
|
|
87
|
-
*/
|
|
88
|
-
function extractSuggestion(thread) {
|
|
89
|
-
if (!thread.path || thread.line === null)
|
|
90
|
-
return null;
|
|
91
|
-
const parsed = parseSuggestion(thread.body);
|
|
92
|
-
if (!parsed)
|
|
93
|
-
return null;
|
|
94
|
-
const startLine = thread.startLine ?? thread.line;
|
|
95
|
-
return {
|
|
96
|
-
startLine,
|
|
97
|
-
endLine: thread.line,
|
|
98
|
-
lines: parsed.lines,
|
|
99
|
-
author: thread.author,
|
|
100
|
-
};
|
|
101
|
-
}
|
|
102
83
|
/**
|
|
103
84
|
* Mutation mode: resolve/minimize/dismiss by ID.
|
|
104
85
|
*/
|
package/bin/commands/status.mjs
CHANGED
|
@@ -2,35 +2,46 @@
|
|
|
2
2
|
* `shepherd status PR1 [PR2 PR3 …]`
|
|
3
3
|
*
|
|
4
4
|
* Fetches readiness status for one or more PRs and prints a table.
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* Issues a single GraphQL request with one alias per PR number rather than
|
|
6
|
+
* N separate requests, so the round-trip count is always 1 (plus optional
|
|
7
|
+
* per-PR pagination calls when a PR has > 100 review threads).
|
|
7
8
|
*
|
|
8
9
|
* Exit code: 0 if all PRs are READY, non-zero otherwise.
|
|
9
10
|
*/
|
|
10
11
|
import { graphql, getRepoInfo } from "../github/client.mjs";
|
|
11
|
-
import {
|
|
12
|
+
import { MULTI_PR_STATUS_QUERY_WITH_CURSOR } from "../github/queries.mjs";
|
|
12
13
|
export async function runStatus(opts) {
|
|
14
|
+
if (opts.prNumbers.length === 0)
|
|
15
|
+
return [];
|
|
13
16
|
const repo = await getRepoInfo();
|
|
14
|
-
const
|
|
17
|
+
const doc = buildBatchStatusQuery(opts.prNumbers);
|
|
18
|
+
const result = await graphql(doc, {
|
|
19
|
+
owner: repo.owner,
|
|
20
|
+
repo: repo.name,
|
|
21
|
+
});
|
|
22
|
+
const summaries = await Promise.all(opts.prNumbers.map((pr) => {
|
|
23
|
+
const rawPr = result.data.repository[`pr_${pr}`];
|
|
24
|
+
if (!rawPr) {
|
|
25
|
+
throw new Error(`PR #${pr} not found in ${repo.owner}/${repo.name}`);
|
|
26
|
+
}
|
|
27
|
+
return paginateAndBuild(pr, rawPr, repo.owner, repo.name);
|
|
28
|
+
}));
|
|
15
29
|
return summaries;
|
|
16
30
|
}
|
|
17
31
|
// ---------------------------------------------------------------------------
|
|
18
32
|
// Internal
|
|
19
33
|
// ---------------------------------------------------------------------------
|
|
20
|
-
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
}
|
|
34
|
+
function buildBatchStatusQuery(prNumbers) {
|
|
35
|
+
const uniquePrs = [...new Set(prNumbers.filter((n) => n > 0))];
|
|
36
|
+
const f = "number title state isDraft mergeStateStatus reviewDecision " +
|
|
37
|
+
"reviewThreads(last:100){totalCount pageInfo{hasPreviousPage startCursor} nodes{isResolved}} " +
|
|
38
|
+
"commits(last:1){nodes{commit{statusCheckRollup{state}}}}";
|
|
39
|
+
const aliases = uniquePrs.map((n) => `pr_${n}:pullRequest(number:${n}){${f}}`).join(" ");
|
|
40
|
+
return `query MultiPrStatusBatch($owner:String!,$repo:String!){repository(owner:$owner,name:$repo){${aliases}}}`;
|
|
41
|
+
}
|
|
42
|
+
async function paginateAndBuild(pr, p, owner, repo) {
|
|
30
43
|
let allNodes = p.reviewThreads.nodes;
|
|
31
|
-
// If the response was truncated, fetch additional pages to get the full count.
|
|
32
44
|
if (p.reviewThreads.totalCount > p.reviewThreads.nodes.length) {
|
|
33
|
-
// Fetch additional pages backward until we have all threads.
|
|
34
45
|
const MAX_THREAD_PAGES = 10;
|
|
35
46
|
let pagesFetched = 0;
|
|
36
47
|
const totalCount = p.reviewThreads.totalCount;
|
|
@@ -59,7 +70,6 @@ async function fetchSummary(pr, owner, repo) {
|
|
|
59
70
|
}
|
|
60
71
|
const unresolvedThreads = allNodes.filter((n) => !n.isResolved).length;
|
|
61
72
|
const ciState = p.commits.nodes[0]?.commit.statusCheckRollup?.state ?? null;
|
|
62
|
-
// If we still have fewer nodes than totalCount, report truncation.
|
|
63
73
|
const threadsTruncated = p.reviewThreads.totalCount > allNodes.length;
|
|
64
74
|
return {
|
|
65
75
|
number: p.number,
|
|
@@ -77,17 +87,19 @@ async function fetchSummary(pr, owner, repo) {
|
|
|
77
87
|
// Output helpers
|
|
78
88
|
// ---------------------------------------------------------------------------
|
|
79
89
|
export function formatStatusTable(summaries, repoFull) {
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
const
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
90
|
+
const heading = `# ${repoFull} — PR status (${summaries.length})`;
|
|
91
|
+
if (summaries.length === 0)
|
|
92
|
+
return heading;
|
|
93
|
+
const rows = summaries.map((s) => {
|
|
94
|
+
const raw = s.title.length > 50 ? `${s.title.slice(0, 47)}...` : s.title;
|
|
95
|
+
const title = raw.replace(/\\/g, "\\\\").replace(/\|/g, "\\|").replace(/\n/g, " ");
|
|
96
|
+
return `| #${s.number} | ${title} | ${deriveVerdict(s)} | ${s.ciState ?? "—"} |`;
|
|
97
|
+
});
|
|
98
|
+
const table = ["| PR | Title | Verdict | CI |", "| --- | --- | --- | --- |", ...rows].join("\n");
|
|
99
|
+
const footnotes = summaries
|
|
100
|
+
.filter((s) => s.threadsTruncated)
|
|
101
|
+
.map((s) => `> Note: PR #${s.number} threads truncated — run \`pr-shepherd check ${s.number}\` for full count.`);
|
|
102
|
+
return [heading, table, ...footnotes].join("\n\n");
|
|
91
103
|
}
|
|
92
104
|
export function deriveVerdict(s) {
|
|
93
105
|
if (s.state === "MERGED")
|
package/bin/comments/resolve.mjs
CHANGED
|
@@ -1,32 +1,12 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Batched mutations for resolving threads, minimizing comments, and dismissing reviews.
|
|
3
|
-
*
|
|
4
|
-
* The three mutation types (resolve / minimize / dismiss) are run sequentially so total
|
|
5
|
-
* in-flight mutations never exceed CONCURRENCY at once, keeping us well within GitHub's
|
|
6
|
-
* secondary rate-limit window.
|
|
7
|
-
*
|
|
8
|
-
* Push-before-resolve safety:
|
|
9
|
-
* When `requireSha` is set, shepherd verifies that GitHub has received that
|
|
10
|
-
* commit before issuing any resolve/dismiss mutations. It polls up to 20 seconds.
|
|
11
|
-
* If the push hasn't landed, shepherd throws rather than resolving prematurely
|
|
12
|
-
* (which could allow auto-merge before reviewers see the fix).
|
|
13
|
-
*/
|
|
14
1
|
import { graphql, getPrHeadSha } from "../github/client.mjs";
|
|
15
|
-
import { RESOLVE_THREAD_MUTATION, MINIMIZE_COMMENT_MUTATION, DISMISS_REVIEW_MUTATION, } from "../github/queries.mjs";
|
|
16
2
|
import { loadConfig } from "../config/load.mjs";
|
|
17
|
-
/**
|
|
18
|
-
* Execute all requested resolve/minimize/dismiss mutations.
|
|
19
|
-
*
|
|
20
|
-
* @throws Error if `requireSha` is set and GitHub hasn't received that commit
|
|
21
|
-
* within the polling window.
|
|
22
|
-
*/
|
|
23
3
|
export async function applyResolveOptions(pr, repo, opts) {
|
|
24
|
-
// Require --message when dismissing reviews.
|
|
25
4
|
if ((opts.dismissReviewIds?.length ?? 0) > 0 && !opts.dismissMessage) {
|
|
26
5
|
throw new Error("--message is required when dismissing reviews");
|
|
27
6
|
}
|
|
28
|
-
// Safety check: verify the push landed before resolving.
|
|
29
7
|
if (opts.requireSha) {
|
|
8
|
+
// Verify GitHub received the commit before resolving — prevents auto-merge
|
|
9
|
+
// before reviewers see the fix.
|
|
30
10
|
await waitForSha(pr, repo, opts.requireSha);
|
|
31
11
|
}
|
|
32
12
|
const result = {
|
|
@@ -35,55 +15,87 @@ export async function applyResolveOptions(pr, repo, opts) {
|
|
|
35
15
|
dismissedReviews: [],
|
|
36
16
|
errors: [],
|
|
37
17
|
};
|
|
38
|
-
await
|
|
39
|
-
await runBatched(opts.minimizeCommentIds ?? [], (id) => minimizeComment(id, "RESOLVED"), result.minimizedComments, result.errors);
|
|
40
|
-
await runBatched(opts.dismissReviewIds ?? [], (id) => dismissReview(id, opts.dismissMessage ?? ""), result.dismissedReviews, result.errors);
|
|
18
|
+
await bulkApply(opts.resolveThreadIds ?? [], opts.minimizeCommentIds ?? [], opts.dismissReviewIds ?? [], opts.dismissMessage ?? "", result);
|
|
41
19
|
return result;
|
|
42
20
|
}
|
|
43
|
-
/**
|
|
44
|
-
* Auto-resolve a batch of outdated threads via the resolveReviewThread mutation.
|
|
45
|
-
*/
|
|
46
21
|
export async function autoResolveOutdated(threadIds) {
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
async function resolveThread(threadId) {
|
|
56
|
-
await graphql(RESOLVE_THREAD_MUTATION, { threadId });
|
|
57
|
-
}
|
|
58
|
-
async function minimizeComment(commentId, classifier) {
|
|
59
|
-
await graphql(MINIMIZE_COMMENT_MUTATION, { commentId, classifier });
|
|
22
|
+
const result = {
|
|
23
|
+
resolvedThreads: [],
|
|
24
|
+
minimizedComments: [],
|
|
25
|
+
dismissedReviews: [],
|
|
26
|
+
errors: [],
|
|
27
|
+
};
|
|
28
|
+
await bulkApply(threadIds, [], [], "", result);
|
|
29
|
+
return { resolved: result.resolvedThreads, errors: result.errors };
|
|
60
30
|
}
|
|
61
|
-
|
|
62
|
-
|
|
31
|
+
// Chunk at 50 so a single oversized list never fails the entire call.
|
|
32
|
+
const BULK_CHUNK_SIZE = 50;
|
|
33
|
+
function buildBulkMutation(resolveIds, minimizeIds, dismissIds, dismissMessage) {
|
|
34
|
+
const ops = [];
|
|
35
|
+
for (let i = 0; i < resolveIds.length; i++) {
|
|
36
|
+
ops.push(` r${i}: resolveReviewThread(input: { threadId: ${JSON.stringify(resolveIds[i])} }) { thread { isResolved } }`);
|
|
37
|
+
}
|
|
38
|
+
for (let i = 0; i < minimizeIds.length; i++) {
|
|
39
|
+
ops.push(` m${i}: minimizeComment(input: { subjectId: ${JSON.stringify(minimizeIds[i])}, classifier: RESOLVED }) { minimizedComment { isMinimized } }`);
|
|
40
|
+
}
|
|
41
|
+
for (let i = 0; i < dismissIds.length; i++) {
|
|
42
|
+
ops.push(` d${i}: dismissPullRequestReview(input: { pullRequestReviewId: ${JSON.stringify(dismissIds[i])}, message: ${JSON.stringify(dismissMessage)} }) { pullRequestReview { state } }`);
|
|
43
|
+
}
|
|
44
|
+
return `mutation BulkApply {\n${ops.join("\n")}\n}`;
|
|
63
45
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
for (let i = 0; i <
|
|
71
|
-
const chunk =
|
|
46
|
+
async function bulkApply(resolveIds, minimizeIds, dismissIds, dismissMessage, result) {
|
|
47
|
+
const allOps = [
|
|
48
|
+
...resolveIds.map((id) => ({ kind: "r", id })),
|
|
49
|
+
...minimizeIds.map((id) => ({ kind: "m", id })),
|
|
50
|
+
...dismissIds.map((id) => ({ kind: "d", id })),
|
|
51
|
+
];
|
|
52
|
+
for (let i = 0; i < allOps.length; i += BULK_CHUNK_SIZE) {
|
|
53
|
+
const chunk = allOps.slice(i, i + BULK_CHUNK_SIZE);
|
|
72
54
|
// eslint-disable-next-line no-await-in-loop
|
|
73
|
-
await
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
55
|
+
await bulkApplyChunk(chunk.filter((o) => o.kind === "r").map((o) => o.id), chunk.filter((o) => o.kind === "m").map((o) => o.id), chunk.filter((o) => o.kind === "d").map((o) => o.id), dismissMessage, result);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
async function bulkApplyChunk(resolveIds, minimizeIds, dismissIds, dismissMessage, result) {
|
|
59
|
+
if (resolveIds.length === 0 && minimizeIds.length === 0 && dismissIds.length === 0)
|
|
60
|
+
return;
|
|
61
|
+
const doc = buildBulkMutation(resolveIds, minimizeIds, dismissIds, dismissMessage);
|
|
62
|
+
let data;
|
|
63
|
+
try {
|
|
64
|
+
const resp = await graphql(doc, {});
|
|
65
|
+
data = resp.data;
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
69
|
+
for (const id of resolveIds)
|
|
70
|
+
result.errors.push(`${id}: ${msg}`);
|
|
71
|
+
for (const id of minimizeIds)
|
|
72
|
+
result.errors.push(`${id}: ${msg}`);
|
|
73
|
+
for (const id of dismissIds)
|
|
74
|
+
result.errors.push(`${id}: ${msg}`);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
for (let i = 0; i < resolveIds.length; i++) {
|
|
78
|
+
const r = data[`r${i}`];
|
|
79
|
+
if (r?.thread?.isResolved === true)
|
|
80
|
+
result.resolvedThreads.push(resolveIds[i]);
|
|
81
|
+
else
|
|
82
|
+
result.errors.push(`${resolveIds[i]}: resolve returned null or thread not resolved`);
|
|
83
|
+
}
|
|
84
|
+
for (let i = 0; i < minimizeIds.length; i++) {
|
|
85
|
+
const m = data[`m${i}`];
|
|
86
|
+
if (m?.minimizedComment?.isMinimized === true)
|
|
87
|
+
result.minimizedComments.push(minimizeIds[i]);
|
|
88
|
+
else
|
|
89
|
+
result.errors.push(`${minimizeIds[i]}: minimize returned null or comment not minimized`);
|
|
90
|
+
}
|
|
91
|
+
for (let i = 0; i < dismissIds.length; i++) {
|
|
92
|
+
const d = data[`d${i}`];
|
|
93
|
+
if (d?.pullRequestReview != null)
|
|
94
|
+
result.dismissedReviews.push(dismissIds[i]);
|
|
95
|
+
else
|
|
96
|
+
result.errors.push(`${dismissIds[i]}: dismiss returned null`);
|
|
82
97
|
}
|
|
83
98
|
}
|
|
84
|
-
// ---------------------------------------------------------------------------
|
|
85
|
-
// SHA polling
|
|
86
|
-
// ---------------------------------------------------------------------------
|
|
87
99
|
async function waitForSha(pr, repo, expectedSha) {
|
|
88
100
|
const { intervalMs: SHA_POLL_INTERVAL_MS, maxAttempts: SHA_POLL_MAX_ATTEMPTS } = loadConfig().resolve.shaPoll;
|
|
89
101
|
for (let attempt = 0; attempt < SHA_POLL_MAX_ATTEMPTS; attempt++) {
|
|
@@ -94,7 +106,6 @@ async function waitForSha(pr, repo, expectedSha) {
|
|
|
94
106
|
return;
|
|
95
107
|
}
|
|
96
108
|
catch (err) {
|
|
97
|
-
// Transient network / 5xx error — keep polling unless this is the last attempt.
|
|
98
109
|
if (attempt === SHA_POLL_MAX_ATTEMPTS - 1)
|
|
99
110
|
throw err;
|
|
100
111
|
}
|
|
@@ -103,7 +114,6 @@ async function waitForSha(pr, repo, expectedSha) {
|
|
|
103
114
|
await sleep(SHA_POLL_INTERVAL_MS);
|
|
104
115
|
}
|
|
105
116
|
}
|
|
106
|
-
// Total actual wait = (SHA_POLL_MAX_ATTEMPTS - 1) * SHA_POLL_INTERVAL_MS (no sleep after last poll).
|
|
107
117
|
throw new Error(`Timeout: GitHub PR #${pr} head SHA has not updated to ${expectedSha} after ${((SHA_POLL_MAX_ATTEMPTS - 1) * SHA_POLL_INTERVAL_MS) / 1000}s. Push may still be in transit — retry shortly.`);
|
|
108
118
|
}
|
|
109
119
|
function sleep(ms) {
|
package/bin/config.json
CHANGED
|
@@ -12,7 +12,6 @@
|
|
|
12
12
|
"maxTurns": 50
|
|
13
13
|
},
|
|
14
14
|
"resolve": {
|
|
15
|
-
"concurrency": 4,
|
|
16
15
|
"shaPoll": {
|
|
17
16
|
"intervalMs": 2000,
|
|
18
17
|
"maxAttempts": 10
|
|
@@ -21,7 +20,8 @@
|
|
|
21
20
|
},
|
|
22
21
|
"checks": {
|
|
23
22
|
"ciTriggerEvents": ["pull_request", "pull_request_target"],
|
|
24
|
-
"logTailLines":
|
|
23
|
+
"logTailLines": 5,
|
|
24
|
+
"logTailChars": 200
|
|
25
25
|
},
|
|
26
26
|
"mergeStatus": {
|
|
27
27
|
"blockingReviewerLogins": ["copilot"]
|
|
@@ -96,6 +96,8 @@ export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes,
|
|
|
96
96
|
mergeStateStatus: raw.mergeStateStatus,
|
|
97
97
|
reviewDecision: (raw.reviewDecision ?? null),
|
|
98
98
|
headRefOid: raw.headRefOid,
|
|
99
|
+
headRefName: raw.headRefName,
|
|
100
|
+
headRepoWithOwner: raw.headRepository?.nameWithOwner ?? null,
|
|
99
101
|
baseRefName: raw.baseRefName,
|
|
100
102
|
reviewRequests,
|
|
101
103
|
latestReviews,
|