pr-shepherd 0.21.0 → 0.23.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 +87 -124
- package/bin/checks/triage.mjs +1 -0
- package/bin/cli/args.mjs +2 -0
- package/bin/cli/fix-formatter.mjs +38 -9
- package/bin/cli/formatters.mjs +3 -45
- package/bin/cli/handlers.mjs +22 -1
- package/bin/cli/help-command-pages.mjs +27 -2
- package/bin/cli/help-top-page.mjs +2 -0
- package/bin/cli/iterate-lean.mjs +4 -0
- package/bin/cli/list-formatters.mjs +55 -4
- package/bin/cli/mark-files-as-viewed-flags.mjs +34 -0
- package/bin/cli/mark-files-as-viewed-formatter.mjs +52 -0
- package/bin/cli/mutate-formatter.mjs +50 -0
- package/bin/cli-parser.mjs +11 -2
- package/bin/cli-parser.test-support.mjs +6 -1
- package/bin/commands/check-annotations.mjs +40 -0
- package/bin/commands/check.mjs +25 -57
- package/bin/commands/check.test-support.mjs +9 -2
- package/bin/commands/commit-suggestion-instruction.mjs +1 -1
- package/bin/commands/iterate/classify.mjs +25 -30
- package/bin/commands/iterate/escalate.mjs +16 -2
- package/bin/commands/iterate/fix-code.mjs +29 -18
- package/bin/commands/iterate/helpers.mjs +1 -0
- package/bin/commands/iterate/render.mjs +9 -3
- package/bin/commands/iterate/stall.mjs +43 -2
- package/bin/commands/iterate-test-support.mjs +1 -1
- package/bin/commands/iterate-thread-test-support.mjs +18 -0
- package/bin/commands/iterate.fix-code-in-progress.test-support.mjs +10 -1
- package/bin/commands/mark-files-as-viewed.mjs +220 -0
- package/bin/commands/resolve-mutate.mjs +41 -4
- package/bin/commands/resolve.mjs +16 -76
- package/bin/commands/resolve.test-support.mjs +2 -0
- package/bin/commands/shepherd-journal.mjs +1 -1
- package/bin/comments/authors.mjs +14 -0
- package/bin/comments/minimize-policy.mjs +6 -3
- package/bin/comments/pending-ops.mjs +6 -0
- package/bin/comments/resolve.mjs +28 -11
- package/bin/comments/resolve.test-support.mjs +6 -1
- package/bin/comments/review-visibility.mjs +14 -0
- package/bin/comments/thread-visibility.mjs +60 -0
- package/bin/comments/visible-comments.mjs +1 -1
- package/bin/config.json +1 -1
- package/bin/github/batch-parser-helpers.mjs +3 -4
- package/bin/github/batch-parsers.mjs +35 -8
- package/bin/github/batch.mjs +2 -0
- package/bin/github/check-annotations.mjs +75 -0
- package/bin/github/client.test-support.mjs +55 -0
- package/bin/github/gql/batch-pr.gql +15 -2
- package/bin/github/gql/check-run-annotations.gql +32 -0
- package/bin/github/gql/review-thread-comments.gql +27 -0
- package/bin/github/queries.mjs +4 -0
- package/bin/github/thread-comments.mjs +34 -0
- package/bin/reporters/agent.mjs +27 -0
- package/bin/state/seen-comments.test-support.mjs +19 -0
- package/bin/threads/transcript.mjs +31 -0
- package/bin/types/agent-thread.mjs +1 -0
- package/bin/types/check-annotations.mjs +1 -0
- package/bin/types/review-thread.mjs +1 -0
- package/bin/types.mjs +3 -0
- package/package.json +1 -1
- package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
- package/plugins/pr-shepherd/skills/mark-files-as-viewed/SKILL.md +31 -0
- package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +1 -1
|
@@ -52,11 +52,25 @@ export function buildEscalateHumanMessage(escalate, pr) {
|
|
|
52
52
|
lines.push(escalate.suggestion);
|
|
53
53
|
const hasItems = escalate.unresolvedThreads.length > 0 ||
|
|
54
54
|
escalate.changesRequestedReviews.length > 0 ||
|
|
55
|
-
escalate.ambiguousComments.length > 0
|
|
55
|
+
escalate.ambiguousComments.length > 0 ||
|
|
56
|
+
(escalate.stalledChecks?.length ?? 0) > 0;
|
|
56
57
|
if (hasItems) {
|
|
57
58
|
lines.push("");
|
|
58
59
|
lines.push("## Items needing attention");
|
|
59
60
|
lines.push("");
|
|
61
|
+
for (const c of escalate.stalledChecks ?? []) {
|
|
62
|
+
const target = c.runId
|
|
63
|
+
? `run \`${c.runId}\``
|
|
64
|
+
: c.detailsUrl
|
|
65
|
+
? `external \`${c.detailsUrl}\``
|
|
66
|
+
: "no run ID";
|
|
67
|
+
const ageMinutes = Math.floor(c.ageSeconds / 60);
|
|
68
|
+
lines.push(`- check \`${c.name}\` — ${c.status} ${c.source}, ${target}, waiting ${ageMinutes} minute${ageMinutes === 1 ? "" : "s"}`);
|
|
69
|
+
if (c.summary)
|
|
70
|
+
lines.push(` > ${c.summary}`);
|
|
71
|
+
}
|
|
72
|
+
if ((escalate.stalledChecks?.length ?? 0) > 0)
|
|
73
|
+
lines.push("");
|
|
60
74
|
for (const t of escalate.unresolvedThreads) {
|
|
61
75
|
const loc = t.path ? `\`${t.path}:${t.line ?? "?"}\`` : "(no location)";
|
|
62
76
|
lines.push(`- thread \`${t.id}\` — ${loc} (@${t.author}):`);
|
|
@@ -96,7 +110,7 @@ export function buildEscalateHumanMessage(escalate, pr) {
|
|
|
96
110
|
}
|
|
97
111
|
export function buildEscalateSuggestion(triggers, detail) {
|
|
98
112
|
if (triggers.includes("stall-timeout")) {
|
|
99
|
-
const mins = detail ?? "
|
|
113
|
+
const mins = detail ?? "60";
|
|
100
114
|
return `No progress detected for ${mins} minute${parseInt(mins, 10) === 1 ? "" : "s"} — state has not changed. This is a manual checkpoint: inspect the PR and apply a manual fix before resuming.`;
|
|
101
115
|
}
|
|
102
116
|
if (triggers.includes("base-branch-unknown")) {
|
|
@@ -1,23 +1,35 @@
|
|
|
1
1
|
/* eslint-disable max-lines */
|
|
2
|
-
import { readFixAttempts, writeFixAttempts } from "../../state/fix-attempts.mjs";
|
|
2
|
+
import { readFixAttempts, writeFixAttempts, } from "../../state/fix-attempts.mjs";
|
|
3
3
|
import { toAgentThread, toAgentComment, toAgentChecks } from "../../reporters/agent.mjs";
|
|
4
|
+
import { hashBody, markSeen } from "../../state/seen-comments.mjs";
|
|
4
5
|
import { checkEscalateTriggers, validateBaseBranch, buildEscalateSuggestion, buildEscalateHumanMessage, } from "./escalate.mjs";
|
|
5
6
|
import { buildResolveCommand } from "./classify.mjs";
|
|
6
7
|
import { buildFixInstructions } from "./render.mjs";
|
|
7
8
|
import { applyStallGuard } from "./stall.mjs";
|
|
8
9
|
import { tryCancelRun, buildInProgressRunIds } from "./helpers.mjs";
|
|
10
|
+
import { annotationMarkerBody } from "../check-annotations.mjs";
|
|
11
|
+
import { threadTranscriptBody } from "../../threads/transcript.mjs";
|
|
12
|
+
function nextFixAttempts(stored, headSha, threads) {
|
|
13
|
+
const threadAttempts = stored ? { ...stored.threadAttempts } : {};
|
|
14
|
+
const threadBodyHashes = stored?.threadBodyHashes
|
|
15
|
+
? { ...stored.threadBodyHashes }
|
|
16
|
+
: {};
|
|
17
|
+
for (const t of threads) {
|
|
18
|
+
const bodyHash = hashBody(threadTranscriptBody(t));
|
|
19
|
+
const previousHash = threadBodyHashes[t.id];
|
|
20
|
+
if (stored?.headSha === headSha && (previousHash === undefined || previousHash === bodyHash))
|
|
21
|
+
continue;
|
|
22
|
+
threadAttempts[t.id] = previousHash === bodyHash ? (threadAttempts[t.id] ?? 0) + 1 : 1;
|
|
23
|
+
threadBodyHashes[t.id] = bodyHash;
|
|
24
|
+
}
|
|
25
|
+
return { threadAttempts, threadBodyHashes };
|
|
26
|
+
}
|
|
9
27
|
export async function handleFixCode(ctx) {
|
|
10
28
|
const { base, report, opts, headSha, stallKey, prNumber, stallTimeoutSeconds, repoOwner, repoName, reviewSummaryIds, firstLookSummaries, editedSummaries, surfacedApprovals, } = ctx;
|
|
11
29
|
const failingChecks = report.checks.failing;
|
|
12
30
|
const stored = await readFixAttempts({ owner: repoOwner, repo: repoName, pr: prNumber });
|
|
13
|
-
const
|
|
14
|
-
const
|
|
15
|
-
if (isNewSha) {
|
|
16
|
-
for (const t of report.threads.actionable) {
|
|
17
|
-
currentAttempts[t.id] = (currentAttempts[t.id] ?? 0) + 1;
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
|
-
const escalateTriggers = checkEscalateTriggers(report.threads.actionable, currentAttempts);
|
|
31
|
+
const { threadAttempts, threadBodyHashes } = nextFixAttempts(stored, headSha, report.threads.actionable);
|
|
32
|
+
const escalateTriggers = checkEscalateTriggers(report.threads.actionable, threadAttempts);
|
|
21
33
|
if (escalateTriggers.triggers.length > 0) {
|
|
22
34
|
const escalateBase = {
|
|
23
35
|
triggers: escalateTriggers.triggers,
|
|
@@ -36,7 +48,7 @@ export async function handleFixCode(ctx) {
|
|
|
36
48
|
},
|
|
37
49
|
};
|
|
38
50
|
}
|
|
39
|
-
await writeFixAttempts({ owner: repoOwner, repo: repoName, pr: prNumber }, { headSha, threadAttempts
|
|
51
|
+
await writeFixAttempts({ owner: repoOwner, repo: repoName, pr: prNumber }, { headSha, threadAttempts, threadBodyHashes });
|
|
40
52
|
let cancelled = [];
|
|
41
53
|
if (!opts.noAutoCancelActionable) {
|
|
42
54
|
const uniqueRunIds = [
|
|
@@ -65,12 +77,6 @@ export async function handleFixCode(ctx) {
|
|
|
65
77
|
const commentMinimizeIds = report.comments.minimizeIds ?? actionableComments.map((c) => c.id);
|
|
66
78
|
const allCommentIds = [...commentMinimizeIds, ...reviewSummaryIds];
|
|
67
79
|
const resolveCommand = buildResolveCommand(threads, resolutionOnlyThreads, allCommentIds, changesRequestedReviews, checks, prNumber);
|
|
68
|
-
const overlappingReviewIds = resolveCommand.droppedDismissReviewIds ?? [];
|
|
69
|
-
if (overlappingReviewIds.length > 0) {
|
|
70
|
-
process.stderr.write(`pr-shepherd: resolve command overlap: ${overlappingReviewIds.length} ` +
|
|
71
|
-
`review IDs were also in minimize/comment IDs and were dropped from --dismiss-review-ids: ` +
|
|
72
|
-
`${overlappingReviewIds.join(", ")}\n`);
|
|
73
|
-
}
|
|
74
80
|
// Safety: if the base branch is unknown, escalate when a push is plausible — the agent
|
|
75
81
|
// would need the correct base to rebase safely. This is a conservative guard, not a
|
|
76
82
|
// prediction that the agent *will* push. Intentionally broader than `pushLikely` above:
|
|
@@ -101,7 +107,7 @@ export async function handleFixCode(ctx) {
|
|
|
101
107
|
const firstLookThreads = report.threads.firstLook;
|
|
102
108
|
const firstLookComments = report.comments.firstLook;
|
|
103
109
|
const instructions = buildFixInstructions(threads, actionableComments, checks, changesRequestedReviews, baseLookup.branch, resolveCommand, hasConflicts, prNumber, cancelled.length, firstLookThreads, firstLookComments, firstLookSummaries, editedSummaries, inProgressRunIds, resolutionOnlyThreads);
|
|
104
|
-
|
|
110
|
+
const prospectiveResult = {
|
|
105
111
|
...base,
|
|
106
112
|
baseBranch: baseLookup.branch,
|
|
107
113
|
action: "fix_code",
|
|
@@ -122,5 +128,10 @@ export async function handleFixCode(ctx) {
|
|
|
122
128
|
inProgressRunIds,
|
|
123
129
|
},
|
|
124
130
|
cancelled,
|
|
125
|
-
}
|
|
131
|
+
};
|
|
132
|
+
const result = await applyStallGuard(stallKey, stallTimeoutSeconds, headSha, base, prNumber, prospectiveResult, report, reviewSummaryIds);
|
|
133
|
+
if (result.action === "fix_code") {
|
|
134
|
+
await Promise.allSettled(result.fix.checks.flatMap((ch) => (ch.annotations ?? []).map((a) => markSeen(stallKey, a.id, annotationMarkerBody(a)))));
|
|
135
|
+
}
|
|
136
|
+
return result;
|
|
126
137
|
}
|
|
@@ -53,6 +53,7 @@ export function buildRelevantChecks(report) {
|
|
|
53
53
|
...(c.jobName !== undefined && { jobName: c.jobName }),
|
|
54
54
|
...(c.failedStep !== undefined && { failedStep: c.failedStep }),
|
|
55
55
|
...(c.summary !== undefined && { summary: c.summary }),
|
|
56
|
+
...(c.annotations !== undefined && { annotations: c.annotations }),
|
|
56
57
|
},
|
|
57
58
|
];
|
|
58
59
|
});
|
|
@@ -30,6 +30,9 @@ export function buildFixInstructions(threads, actionableComments, checks, change
|
|
|
30
30
|
actionableSections.push("`## Actionable comments`");
|
|
31
31
|
if (checks.length > 0)
|
|
32
32
|
actionableSections.push("`## Failing checks`");
|
|
33
|
+
if (checks.some((c) => (c.annotations?.length ?? 0) > 0)) {
|
|
34
|
+
actionableSections.push("`## Check annotations`");
|
|
35
|
+
}
|
|
33
36
|
if (changesRequestedReviews.length > 0)
|
|
34
37
|
actionableSections.push("`## Changes-requested reviews`");
|
|
35
38
|
const sectionRef = actionableSections.length > 0 ? `under ${actionableSections.join(", ")}` : "above";
|
|
@@ -62,9 +65,12 @@ export function buildFixInstructions(threads, actionableComments, checks, change
|
|
|
62
65
|
instructions.push(`Apply code fixes: read and edit each file referenced under \`## Review threads\` and \`## Actionable comments\` above.${suggestionFallback}`);
|
|
63
66
|
}
|
|
64
67
|
if (resolutionOnlyThreads.length > 0) {
|
|
65
|
-
instructions.push(`
|
|
68
|
+
instructions.push(`Review the threads under \`## Review threads to resolve\`. Human-authored threads are replied to by the \`resolve:\` command shown below; Shepherd does not resolve them. Bot/non-human threads are included in \`--resolve-thread-ids\`.`);
|
|
66
69
|
}
|
|
67
70
|
instructions.push(...buildFailingCheckInstructions(checks));
|
|
71
|
+
if (checks.some((c) => (c.annotations?.length ?? 0) > 0)) {
|
|
72
|
+
instructions.push(`For each item under \`## Check annotations\`: inspect the referenced file range and decide whether the annotation requires a code change. These annotations are surfaced once per PR and do not need any resolve/minimize mutation.`);
|
|
73
|
+
}
|
|
68
74
|
if (changesRequestedReviews.length > 0) {
|
|
69
75
|
instructions.push(`For each bullet under \`## Changes-requested reviews\` above: read the review body and apply the requested changes.`);
|
|
70
76
|
}
|
|
@@ -77,7 +83,7 @@ export function buildFixInstructions(threads, actionableComments, checks, change
|
|
|
77
83
|
substituteParts.push(`\`$HEAD_SHA\` with the pushed commit SHA (or \`$(git rev-parse HEAD)\` if you did not push)`);
|
|
78
84
|
}
|
|
79
85
|
if (resolveCommand.requiresDismissMessage) {
|
|
80
|
-
substituteParts.push(`\`$DISMISS_MESSAGE\` with a one-sentence description of what you changed`);
|
|
86
|
+
substituteParts.push(`\`$DISMISS_MESSAGE\` with a one-sentence reply/description of what you changed`);
|
|
81
87
|
}
|
|
82
88
|
const substituteHint = substituteParts.length > 0 ? `, substituting ${substituteParts.join(" and ")}` : "";
|
|
83
89
|
instructions.push(`Run the \`resolve:\` command shown above${substituteHint}.`);
|
|
@@ -99,7 +105,7 @@ export function buildFixInstructions(threads, actionableComments, checks, change
|
|
|
99
105
|
if (editedTotal > 0) {
|
|
100
106
|
instructions.push(`Items marked \`[edited since first look]\`, items under \`## Review summaries (edited since first look)\`, and any first-look bullet tagged \`, edited\` were updated by their author after you previously acknowledged them. Read the updated body before deciding whether any matching \`## Review threads to resolve\` item should be resolved.`);
|
|
101
107
|
}
|
|
102
|
-
if (resolveCommand.hasMutations) {
|
|
108
|
+
if (resolveCommand.hasMutations || hasNonConflictHints || firstLookTotal > 0) {
|
|
103
109
|
instructions.push(buildShepherdJournalInstruction(prNumber, SHEPHERD_JOURNAL_REFERENCE_GUIDANCE_THREADS_AND_COMMENTS_IN_ITEM_HEADINGS));
|
|
104
110
|
}
|
|
105
111
|
instructions.push(FIX_INSTRUCTION_STOP);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readStallState, writeStallState } from "../../state/iterate-stall.mjs";
|
|
2
|
-
import { toAgentThread, toAgentComment } from "../../reporters/agent.mjs";
|
|
2
|
+
import { toAgentThread, toAgentComment, toAgentStalledCheck } from "../../reporters/agent.mjs";
|
|
3
3
|
import { buildEscalateSuggestion, buildEscalateHumanMessage } from "./escalate.mjs";
|
|
4
4
|
export function computeStallFingerprint(action, headSha, base, report, reviewSummaryIds) {
|
|
5
5
|
const checks = [
|
|
@@ -27,8 +27,31 @@ export function computeStallFingerprint(action, headSha, base, report, reviewSum
|
|
|
27
27
|
});
|
|
28
28
|
}
|
|
29
29
|
export async function applyStallGuard(stallKey, stallTimeoutSeconds, headSha, base, prNumber, prospectiveResult, report, reviewSummaryIds) {
|
|
30
|
-
const fingerprint = computeStallFingerprint(prospectiveResult.action, headSha, base, report, reviewSummaryIds);
|
|
31
30
|
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
31
|
+
const stalledChecks = findCiStartStalledChecks(report.checks.inProgress, nowSeconds, {
|
|
32
|
+
stallTimeoutSeconds,
|
|
33
|
+
action: prospectiveResult.action,
|
|
34
|
+
});
|
|
35
|
+
if (stalledChecks.length > 0) {
|
|
36
|
+
const stalledMinutes = Math.floor(Math.max(...stalledChecks.map((c) => c.ageSeconds)) / 60);
|
|
37
|
+
const escalateBase = {
|
|
38
|
+
triggers: ["stall-timeout"],
|
|
39
|
+
unresolvedThreads: [],
|
|
40
|
+
ambiguousComments: [],
|
|
41
|
+
changesRequestedReviews: [],
|
|
42
|
+
stalledChecks,
|
|
43
|
+
suggestion: buildEscalateSuggestion(["stall-timeout"], String(stalledMinutes)),
|
|
44
|
+
};
|
|
45
|
+
return {
|
|
46
|
+
...base,
|
|
47
|
+
action: "escalate",
|
|
48
|
+
escalate: {
|
|
49
|
+
...escalateBase,
|
|
50
|
+
humanMessage: buildEscalateHumanMessage(escalateBase, prNumber),
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
const fingerprint = computeStallFingerprint(prospectiveResult.action, headSha, base, report, reviewSummaryIds);
|
|
32
55
|
const stored = await readStallState(stallKey);
|
|
33
56
|
if (stored && stored.fingerprint === fingerprint) {
|
|
34
57
|
const ageSeconds = nowSeconds - stored.firstSeenAt;
|
|
@@ -65,3 +88,21 @@ export async function applyStallGuard(stallKey, stallTimeoutSeconds, headSha, ba
|
|
|
65
88
|
await writeStallState(stallKey, { fingerprint, firstSeenAt: nowSeconds });
|
|
66
89
|
return prospectiveResult;
|
|
67
90
|
}
|
|
91
|
+
function findCiStartStalledChecks(checks, nowSeconds, opts) {
|
|
92
|
+
if (opts.stallTimeoutSeconds <= 0 || opts.action !== "wait")
|
|
93
|
+
return [];
|
|
94
|
+
return checks
|
|
95
|
+
.filter((c) => isUnstartedCheck(c))
|
|
96
|
+
.map((c) => toAgentStalledCheck(c, nowSeconds))
|
|
97
|
+
.filter((c) => c.createdAtUnix !== undefined && c.ageSeconds >= opts.stallTimeoutSeconds);
|
|
98
|
+
}
|
|
99
|
+
function isUnstartedCheck(check) {
|
|
100
|
+
if (check.source === "status_context")
|
|
101
|
+
return true;
|
|
102
|
+
if (check.startedAtUnix !== undefined)
|
|
103
|
+
return false;
|
|
104
|
+
return (check.status === "PENDING" ||
|
|
105
|
+
check.status === "QUEUED" ||
|
|
106
|
+
check.status === "REQUESTED" ||
|
|
107
|
+
check.status === "WAITING");
|
|
108
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { NOW } from "./iterate-test-support.mjs";
|
|
2
|
+
export function makeThread(overrides = {}) {
|
|
3
|
+
return {
|
|
4
|
+
id: "thread-1",
|
|
5
|
+
isResolved: false,
|
|
6
|
+
isOutdated: false,
|
|
7
|
+
isMinimized: false,
|
|
8
|
+
path: "src/foo.mts",
|
|
9
|
+
line: 10,
|
|
10
|
+
startLine: null,
|
|
11
|
+
author: "reviewer",
|
|
12
|
+
authorType: "Unknown",
|
|
13
|
+
body: "Fix this",
|
|
14
|
+
url: "",
|
|
15
|
+
createdAtUnix: NOW - 3600,
|
|
16
|
+
...overrides,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
@@ -23,6 +23,13 @@ vi.mock("../state/iterate-stall.mts", () => ({
|
|
|
23
23
|
readStallState: vi.fn().mockResolvedValue(null),
|
|
24
24
|
writeStallState: vi.fn().mockResolvedValue(undefined),
|
|
25
25
|
}));
|
|
26
|
+
vi.mock("../state/seen-comments.mts", async (importOriginal) => {
|
|
27
|
+
const actual = await importOriginal();
|
|
28
|
+
return {
|
|
29
|
+
...actual,
|
|
30
|
+
markSeen: vi.fn().mockResolvedValue(undefined),
|
|
31
|
+
};
|
|
32
|
+
});
|
|
26
33
|
const { mockLoadConfig } = vi.hoisted(() => ({ mockLoadConfig: vi.fn() }));
|
|
27
34
|
vi.mock("../config/load.mts", () => ({ loadConfig: mockLoadConfig }));
|
|
28
35
|
import { runIterate } from "./iterate/index.mjs";
|
|
@@ -30,12 +37,14 @@ import { runCheck } from "./check.mjs";
|
|
|
30
37
|
import { updateReadyDelay } from "./ready-delay.mjs";
|
|
31
38
|
import { readFixAttempts, writeFixAttempts } from "../state/fix-attempts.mjs";
|
|
32
39
|
import { readStallState, writeStallState } from "../state/iterate-stall.mjs";
|
|
40
|
+
import { markSeen } from "../state/seen-comments.mjs";
|
|
33
41
|
const mockRunCheck = vi.mocked(runCheck);
|
|
34
42
|
const mockUpdateReadyDelay = vi.mocked(updateReadyDelay);
|
|
35
43
|
const mockReadFixAttempts = vi.mocked(readFixAttempts);
|
|
36
44
|
const mockWriteFixAttempts = vi.mocked(writeFixAttempts);
|
|
37
45
|
const mockReadStallState = vi.mocked(readStallState);
|
|
38
46
|
const mockWriteStallState = vi.mocked(writeStallState);
|
|
47
|
+
const mockMarkSeen = vi.mocked(markSeen);
|
|
39
48
|
function makeReport(overrides = {}) {
|
|
40
49
|
return {
|
|
41
50
|
pr: 42,
|
|
@@ -115,4 +124,4 @@ export function registerHooks() {
|
|
|
115
124
|
vi.restoreAllMocks();
|
|
116
125
|
});
|
|
117
126
|
}
|
|
118
|
-
export { makeOpts, makeReport, mockExecFile, mockFetch, mockLoadConfig, mockReadFixAttempts, mockReadStallState, mockRunCheck, mockUpdateReadyDelay, mockWriteFixAttempts, mockWriteStallState, readFixAttempts, readStallState, runCheck, runIterate, updateReadyDelay, writeFixAttempts, writeStallState, };
|
|
127
|
+
export { makeOpts, makeReport, mockExecFile, mockFetch, mockLoadConfig, mockReadFixAttempts, mockReadStallState, mockMarkSeen, mockRunCheck, mockUpdateReadyDelay, mockWriteFixAttempts, mockWriteStallState, readFixAttempts, readStallState, runCheck, runIterate, updateReadyDelay, writeFixAttempts, writeStallState, };
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/* eslint-disable max-lines */
|
|
2
|
+
import { graphql, graphqlWithRateLimit, getCurrentPrNumber, getRepoInfo, } from "../github/client.mjs";
|
|
3
|
+
import { paginateForward } from "../github/pagination.mjs";
|
|
4
|
+
import { isRateLimitMessage, rateLimitFromError, rateLimitFromGraphQlResult, } from "../comments/rate-limit.mjs";
|
|
5
|
+
const FILES_QUERY = `query PullRequestFiles($owner: String!, $repo: String!, $pr: Int!, $filesCursor: String) {
|
|
6
|
+
repository(owner: $owner, name: $repo) {
|
|
7
|
+
pullRequest(number: $pr) {
|
|
8
|
+
id
|
|
9
|
+
number
|
|
10
|
+
files(first: 100, after: $filesCursor) {
|
|
11
|
+
pageInfo {
|
|
12
|
+
hasNextPage
|
|
13
|
+
endCursor
|
|
14
|
+
}
|
|
15
|
+
nodes {
|
|
16
|
+
path
|
|
17
|
+
viewerViewedState
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}`;
|
|
23
|
+
const TEST_FILE_RE = /(^|\/)(tests?|__tests__|spec)(\/|$)|\.(test|spec)\.[cm]?[jt]sx?$|_tests?\.rs$|(^|\/)tests?\.rs$/i;
|
|
24
|
+
const BULK_CHUNK_SIZE = 10;
|
|
25
|
+
export async function runMarkFilesAsViewed(opts) {
|
|
26
|
+
const repo = await getRepoInfo();
|
|
27
|
+
const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
|
|
28
|
+
if (!prNumber)
|
|
29
|
+
throw new Error("No PR number provided and no current branch PR found");
|
|
30
|
+
const matchPatterns = opts.matchPatterns ?? [];
|
|
31
|
+
const matchRegexes = matchPatterns.map((pattern) => compilePattern(pattern));
|
|
32
|
+
const fetched = await fetchPullRequestFiles(prNumber, repo);
|
|
33
|
+
const selected = selectChangedFiles(fetched.files, {
|
|
34
|
+
files: opts.files,
|
|
35
|
+
tests: opts.tests === true,
|
|
36
|
+
matchPatterns,
|
|
37
|
+
matchRegexes,
|
|
38
|
+
});
|
|
39
|
+
const result = {
|
|
40
|
+
repo: `${repo.owner}/${repo.name}`,
|
|
41
|
+
prNumber,
|
|
42
|
+
pullRequestId: fetched.pullRequestId,
|
|
43
|
+
requestedPaths: opts.files,
|
|
44
|
+
testSelector: opts.tests === true,
|
|
45
|
+
matchPatterns,
|
|
46
|
+
matchedPaths: selected.matchedPaths,
|
|
47
|
+
markedPaths: [],
|
|
48
|
+
alreadyViewedPaths: selected.alreadyViewedPaths,
|
|
49
|
+
missingPaths: selected.missingPaths,
|
|
50
|
+
unmatchedSelectors: selected.unmatchedSelectors,
|
|
51
|
+
errors: [],
|
|
52
|
+
};
|
|
53
|
+
await bulkMarkFilesAsViewed(fetched.pullRequestId, selected.pathsToMark, result);
|
|
54
|
+
return result;
|
|
55
|
+
}
|
|
56
|
+
async function fetchPullRequestFiles(pr, repo) {
|
|
57
|
+
const first = await graphql(FILES_QUERY, {
|
|
58
|
+
owner: repo.owner,
|
|
59
|
+
repo: repo.name,
|
|
60
|
+
pr,
|
|
61
|
+
});
|
|
62
|
+
const raw = first.data.repository?.pullRequest;
|
|
63
|
+
if (!raw)
|
|
64
|
+
throw new Error(`PR #${pr} not found`);
|
|
65
|
+
let files = raw.files.nodes;
|
|
66
|
+
if (raw.files.pageInfo.hasNextPage && raw.files.pageInfo.endCursor) {
|
|
67
|
+
const extra = await paginateForward(async (cursor) => {
|
|
68
|
+
const res = await graphql(FILES_QUERY, {
|
|
69
|
+
owner: repo.owner,
|
|
70
|
+
repo: repo.name,
|
|
71
|
+
pr,
|
|
72
|
+
...(cursor ? { filesCursor: cursor } : {}),
|
|
73
|
+
});
|
|
74
|
+
const pr2 = res.data.repository?.pullRequest;
|
|
75
|
+
if (!pr2)
|
|
76
|
+
throw new Error(`PR #${pr} not found`);
|
|
77
|
+
return pr2.files;
|
|
78
|
+
}, raw.files.pageInfo.endCursor);
|
|
79
|
+
files = [...files, ...extra];
|
|
80
|
+
}
|
|
81
|
+
return { pullRequestId: raw.id, files };
|
|
82
|
+
}
|
|
83
|
+
function compilePattern(pattern) {
|
|
84
|
+
try {
|
|
85
|
+
return new RegExp(pattern, "i");
|
|
86
|
+
}
|
|
87
|
+
catch (e) {
|
|
88
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
89
|
+
throw new Error(`Invalid --match regex ${JSON.stringify(pattern)}: ${msg}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
function selectChangedFiles(changedFiles, opts) {
|
|
93
|
+
const byPath = new Map(changedFiles.map((f) => [f.path, f]));
|
|
94
|
+
const matched = new Set();
|
|
95
|
+
const missingPaths = [];
|
|
96
|
+
const unmatchedSelectors = [];
|
|
97
|
+
for (const path of opts.files) {
|
|
98
|
+
if (byPath.has(path))
|
|
99
|
+
matched.add(path);
|
|
100
|
+
else
|
|
101
|
+
missingPaths.push(path);
|
|
102
|
+
}
|
|
103
|
+
if (opts.tests) {
|
|
104
|
+
let matchedAny = false;
|
|
105
|
+
for (const file of changedFiles) {
|
|
106
|
+
if (TEST_FILE_RE.test(file.path)) {
|
|
107
|
+
matched.add(file.path);
|
|
108
|
+
matchedAny = true;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (!matchedAny)
|
|
112
|
+
unmatchedSelectors.push("--tests");
|
|
113
|
+
}
|
|
114
|
+
for (let i = 0; i < opts.matchRegexes.length; i += 1) {
|
|
115
|
+
let matchedAny = false;
|
|
116
|
+
const regex = opts.matchRegexes[i];
|
|
117
|
+
for (const file of changedFiles) {
|
|
118
|
+
if (regex.test(file.path)) {
|
|
119
|
+
matched.add(file.path);
|
|
120
|
+
matchedAny = true;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (!matchedAny)
|
|
124
|
+
unmatchedSelectors.push(`--match ${opts.matchPatterns[i]}`);
|
|
125
|
+
}
|
|
126
|
+
const matchedPaths = [...matched];
|
|
127
|
+
const alreadyViewedPaths = matchedPaths.filter((path) => byPath.get(path)?.viewerViewedState === "VIEWED");
|
|
128
|
+
const alreadyViewedSet = new Set(alreadyViewedPaths);
|
|
129
|
+
const pathsToMark = matchedPaths.filter((path) => !alreadyViewedSet.has(path));
|
|
130
|
+
return { matchedPaths, alreadyViewedPaths, missingPaths, unmatchedSelectors, pathsToMark };
|
|
131
|
+
}
|
|
132
|
+
function buildBulkMutation(paths) {
|
|
133
|
+
const ops = paths.map((path, i) => ` m${i}: markFileAsViewed(input: { pullRequestId: $pullRequestId, path: ${JSON.stringify(path)} }) { pullRequest { id } }`);
|
|
134
|
+
return `mutation BulkMarkFilesAsViewed($pullRequestId: ID!) {\n${ops.join("\n")}\n}`;
|
|
135
|
+
}
|
|
136
|
+
async function bulkMarkFilesAsViewed(pullRequestId, paths, result) {
|
|
137
|
+
for (let i = 0; i < paths.length; i += BULK_CHUNK_SIZE) {
|
|
138
|
+
const chunk = paths.slice(i, i + BULK_CHUNK_SIZE);
|
|
139
|
+
// eslint-disable-next-line no-await-in-loop
|
|
140
|
+
const stopped = await bulkMarkFilesAsViewedChunk(pullRequestId, chunk, result, i + BULK_CHUNK_SIZE < paths.length);
|
|
141
|
+
if (stopped) {
|
|
142
|
+
const markedSet = new Set(result.markedPaths);
|
|
143
|
+
result.unmarkedPaths = paths.slice(i).filter((path) => !markedSet.has(path));
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
async function bulkMarkFilesAsViewedChunk(pullRequestId, paths, result, hasPendingAfter) {
|
|
149
|
+
if (paths.length === 0)
|
|
150
|
+
return false;
|
|
151
|
+
let data = {};
|
|
152
|
+
let graphQlErrors = [];
|
|
153
|
+
let suppressCurrentChunkErrors = false;
|
|
154
|
+
let rateLimitStop;
|
|
155
|
+
try {
|
|
156
|
+
const resp = await graphqlWithRateLimit(buildBulkMutation(paths), {
|
|
157
|
+
pullRequestId,
|
|
158
|
+
});
|
|
159
|
+
data = resp.data;
|
|
160
|
+
graphQlErrors = (resp.errors ?? []);
|
|
161
|
+
const messages = graphQlErrors.map((e) => e.message);
|
|
162
|
+
suppressCurrentChunkErrors = messages.some(isRateLimitMessage);
|
|
163
|
+
rateLimitStop = rateLimitFromGraphQlResult(messages, {
|
|
164
|
+
rateLimit: resp.rateLimit,
|
|
165
|
+
retryAfterSeconds: resp.retryAfterSeconds,
|
|
166
|
+
stopOnZeroRemaining: hasPendingAfter,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
catch (err) {
|
|
170
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
171
|
+
const stop = rateLimitFromError(err, msg);
|
|
172
|
+
if (stop) {
|
|
173
|
+
result.errors.push(`rate limit: ${stop.message}`);
|
|
174
|
+
result.rateLimit = stop;
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
for (const path of paths)
|
|
178
|
+
result.errors.push(`${path}: ${msg}`);
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
const errorMessagesByAlias = mapAliasErrors(graphQlErrors);
|
|
182
|
+
for (let i = 0; i < paths.length; i += 1) {
|
|
183
|
+
const alias = `m${i}`;
|
|
184
|
+
const m = data[alias];
|
|
185
|
+
if (m?.pullRequest?.id === pullRequestId) {
|
|
186
|
+
result.markedPaths.push(paths[i]);
|
|
187
|
+
}
|
|
188
|
+
else if (!suppressCurrentChunkErrors) {
|
|
189
|
+
result.errors.push(`${paths[i]}: ${errorMessagesByAlias.get(alias) ?? "mark returned null"}`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (rateLimitStop) {
|
|
193
|
+
result.errors.push(`rate limit: ${rateLimitStop.message}`);
|
|
194
|
+
result.rateLimit = rateLimitStop;
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
function mapAliasErrors(errors) {
|
|
200
|
+
const out = new Map();
|
|
201
|
+
for (const error of errors) {
|
|
202
|
+
if (!Array.isArray(error.path))
|
|
203
|
+
continue;
|
|
204
|
+
const alias = error.path.find((part) => typeof part === "string" && isMarkAlias(part));
|
|
205
|
+
if (typeof alias === "string")
|
|
206
|
+
out.set(alias, error.message);
|
|
207
|
+
}
|
|
208
|
+
return out;
|
|
209
|
+
}
|
|
210
|
+
function isMarkAlias(value) {
|
|
211
|
+
if (!value.startsWith("m"))
|
|
212
|
+
return false;
|
|
213
|
+
if (value.length === 1)
|
|
214
|
+
return false;
|
|
215
|
+
for (const char of value.slice(1)) {
|
|
216
|
+
if (char < "0" || char > "9")
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
return true;
|
|
220
|
+
}
|
|
@@ -1,16 +1,53 @@
|
|
|
1
1
|
import { getRepoInfo, getCurrentPrNumber } from "../github/client.mjs";
|
|
2
2
|
import { applyResolveOptions } from "../comments/resolve.mjs";
|
|
3
|
+
import { fetchPrBatch } from "../github/batch.mjs";
|
|
4
|
+
import { isHumanAuthor } from "../comments/authors.mjs";
|
|
5
|
+
import { markSeen } from "../state/seen-comments.mjs";
|
|
6
|
+
import { threadTranscriptBody } from "../threads/transcript.mjs";
|
|
3
7
|
export async function runResolveMutate(opts) {
|
|
4
8
|
const repo = await getRepoInfo();
|
|
5
9
|
const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
|
|
6
10
|
if (prNumber === null) {
|
|
7
11
|
throw new Error("No open PR found for current branch. Pass a PR number explicitly.");
|
|
8
12
|
}
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
+
const { data } = await fetchPrBatch(prNumber, repo, { paginateApprovedReviews: true });
|
|
14
|
+
const threadById = new Map(data.reviewThreads.map((t) => [t.id, t]));
|
|
15
|
+
const humanThreadIds = new Set(data.reviewThreads.filter(isHumanAuthor).map((t) => t.id));
|
|
16
|
+
const humanCommentIds = new Set(data.comments.filter(isHumanAuthor).map((c) => c.id));
|
|
17
|
+
const humanReviewIds = new Set([...data.reviewSummaries, ...data.approvedReviews, ...data.changesRequestedReviews]
|
|
18
|
+
.filter(isHumanAuthor)
|
|
19
|
+
.map((r) => r.id));
|
|
20
|
+
const resolveThreadIds = (opts.resolveThreadIds ?? []).filter((id) => !humanThreadIds.has(id));
|
|
21
|
+
const skippedHumanResolves = (opts.resolveThreadIds ?? []).filter((id) => humanThreadIds.has(id));
|
|
22
|
+
const replyThreadIds = opts.replyThreadIds?.filter((id) => humanThreadIds.has(id));
|
|
23
|
+
const skippedNonHumanReplies = (opts.replyThreadIds ?? []).filter((id) => !humanThreadIds.has(id));
|
|
24
|
+
const minimizeCommentIds = (opts.minimizeCommentIds ?? []).filter((id) => !humanCommentIds.has(id) && !humanReviewIds.has(id));
|
|
25
|
+
const skippedHumanMinimizes = (opts.minimizeCommentIds ?? []).filter((id) => humanCommentIds.has(id) || humanReviewIds.has(id));
|
|
26
|
+
const dismissReviewIds = (opts.dismissReviewIds ?? []).filter((id) => !humanReviewIds.has(id));
|
|
27
|
+
const skippedHumanDismissals = (opts.dismissReviewIds ?? []).filter((id) => humanReviewIds.has(id));
|
|
28
|
+
const result = await applyResolveOptions(prNumber, repo, {
|
|
29
|
+
resolveThreadIds,
|
|
30
|
+
replyThreadIds,
|
|
31
|
+
minimizeCommentIds,
|
|
32
|
+
dismissReviewIds,
|
|
13
33
|
dismissMessage: opts.dismissMessage,
|
|
14
34
|
requireSha: opts.requireSha,
|
|
15
35
|
});
|
|
36
|
+
if (skippedHumanResolves.length > 0)
|
|
37
|
+
result.skippedHumanResolves = skippedHumanResolves;
|
|
38
|
+
if (skippedHumanMinimizes.length > 0)
|
|
39
|
+
result.skippedHumanMinimizes = skippedHumanMinimizes;
|
|
40
|
+
if (skippedHumanDismissals.length > 0)
|
|
41
|
+
result.skippedHumanDismissals = skippedHumanDismissals;
|
|
42
|
+
if (skippedNonHumanReplies.length > 0)
|
|
43
|
+
result.skippedNonHumanReplies = skippedNonHumanReplies;
|
|
44
|
+
if (opts.dismissMessage) {
|
|
45
|
+
await Promise.all(result.repliedThreads.map((id) => {
|
|
46
|
+
const thread = threadById.get(id);
|
|
47
|
+
if (!thread)
|
|
48
|
+
return Promise.resolve();
|
|
49
|
+
return markSeen({ owner: repo.owner, repo: repo.name, pr: prNumber }, id, threadTranscriptBody(thread, [opts.dismissMessage]));
|
|
50
|
+
}));
|
|
51
|
+
}
|
|
52
|
+
return result;
|
|
16
53
|
}
|