pr-shepherd 0.8.1 → 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 +123 -21
- package/bin/checks/classify.mjs +2 -1
- package/bin/checks/triage.mjs +63 -61
- package/bin/cli/args.mjs +9 -3
- package/bin/cli/fence.mjs +4 -0
- package/bin/cli/fix-formatter.mjs +45 -19
- package/bin/cli/formatters.mjs +61 -44
- package/bin/cli/handlers.mjs +5 -4
- package/bin/cli/iterate-formatter.mjs +51 -45
- package/bin/cli/iterate-lean.mjs +95 -0
- package/bin/cli/list-formatters.mjs +32 -0
- package/bin/cli/suggestion-renderer.mjs +31 -0
- package/bin/cli-parser.iterate-fixtures.mjs +6 -5
- package/bin/cli-parser.mjs +27 -3
- package/bin/commands/check-status.mjs +7 -4
- package/bin/commands/check.mjs +39 -3
- package/bin/commands/commit-suggestion.mjs +12 -10
- package/bin/commands/iterate/classify.mjs +10 -54
- package/bin/commands/iterate/escalate.mjs +5 -14
- package/bin/commands/iterate/fix-code.mjs +15 -16
- package/bin/commands/iterate/helpers.mjs +8 -5
- package/bin/commands/iterate/index.mjs +18 -12
- package/bin/commands/iterate/render.mjs +28 -12
- package/bin/commands/iterate/stall.mjs +1 -1
- 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 +14 -5
- package/bin/commands/resolve.mjs +38 -37
- package/bin/commands/status.mjs +41 -29
- package/bin/comments/resolve.mjs +75 -65
- package/bin/config.json +4 -7
- package/bin/github/batch-parsers.mjs +4 -0
- package/bin/github/client.mjs +11 -32
- package/bin/github/gql/batch-pr.gql +6 -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 +21 -13
- package/bin/reporters/check-instructions.mjs +9 -14
- package/bin/reporters/text.mjs +93 -105
- 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 +76 -0
- package/bin/suggestions/extract.mjs +15 -0
- package/bin/suggestions/parse.mjs +1 -26
- package/bin/types/report.mjs +0 -1
- package/bin/util/markdown.mjs +7 -0
- package/bin/util/worktree.mjs +23 -0
- package/package.json +2 -2
- package/bin/commands/iterate/steps.mjs +0 -31
- 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
|
@@ -4,13 +4,15 @@
|
|
|
4
4
|
* `buildFixInstructions` in `commands/iterate/render.mts`).
|
|
5
5
|
*/
|
|
6
6
|
export function buildFetchInstructions(prNumber, result) {
|
|
7
|
-
const { actionableThreads, actionableComments, changesRequestedReviews, reviewSummaries, commitSuggestionsEnabled, } = result;
|
|
7
|
+
const { actionableThreads, firstLookThreads, actionableComments, firstLookComments, changesRequestedReviews, reviewSummaries, commitSuggestionsEnabled, } = result;
|
|
8
|
+
const firstLookTotal = firstLookThreads.length + firstLookComments.length;
|
|
8
9
|
const total = actionableThreads.length +
|
|
9
10
|
actionableComments.length +
|
|
10
11
|
changesRequestedReviews.length +
|
|
11
|
-
reviewSummaries.length
|
|
12
|
+
reviewSummaries.length +
|
|
13
|
+
firstLookTotal;
|
|
12
14
|
if (total === 0) {
|
|
13
|
-
return ["No actionable items — end this invocation."];
|
|
15
|
+
return ["No actionable items and no first-look items — end this invocation."];
|
|
14
16
|
}
|
|
15
17
|
const hasCodeItems = actionableThreads.length > 0 ||
|
|
16
18
|
actionableComments.length > 0 ||
|
|
@@ -18,15 +20,21 @@ export function buildFetchInstructions(prNumber, result) {
|
|
|
18
20
|
const hasSuggestions = commitSuggestionsEnabled && actionableThreads.some((t) => t.suggestion != null);
|
|
19
21
|
const instructions = [];
|
|
20
22
|
instructions.push(`Classify every item listed above into exactly one of: Fixed / Actionable / Not relevant / Outdated / Acknowledge. Do not silently skip any item. Bot-authored review summaries (authors whose name contains \`[bot]\` or matches \`copilot-pull-request-reviewer\`, \`gemini-code-assist\`) default to Acknowledge with reason "bot summary — no actionable content" unless the body calls out an unaddressed issue.`);
|
|
23
|
+
if (firstLookTotal > 0) {
|
|
24
|
+
instructions.push(`Items in \`## First-look items\` are for acknowledgement only — do not pass their IDs to \`--resolve-thread-ids\`, \`--minimize-comment-ids\`, or \`--dismiss-review-ids\`. Acknowledge each one with a one-line classification (e.g. "outdated — addressed by commit abc1234", "resolved — already fixed", "minimized — noise").`);
|
|
25
|
+
}
|
|
21
26
|
if (hasSuggestions) {
|
|
22
27
|
instructions.push(`For each Actionable thread marked \`[suggestion]\` in \`## Actionable Review Threads\` above: run \`npx pr-shepherd commit-suggestion ${prNumber} --thread-id <id> --message "<one-sentence headline>" --format=json\`, one thread at a time. On \`applied: true\` mark it Fixed — the CLI already resolved the thread, so exclude the ID from \`--resolve-thread-ids\`. On \`applied: false\` read \`reason\` and \`patch\`, then fall through to the manual fix step — do not retry the same command. Optionally pass \`--dry-run\` (omitting \`--message\`) if you want to inspect the unified diff before it mutates the working tree — the CLI validates with \`git apply --check\`, returns the patch and \`valid: true/false\`, and exits \`1\` on drift without committing or resolving the thread.`);
|
|
23
28
|
}
|
|
24
29
|
if (hasCodeItems) {
|
|
25
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.`);
|
|
26
|
-
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 {}\`.`);
|
|
27
35
|
}
|
|
28
36
|
const requireShaHint = hasCodeItems
|
|
29
|
-
? ` 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.`
|
|
30
38
|
: "";
|
|
31
39
|
const dismissNote = changesRequestedReviews.length > 0
|
|
32
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\`.`
|
|
@@ -34,6 +42,7 @@ export function buildFetchInstructions(prNumber, result) {
|
|
|
34
42
|
? ` Review-summary IDs (\`PRR_…\` from \`## Review summaries\`) go into \`--minimize-comment-ids\`.`
|
|
35
43
|
: "";
|
|
36
44
|
instructions.push(`Run \`npx pr-shepherd resolve ${prNumber} [--resolve-thread-ids <ids>] [--minimize-comment-ids <ids>] [--dismiss-review-ids <ids> --message "<reason>"]\` with only the non-empty flag subsets. Skip the command entirely if all three ID lists are empty.${requireShaHint}${dismissNote}`);
|
|
45
|
+
instructions.push(`For any large decisions or rejections you made this iteration, add or update a \`## Shepherd Journal\` section in the PR description (\`gh pr edit ${prNumber} --body …\`) summarizing each decision. For threads and comments, use the markdown link shown in each item's bullet above; for reviews, reference the review ID.`);
|
|
37
46
|
instructions.push(`Report: echo the CLI's mutation output, then one line per Acknowledged item: \`Acknowledged <id> (@<author>): <reason>\`. If any fetched item was neither resolved nor acknowledged, stop and escalate: "<N> item(s) fetched but not acted on or acknowledged — need human direction before closing".`);
|
|
38
47
|
return instructions;
|
|
39
48
|
}
|
package/bin/commands/resolve.mjs
CHANGED
|
@@ -1,25 +1,11 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `shepherd resolve [PR] [flags]`
|
|
3
|
-
*
|
|
4
|
-
* Two modes:
|
|
5
|
-
*
|
|
6
|
-
* Fetch mode (--fetch or no mutation flags):
|
|
7
|
-
* Auto-resolves outdated threads and returns all active threads,
|
|
8
|
-
* visible comments, and CHANGES_REQUESTED reviews for LLM triage.
|
|
9
|
-
* Sonnet reads this output, applies code fixes, pushes, then calls
|
|
10
|
-
* resolve in mutation mode to resolve/minimize/dismiss by ID.
|
|
11
|
-
*
|
|
12
|
-
* Mutation mode (--resolve-thread-ids, --minimize-comment-ids, --dismiss-review-ids):
|
|
13
|
-
* Resolves/minimizes/dismisses by ID. Optionally verifies the push
|
|
14
|
-
* has landed on GitHub before mutating (--require-sha).
|
|
15
|
-
*/
|
|
16
1
|
import { getRepoInfo, getCurrentPrNumber } from "../github/client.mjs";
|
|
17
2
|
import { fetchPrBatch } from "../github/batch.mjs";
|
|
18
3
|
import { getOutdatedThreads } from "../comments/outdated.mjs";
|
|
19
4
|
import { autoResolveOutdated, applyResolveOptions } from "../comments/resolve.mjs";
|
|
20
5
|
import { loadConfig } from "../config/load.mjs";
|
|
21
|
-
import {
|
|
6
|
+
import { extractSuggestion } from "../suggestions/extract.mjs";
|
|
22
7
|
import { buildFetchInstructions } from "./resolve-instructions.mjs";
|
|
8
|
+
import { loadSeenSet, markSeen } from "../state/seen-comments.mjs";
|
|
23
9
|
/**
|
|
24
10
|
* Fetch mode: auto-resolve outdated threads and return all active items for LLM triage.
|
|
25
11
|
*/
|
|
@@ -31,12 +17,26 @@ export async function runResolveFetch(opts) {
|
|
|
31
17
|
}
|
|
32
18
|
// Always bypass cache for resolve — we need fresh data before mutating.
|
|
33
19
|
const { data } = await fetchPrBatch(prNumber, repo);
|
|
20
|
+
const stateKey = { owner: repo.owner, repo: repo.name, pr: prNumber };
|
|
34
21
|
const unresolvedThreads = data.reviewThreads.filter((t) => !t.isResolved && !t.isMinimized);
|
|
35
22
|
const visibleComments = data.comments.filter((c) => !c.isMinimized);
|
|
36
|
-
//
|
|
23
|
+
// First-look: collect items that would normally be hidden and check seen markers.
|
|
24
|
+
const outdatedCandidates = data.reviewThreads.filter((t) => t.isOutdated);
|
|
25
|
+
const resolvedCandidates = data.reviewThreads.filter((t) => t.isResolved && !t.isOutdated);
|
|
26
|
+
const minimizedThreadCandidates = data.reviewThreads.filter((t) => t.isMinimized && !t.isResolved && !t.isOutdated);
|
|
27
|
+
const minimizedCommentCandidates = data.comments.filter((c) => c.isMinimized);
|
|
28
|
+
const seenSet = await loadSeenSet(stateKey);
|
|
29
|
+
const unseenOutdated = outdatedCandidates.filter((t) => !seenSet.has(t.id));
|
|
30
|
+
const unseenResolved = resolvedCandidates.filter((t) => !seenSet.has(t.id));
|
|
31
|
+
const unseenMinimizedThreads = minimizedThreadCandidates.filter((t) => !seenSet.has(t.id));
|
|
32
|
+
const unseenMinimizedComments = minimizedCommentCandidates.filter((c) => !seenSet.has(c.id));
|
|
33
|
+
// Auto-resolve outdated (same as before — fires regardless of first-look status).
|
|
37
34
|
const outdated = getOutdatedThreads(unresolvedThreads);
|
|
35
|
+
const autoResolvedIds = new Set();
|
|
38
36
|
if (outdated.length > 0) {
|
|
39
|
-
const { errors } = await autoResolveOutdated(outdated.map((t) => t.id));
|
|
37
|
+
const { resolved: resolvedIds, errors } = await autoResolveOutdated(outdated.map((t) => t.id));
|
|
38
|
+
for (const id of resolvedIds)
|
|
39
|
+
autoResolvedIds.add(id);
|
|
40
40
|
if (errors.length > 0) {
|
|
41
41
|
process.stderr.write(`pr-shepherd: auto-resolve outdated threads failed (continuing): ${errors.join(", ")}\n`);
|
|
42
42
|
}
|
|
@@ -50,35 +50,36 @@ export async function runResolveFetch(opts) {
|
|
|
50
50
|
thread.suggestion = suggestion;
|
|
51
51
|
return thread;
|
|
52
52
|
});
|
|
53
|
+
const firstLookThreads = [
|
|
54
|
+
...unseenOutdated.map((t) => ({
|
|
55
|
+
...t,
|
|
56
|
+
firstLookStatus: "outdated",
|
|
57
|
+
autoResolved: autoResolvedIds.has(t.id),
|
|
58
|
+
})),
|
|
59
|
+
...unseenResolved.map((t) => ({ ...t, firstLookStatus: "resolved" })),
|
|
60
|
+
...unseenMinimizedThreads.map((t) => ({ ...t, firstLookStatus: "minimized" })),
|
|
61
|
+
];
|
|
62
|
+
const firstLookComments = unseenMinimizedComments.map((c) => ({
|
|
63
|
+
...c,
|
|
64
|
+
firstLookStatus: "minimized",
|
|
65
|
+
}));
|
|
66
|
+
// Mark first-look items as seen (best-effort — markSeen never throws).
|
|
67
|
+
await Promise.allSettled([
|
|
68
|
+
...firstLookThreads.map((t) => markSeen(stateKey, t.id)),
|
|
69
|
+
...firstLookComments.map((c) => markSeen(stateKey, c.id)),
|
|
70
|
+
]);
|
|
53
71
|
const result = {
|
|
54
72
|
prNumber,
|
|
55
73
|
actionableThreads,
|
|
74
|
+
firstLookThreads,
|
|
56
75
|
actionableComments: visibleComments,
|
|
76
|
+
firstLookComments,
|
|
57
77
|
changesRequestedReviews: data.changesRequestedReviews,
|
|
58
78
|
reviewSummaries: cfg.resolve.fetchReviewSummaries ? data.reviewSummaries : [],
|
|
59
79
|
commitSuggestionsEnabled: cfg.actions.commitSuggestions,
|
|
60
80
|
};
|
|
61
81
|
return { ...result, instructions: buildFetchInstructions(prNumber, result) };
|
|
62
82
|
}
|
|
63
|
-
/**
|
|
64
|
-
* Attach a parsed suggestion block to a thread if the comment body contains one
|
|
65
|
-
* and the thread has a resolvable line anchor. Threads without `path`/`line`
|
|
66
|
-
* (rare — usually file-level comments) can't accept a suggestion commit.
|
|
67
|
-
*/
|
|
68
|
-
function extractSuggestion(thread) {
|
|
69
|
-
if (!thread.path || thread.line === null)
|
|
70
|
-
return null;
|
|
71
|
-
const parsed = parseSuggestion(thread.body);
|
|
72
|
-
if (!parsed)
|
|
73
|
-
return null;
|
|
74
|
-
const startLine = thread.startLine ?? thread.line;
|
|
75
|
-
return {
|
|
76
|
-
startLine,
|
|
77
|
-
endLine: thread.line,
|
|
78
|
-
lines: parsed.lines,
|
|
79
|
-
author: thread.author,
|
|
80
|
-
};
|
|
81
|
-
}
|
|
82
83
|
/**
|
|
83
84
|
* Mutation mode: resolve/minimize/dismiss by ID.
|
|
84
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")
|
|
@@ -102,7 +114,7 @@ export function deriveVerdict(s) {
|
|
|
102
114
|
s.reviewDecision !== "CHANGES_REQUESTED") {
|
|
103
115
|
return "READY";
|
|
104
116
|
}
|
|
105
|
-
if (s.mergeStateStatus === "BLOCKED")
|
|
117
|
+
if (s.mergeStateStatus === "BLOCKED" || s.mergeStateStatus === "HAS_HOOKS")
|
|
106
118
|
return "BLOCKED";
|
|
107
119
|
if (s.mergeStateStatus === "DIRTY")
|
|
108
120
|
return "CONFLICTS";
|
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
|
@@ -3,11 +3,7 @@
|
|
|
3
3
|
"cooldownSeconds": 30,
|
|
4
4
|
"fixAttemptsPerThread": 3,
|
|
5
5
|
"stallTimeoutMinutes": 30,
|
|
6
|
-
"
|
|
7
|
-
"bots": true,
|
|
8
|
-
"humans": true,
|
|
9
|
-
"approvals": false
|
|
10
|
-
}
|
|
6
|
+
"minimizeApprovals": false
|
|
11
7
|
},
|
|
12
8
|
"watch": {
|
|
13
9
|
"interval": "4m",
|
|
@@ -16,7 +12,6 @@
|
|
|
16
12
|
"maxTurns": 50
|
|
17
13
|
},
|
|
18
14
|
"resolve": {
|
|
19
|
-
"concurrency": 4,
|
|
20
15
|
"shaPoll": {
|
|
21
16
|
"intervalMs": 2000,
|
|
22
17
|
"maxAttempts": 10
|
|
@@ -24,7 +19,9 @@
|
|
|
24
19
|
"fetchReviewSummaries": true
|
|
25
20
|
},
|
|
26
21
|
"checks": {
|
|
27
|
-
"ciTriggerEvents": ["pull_request", "pull_request_target"]
|
|
22
|
+
"ciTriggerEvents": ["pull_request", "pull_request_target"],
|
|
23
|
+
"logTailLines": 5,
|
|
24
|
+
"logTailChars": 200
|
|
28
25
|
},
|
|
29
26
|
"mergeStatus": {
|
|
30
27
|
"blockingReviewerLogins": ["copilot"]
|
|
@@ -19,6 +19,7 @@ export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes,
|
|
|
19
19
|
startLine: comment?.startLine ?? null,
|
|
20
20
|
author: comment?.author?.login ?? "unknown",
|
|
21
21
|
body: comment?.body ?? "",
|
|
22
|
+
url: comment?.url ?? "",
|
|
22
23
|
createdAtUnix: comment ? parseCreatedAt(comment.createdAt) : 0,
|
|
23
24
|
};
|
|
24
25
|
});
|
|
@@ -27,6 +28,7 @@ export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes,
|
|
|
27
28
|
isMinimized: c.isMinimized,
|
|
28
29
|
author: c.author?.login ?? "unknown",
|
|
29
30
|
body: c.body,
|
|
31
|
+
url: c.url,
|
|
30
32
|
createdAtUnix: parseCreatedAt(c.createdAt),
|
|
31
33
|
}));
|
|
32
34
|
const changesRequestedReviews = rawReviewNodes.map((r) => ({
|
|
@@ -94,6 +96,8 @@ export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes,
|
|
|
94
96
|
mergeStateStatus: raw.mergeStateStatus,
|
|
95
97
|
reviewDecision: (raw.reviewDecision ?? null),
|
|
96
98
|
headRefOid: raw.headRefOid,
|
|
99
|
+
headRefName: raw.headRefName,
|
|
100
|
+
headRepoWithOwner: raw.headRepository?.nameWithOwner ?? null,
|
|
97
101
|
baseRefName: raw.baseRefName,
|
|
98
102
|
reviewRequests,
|
|
99
103
|
latestReviews,
|
package/bin/github/client.mjs
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { execFile as execFileCb } from "node:child_process";
|
|
9
9
|
import { promisify } from "node:util";
|
|
10
10
|
import { graphql as httpGraphql, rest } from "./http.mjs";
|
|
11
|
-
import { PR_NUMBER_BY_BRANCH_QUERY } from "./queries.mjs";
|
|
11
|
+
import { PR_NUMBER_BY_BRANCH_QUERY, GET_PR_HEAD_SHA_QUERY } from "./queries.mjs";
|
|
12
12
|
const execFile = promisify(execFileCb);
|
|
13
13
|
// ---------------------------------------------------------------------------
|
|
14
14
|
// GraphQL — thin re-exports so callers don't need to import http.mts directly
|
|
@@ -42,38 +42,17 @@ export async function getCurrentPrNumber() {
|
|
|
42
42
|
}
|
|
43
43
|
/** Returns the `headRefOid` (commit SHA) of the given PR as reported by GitHub. */
|
|
44
44
|
export async function getPrHeadSha(pr, owner, name) {
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
if (!data.head.repo) {
|
|
55
|
-
throw new Error(`PR #${pr} head repository is unavailable (fork may have been deleted).`);
|
|
56
|
-
}
|
|
57
|
-
return {
|
|
58
|
-
sha: data.head.sha,
|
|
59
|
-
ref: data.head.ref,
|
|
60
|
-
repoWithOwner: data.head.repo.full_name,
|
|
61
|
-
};
|
|
62
|
-
}
|
|
63
|
-
/**
|
|
64
|
-
* Fetch a file's raw text content at a given ref. Uses the REST contents endpoint,
|
|
65
|
-
* which returns base64 for files under 1MB. Throws for binary / oversize files.
|
|
66
|
-
*/
|
|
67
|
-
export async function getFileContents(repoWithOwner, path, ref) {
|
|
68
|
-
const data = await rest("GET", `/repos/${repoWithOwner}/contents/${encodePathForApi(path)}?ref=${encodeURIComponent(ref)}`);
|
|
69
|
-
if (!data.content || data.encoding !== "base64") {
|
|
70
|
-
throw new Error(`File ${path} could not be read as text (encoding=${data.encoding ?? "n/a"}).`);
|
|
45
|
+
const result = await httpGraphql(GET_PR_HEAD_SHA_QUERY, { owner, repo: name, pr });
|
|
46
|
+
const sha = result.data.repository?.pullRequest?.headRefOid;
|
|
47
|
+
if (!sha) {
|
|
48
|
+
const detail = !result.data.repository
|
|
49
|
+
? "repository not found or access denied"
|
|
50
|
+
: !result.data.repository.pullRequest
|
|
51
|
+
? "PR not found or access denied"
|
|
52
|
+
: "headRefOid missing";
|
|
53
|
+
throw new Error(`Could not resolve head SHA for ${owner}/${name} PR #${pr}: ${detail}`);
|
|
71
54
|
}
|
|
72
|
-
return
|
|
73
|
-
}
|
|
74
|
-
// Encode every path segment but preserve the slashes between them.
|
|
75
|
-
function encodePathForApi(path) {
|
|
76
|
-
return path.split("/").map(encodeURIComponent).join("/");
|
|
55
|
+
return sha;
|
|
77
56
|
}
|
|
78
57
|
/**
|
|
79
58
|
* Fetches `mergeable` and `mergeStateStatus` via the REST API.
|
|
@@ -19,6 +19,10 @@ query BatchPr(
|
|
|
19
19
|
mergeStateStatus
|
|
20
20
|
reviewDecision
|
|
21
21
|
headRefOid
|
|
22
|
+
headRefName
|
|
23
|
+
headRepository {
|
|
24
|
+
nameWithOwner
|
|
25
|
+
}
|
|
22
26
|
baseRefName
|
|
23
27
|
# Not paginated — capped at 50; PRs with more pending reviewers truncate silently.
|
|
24
28
|
reviewRequests(last: 50) {
|
|
@@ -58,6 +62,7 @@ query BatchPr(
|
|
|
58
62
|
nodes {
|
|
59
63
|
id
|
|
60
64
|
isMinimized
|
|
65
|
+
url
|
|
61
66
|
author {
|
|
62
67
|
login
|
|
63
68
|
}
|
|
@@ -78,6 +83,7 @@ query BatchPr(
|
|
|
78
83
|
nodes {
|
|
79
84
|
id
|
|
80
85
|
isMinimized
|
|
86
|
+
url
|
|
81
87
|
author {
|
|
82
88
|
login
|
|
83
89
|
}
|