pr-shepherd 0.23.0 → 0.24.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 -1
- package/bin/cli/fix-formatter.mjs +2 -1
- package/bin/cli/iterate-instructions.mjs +1 -1
- package/bin/cli/list-formatters.mjs +3 -2
- package/bin/commands/check.mjs +6 -2
- package/bin/commands/iterate/classify.mjs +13 -7
- package/bin/commands/iterate/fix-code.mjs +2 -2
- package/bin/commands/iterate/index.mjs +4 -1
- package/bin/commands/iterate/render.mjs +3 -0
- package/bin/commands/iterate/stall.mjs +1 -1
- package/bin/commands/resolve-instructions.mjs +5 -2
- package/bin/commands/resolve-mutate.mjs +14 -6
- package/bin/commands/resolve.mjs +7 -3
- package/bin/comments/authors.mjs +19 -1
- package/bin/comments/minimize-policy.mjs +6 -5
- package/bin/comments/review-thread-markers.mjs +18 -0
- package/bin/comments/thread-visibility.mjs +5 -5
- package/bin/comments/visible-comments.mjs +2 -2
- package/bin/config/load.mjs +7 -0
- package/bin/config.json +14 -0
- package/bin/github/batch-parsers.mjs +2 -0
- package/bin/github/client.mjs +1 -2
- package/bin/github/gql/batch-pr.gql +3 -0
- package/bin/reporters/agent.mjs +1 -0
- package/bin/state/seen-comments.mjs +40 -4
- package/package.json +8 -4
- package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
- package/bin/checks/triage.test-support.mjs +0 -60
- package/bin/cli/iterate-lean.test-support.mjs +0 -3
- package/bin/cli-parser.clean.test-support.mjs +0 -44
- package/bin/cli-parser.commit-suggestion.test-support.mjs +0 -65
- package/bin/cli-parser.iterate-fix.test-support.mjs +0 -43
- package/bin/cli-parser.iterate-fixtures.mjs +0 -75
- package/bin/cli-parser.iterate.test-support.mjs +0 -44
- package/bin/cli-parser.test-support.mjs +0 -47
- package/bin/commands/check.test-support.mjs +0 -147
- package/bin/commands/clean.test-support.mjs +0 -47
- package/bin/commands/commit-suggestion.apply.test-support.mjs +0 -87
- package/bin/commands/commit-suggestion.test-support.mjs +0 -112
- package/bin/commands/iterate-stall.test-support.mjs +0 -24
- package/bin/commands/iterate-test-support.mjs +0 -150
- package/bin/commands/iterate-thread-test-support.mjs +0 -18
- package/bin/commands/iterate.fix-code-in-progress.test-support.mjs +0 -127
- package/bin/commands/poll.test-support.mjs +0 -77
- package/bin/commands/resolve.test-support.mjs +0 -116
- package/bin/commands/shepherd-journal.test-support.mjs +0 -7
- package/bin/comments/outdated.mjs +0 -15
- package/bin/comments/resolve.test-support.mjs +0 -44
- package/bin/github/batch-parsers.test-support.mjs +0 -66
- package/bin/github/batch.test-support.mjs +0 -66
- package/bin/github/client.test-support.mjs +0 -55
- package/bin/github/http.test-support.mjs +0 -51
- package/bin/state/seen-comments.test-support.mjs +0 -19
- package/bin/suggestions/patch.test-support.mjs +0 -2
package/README.md
CHANGED
|
@@ -59,7 +59,8 @@ See [docs/actions.md](docs/actions.md) for the complete output contract.
|
|
|
59
59
|
This system is opinionated and works best with PRs that use required status checks and conversation resolution.
|
|
60
60
|
|
|
61
61
|
- Human-authored threads are replied to, not resolved or minimized by Shepherd.
|
|
62
|
-
-
|
|
62
|
+
- Detected bots and configured `botUsernames` review threads are returned until resolved; bot/non-human threads, PR comments, and review summaries can be resolved or minimized when eligible. Review summaries are not minimized while known inline child threads from that review remain unresolved.
|
|
63
|
+
- Agents must not reply to their own latest thread reply; generated instructions call this out before `--reply-thread-ids` mutations.
|
|
63
64
|
- Every review thread/comment/review summary is surfaced at least once, even if already outdated, resolved, or minimized; edited items re-surface through seen markers.
|
|
64
65
|
- Draft PRs can be marked ready automatically when clean; disable with `actions.autoMarkReady: false` or `--no-auto-mark-ready`.
|
|
65
66
|
- The CLI never performs git mutations. It emits instructions; the caller commits, rebases, pushes, and handles repository hooks.
|
|
@@ -12,9 +12,10 @@ export function formatFixCodeResult(header, result, opts) {
|
|
|
12
12
|
const lineLabel = renderLineRange(t.startLine, t.line);
|
|
13
13
|
const loc = t.path ? `\`${t.path}:${lineLabel}\`` : "(no location)";
|
|
14
14
|
const heading = t.url ? `[threadId=${t.id}](${t.url})` : `\`threadId=${t.id}\``;
|
|
15
|
+
const reviewMarker = t.reviewId ? ` [reviewId=${t.reviewId}]` : "";
|
|
15
16
|
const suggestionMarker = t.suggestion ? " [suggestion]" : "";
|
|
16
17
|
const editedMarker = t.edited ? " [edited since first look]" : "";
|
|
17
|
-
sections.push(`### ${heading} — ${loc} (${renderAuthor(t.author, t.authorType)})${suggestionMarker}${editedMarker}`);
|
|
18
|
+
sections.push(`### ${heading} — ${loc} (${renderAuthor(t.author, t.authorType)})${reviewMarker}${suggestionMarker}${editedMarker}`);
|
|
18
19
|
sections.push(renderThreadConversation(t));
|
|
19
20
|
if (t.suggestion) {
|
|
20
21
|
sections.push(renderSuggestionBlock(t.suggestion, ""));
|
|
@@ -32,7 +32,7 @@ export function adaptFixCodeInstructions(instructions, pr, readyDelaySuffix) {
|
|
|
32
32
|
export function adaptIterateLog(log) {
|
|
33
33
|
return log.replace(/\s+—\s+\d+s until auto-cancel/g, "");
|
|
34
34
|
}
|
|
35
|
-
|
|
35
|
+
function buildIterateCommand(pr, readyDelaySuffix) {
|
|
36
36
|
const suffix = readyDelaySuffix?.trim();
|
|
37
37
|
return buildPrShepherdCommand([String(pr), ...(suffix ? ["--ready-delay", suffix] : [])]).text;
|
|
38
38
|
}
|
|
@@ -9,7 +9,7 @@ export function renderBodyPreview(body) {
|
|
|
9
9
|
const firstLine = normalizedBody.split("\n")[0]?.trim() ?? "";
|
|
10
10
|
return firstLine.slice(0, BODY_PREVIEW_MAX);
|
|
11
11
|
}
|
|
12
|
-
|
|
12
|
+
function renderFirstLookStatusTag(t) {
|
|
13
13
|
const editedSuffix = t.edited ? ", edited" : "";
|
|
14
14
|
return t.autoResolved
|
|
15
15
|
? `[status: outdated, auto-resolved${editedSuffix}]`
|
|
@@ -28,8 +28,9 @@ export function renderThreadBullet(t, opts = {}) {
|
|
|
28
28
|
: "`(no location)`";
|
|
29
29
|
const suggestionMarker = t.suggestion ? " [suggestion]" : "";
|
|
30
30
|
const editedMarker = t.edited && !opts.suppressEditedMarker ? " [edited since first look]" : "";
|
|
31
|
+
const reviewMarker = t.reviewId ? ` [reviewId=${t.reviewId}]` : "";
|
|
31
32
|
const statusSuffix = opts.statusTag ? ` ${opts.statusTag}` : "";
|
|
32
|
-
const bulletLine = `- \`threadId=${t.id}\`${link} ${loc} (${renderAuthor(t.author, t.authorType)})${suggestionMarker}${editedMarker}${statusSuffix}`;
|
|
33
|
+
const bulletLine = `- \`threadId=${t.id}\`${link} ${loc} (${renderAuthor(t.author, t.authorType)})${reviewMarker}${suggestionMarker}${editedMarker}${statusSuffix}`;
|
|
33
34
|
if (!opts.noBody && (!t.comments || t.comments.length === 0)) {
|
|
34
35
|
const legacyLine = `${bulletLine}: ${renderBodyPreview(t.body)}`;
|
|
35
36
|
return t.suggestion && opts.renderSuggestion
|
package/bin/commands/check.mjs
CHANGED
|
@@ -14,6 +14,8 @@ import { loadSeenMap, markSeen, classifyItem } from "../state/seen-comments.mjs"
|
|
|
14
14
|
import { threadTranscriptBody } from "../threads/transcript.mjs";
|
|
15
15
|
import { classifyThreadVisibility } from "../comments/thread-visibility.mjs";
|
|
16
16
|
import { classifyReviewsForDisplay } from "../comments/review-visibility.mjs";
|
|
17
|
+
import { markReviewInlineThreadMarkers } from "../comments/review-thread-markers.mjs";
|
|
18
|
+
import { normalizeBotUsernames } from "../comments/authors.mjs";
|
|
17
19
|
export async function runCheck(opts) {
|
|
18
20
|
const repo = await getRepoInfo();
|
|
19
21
|
const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
|
|
@@ -43,10 +45,11 @@ export async function runCheck(opts) {
|
|
|
43
45
|
const triagedBase = failing.length > 0 && !opts.skipTriage ? await triageFailingChecks(failing, repo) : failing;
|
|
44
46
|
const stateKey = { owner: repo.owner, repo: repo.name, pr: prNumber };
|
|
45
47
|
const seenMap = await loadSeenMap(stateKey);
|
|
48
|
+
const botUsernames = normalizeBotUsernames(config.botUsernames);
|
|
46
49
|
const triaged = await attachUnseenCheckAnnotations(triagedBase, seenMap, prNumber);
|
|
47
50
|
const minimizedCommentCandidates = batchData.comments.filter((c) => c.isMinimized);
|
|
48
|
-
const visibleCommentClassification = classifyVisibleComments(batchData.comments, seenMap, config.iterate.minimizeComments);
|
|
49
|
-
const threadVisibility = classifyThreadVisibility(batchData.reviewThreads, seenMap);
|
|
51
|
+
const visibleCommentClassification = classifyVisibleComments(batchData.comments, seenMap, config.iterate.minimizeComments, botUsernames);
|
|
52
|
+
const threadVisibility = classifyThreadVisibility(batchData.reviewThreads, seenMap, botUsernames);
|
|
50
53
|
const firstLookComments = minimizedCommentCandidates.flatMap((c) => {
|
|
51
54
|
const cls = classifyItem(c.id, c.body, seenMap);
|
|
52
55
|
if (cls === "unchanged")
|
|
@@ -76,6 +79,7 @@ export async function runCheck(opts) {
|
|
|
76
79
|
...changesRequestedReviewVisibility.toMarkSeen.map((r) => markSeen(stateKey, r.id, r.body)),
|
|
77
80
|
...approvedReviewVisibility.toMarkSeen.map((r) => markSeen(stateKey, r.id, r.body)),
|
|
78
81
|
]);
|
|
82
|
+
await markReviewInlineThreadMarkers(stateKey, batchData.reviewThreads);
|
|
79
83
|
const changesRequestedReviews = changesRequestedReviewVisibility.visible;
|
|
80
84
|
const changesRequestedReviewCount = batchData.changesRequestedReviews.length;
|
|
81
85
|
const approvedReviews = approvedReviewVisibility.visible;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { buildPrShepherdCommand } from "../../cli/runner.mjs";
|
|
2
2
|
import { shouldMinimizeAuthor } from "../../comments/minimize-policy.mjs";
|
|
3
|
-
import { isHumanAuthor } from "../../comments/authors.mjs";
|
|
3
|
+
import { isConfiguredBotAuthor, isHumanAuthor, } from "../../comments/authors.mjs";
|
|
4
4
|
function dedupeIds(ids) {
|
|
5
5
|
const seen = new Set();
|
|
6
6
|
const out = [];
|
|
@@ -12,17 +12,19 @@ function dedupeIds(ids) {
|
|
|
12
12
|
}
|
|
13
13
|
return out;
|
|
14
14
|
}
|
|
15
|
-
export function classifyReviewSummaries(summaries, approvals, minimizeApprovals, minimizeComments = "all") {
|
|
15
|
+
export function classifyReviewSummaries(summaries, approvals, minimizeApprovals, minimizeComments = "all", botUsernames = new Set(), unresolvedThreads = []) {
|
|
16
|
+
const blockedReviewIds = new Set(unresolvedThreads.flatMap((t) => (t.reviewId !== undefined ? [t.reviewId] : [])));
|
|
16
17
|
// First-look and seen summaries go into the minimize mutation; edited summaries do NOT —
|
|
17
18
|
// they are already minimized server-side (body changed after minimize was applied).
|
|
18
19
|
// First-look bodies are rendered so the agent sees them before the minimize happens.
|
|
19
20
|
const minimizeIds = [...summaries.firstLook, ...summaries.seen]
|
|
20
|
-
.filter((r) => shouldMinimizeAuthor(r.authorType, minimizeComments, r.author))
|
|
21
|
+
.filter((r) => shouldMinimizeAuthor(r.authorType, minimizeComments, r.author, botUsernames))
|
|
22
|
+
.filter((r) => !blockedReviewIds.has(r.id))
|
|
21
23
|
.map((r) => r.id);
|
|
22
24
|
if (minimizeApprovals) {
|
|
23
25
|
const surfacedApprovals = [];
|
|
24
26
|
for (const r of approvals) {
|
|
25
|
-
if (shouldMinimizeAuthor(r.authorType, minimizeComments, r.author))
|
|
27
|
+
if (shouldMinimizeAuthor(r.authorType, minimizeComments, r.author, botUsernames))
|
|
26
28
|
minimizeIds.push(r.id);
|
|
27
29
|
else
|
|
28
30
|
surfacedApprovals.push(r);
|
|
@@ -41,11 +43,15 @@ export function classifyReviewSummaries(summaries, approvals, minimizeApprovals,
|
|
|
41
43
|
surfacedApprovals: approvals,
|
|
42
44
|
};
|
|
43
45
|
}
|
|
44
|
-
export function buildResolveCommand(threads, resolutionOnlyThreads, allCommentIds, _reviews, checks, prNumber) {
|
|
46
|
+
export function buildResolveCommand(threads, resolutionOnlyThreads, allCommentIds, _reviews, checks, prNumber, botUsernames = new Set()) {
|
|
45
47
|
const argv = buildPrShepherdCommand(["resolve", String(prNumber)]).argv;
|
|
46
48
|
const allThreads = [...threads, ...resolutionOnlyThreads];
|
|
47
|
-
const replyThreadIds = dedupeIds(allThreads
|
|
48
|
-
|
|
49
|
+
const replyThreadIds = dedupeIds(allThreads
|
|
50
|
+
.filter((t) => isHumanAuthor(t) && !isConfiguredBotAuthor(t, botUsernames))
|
|
51
|
+
.map((t) => t.id));
|
|
52
|
+
const resolveThreadIds = dedupeIds(allThreads
|
|
53
|
+
.filter((t) => !isHumanAuthor(t) || isConfiguredBotAuthor(t, botUsernames))
|
|
54
|
+
.map((t) => t.id));
|
|
49
55
|
if (replyThreadIds.length > 0) {
|
|
50
56
|
argv.push("--reply-thread-ids", replyThreadIds.join(","));
|
|
51
57
|
argv.push("--message", "$DISMISS_MESSAGE");
|
|
@@ -25,7 +25,7 @@ function nextFixAttempts(stored, headSha, threads) {
|
|
|
25
25
|
return { threadAttempts, threadBodyHashes };
|
|
26
26
|
}
|
|
27
27
|
export async function handleFixCode(ctx) {
|
|
28
|
-
const { base, report, opts, headSha, stallKey, prNumber, stallTimeoutSeconds, repoOwner, repoName, reviewSummaryIds, firstLookSummaries, editedSummaries, surfacedApprovals, } = ctx;
|
|
28
|
+
const { base, report, opts, headSha, stallKey, prNumber, stallTimeoutSeconds, repoOwner, repoName, reviewSummaryIds, firstLookSummaries, editedSummaries, surfacedApprovals, botUsernames, } = ctx;
|
|
29
29
|
const failingChecks = report.checks.failing;
|
|
30
30
|
const stored = await readFixAttempts({ owner: repoOwner, repo: repoName, pr: prNumber });
|
|
31
31
|
const { threadAttempts, threadBodyHashes } = nextFixAttempts(stored, headSha, report.threads.actionable);
|
|
@@ -76,7 +76,7 @@ export async function handleFixCode(ctx) {
|
|
|
76
76
|
const inProgressRunIds = pushLikely ? buildInProgressRunIds(report, cancelledSet) : [];
|
|
77
77
|
const commentMinimizeIds = report.comments.minimizeIds ?? actionableComments.map((c) => c.id);
|
|
78
78
|
const allCommentIds = [...commentMinimizeIds, ...reviewSummaryIds];
|
|
79
|
-
const resolveCommand = buildResolveCommand(threads, resolutionOnlyThreads, allCommentIds, changesRequestedReviews, checks, prNumber);
|
|
79
|
+
const resolveCommand = buildResolveCommand(threads, resolutionOnlyThreads, allCommentIds, changesRequestedReviews, checks, prNumber, botUsernames);
|
|
80
80
|
// Safety: if the base branch is unknown, escalate when a push is plausible — the agent
|
|
81
81
|
// would need the correct base to rebase safely. This is a conservative guard, not a
|
|
82
82
|
// prediction that the agent *will* push. Intentionally broader than `pushLikely` above:
|
|
@@ -9,8 +9,10 @@ import { classifyReviewSummaries } from "./classify.mjs";
|
|
|
9
9
|
import { applyStallGuard } from "./stall.mjs";
|
|
10
10
|
import { clearStallState } from "../../state/iterate-stall.mjs";
|
|
11
11
|
import { handleFixCode } from "./fix-code.mjs";
|
|
12
|
+
import { normalizeBotUsernames } from "../../comments/authors.mjs";
|
|
12
13
|
export async function runIterate(opts) {
|
|
13
14
|
const config = loadConfig();
|
|
15
|
+
const botUsernames = normalizeBotUsernames(config.botUsernames);
|
|
14
16
|
const readyDelaySeconds = opts.readyDelaySeconds ?? config.watch.readyDelayMinutes * 60;
|
|
15
17
|
const stallTimeoutSeconds = opts.stallTimeoutSeconds ?? config.iterate.stallTimeoutMinutes * 60;
|
|
16
18
|
const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
|
|
@@ -56,7 +58,7 @@ export async function runIterate(opts) {
|
|
|
56
58
|
firstLook: report.firstLookSummaries,
|
|
57
59
|
seen: report.reviewSummaries,
|
|
58
60
|
edited: report.editedSummaries,
|
|
59
|
-
}, report.approvedReviews, config.iterate.minimizeApprovals, config.iterate.minimizeComments);
|
|
61
|
+
}, report.approvedReviews, config.iterate.minimizeApprovals, config.iterate.minimizeComments, botUsernames, [...report.threads.actionable, ...report.threads.resolutionOnly]);
|
|
60
62
|
const hasActionableWork = report.threads.actionable.length > 0 ||
|
|
61
63
|
report.threads.resolutionOnly.length > 0 ||
|
|
62
64
|
report.threads.firstLook.length > 0 ||
|
|
@@ -122,6 +124,7 @@ export async function runIterate(opts) {
|
|
|
122
124
|
firstLookSummaries,
|
|
123
125
|
editedSummaries,
|
|
124
126
|
surfacedApprovals,
|
|
127
|
+
botUsernames,
|
|
125
128
|
});
|
|
126
129
|
}
|
|
127
130
|
const canMarkReady = report.status === "READY" &&
|
|
@@ -78,6 +78,9 @@ export function buildFixInstructions(threads, actionableComments, checks, change
|
|
|
78
78
|
instructions.push(`If you applied code edits: commit them with a descriptive message, then rebase onto \`origin/${baseBranch}\` per your repository's conventions before pushing.`);
|
|
79
79
|
}
|
|
80
80
|
if (resolveCommand.hasMutations) {
|
|
81
|
+
if ((resolveCommand.replyThreadIds?.length ?? 0) > 0) {
|
|
82
|
+
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.`);
|
|
83
|
+
}
|
|
81
84
|
const substituteParts = [];
|
|
82
85
|
if (resolveCommand.requiresHeadSha) {
|
|
83
86
|
substituteParts.push(`\`$HEAD_SHA\` with the pushed commit SHA (or \`$(git rev-parse HEAD)\` if you did not push)`);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readStallState, writeStallState } from "../../state/iterate-stall.mjs";
|
|
2
2
|
import { toAgentThread, toAgentComment, toAgentStalledCheck } from "../../reporters/agent.mjs";
|
|
3
3
|
import { buildEscalateSuggestion, buildEscalateHumanMessage } from "./escalate.mjs";
|
|
4
|
-
|
|
4
|
+
function computeStallFingerprint(action, headSha, base, report, reviewSummaryIds) {
|
|
5
5
|
const checks = [
|
|
6
6
|
...report.checks.failing.map((f) => `failing:${f.name}:${f.conclusion}`),
|
|
7
7
|
...report.checks.inProgress.map((p) => `inProgress:${p.name}`),
|
|
@@ -51,8 +51,11 @@ export function buildFetchInstructions(prNumber, result) {
|
|
|
51
51
|
: reviewSummaries.length > 0
|
|
52
52
|
? ` Review-summary IDs (\`PRR_…\` from \`## Review summaries\`) go into \`--minimize-comment-ids\`.`
|
|
53
53
|
: "";
|
|
54
|
-
const resolveCommand = `${buildPrShepherdCommand(["resolve", String(prNumber)]).text} [--resolve-thread-ids <ids>] [--minimize-comment-ids <ids>] [--dismiss-review-ids <ids> --message "<reason>"]`;
|
|
55
|
-
|
|
54
|
+
const resolveCommand = `${buildPrShepherdCommand(["resolve", String(prNumber)]).text} [--reply-thread-ids <ids>] [--resolve-thread-ids <ids>] [--minimize-comment-ids <ids>] [--dismiss-review-ids <ids>] [--message "<reason>"]`;
|
|
55
|
+
if (actionableThreads.length > 0 || resolutionOnlyThreads.length > 0) {
|
|
56
|
+
instructions.push(`Do not reply to your own thread comments. If the latest visible comment in a thread is your own prior Shepherd reply, leave that thread out of \`--reply-thread-ids\`.`);
|
|
57
|
+
}
|
|
58
|
+
instructions.push(`Run \`${resolveCommand}\` with only the non-empty flag subsets. Skip the command entirely if all ID lists are empty.${requireShaHint}${dismissNote}`);
|
|
56
59
|
instructions.push(buildShepherdJournalInstruction(prNumber, SHEPHERD_JOURNAL_REFERENCE_GUIDANCE_THREADS_AND_COMMENTS_IN_ITEMS));
|
|
57
60
|
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".`);
|
|
58
61
|
return instructions;
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { getRepoInfo, getCurrentPrNumber } from "../github/client.mjs";
|
|
2
2
|
import { applyResolveOptions } from "../comments/resolve.mjs";
|
|
3
3
|
import { fetchPrBatch } from "../github/batch.mjs";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
4
|
+
import { loadConfig } from "../config/load.mjs";
|
|
5
|
+
import { isConfiguredBotAuthor, isHumanAuthor, normalizeBotUsernames, } from "../comments/authors.mjs";
|
|
6
|
+
import { markReplySeen } from "../state/seen-comments.mjs";
|
|
6
7
|
import { threadTranscriptBody } from "../threads/transcript.mjs";
|
|
7
8
|
export async function runResolveMutate(opts) {
|
|
8
9
|
const repo = await getRepoInfo();
|
|
@@ -11,11 +12,17 @@ export async function runResolveMutate(opts) {
|
|
|
11
12
|
throw new Error("No open PR found for current branch. Pass a PR number explicitly.");
|
|
12
13
|
}
|
|
13
14
|
const { data } = await fetchPrBatch(prNumber, repo, { paginateApprovedReviews: true });
|
|
15
|
+
const config = loadConfig();
|
|
16
|
+
const botUsernames = normalizeBotUsernames(config.botUsernames);
|
|
14
17
|
const threadById = new Map(data.reviewThreads.map((t) => [t.id, t]));
|
|
15
|
-
const humanThreadIds = new Set(data.reviewThreads
|
|
16
|
-
|
|
18
|
+
const humanThreadIds = new Set(data.reviewThreads
|
|
19
|
+
.filter((t) => isHumanAuthor(t) && !isConfiguredBotAuthor(t, botUsernames))
|
|
20
|
+
.map((t) => t.id));
|
|
21
|
+
const humanCommentIds = new Set(data.comments
|
|
22
|
+
.filter((c) => isHumanAuthor(c) && !isConfiguredBotAuthor(c, botUsernames))
|
|
23
|
+
.map((c) => c.id));
|
|
17
24
|
const humanReviewIds = new Set([...data.reviewSummaries, ...data.approvedReviews, ...data.changesRequestedReviews]
|
|
18
|
-
.filter(isHumanAuthor)
|
|
25
|
+
.filter((r) => isHumanAuthor(r) && !isConfiguredBotAuthor(r, botUsernames))
|
|
19
26
|
.map((r) => r.id));
|
|
20
27
|
const resolveThreadIds = (opts.resolveThreadIds ?? []).filter((id) => !humanThreadIds.has(id));
|
|
21
28
|
const skippedHumanResolves = (opts.resolveThreadIds ?? []).filter((id) => humanThreadIds.has(id));
|
|
@@ -46,7 +53,8 @@ export async function runResolveMutate(opts) {
|
|
|
46
53
|
const thread = threadById.get(id);
|
|
47
54
|
if (!thread)
|
|
48
55
|
return Promise.resolve();
|
|
49
|
-
|
|
56
|
+
const previousBody = threadTranscriptBody(thread);
|
|
57
|
+
return markReplySeen({ owner: repo.owner, repo: repo.name, pr: prNumber }, id, previousBody, threadTranscriptBody(thread, [opts.dismissMessage]), opts.dismissMessage);
|
|
50
58
|
}));
|
|
51
59
|
}
|
|
52
60
|
return result;
|
package/bin/commands/resolve.mjs
CHANGED
|
@@ -8,6 +8,8 @@ import { loadSeenMap, markSeen, classifyItem } from "../state/seen-comments.mjs"
|
|
|
8
8
|
import { threadTranscriptBody } from "../threads/transcript.mjs";
|
|
9
9
|
import { classifyThreadVisibility } from "../comments/thread-visibility.mjs";
|
|
10
10
|
import { classifyReviewsForDisplay } from "../comments/review-visibility.mjs";
|
|
11
|
+
import { markReviewInlineThreadMarkers } from "../comments/review-thread-markers.mjs";
|
|
12
|
+
import { normalizeBotUsernames } from "../comments/authors.mjs";
|
|
11
13
|
export { runResolveMutate } from "./resolve-mutate.mjs";
|
|
12
14
|
export async function runResolveFetch(opts) {
|
|
13
15
|
const repo = await getRepoInfo();
|
|
@@ -19,7 +21,9 @@ export async function runResolveFetch(opts) {
|
|
|
19
21
|
const stateKey = { owner: repo.owner, repo: repo.name, pr: prNumber };
|
|
20
22
|
const minimizedCommentCandidates = data.comments.filter((c) => c.isMinimized);
|
|
21
23
|
const seenMap = await loadSeenMap(stateKey);
|
|
22
|
-
const
|
|
24
|
+
const cfg = loadConfig();
|
|
25
|
+
const botUsernames = normalizeBotUsernames(cfg.botUsernames);
|
|
26
|
+
const threadVisibility = classifyThreadVisibility(data.reviewThreads, seenMap, botUsernames);
|
|
23
27
|
const unseenMinimizedComments = [];
|
|
24
28
|
const editedMinimizedComments = [];
|
|
25
29
|
for (const c of minimizedCommentCandidates) {
|
|
@@ -29,8 +33,7 @@ export async function runResolveFetch(opts) {
|
|
|
29
33
|
else if (cls === "edited")
|
|
30
34
|
editedMinimizedComments.push(c);
|
|
31
35
|
}
|
|
32
|
-
const
|
|
33
|
-
const visibleCommentClassification = classifyVisibleComments(data.comments, seenMap, cfg.iterate?.minimizeComments);
|
|
36
|
+
const visibleCommentClassification = classifyVisibleComments(data.comments, seenMap, cfg.iterate?.minimizeComments, botUsernames);
|
|
34
37
|
const changesRequestedReviewVisibility = classifyReviewsForDisplay(data.changesRequestedReviews, seenMap);
|
|
35
38
|
const reviewSummaryVisibility = cfg.resolve.fetchReviewSummaries
|
|
36
39
|
? classifyReviewsForDisplay(data.reviewSummaries, seenMap)
|
|
@@ -58,6 +61,7 @@ export async function runResolveFetch(opts) {
|
|
|
58
61
|
...changesRequestedReviewVisibility.toMarkSeen.map((r) => markSeen(stateKey, r.id, r.body)),
|
|
59
62
|
...reviewSummaryVisibility.toMarkSeen.map((r) => markSeen(stateKey, r.id, r.body)),
|
|
60
63
|
]);
|
|
64
|
+
await markReviewInlineThreadMarkers(stateKey, data.reviewThreads);
|
|
61
65
|
const result = {
|
|
62
66
|
prNumber,
|
|
63
67
|
actionableThreads,
|
package/bin/comments/authors.mjs
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
|
-
|
|
1
|
+
function isBotLogin(login) {
|
|
2
2
|
return (login ?? "").toLowerCase().includes("[bot]");
|
|
3
3
|
}
|
|
4
|
+
function normalizeBotUsername(login) {
|
|
5
|
+
return (login ?? "")
|
|
6
|
+
.trim()
|
|
7
|
+
.toLowerCase()
|
|
8
|
+
.replace(/\[bot\]$/i, "");
|
|
9
|
+
}
|
|
10
|
+
export function normalizeBotUsernames(botUsernames = []) {
|
|
11
|
+
return new Set((botUsernames ?? []).map((name) => normalizeBotUsername(name)));
|
|
12
|
+
}
|
|
4
13
|
export function normalizeAuthorType(typeName, login) {
|
|
5
14
|
if (isBotLogin(login))
|
|
6
15
|
return "Bot";
|
|
@@ -12,3 +21,12 @@ export function isHumanAuthor(author) {
|
|
|
12
21
|
const login = author.author ?? author.login ?? "";
|
|
13
22
|
return author.authorType === "User" && !isBotLogin(login);
|
|
14
23
|
}
|
|
24
|
+
export function isConfiguredBotAuthor(author, botUsernames = new Set()) {
|
|
25
|
+
const login = author.author ?? author.login ?? "";
|
|
26
|
+
if (author.authorType === "Bot" || isBotLogin(login))
|
|
27
|
+
return true;
|
|
28
|
+
const normalized = normalizeBotUsername(login);
|
|
29
|
+
if (normalized === "")
|
|
30
|
+
return false;
|
|
31
|
+
return botUsernames.has(normalized);
|
|
32
|
+
}
|
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
import { isHumanAuthor } from "./authors.mjs";
|
|
2
|
-
export function shouldMinimizeAuthor(authorType, policy, author) {
|
|
3
|
-
|
|
1
|
+
import { isConfiguredBotAuthor, isHumanAuthor } from "./authors.mjs";
|
|
2
|
+
export function shouldMinimizeAuthor(authorType, policy, author, botUsernames = new Set()) {
|
|
3
|
+
const isConfiguredBot = isConfiguredBotAuthor({ author, authorType }, botUsernames);
|
|
4
|
+
if (isHumanAuthor({ author, authorType }) && !isConfiguredBot)
|
|
4
5
|
return false;
|
|
5
6
|
switch (policy) {
|
|
6
7
|
case undefined:
|
|
7
8
|
case "all":
|
|
8
|
-
return authorType !== "User";
|
|
9
|
+
return authorType !== "User" || isConfiguredBot;
|
|
9
10
|
case "bots":
|
|
10
|
-
return
|
|
11
|
+
return isConfiguredBot;
|
|
11
12
|
case "users":
|
|
12
13
|
return false;
|
|
13
14
|
case "none":
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { markReviewInlineThreads } from "../state/seen-comments.mjs";
|
|
2
|
+
function groupInlineThreadIdsByReview(threads) {
|
|
3
|
+
const byReview = new Map();
|
|
4
|
+
for (const thread of threads) {
|
|
5
|
+
if (thread.reviewId === undefined)
|
|
6
|
+
continue;
|
|
7
|
+
const ids = byReview.get(thread.reviewId) ?? new Set();
|
|
8
|
+
ids.add(thread.id);
|
|
9
|
+
byReview.set(thread.reviewId, ids);
|
|
10
|
+
}
|
|
11
|
+
return new Map([...byReview.entries()].map(([reviewId, ids]) => [
|
|
12
|
+
reviewId,
|
|
13
|
+
[...ids].sort((a, b) => a.localeCompare(b)),
|
|
14
|
+
]));
|
|
15
|
+
}
|
|
16
|
+
export async function markReviewInlineThreadMarkers(key, threads) {
|
|
17
|
+
await Promise.allSettled([...groupInlineThreadIdsByReview(threads)].map(([reviewId, inlineThreadIds]) => markReviewInlineThreads(key, reviewId, inlineThreadIds)));
|
|
18
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { classifyItem } from "../state/seen-comments.mjs";
|
|
2
2
|
import { threadTranscriptBody } from "../threads/transcript.mjs";
|
|
3
|
+
import { isConfiguredBotAuthor } from "./authors.mjs";
|
|
3
4
|
function withEdited(thread, edited) {
|
|
4
5
|
return edited ? { ...thread, edited: true } : thread;
|
|
5
6
|
}
|
|
@@ -15,20 +16,19 @@ function classifyFirstLookThread(thread, seenMap, firstLookStatus) {
|
|
|
15
16
|
return null;
|
|
16
17
|
return { ...visible, firstLookStatus };
|
|
17
18
|
}
|
|
18
|
-
export function classifyThreadVisibility(threads, seenMap) {
|
|
19
|
+
export function classifyThreadVisibility(threads, seenMap, botUsernames = new Set()) {
|
|
19
20
|
const unresolvedThreads = threads.filter((t) => !t.isResolved);
|
|
20
21
|
const activeThreads = unresolvedThreads
|
|
21
22
|
.filter((t) => !t.isOutdated && !t.isMinimized)
|
|
22
23
|
.flatMap((t) => {
|
|
24
|
+
if (isConfiguredBotAuthor(t, botUsernames))
|
|
25
|
+
return [t];
|
|
23
26
|
const visible = classifyVisibleThread(t, seenMap);
|
|
24
27
|
return visible ? [visible] : [];
|
|
25
28
|
});
|
|
26
29
|
const resolutionOnlyThreads = unresolvedThreads
|
|
27
30
|
.filter((t) => t.isOutdated || t.isMinimized)
|
|
28
|
-
.
|
|
29
|
-
const visible = classifyVisibleThread(t, seenMap);
|
|
30
|
-
return visible ? [visible] : [];
|
|
31
|
-
});
|
|
31
|
+
.map((t) => classifyVisibleThread(t, seenMap) ?? t);
|
|
32
32
|
const firstLookThreads = [
|
|
33
33
|
...threads.flatMap((t) => {
|
|
34
34
|
if (!t.isOutdated)
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { shouldMinimizeAuthor } from "./minimize-policy.mjs";
|
|
2
2
|
import { classifyItem } from "../state/seen-comments.mjs";
|
|
3
|
-
export function classifyVisibleComments(comments, seenMap, minimizeComments) {
|
|
3
|
+
export function classifyVisibleComments(comments, seenMap, minimizeComments, botUsernames = new Set()) {
|
|
4
4
|
const actionable = [];
|
|
5
5
|
const minimizeIds = [];
|
|
6
6
|
const toMarkSeen = [];
|
|
7
7
|
for (const c of comments.filter((comment) => !comment.isMinimized)) {
|
|
8
|
-
if (shouldMinimizeAuthor(c.authorType, minimizeComments, c.author)) {
|
|
8
|
+
if (shouldMinimizeAuthor(c.authorType, minimizeComments, c.author, botUsernames)) {
|
|
9
9
|
actionable.push(c);
|
|
10
10
|
minimizeIds.push(c.id);
|
|
11
11
|
continue;
|
package/bin/config/load.mjs
CHANGED
|
@@ -45,6 +45,12 @@ function parseMinimizeCommentsPolicy(value) {
|
|
|
45
45
|
return value;
|
|
46
46
|
throw new Error(`Invalid config: iterate.minimizeComments must be one of "all", "bots", "users", or "none", got ${JSON.stringify(value)}`);
|
|
47
47
|
}
|
|
48
|
+
function parseBotUsernames(value) {
|
|
49
|
+
if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) {
|
|
50
|
+
throw new Error(`Invalid config: botUsernames must be an array of strings`);
|
|
51
|
+
}
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
48
54
|
const defaults = builtins;
|
|
49
55
|
const configCache = new Map();
|
|
50
56
|
export function loadConfig() {
|
|
@@ -60,6 +66,7 @@ export function loadConfig() {
|
|
|
60
66
|
const raw = readFileSync(rcPath, "utf8");
|
|
61
67
|
const parsed = (parse(raw) ?? {});
|
|
62
68
|
const config = deepMerge(defaults, parsed);
|
|
69
|
+
config.botUsernames = parseBotUsernames(config.botUsernames);
|
|
63
70
|
config.iterate.minimizeComments = parseMinimizeCommentsPolicy(config.iterate.minimizeComments);
|
|
64
71
|
configCache.set(cwd, config);
|
|
65
72
|
return config;
|
package/bin/config.json
CHANGED
|
@@ -1,4 +1,18 @@
|
|
|
1
1
|
{
|
|
2
|
+
"botUsernames": [
|
|
3
|
+
"chatgpt-connector",
|
|
4
|
+
"openai-codex",
|
|
5
|
+
"codex",
|
|
6
|
+
"claude",
|
|
7
|
+
"copilot-pull-request-reviewer",
|
|
8
|
+
"gemini-code-assist",
|
|
9
|
+
"coderabbitai",
|
|
10
|
+
"greptile-apps",
|
|
11
|
+
"qodo-merge-pro",
|
|
12
|
+
"deepsource-io",
|
|
13
|
+
"sonarqubecloud",
|
|
14
|
+
"what-the-diff"
|
|
15
|
+
],
|
|
2
16
|
"iterate": {
|
|
3
17
|
"fixAttemptsPerThread": 3,
|
|
4
18
|
"stallTimeoutMinutes": 60,
|
|
@@ -13,6 +13,7 @@ export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes,
|
|
|
13
13
|
const comments = t.comments.nodes.map((c) => ({
|
|
14
14
|
id: c.id,
|
|
15
15
|
isMinimized: c.isMinimized,
|
|
16
|
+
...(c.pullRequestReview?.id ? { reviewId: c.pullRequestReview.id } : undefined),
|
|
16
17
|
author: c.author?.login ?? "unknown",
|
|
17
18
|
authorType: mapAuthorType(c.author?.__typename, c.author?.login),
|
|
18
19
|
body: c.body,
|
|
@@ -27,6 +28,7 @@ export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes,
|
|
|
27
28
|
path: t.path ?? comment?.path ?? null,
|
|
28
29
|
line: t.line ?? comment?.line ?? null,
|
|
29
30
|
startLine: t.startLine ?? comment?.startLine ?? null,
|
|
31
|
+
...(comment?.pullRequestReview?.id ? { reviewId: comment.pullRequestReview.id } : undefined),
|
|
30
32
|
author: comment?.author?.login ?? "unknown",
|
|
31
33
|
authorType: mapAuthorType(comment?.author?.__typename, comment?.author?.login),
|
|
32
34
|
body: comment?.body ?? "",
|
package/bin/github/client.mjs
CHANGED
|
@@ -7,9 +7,8 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { execFile as execFileCb } from "node:child_process";
|
|
9
9
|
import { promisify } from "node:util";
|
|
10
|
-
import { graphql as httpGraphql, rest
|
|
10
|
+
import { graphql as httpGraphql, rest } from "./http.mjs";
|
|
11
11
|
import { PR_NUMBER_BY_BRANCH_QUERY, GET_PR_HEAD_SHA_QUERY } from "./queries.mjs";
|
|
12
|
-
export { GitHubRequestError };
|
|
13
12
|
const execFile = promisify(execFileCb);
|
|
14
13
|
// ---------------------------------------------------------------------------
|
|
15
14
|
// GraphQL — thin re-exports so callers don't need to import http.mts directly
|
package/bin/reporters/agent.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
/* eslint-disable max-lines */
|
|
1
2
|
import { readFile, writeFile, rename, unlink, mkdir, access, readdir } from "node:fs/promises";
|
|
2
3
|
import { join, dirname } from "node:path";
|
|
3
4
|
import { createHash, randomUUID } from "node:crypto";
|
|
@@ -23,8 +24,14 @@ export function classifyItem(id, body, map) {
|
|
|
23
24
|
const m = map.get(id);
|
|
24
25
|
if (!m)
|
|
25
26
|
return "new";
|
|
26
|
-
|
|
27
|
+
const currentHash = hashBody(body);
|
|
28
|
+
if (typeof m.previousBodyHash === "string" && m.previousBodyHash === currentHash) {
|
|
29
|
+
return "unchanged";
|
|
30
|
+
}
|
|
31
|
+
if (typeof m.bodyHash === "string" && m.bodyHash !== currentHash)
|
|
27
32
|
return "edited";
|
|
33
|
+
if (m.bodyHash === undefined && Array.isArray(m.inlineThreadIds))
|
|
34
|
+
return "new";
|
|
28
35
|
return "unchanged";
|
|
29
36
|
}
|
|
30
37
|
/**
|
|
@@ -94,11 +101,31 @@ export async function hasSeen(key, id) {
|
|
|
94
101
|
* All errors are silently swallowed — the marker is best-effort.
|
|
95
102
|
*/
|
|
96
103
|
export async function markSeen(key, id, body) {
|
|
104
|
+
await writeSeenMarker(key, id, { bodyHash: hashBody(body) });
|
|
105
|
+
}
|
|
106
|
+
export async function markReviewInlineThreads(key, reviewId, inlineThreadIds) {
|
|
107
|
+
await writeSeenMarker(key, reviewId, {
|
|
108
|
+
inlineThreadIds: [...new Set(inlineThreadIds)].sort((a, b) => a.localeCompare(b)),
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Write a marker after Shepherd successfully replies to a review thread.
|
|
113
|
+
*
|
|
114
|
+
* `previousBody` suppresses stale GitHub fetches that have not yet included the new reply.
|
|
115
|
+
* `body` suppresses the expected final transcript once GitHub includes Shepherd's reply.
|
|
116
|
+
*/
|
|
117
|
+
export async function markReplySeen(key, id, previousBody, body, replyBody) {
|
|
118
|
+
await writeSeenMarker(key, id, {
|
|
119
|
+
bodyHash: hashBody(body),
|
|
120
|
+
previousBodyHash: hashBody(previousBody),
|
|
121
|
+
replyBodyHash: hashBody(replyBody),
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
async function writeSeenMarker(key, id, markerFields) {
|
|
97
125
|
let tmp;
|
|
98
126
|
try {
|
|
99
127
|
const path = resolvePath(key, id);
|
|
100
128
|
await mkdir(dirname(path), { recursive: true });
|
|
101
|
-
const newHash = hashBody(body);
|
|
102
129
|
let existing = null;
|
|
103
130
|
try {
|
|
104
131
|
const raw = await readFile(path, "utf8");
|
|
@@ -107,14 +134,15 @@ export async function markSeen(key, id, body) {
|
|
|
107
134
|
catch {
|
|
108
135
|
// no existing marker — will create below
|
|
109
136
|
}
|
|
110
|
-
|
|
137
|
+
const unchanged = Object.entries(markerFields).every(([field, value]) => markerFieldEqual(existing?.[field], value));
|
|
138
|
+
if (existing !== null && unchanged)
|
|
111
139
|
return;
|
|
112
140
|
const seenAt = existing?.seenAt ?? Date.now();
|
|
113
141
|
tmp = `${path}.${randomUUID()}.tmp`;
|
|
114
142
|
// Store `id` in the payload so loadSeenMap can key by the original ID
|
|
115
143
|
// rather than the filename, guarding against case-insensitive filesystems
|
|
116
144
|
// (e.g. macOS APFS) where IDs differing only in case would collide.
|
|
117
|
-
await writeFile(tmp, JSON.stringify({ seenAt,
|
|
145
|
+
await writeFile(tmp, JSON.stringify({ ...existing, seenAt, ...markerFields, id }), "utf8");
|
|
118
146
|
await rename(tmp, path);
|
|
119
147
|
tmp = undefined;
|
|
120
148
|
}
|
|
@@ -132,6 +160,14 @@ export async function markSeen(key, id, body) {
|
|
|
132
160
|
}
|
|
133
161
|
}
|
|
134
162
|
}
|
|
163
|
+
function markerFieldEqual(left, right) {
|
|
164
|
+
if (Object.is(left, right))
|
|
165
|
+
return true;
|
|
166
|
+
if (Array.isArray(left) && Array.isArray(right)) {
|
|
167
|
+
return left.length === right.length && left.every((value, index) => value === right[index]);
|
|
168
|
+
}
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
135
171
|
/** Read the full marker for inspection (returns null on miss or error). */
|
|
136
172
|
export async function readSeenMarker(key, id) {
|
|
137
173
|
try {
|