pr-shepherd 0.20.0 → 0.22.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 +11 -9
- package/bin/checks/triage.mjs +1 -0
- package/bin/checks/triage.test-support.mjs +1 -2
- package/bin/cli/default-poll.mjs +32 -0
- package/bin/cli/fix-formatter.mjs +36 -8
- package/bin/cli/help-command-pages.mjs +3 -4
- package/bin/cli/help-top-page.mjs +5 -5
- package/bin/cli/iterate-lean.mjs +4 -0
- package/bin/cli/iterate-lean.test-support.mjs +0 -2
- package/bin/cli/list-formatters.mjs +53 -4
- package/bin/cli/validate-default-args.mjs +32 -0
- package/bin/cli-parser.clean.test-support.mjs +0 -1
- package/bin/cli-parser.commit-suggestion.test-support.mjs +1 -2
- package/bin/cli-parser.iterate-fix.test-support.mjs +1 -2
- package/bin/cli-parser.iterate.test-support.mjs +1 -2
- package/bin/cli-parser.mjs +7 -7
- package/bin/cli-parser.test-support.mjs +1 -2
- package/bin/commands/check-annotations.mjs +40 -0
- package/bin/commands/check.mjs +11 -7
- package/bin/commands/check.test-support.mjs +10 -4
- package/bin/commands/clean.test-support.mjs +0 -1
- package/bin/commands/commit-suggestion.apply.test-support.mjs +1 -2
- package/bin/commands/commit-suggestion.test-support.mjs +1 -2
- package/bin/commands/iterate/escalate.mjs +16 -2
- package/bin/commands/iterate/fix-code.mjs +9 -2
- package/bin/commands/iterate/helpers.mjs +1 -0
- package/bin/commands/iterate/render.mjs +6 -0
- package/bin/commands/iterate/stall.mjs +43 -2
- package/bin/commands/iterate-stall.test-support.mjs +0 -1
- package/bin/commands/iterate-test-support.mjs +1 -1
- package/bin/commands/iterate.fix-code-in-progress.test-support.mjs +8 -3
- package/bin/commands/poll.mjs +12 -3
- package/bin/commands/resolve.mjs +5 -4
- package/bin/commands/resolve.test-support.mjs +2 -2
- package/bin/commands/shepherd-journal.test-support.mjs +0 -2
- package/bin/comments/resolve.test-support.mjs +1 -2
- package/bin/config.json +1 -1
- package/bin/github/batch-parsers.mjs +30 -3
- package/bin/github/batch-parsers.test-support.mjs +1 -2
- package/bin/github/batch.mjs +2 -0
- package/bin/github/batch.test-support.mjs +1 -2
- 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/http.test-support.mjs +1 -2
- package/bin/github/queries.mjs +4 -0
- package/bin/github/thread-comments.mjs +34 -0
- package/bin/reporters/agent.mjs +26 -0
- package/bin/state/seen-comments.test-support.mjs +19 -0
- package/bin/suggestions/patch.test-support.mjs +0 -2
- 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/pr-shepherd/SKILL.md +9 -9
- package/bin/cli/default-iterate.mjs +0 -50
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
1
|
+
import { vi } from "vitest";
|
|
3
2
|
// ---------------------------------------------------------------------------
|
|
4
3
|
// Hoisted mocks
|
|
5
4
|
// ---------------------------------------------------------------------------
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
1
|
+
import { vi, beforeEach } from "vitest";
|
|
3
2
|
// ---------------------------------------------------------------------------
|
|
4
3
|
// Hoisted mocks
|
|
5
4
|
// ---------------------------------------------------------------------------
|
|
@@ -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,11 +1,13 @@
|
|
|
1
1
|
/* eslint-disable max-lines */
|
|
2
2
|
import { readFixAttempts, writeFixAttempts } from "../../state/fix-attempts.mjs";
|
|
3
3
|
import { toAgentThread, toAgentComment, toAgentChecks } from "../../reporters/agent.mjs";
|
|
4
|
+
import { 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";
|
|
9
11
|
export async function handleFixCode(ctx) {
|
|
10
12
|
const { base, report, opts, headSha, stallKey, prNumber, stallTimeoutSeconds, repoOwner, repoName, reviewSummaryIds, firstLookSummaries, editedSummaries, surfacedApprovals, } = ctx;
|
|
11
13
|
const failingChecks = report.checks.failing;
|
|
@@ -101,7 +103,7 @@ export async function handleFixCode(ctx) {
|
|
|
101
103
|
const firstLookThreads = report.threads.firstLook;
|
|
102
104
|
const firstLookComments = report.comments.firstLook;
|
|
103
105
|
const instructions = buildFixInstructions(threads, actionableComments, checks, changesRequestedReviews, baseLookup.branch, resolveCommand, hasConflicts, prNumber, cancelled.length, firstLookThreads, firstLookComments, firstLookSummaries, editedSummaries, inProgressRunIds, resolutionOnlyThreads);
|
|
104
|
-
|
|
106
|
+
const prospectiveResult = {
|
|
105
107
|
...base,
|
|
106
108
|
baseBranch: baseLookup.branch,
|
|
107
109
|
action: "fix_code",
|
|
@@ -122,5 +124,10 @@ export async function handleFixCode(ctx) {
|
|
|
122
124
|
inProgressRunIds,
|
|
123
125
|
},
|
|
124
126
|
cancelled,
|
|
125
|
-
}
|
|
127
|
+
};
|
|
128
|
+
const result = await applyStallGuard(stallKey, stallTimeoutSeconds, headSha, base, prNumber, prospectiveResult, report, reviewSummaryIds);
|
|
129
|
+
if (result.action === "fix_code") {
|
|
130
|
+
await Promise.allSettled(result.fix.checks.flatMap((ch) => (ch.annotations ?? []).map((a) => markSeen(stallKey, a.id, annotationMarkerBody(a)))));
|
|
131
|
+
}
|
|
132
|
+
return result;
|
|
126
133
|
}
|
|
@@ -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";
|
|
@@ -65,6 +68,9 @@ export function buildFixInstructions(threads, actionableComments, checks, change
|
|
|
65
68
|
instructions.push(`Resolve the threads under \`## Review threads to resolve\` with the \`resolve:\` command shown below. These threads are already outdated or minimized, so no code edit is required for them unless their body reveals separate work you choose to do.`);
|
|
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
|
}
|
|
@@ -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
|
+
}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
1
|
+
import { vi, beforeEach, afterEach } from "vitest";
|
|
3
2
|
const mockFetch = vi.fn();
|
|
4
3
|
vi.stubGlobal("fetch", mockFetch);
|
|
5
4
|
const { mockExecFile } = vi.hoisted(() => ({ mockExecFile: vi.fn() }));
|
|
@@ -24,6 +23,9 @@ vi.mock("../state/iterate-stall.mts", () => ({
|
|
|
24
23
|
readStallState: vi.fn().mockResolvedValue(null),
|
|
25
24
|
writeStallState: vi.fn().mockResolvedValue(undefined),
|
|
26
25
|
}));
|
|
26
|
+
vi.mock("../state/seen-comments.mts", () => ({
|
|
27
|
+
markSeen: vi.fn().mockResolvedValue(undefined),
|
|
28
|
+
}));
|
|
27
29
|
const { mockLoadConfig } = vi.hoisted(() => ({ mockLoadConfig: vi.fn() }));
|
|
28
30
|
vi.mock("../config/load.mts", () => ({ loadConfig: mockLoadConfig }));
|
|
29
31
|
import { runIterate } from "./iterate/index.mjs";
|
|
@@ -31,12 +33,14 @@ import { runCheck } from "./check.mjs";
|
|
|
31
33
|
import { updateReadyDelay } from "./ready-delay.mjs";
|
|
32
34
|
import { readFixAttempts, writeFixAttempts } from "../state/fix-attempts.mjs";
|
|
33
35
|
import { readStallState, writeStallState } from "../state/iterate-stall.mjs";
|
|
36
|
+
import { markSeen } from "../state/seen-comments.mjs";
|
|
34
37
|
const mockRunCheck = vi.mocked(runCheck);
|
|
35
38
|
const mockUpdateReadyDelay = vi.mocked(updateReadyDelay);
|
|
36
39
|
const mockReadFixAttempts = vi.mocked(readFixAttempts);
|
|
37
40
|
const mockWriteFixAttempts = vi.mocked(writeFixAttempts);
|
|
38
41
|
const mockReadStallState = vi.mocked(readStallState);
|
|
39
42
|
const mockWriteStallState = vi.mocked(writeStallState);
|
|
43
|
+
const mockMarkSeen = vi.mocked(markSeen);
|
|
40
44
|
function makeReport(overrides = {}) {
|
|
41
45
|
return {
|
|
42
46
|
pr: 42,
|
|
@@ -44,6 +48,7 @@ function makeReport(overrides = {}) {
|
|
|
44
48
|
repo: "owner/repo",
|
|
45
49
|
status: "READY",
|
|
46
50
|
baseBranch: "main",
|
|
51
|
+
branchProtection: null,
|
|
47
52
|
mergeStatus: {
|
|
48
53
|
status: "CLEAN",
|
|
49
54
|
state: "OPEN",
|
|
@@ -115,4 +120,4 @@ export function registerHooks() {
|
|
|
115
120
|
vi.restoreAllMocks();
|
|
116
121
|
});
|
|
117
122
|
}
|
|
118
|
-
export { makeOpts, makeReport, mockExecFile, mockFetch, mockLoadConfig, mockReadFixAttempts, mockReadStallState, mockRunCheck, mockUpdateReadyDelay, mockWriteFixAttempts, mockWriteStallState, readFixAttempts, readStallState, runCheck, runIterate, updateReadyDelay, writeFixAttempts, writeStallState, };
|
|
123
|
+
export { makeOpts, makeReport, mockExecFile, mockFetch, mockLoadConfig, mockReadFixAttempts, mockReadStallState, mockMarkSeen, mockRunCheck, mockUpdateReadyDelay, mockWriteFixAttempts, mockWriteStallState, readFixAttempts, readStallState, runCheck, runIterate, updateReadyDelay, writeFixAttempts, writeStallState, };
|
package/bin/commands/poll.mjs
CHANGED
|
@@ -3,9 +3,12 @@ function sleep(ms) {
|
|
|
3
3
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
4
4
|
}
|
|
5
5
|
function writeTickProgress(tick, elapsedSeconds, sleepSeconds, verbose) {
|
|
6
|
-
if (
|
|
6
|
+
if (verbose) {
|
|
7
7
|
process.stderr.write(`[poll tick ${tick} / +${elapsedSeconds}s] WAIT — sleeping ${sleepSeconds}s\n`);
|
|
8
8
|
}
|
|
9
|
+
else {
|
|
10
|
+
process.stderr.write(".");
|
|
11
|
+
}
|
|
9
12
|
}
|
|
10
13
|
const MAX_TIMER_MS = 2 ** 31 - 1;
|
|
11
14
|
export async function runPoll(opts) {
|
|
@@ -16,17 +19,23 @@ export async function runPoll(opts) {
|
|
|
16
19
|
let tick = 0;
|
|
17
20
|
let lastResult;
|
|
18
21
|
const verbose = opts.verbose === true;
|
|
22
|
+
let dotsPrinted = false;
|
|
19
23
|
while (true) {
|
|
20
24
|
tick += 1;
|
|
21
25
|
lastResult = await runIterate(iterateOpts);
|
|
22
26
|
if (lastResult.action !== "wait")
|
|
23
|
-
|
|
27
|
+
break;
|
|
24
28
|
const elapsedMs = Date.now() - start;
|
|
25
29
|
const remainingMs = timeoutMs - elapsedMs;
|
|
26
30
|
if (remainingMs <= 0)
|
|
27
|
-
|
|
31
|
+
break;
|
|
28
32
|
const nextSleepMs = Math.min(intervalMs, remainingMs);
|
|
29
33
|
writeTickProgress(tick, Math.round(elapsedMs / 1000), Math.round(nextSleepMs / 1000), verbose);
|
|
34
|
+
if (!verbose)
|
|
35
|
+
dotsPrinted = true;
|
|
30
36
|
await sleep(nextSleepMs);
|
|
31
37
|
}
|
|
38
|
+
if (dotsPrinted)
|
|
39
|
+
process.stderr.write("\n");
|
|
40
|
+
return lastResult;
|
|
32
41
|
}
|
package/bin/commands/resolve.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import { classifyVisibleComments } from "../comments/visible-comments.mjs";
|
|
|
7
7
|
import { extractSuggestion } from "../suggestions/extract.mjs";
|
|
8
8
|
import { buildFetchInstructions } from "./resolve-instructions.mjs";
|
|
9
9
|
import { loadSeenMap, markSeen, classifyItem } from "../state/seen-comments.mjs";
|
|
10
|
+
import { threadTranscriptBody } from "../threads/transcript.mjs";
|
|
10
11
|
export { runResolveMutate } from "./resolve-mutate.mjs";
|
|
11
12
|
export async function runResolveFetch(opts) {
|
|
12
13
|
const repo = await getRepoInfo();
|
|
@@ -25,7 +26,7 @@ export async function runResolveFetch(opts) {
|
|
|
25
26
|
const unseenOutdated = [];
|
|
26
27
|
const editedOutdated = [];
|
|
27
28
|
for (const t of outdatedCandidates) {
|
|
28
|
-
const cls = classifyItem(t.id, t
|
|
29
|
+
const cls = classifyItem(t.id, threadTranscriptBody(t), seenMap);
|
|
29
30
|
if (cls === "new")
|
|
30
31
|
unseenOutdated.push(t);
|
|
31
32
|
else if (cls === "edited")
|
|
@@ -34,7 +35,7 @@ export async function runResolveFetch(opts) {
|
|
|
34
35
|
const unseenResolved = [];
|
|
35
36
|
const editedResolved = [];
|
|
36
37
|
for (const t of resolvedCandidates) {
|
|
37
|
-
const cls = classifyItem(t.id, t
|
|
38
|
+
const cls = classifyItem(t.id, threadTranscriptBody(t), seenMap);
|
|
38
39
|
if (cls === "new")
|
|
39
40
|
unseenResolved.push(t);
|
|
40
41
|
else if (cls === "edited")
|
|
@@ -43,7 +44,7 @@ export async function runResolveFetch(opts) {
|
|
|
43
44
|
const unseenMinimizedThreads = [];
|
|
44
45
|
const editedMinimizedThreads = [];
|
|
45
46
|
for (const t of minimizedThreadCandidates) {
|
|
46
|
-
const cls = classifyItem(t.id, t
|
|
47
|
+
const cls = classifyItem(t.id, threadTranscriptBody(t), seenMap);
|
|
47
48
|
if (cls === "new")
|
|
48
49
|
unseenMinimizedThreads.push(t);
|
|
49
50
|
else if (cls === "edited")
|
|
@@ -114,7 +115,7 @@ export async function runResolveFetch(opts) {
|
|
|
114
115
|
];
|
|
115
116
|
// Mark new and edited items as seen (best-effort — markSeen never throws).
|
|
116
117
|
await Promise.allSettled([
|
|
117
|
-
...firstLookThreads.map((t) => markSeen(stateKey, t.id, t
|
|
118
|
+
...firstLookThreads.map((t) => markSeen(stateKey, t.id, threadTranscriptBody(t))),
|
|
118
119
|
...firstLookComments.map((c) => markSeen(stateKey, c.id, c.body)),
|
|
119
120
|
...visibleCommentClassification.toMarkSeen.map((c) => markSeen(stateKey, c.id, c.body)),
|
|
120
121
|
]);
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
1
|
+
import { vi, beforeEach } from "vitest";
|
|
3
2
|
vi.mock("../github/client.mts", () => ({
|
|
4
3
|
getRepoInfo: vi.fn().mockResolvedValue({ owner: "owner", name: "repo" }),
|
|
5
4
|
getCurrentPrNumber: vi.fn().mockResolvedValue(42),
|
|
@@ -67,6 +66,7 @@ function makeBatchData(overrides = {}) {
|
|
|
67
66
|
reviewSummaries: [],
|
|
68
67
|
approvedReviews: [],
|
|
69
68
|
checks: [],
|
|
69
|
+
branchProtection: null,
|
|
70
70
|
...overrides,
|
|
71
71
|
};
|
|
72
72
|
}
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
// @ts-nocheck
|
|
2
|
-
import { describe, expect, it } from "vitest";
|
|
3
1
|
import { buildShepherdJournalInstruction, SHEPHERD_JOURNAL_APPEND_HINT, SHEPHERD_JOURNAL_FIRST_LOOK_GUIDANCE, SHEPHERD_JOURNAL_REFERENCE_GUIDANCE_THREADS_AND_COMMENTS_IN_ITEM_HEADINGS, SHEPHERD_JOURNAL_REFERENCE_GUIDANCE_THREADS_AND_COMMENTS_IN_ITEMS, SHEPHERD_JOURNAL_SECTION, SHEPHERD_JOURNAL_SECTION_PATTERN, } from "./shepherd-journal.mjs";
|
|
4
2
|
import { buildFixInstructions } from "./iterate/render.mjs";
|
|
5
3
|
import { buildFetchInstructions } from "./resolve-instructions.mjs";
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
1
|
+
import { vi, beforeEach } from "vitest";
|
|
3
2
|
// ---------------------------------------------------------------------------
|
|
4
3
|
// Mock github/client.mts before any imports.
|
|
5
4
|
// ---------------------------------------------------------------------------
|
package/bin/config.json
CHANGED
|
@@ -10,19 +10,29 @@ export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes,
|
|
|
10
10
|
}));
|
|
11
11
|
const reviewThreads = rawThreadPages.map((t) => {
|
|
12
12
|
const comment = t.comments.nodes[0];
|
|
13
|
+
const comments = t.comments.nodes.map((c) => ({
|
|
14
|
+
id: c.id,
|
|
15
|
+
isMinimized: c.isMinimized,
|
|
16
|
+
author: c.author?.login ?? "unknown",
|
|
17
|
+
authorType: mapAuthorType(c.author?.__typename),
|
|
18
|
+
body: c.body,
|
|
19
|
+
url: c.url,
|
|
20
|
+
createdAtUnix: parseCreatedAt(c.createdAt),
|
|
21
|
+
}));
|
|
13
22
|
return {
|
|
14
23
|
id: t.id,
|
|
15
24
|
isResolved: t.isResolved,
|
|
16
25
|
isOutdated: t.isOutdated,
|
|
17
26
|
isMinimized: comment?.isMinimized ?? false,
|
|
18
|
-
path: comment?.path ?? null,
|
|
19
|
-
line: comment?.line ?? null,
|
|
20
|
-
startLine: comment?.startLine ?? null,
|
|
27
|
+
path: t.path ?? comment?.path ?? null,
|
|
28
|
+
line: t.line ?? comment?.line ?? null,
|
|
29
|
+
startLine: t.startLine ?? comment?.startLine ?? null,
|
|
21
30
|
author: comment?.author?.login ?? "unknown",
|
|
22
31
|
authorType: mapAuthorType(comment?.author?.__typename),
|
|
23
32
|
body: comment?.body ?? "",
|
|
24
33
|
url: comment?.url ?? "",
|
|
25
34
|
createdAtUnix: comment ? parseCreatedAt(comment.createdAt) : 0,
|
|
35
|
+
comments,
|
|
26
36
|
};
|
|
27
37
|
});
|
|
28
38
|
const comments = rawCommentNodes.map((c) => ({
|
|
@@ -64,14 +74,28 @@ export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes,
|
|
|
64
74
|
const event = node.checkSuite?.workflowRun?.event ?? null;
|
|
65
75
|
const runId = extractRunId(node.detailsUrl);
|
|
66
76
|
const summary = extractCheckRunSummary(node.title, node.summary);
|
|
77
|
+
const rawCreatedAt = node.checkSuite
|
|
78
|
+
? (node.checkSuite.workflowRun?.createdAt ?? node.checkSuite.createdAt)
|
|
79
|
+
: undefined;
|
|
80
|
+
const rawUpdatedAt = node.checkSuite
|
|
81
|
+
? (node.checkSuite.workflowRun?.updatedAt ?? node.checkSuite.updatedAt)
|
|
82
|
+
: undefined;
|
|
83
|
+
const createdAtUnix = rawCreatedAt ? parseCreatedAt(rawCreatedAt) : undefined;
|
|
84
|
+
const startedAtUnix = node.startedAt ? parseCreatedAt(node.startedAt) : undefined;
|
|
85
|
+
const updatedAtUnix = rawUpdatedAt ? parseCreatedAt(rawUpdatedAt) : undefined;
|
|
67
86
|
return [
|
|
68
87
|
{
|
|
88
|
+
id: node.id,
|
|
69
89
|
name: node.name,
|
|
70
90
|
status: node.status,
|
|
71
91
|
conclusion: node.conclusion,
|
|
92
|
+
source: "check_run",
|
|
72
93
|
detailsUrl: node.detailsUrl ?? "",
|
|
73
94
|
event,
|
|
74
95
|
runId,
|
|
96
|
+
...(createdAtUnix !== undefined && { createdAtUnix }),
|
|
97
|
+
...(startedAtUnix !== undefined && { startedAtUnix }),
|
|
98
|
+
...(updatedAtUnix !== undefined && { updatedAtUnix }),
|
|
75
99
|
...(summary !== undefined && { summary }),
|
|
76
100
|
},
|
|
77
101
|
];
|
|
@@ -79,14 +103,17 @@ export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes,
|
|
|
79
103
|
if (node.__typename === "StatusContext") {
|
|
80
104
|
const { status, conclusion } = mapStatusContextState(node.state);
|
|
81
105
|
const summary = node.description?.trim() || undefined;
|
|
106
|
+
const createdAtUnix = node.createdAt ? parseCreatedAt(node.createdAt) : undefined;
|
|
82
107
|
return [
|
|
83
108
|
{
|
|
84
109
|
name: node.context,
|
|
85
110
|
status,
|
|
86
111
|
conclusion,
|
|
112
|
+
source: "status_context",
|
|
87
113
|
detailsUrl: node.targetUrl ?? "",
|
|
88
114
|
event: null,
|
|
89
115
|
runId: null,
|
|
116
|
+
...(createdAtUnix !== undefined && { createdAtUnix }),
|
|
90
117
|
...(summary !== undefined && { summary }),
|
|
91
118
|
},
|
|
92
119
|
];
|
package/bin/github/batch.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { graphql, graphqlWithRateLimit } from "./client.mjs";
|
|
2
2
|
import { paginateForward, paginateBackward } from "./pagination.mjs";
|
|
3
|
+
import { hydrateThreadCommentPages } from "./thread-comments.mjs";
|
|
3
4
|
import { BATCH_PR_QUERY } from "./queries.mjs";
|
|
4
5
|
import { parseRawPr } from "./batch-parsers.mjs";
|
|
5
6
|
/**
|
|
@@ -36,6 +37,7 @@ export async function fetchPrBatch(pr, repo, opts = {}) {
|
|
|
36
37
|
// extra contains pages before the first page.
|
|
37
38
|
rawThreadPages = [...extra, ...rawThreadPages];
|
|
38
39
|
}
|
|
40
|
+
rawThreadPages = await hydrateThreadCommentPages(rawThreadPages);
|
|
39
41
|
// Paginate comments backward if the first page is incomplete.
|
|
40
42
|
let rawCommentNodes = raw.comments.nodes;
|
|
41
43
|
if (raw.comments.pageInfo.hasPreviousPage && raw.comments.pageInfo.startCursor) {
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { graphql } from "./client.mjs";
|
|
3
|
+
import { CHECK_RUN_ANNOTATIONS_QUERY } from "./queries.mjs";
|
|
4
|
+
const ANNOTATIONS_PER_PAGE = 100;
|
|
5
|
+
const MAX_ANNOTATION_PAGES = 10;
|
|
6
|
+
export async function fetchCheckRunAnnotations(checkRunId) {
|
|
7
|
+
let cursor = null;
|
|
8
|
+
const nodes = [];
|
|
9
|
+
for (let page = 1; page <= MAX_ANNOTATION_PAGES; page++) {
|
|
10
|
+
// eslint-disable-next-line no-await-in-loop
|
|
11
|
+
const result = await fetchAnnotationPage(checkRunId, cursor);
|
|
12
|
+
nodes.push(...result.nodes);
|
|
13
|
+
if (!result.pageInfo.hasNextPage || !result.pageInfo.endCursor)
|
|
14
|
+
break;
|
|
15
|
+
if (page === MAX_ANNOTATION_PAGES) {
|
|
16
|
+
process.stderr.write(`pr-shepherd: annotation pagination cap (${MAX_ANNOTATION_PAGES * ANNOTATIONS_PER_PAGE} annotations) reached for check run ${checkRunId} — annotation output may be incomplete\n`);
|
|
17
|
+
break;
|
|
18
|
+
}
|
|
19
|
+
cursor = result.pageInfo.endCursor;
|
|
20
|
+
}
|
|
21
|
+
return nodes.map((node) => toCheckAnnotation(checkRunId, node));
|
|
22
|
+
}
|
|
23
|
+
async function fetchAnnotationPage(checkRunId, cursor) {
|
|
24
|
+
const res = await graphql(CHECK_RUN_ANNOTATIONS_QUERY, {
|
|
25
|
+
id: checkRunId,
|
|
26
|
+
...(cursor ? { cursor } : {}),
|
|
27
|
+
});
|
|
28
|
+
const node = res.data.node;
|
|
29
|
+
if (node?.__typename !== "CheckRun" || node.annotations === undefined) {
|
|
30
|
+
return { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] };
|
|
31
|
+
}
|
|
32
|
+
return node.annotations;
|
|
33
|
+
}
|
|
34
|
+
function toCheckAnnotation(checkRunId, raw) {
|
|
35
|
+
const id = `check_annotation_${raw.fullDatabaseId ?? fallbackId(checkRunId, raw)}`;
|
|
36
|
+
const title = raw.title?.trim() || undefined;
|
|
37
|
+
const rawDetails = raw.rawDetails?.trim() || undefined;
|
|
38
|
+
const blobUrl = raw.blobUrl?.trim() || undefined;
|
|
39
|
+
return {
|
|
40
|
+
id,
|
|
41
|
+
path: raw.path,
|
|
42
|
+
startLine: raw.location?.start.line ?? null,
|
|
43
|
+
endLine: raw.location?.end.line ?? raw.location?.start.line ?? null,
|
|
44
|
+
...(raw.location?.start.column !== undefined && {
|
|
45
|
+
startColumn: raw.location.start.column,
|
|
46
|
+
}),
|
|
47
|
+
...(raw.location?.end.column !== undefined && {
|
|
48
|
+
endColumn: raw.location.end.column,
|
|
49
|
+
}),
|
|
50
|
+
level: raw.annotationLevel,
|
|
51
|
+
...(title !== undefined && { title }),
|
|
52
|
+
message: raw.message,
|
|
53
|
+
...(rawDetails !== undefined && { rawDetails }),
|
|
54
|
+
...(blobUrl !== undefined && { blobUrl }),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
function fallbackId(checkRunId, raw) {
|
|
58
|
+
const start = raw.location?.start;
|
|
59
|
+
const end = raw.location?.end;
|
|
60
|
+
const parts = [
|
|
61
|
+
checkRunId,
|
|
62
|
+
raw.path,
|
|
63
|
+
raw.annotationLevel,
|
|
64
|
+
raw.title ?? "",
|
|
65
|
+
raw.message,
|
|
66
|
+
raw.rawDetails ?? "",
|
|
67
|
+
raw.blobUrl ?? "",
|
|
68
|
+
String(start?.line ?? ""),
|
|
69
|
+
String(start?.column ?? ""),
|
|
70
|
+
String(end?.line ?? ""),
|
|
71
|
+
String(end?.column ?? ""),
|
|
72
|
+
];
|
|
73
|
+
const input = parts.map((part) => `${part.length}:${part}`).join("|");
|
|
74
|
+
return createHash("sha256").update(input).digest("hex").slice(0, 24);
|
|
75
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { _resetTokenCache } from "./http.mjs";
|
|
3
|
+
export const mockFetch = vi.fn();
|
|
4
|
+
vi.stubGlobal("fetch", mockFetch);
|
|
5
|
+
const { _mockExecFile } = vi.hoisted(() => ({ _mockExecFile: vi.fn() }));
|
|
6
|
+
export const mockExecFile = _mockExecFile;
|
|
7
|
+
vi.mock("node:child_process", () => ({
|
|
8
|
+
execFile: (cmd, args, optsOrCb, maybeCb) => {
|
|
9
|
+
const cb = typeof optsOrCb === "function" ? optsOrCb : maybeCb;
|
|
10
|
+
_mockExecFile(cmd, args)
|
|
11
|
+
.then((result) => cb(null, result))
|
|
12
|
+
.catch((err) => cb(err, { stdout: "", stderr: "" }));
|
|
13
|
+
},
|
|
14
|
+
}));
|
|
15
|
+
export function gqlOk(data) {
|
|
16
|
+
return {
|
|
17
|
+
ok: true,
|
|
18
|
+
status: 200,
|
|
19
|
+
headers: new Headers({ "content-type": "application/json" }),
|
|
20
|
+
json: () => Promise.resolve({ data }),
|
|
21
|
+
text: () => Promise.resolve(JSON.stringify({ data })),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export function restOk(data) {
|
|
25
|
+
return {
|
|
26
|
+
ok: true,
|
|
27
|
+
status: 200,
|
|
28
|
+
headers: new Headers({ "content-type": "application/json" }),
|
|
29
|
+
json: () => Promise.resolve(data),
|
|
30
|
+
text: () => Promise.resolve(JSON.stringify(data)),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
export function gqlErrors(errors) {
|
|
34
|
+
return {
|
|
35
|
+
ok: true,
|
|
36
|
+
status: 200,
|
|
37
|
+
headers: new Headers({ "content-type": "application/json" }),
|
|
38
|
+
json: () => Promise.resolve({ data: null, errors }),
|
|
39
|
+
text: () => Promise.resolve(JSON.stringify({ data: null, errors })),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
export function registerClientHooks() {
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
mockFetch.mockReset();
|
|
45
|
+
mockExecFile.mockReset();
|
|
46
|
+
_resetTokenCache();
|
|
47
|
+
delete process.env["GITHUB_TOKEN"];
|
|
48
|
+
delete process.env["GITHUB_PERSONAL_ACCESS_TOKEN"];
|
|
49
|
+
process.env["GH_TOKEN"] = "test-token";
|
|
50
|
+
});
|
|
51
|
+
afterEach(() => {
|
|
52
|
+
delete process.env["GH_TOKEN"];
|
|
53
|
+
_resetTokenCache();
|
|
54
|
+
});
|
|
55
|
+
}
|
|
@@ -67,8 +67,14 @@ query BatchPr(
|
|
|
67
67
|
id
|
|
68
68
|
isResolved
|
|
69
69
|
isOutdated
|
|
70
|
-
|
|
71
|
-
|
|
70
|
+
path
|
|
71
|
+
line
|
|
72
|
+
startLine
|
|
73
|
+
comments(first: 100) {
|
|
74
|
+
pageInfo {
|
|
75
|
+
hasNextPage
|
|
76
|
+
endCursor
|
|
77
|
+
}
|
|
72
78
|
nodes {
|
|
73
79
|
id
|
|
74
80
|
isMinimized
|
|
@@ -164,21 +170,28 @@ query BatchPr(
|
|
|
164
170
|
nodes {
|
|
165
171
|
__typename
|
|
166
172
|
... on CheckRun {
|
|
173
|
+
id
|
|
167
174
|
name
|
|
168
175
|
status
|
|
169
176
|
conclusion
|
|
170
177
|
detailsUrl
|
|
178
|
+
startedAt
|
|
171
179
|
title
|
|
172
180
|
summary
|
|
173
181
|
checkSuite {
|
|
182
|
+
createdAt
|
|
183
|
+
updatedAt
|
|
174
184
|
workflowRun {
|
|
175
185
|
event
|
|
186
|
+
createdAt
|
|
187
|
+
updatedAt
|
|
176
188
|
}
|
|
177
189
|
}
|
|
178
190
|
}
|
|
179
191
|
... on StatusContext {
|
|
180
192
|
context
|
|
181
193
|
state
|
|
194
|
+
createdAt
|
|
182
195
|
targetUrl
|
|
183
196
|
description
|
|
184
197
|
}
|