pr-shepherd 0.22.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 +87 -125
- package/bin/cli/args.mjs +2 -0
- package/bin/cli/fix-formatter.mjs +3 -1
- 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-instructions.mjs +1 -1
- package/bin/cli/list-formatters.mjs +5 -2
- 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/commands/check.mjs +25 -56
- package/bin/commands/commit-suggestion-instruction.mjs +1 -1
- package/bin/commands/iterate/classify.mjs +32 -31
- package/bin/commands/iterate/fix-code.mjs +23 -19
- package/bin/commands/iterate/index.mjs +4 -1
- package/bin/commands/iterate/render.mjs +6 -3
- package/bin/commands/iterate/stall.mjs +1 -1
- package/bin/commands/mark-files-as-viewed.mjs +220 -0
- package/bin/commands/resolve-instructions.mjs +5 -2
- package/bin/commands/resolve-mutate.mjs +49 -4
- package/bin/commands/resolve.mjs +21 -78
- package/bin/commands/shepherd-journal.mjs +1 -1
- package/bin/comments/authors.mjs +32 -0
- package/bin/comments/minimize-policy.mjs +8 -4
- package/bin/comments/pending-ops.mjs +6 -0
- package/bin/comments/resolve.mjs +28 -11
- package/bin/comments/review-thread-markers.mjs +18 -0
- package/bin/comments/review-visibility.mjs +14 -0
- package/bin/comments/thread-visibility.mjs +60 -0
- 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-parser-helpers.mjs +3 -4
- package/bin/github/batch-parsers.mjs +8 -6
- package/bin/github/client.mjs +1 -2
- package/bin/github/gql/batch-pr.gql +3 -0
- package/bin/reporters/agent.mjs +2 -0
- package/bin/state/seen-comments.mjs +40 -4
- package/bin/threads/transcript.mjs +6 -6
- package/package.json +8 -4
- package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
- package/plugins/pr-shepherd/skills/mark-files-as-viewed/SKILL.md +31 -0
- 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 -42
- 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.fix-code-in-progress.test-support.mjs +0 -123
- package/bin/commands/poll.test-support.mjs +0 -77
- package/bin/commands/resolve.test-support.mjs +0 -114
- 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 -39
- 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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { classifyItem } from "../state/seen-comments.mjs";
|
|
2
|
+
export function classifyReviewsForDisplay(reviews, seenMap) {
|
|
3
|
+
const visible = [];
|
|
4
|
+
const toMarkSeen = [];
|
|
5
|
+
for (const review of reviews) {
|
|
6
|
+
const cls = classifyItem(review.id, review.body, seenMap);
|
|
7
|
+
if (cls === "unchanged")
|
|
8
|
+
continue;
|
|
9
|
+
const rendered = cls === "edited" ? { ...review, edited: true } : review;
|
|
10
|
+
visible.push(rendered);
|
|
11
|
+
toMarkSeen.push(review);
|
|
12
|
+
}
|
|
13
|
+
return { visible, toMarkSeen };
|
|
14
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { classifyItem } from "../state/seen-comments.mjs";
|
|
2
|
+
import { threadTranscriptBody } from "../threads/transcript.mjs";
|
|
3
|
+
import { isConfiguredBotAuthor } from "./authors.mjs";
|
|
4
|
+
function withEdited(thread, edited) {
|
|
5
|
+
return edited ? { ...thread, edited: true } : thread;
|
|
6
|
+
}
|
|
7
|
+
function classifyVisibleThread(thread, seenMap) {
|
|
8
|
+
const cls = classifyItem(thread.id, threadTranscriptBody(thread), seenMap);
|
|
9
|
+
if (cls === "unchanged")
|
|
10
|
+
return null;
|
|
11
|
+
return withEdited(thread, cls === "edited");
|
|
12
|
+
}
|
|
13
|
+
function classifyFirstLookThread(thread, seenMap, firstLookStatus) {
|
|
14
|
+
const visible = classifyVisibleThread(thread, seenMap);
|
|
15
|
+
if (visible === null)
|
|
16
|
+
return null;
|
|
17
|
+
return { ...visible, firstLookStatus };
|
|
18
|
+
}
|
|
19
|
+
export function classifyThreadVisibility(threads, seenMap, botUsernames = new Set()) {
|
|
20
|
+
const unresolvedThreads = threads.filter((t) => !t.isResolved);
|
|
21
|
+
const activeThreads = unresolvedThreads
|
|
22
|
+
.filter((t) => !t.isOutdated && !t.isMinimized)
|
|
23
|
+
.flatMap((t) => {
|
|
24
|
+
if (isConfiguredBotAuthor(t, botUsernames))
|
|
25
|
+
return [t];
|
|
26
|
+
const visible = classifyVisibleThread(t, seenMap);
|
|
27
|
+
return visible ? [visible] : [];
|
|
28
|
+
});
|
|
29
|
+
const resolutionOnlyThreads = unresolvedThreads
|
|
30
|
+
.filter((t) => t.isOutdated || t.isMinimized)
|
|
31
|
+
.map((t) => classifyVisibleThread(t, seenMap) ?? t);
|
|
32
|
+
const firstLookThreads = [
|
|
33
|
+
...threads.flatMap((t) => {
|
|
34
|
+
if (!t.isOutdated)
|
|
35
|
+
return [];
|
|
36
|
+
const visible = classifyFirstLookThread(t, seenMap, "outdated");
|
|
37
|
+
return visible ? [visible] : [];
|
|
38
|
+
}),
|
|
39
|
+
...threads.flatMap((t) => {
|
|
40
|
+
if (!t.isResolved || t.isOutdated)
|
|
41
|
+
return [];
|
|
42
|
+
const visible = classifyFirstLookThread(t, seenMap, "resolved");
|
|
43
|
+
return visible ? [visible] : [];
|
|
44
|
+
}),
|
|
45
|
+
...threads.flatMap((t) => {
|
|
46
|
+
if (!t.isMinimized || t.isResolved || t.isOutdated)
|
|
47
|
+
return [];
|
|
48
|
+
const visible = classifyFirstLookThread(t, seenMap, "minimized");
|
|
49
|
+
return visible ? [visible] : [];
|
|
50
|
+
}),
|
|
51
|
+
];
|
|
52
|
+
const seenMarkerIds = new Set();
|
|
53
|
+
const toMarkSeen = [...activeThreads, ...resolutionOnlyThreads, ...firstLookThreads].filter((thread) => {
|
|
54
|
+
if (seenMarkerIds.has(thread.id))
|
|
55
|
+
return false;
|
|
56
|
+
seenMarkerIds.add(thread.id);
|
|
57
|
+
return true;
|
|
58
|
+
});
|
|
59
|
+
return { activeThreads, resolutionOnlyThreads, firstLookThreads, toMarkSeen };
|
|
60
|
+
}
|
|
@@ -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)) {
|
|
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,
|
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
return "Unknown";
|
|
1
|
+
import { normalizeAuthorType } from "../comments/authors.mjs";
|
|
2
|
+
export function mapAuthorType(typeName, login) {
|
|
3
|
+
return normalizeAuthorType(typeName, login);
|
|
5
4
|
}
|
|
6
5
|
export function parseCreatedAt(iso) {
|
|
7
6
|
const ms = new Date(iso).getTime();
|
|
@@ -13,8 +13,9 @@ 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
|
-
authorType: mapAuthorType(c.author?.__typename),
|
|
18
|
+
authorType: mapAuthorType(c.author?.__typename, c.author?.login),
|
|
18
19
|
body: c.body,
|
|
19
20
|
url: c.url,
|
|
20
21
|
createdAtUnix: parseCreatedAt(c.createdAt),
|
|
@@ -27,8 +28,9 @@ 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
|
-
authorType: mapAuthorType(comment?.author?.__typename),
|
|
33
|
+
authorType: mapAuthorType(comment?.author?.__typename, comment?.author?.login),
|
|
32
34
|
body: comment?.body ?? "",
|
|
33
35
|
url: comment?.url ?? "",
|
|
34
36
|
createdAtUnix: comment ? parseCreatedAt(comment.createdAt) : 0,
|
|
@@ -39,7 +41,7 @@ export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes,
|
|
|
39
41
|
id: c.id,
|
|
40
42
|
isMinimized: c.isMinimized,
|
|
41
43
|
author: c.author?.login ?? "unknown",
|
|
42
|
-
authorType: mapAuthorType(c.author?.__typename),
|
|
44
|
+
authorType: mapAuthorType(c.author?.__typename, c.author?.login),
|
|
43
45
|
body: c.body,
|
|
44
46
|
url: c.url,
|
|
45
47
|
createdAtUnix: parseCreatedAt(c.createdAt),
|
|
@@ -47,7 +49,7 @@ export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes,
|
|
|
47
49
|
const changesRequestedReviews = rawReviewNodes.map((r) => ({
|
|
48
50
|
id: r.id,
|
|
49
51
|
author: r.author?.login ?? "unknown",
|
|
50
|
-
authorType: mapAuthorType(r.author?.__typename),
|
|
52
|
+
authorType: mapAuthorType(r.author?.__typename, r.author?.login),
|
|
51
53
|
body: r.body,
|
|
52
54
|
}));
|
|
53
55
|
const reviewSummaries = rawReviewSummaryNodes
|
|
@@ -55,7 +57,7 @@ export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes,
|
|
|
55
57
|
.map((r) => ({
|
|
56
58
|
id: r.id,
|
|
57
59
|
author: r.author?.login ?? "unknown",
|
|
58
|
-
authorType: mapAuthorType(r.author?.__typename),
|
|
60
|
+
authorType: mapAuthorType(r.author?.__typename, r.author?.login),
|
|
59
61
|
body: r.body,
|
|
60
62
|
}));
|
|
61
63
|
// APPROVED reviews often have empty bodies (clicking "Approve" without a comment), so
|
|
@@ -66,7 +68,7 @@ export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes,
|
|
|
66
68
|
.map((r) => ({
|
|
67
69
|
id: r.id,
|
|
68
70
|
author: r.author?.login ?? "unknown",
|
|
69
|
-
authorType: mapAuthorType(r.author?.__typename),
|
|
71
|
+
authorType: mapAuthorType(r.author?.__typename, r.author?.login),
|
|
70
72
|
body: r.body,
|
|
71
73
|
}));
|
|
72
74
|
const checks = rawCheckNodes.flatMap((node) => {
|
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
|
@@ -13,6 +13,7 @@ export function toAgentThread(t) {
|
|
|
13
13
|
const suggestion = extractSuggestion(t) ?? undefined;
|
|
14
14
|
return {
|
|
15
15
|
id: t.id,
|
|
16
|
+
...(t.reviewId !== undefined && { reviewId: t.reviewId }),
|
|
16
17
|
path: t.path,
|
|
17
18
|
line: t.line,
|
|
18
19
|
...(t.line !== null &&
|
|
@@ -22,6 +23,7 @@ export function toAgentThread(t) {
|
|
|
22
23
|
...(t.authorType !== undefined && { authorType: t.authorType }),
|
|
23
24
|
body: t.body,
|
|
24
25
|
url: t.url,
|
|
26
|
+
...(t.edited === true && { edited: true }),
|
|
25
27
|
...(t.comments !== undefined && {
|
|
26
28
|
comments: t.comments.map((c) => ({
|
|
27
29
|
id: c.id,
|
|
@@ -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 {
|
|
@@ -22,10 +22,10 @@ export function threadComments(thread) {
|
|
|
22
22
|
},
|
|
23
23
|
];
|
|
24
24
|
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
25
|
+
const THREAD_COMMENT_SEPARATOR = "\n\n--- thread comment ---\n\n";
|
|
26
|
+
export function threadTranscriptBody(thread, appendedBodies = []) {
|
|
27
|
+
const bodies = thread.comments && thread.comments.length > 0
|
|
28
|
+
? threadComments(thread).map((c) => c.body)
|
|
29
|
+
: [thread.body];
|
|
30
|
+
return [...bodies, ...appendedBodies].join(THREAD_COMMENT_SEPARATOR);
|
|
31
31
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pr-shepherd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.0",
|
|
4
4
|
"description": "Autonomous PR CI monitor and review-comment resolver for agentic coding tools",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Jonathan Ong",
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
"@types/node": "^25.6.0",
|
|
29
29
|
"@vitest/coverage-v8": "^4.1.4",
|
|
30
30
|
"husky": "^9.1.7",
|
|
31
|
+
"knip": "^5.88.1",
|
|
31
32
|
"oxfmt": "^0.48.0",
|
|
32
33
|
"oxlint": "^1.60.0",
|
|
33
34
|
"typescript": "^6.0.3",
|
|
@@ -38,9 +39,12 @@
|
|
|
38
39
|
"prepare": "node scripts/install-husky.mjs && npm run build",
|
|
39
40
|
"prepublishOnly": "npm run typecheck && npm test && npm run build",
|
|
40
41
|
"typecheck": "tsc --noEmit",
|
|
41
|
-
"
|
|
42
|
-
"
|
|
43
|
-
"
|
|
42
|
+
"knip": "knip",
|
|
43
|
+
"knip:production": "knip --production",
|
|
44
|
+
"lint:dead-code": "npm run --silent knip && npm run --silent knip:production",
|
|
45
|
+
"lint": "oxlint src/ test-helpers/ fixtures/*.mts test-cases/ plugins/ .agents/plugins/ && npm run --silent lint:dead-code",
|
|
46
|
+
"format": "oxfmt src/ test-helpers/ fixtures/*.mts test-cases/*.mts plugins/ .agents/plugins/ docs/ README.md",
|
|
47
|
+
"format:check": "oxfmt --check src/ test-helpers/ fixtures/*.mts test-cases/*.mts plugins/ .agents/plugins/ docs/ README.md",
|
|
44
48
|
"test": "vitest run",
|
|
45
49
|
"test:coverage": "vitest run --coverage && node scripts/strip-lcov-branches.mjs",
|
|
46
50
|
"test:watch": "vitest"
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: mark-files-as-viewed
|
|
3
|
+
description: 'Mark PR changed files as viewed in GitHub with pr-shepherd. Use for requests like "mark tests as viewed" or "mark these files as viewed".'
|
|
4
|
+
user-invocable: true
|
|
5
|
+
argument-hint: "[PR number or URL] [files|tests|--tests|--match REGEX]"
|
|
6
|
+
allowed-tools: ["Bash", "Read", "Grep", "Glob"]
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# mark-files-as-viewed
|
|
10
|
+
|
|
11
|
+
Thin dispatcher for marking GitHub PR changed files as viewed.
|
|
12
|
+
|
|
13
|
+
## Arguments: $ARGUMENTS
|
|
14
|
+
|
|
15
|
+
## Steps
|
|
16
|
+
|
|
17
|
+
1. **Resolve arguments:**
|
|
18
|
+
- If `$ARGUMENTS` contains a PR number, use it.
|
|
19
|
+
- If `$ARGUMENTS` contains a GitHub PR URL, extract the number.
|
|
20
|
+
- Otherwise, infer: `gh pr view --json number --jq .number`
|
|
21
|
+
- Treat a standalone `tests` argument as `--tests`.
|
|
22
|
+
- Preserve explicit file paths, `--tests`, and `--match <regex>` arguments.
|
|
23
|
+
- If no PR found, report an error and stop.
|
|
24
|
+
|
|
25
|
+
2. **Run `pr-shepherd`:**
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pr-shepherd mark-files-as-viewed <N> <selectors>
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Print the full output.
|
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
import { vi, beforeEach } from "vitest";
|
|
2
|
-
// ---------------------------------------------------------------------------
|
|
3
|
-
// Stub fetch globally so http.mts uses our mock.
|
|
4
|
-
// ---------------------------------------------------------------------------
|
|
5
|
-
const mockFetch = vi.fn();
|
|
6
|
-
vi.stubGlobal("fetch", mockFetch);
|
|
7
|
-
import { fetchStartupFailureChecks, triageFailingChecks } from "./triage.mjs";
|
|
8
|
-
import { mergeStartupFailureChecks } from "./startup-failures.mjs";
|
|
9
|
-
const REPO = { owner: "owner", name: "repo" };
|
|
10
|
-
// ---------------------------------------------------------------------------
|
|
11
|
-
// Helpers
|
|
12
|
-
// ---------------------------------------------------------------------------
|
|
13
|
-
function makeCheck(overrides = {}) {
|
|
14
|
-
return {
|
|
15
|
-
name: "tests",
|
|
16
|
-
status: "COMPLETED",
|
|
17
|
-
conclusion: "FAILURE",
|
|
18
|
-
detailsUrl: "https://github.com/owner/repo/actions/runs/99/jobs/1",
|
|
19
|
-
event: "pull_request",
|
|
20
|
-
runId: "run-99",
|
|
21
|
-
category: "failing",
|
|
22
|
-
...overrides,
|
|
23
|
-
};
|
|
24
|
-
}
|
|
25
|
-
function makeJobsResponse(jobs) {
|
|
26
|
-
return {
|
|
27
|
-
ok: true,
|
|
28
|
-
status: 200,
|
|
29
|
-
headers: new Headers({ "content-type": "application/json" }),
|
|
30
|
-
json: () => Promise.resolve({ jobs }),
|
|
31
|
-
text: () => Promise.resolve(JSON.stringify({ jobs })),
|
|
32
|
-
};
|
|
33
|
-
}
|
|
34
|
-
function makeErrorResponse(status) {
|
|
35
|
-
return {
|
|
36
|
-
ok: false,
|
|
37
|
-
status,
|
|
38
|
-
headers: new Headers(),
|
|
39
|
-
text: () => Promise.resolve("error"),
|
|
40
|
-
};
|
|
41
|
-
}
|
|
42
|
-
function makeWorkflowRunsResponse(runs) {
|
|
43
|
-
return {
|
|
44
|
-
ok: true,
|
|
45
|
-
status: 200,
|
|
46
|
-
headers: new Headers({ "content-type": "application/json" }),
|
|
47
|
-
json: () => Promise.resolve({ workflow_runs: runs }),
|
|
48
|
-
text: () => Promise.resolve(JSON.stringify({ workflow_runs: runs })),
|
|
49
|
-
};
|
|
50
|
-
}
|
|
51
|
-
// ---------------------------------------------------------------------------
|
|
52
|
-
// Tests
|
|
53
|
-
// ---------------------------------------------------------------------------
|
|
54
|
-
export function registerHooks() {
|
|
55
|
-
beforeEach(() => {
|
|
56
|
-
mockFetch.mockReset();
|
|
57
|
-
process.env["GH_TOKEN"] = "test-token";
|
|
58
|
-
});
|
|
59
|
-
}
|
|
60
|
-
export { REPO, fetchStartupFailureChecks, makeCheck, makeErrorResponse, makeJobsResponse, makeWorkflowRunsResponse, mergeStartupFailureChecks, mockFetch, triageFailingChecks, };
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
import { vi, beforeEach, afterEach } from "vitest";
|
|
2
|
-
vi.mock("./commands/clean.mts", () => ({
|
|
3
|
-
runClean: vi.fn(),
|
|
4
|
-
}));
|
|
5
|
-
vi.mock("./commands/resolve.mts", () => ({
|
|
6
|
-
runResolveFetch: vi.fn(),
|
|
7
|
-
runResolveMutate: vi.fn(),
|
|
8
|
-
}));
|
|
9
|
-
vi.mock("./commands/log-file.mts", () => ({
|
|
10
|
-
runLogFile: vi.fn(),
|
|
11
|
-
}));
|
|
12
|
-
vi.mock("./commands/commit-suggestion.mts", () => ({
|
|
13
|
-
runCommitSuggestion: vi.fn(),
|
|
14
|
-
}));
|
|
15
|
-
vi.mock("./commands/iterate/index.mts", async (importOriginal) => {
|
|
16
|
-
const actual = await importOriginal();
|
|
17
|
-
return { ...actual, runIterate: vi.fn() };
|
|
18
|
-
});
|
|
19
|
-
import { main } from "./cli-parser.mjs";
|
|
20
|
-
import { runClean } from "./commands/clean.mjs";
|
|
21
|
-
export const mockRunClean = vi.mocked(runClean);
|
|
22
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
23
|
-
let stdoutSpy;
|
|
24
|
-
let stderrSpy;
|
|
25
|
-
export function getStdout() {
|
|
26
|
-
return stdoutSpy.mock.calls.map((c) => c[0]).join("");
|
|
27
|
-
}
|
|
28
|
-
export function getStderr() {
|
|
29
|
-
return stderrSpy.mock.calls.map((c) => c[0]).join("");
|
|
30
|
-
}
|
|
31
|
-
export function registerHooks() {
|
|
32
|
-
beforeEach(() => {
|
|
33
|
-
vi.clearAllMocks();
|
|
34
|
-
process.exitCode = undefined;
|
|
35
|
-
stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
|
36
|
-
stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
|
37
|
-
});
|
|
38
|
-
afterEach(() => {
|
|
39
|
-
process.exitCode = undefined;
|
|
40
|
-
stdoutSpy.mockRestore();
|
|
41
|
-
stderrSpy.mockRestore();
|
|
42
|
-
});
|
|
43
|
-
}
|
|
44
|
-
export { main, stderrSpy, stdoutSpy };
|
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
import { vi, beforeEach, afterEach } from "vitest";
|
|
2
|
-
vi.mock("./commands/check.mts", () => ({ runCheck: vi.fn() }));
|
|
3
|
-
vi.mock("./commands/resolve.mts", () => ({
|
|
4
|
-
runResolveFetch: vi.fn(),
|
|
5
|
-
runResolveMutate: vi.fn(),
|
|
6
|
-
}));
|
|
7
|
-
vi.mock("./commands/commit-suggestion.mts", () => ({
|
|
8
|
-
runCommitSuggestion: vi.fn(),
|
|
9
|
-
}));
|
|
10
|
-
vi.mock("./commands/iterate/index.mts", async (importOriginal) => {
|
|
11
|
-
const actual = await importOriginal();
|
|
12
|
-
return { ...actual, runIterate: vi.fn() };
|
|
13
|
-
});
|
|
14
|
-
vi.mock("./github/client.mts", () => ({
|
|
15
|
-
getRepoInfo: vi.fn().mockResolvedValue({ owner: "owner", name: "repo" }),
|
|
16
|
-
}));
|
|
17
|
-
import { main } from "./cli-parser.mjs";
|
|
18
|
-
import { runCommitSuggestion } from "./commands/commit-suggestion.mjs";
|
|
19
|
-
const mockRunCommitSuggestion = vi.mocked(runCommitSuggestion);
|
|
20
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
21
|
-
let stdoutSpy;
|
|
22
|
-
let stderrSpy;
|
|
23
|
-
function getStdout() {
|
|
24
|
-
return stdoutSpy.mock.calls.map((c) => c[0]).join("");
|
|
25
|
-
}
|
|
26
|
-
// ---------------------------------------------------------------------------
|
|
27
|
-
// Fixtures
|
|
28
|
-
// ---------------------------------------------------------------------------
|
|
29
|
-
const SUGGESTION_RESULT = {
|
|
30
|
-
pr: 42,
|
|
31
|
-
repo: "owner/repo",
|
|
32
|
-
threadId: "t1",
|
|
33
|
-
path: "a.ts",
|
|
34
|
-
startLine: 5,
|
|
35
|
-
endLine: 5,
|
|
36
|
-
author: "alice",
|
|
37
|
-
patch: "--- a/a.ts\n+++ b/a.ts\n@@ -5,1 +5,1 @@\n-old\n+new\n",
|
|
38
|
-
commitMessage: "apply fix",
|
|
39
|
-
commitBody: "Co-authored-by: alice <alice@users.noreply.github.com>",
|
|
40
|
-
filesToStage: ["a.ts"],
|
|
41
|
-
postActionInstructions: [
|
|
42
|
-
"Apply the patch to `a.ts`: run `git apply` with the diff shown above.",
|
|
43
|
-
"Stage the file: `git add -- a.ts`",
|
|
44
|
-
'Commit: `git commit -m "apply fix" -m "Co-authored-by: alice <alice@users.noreply.github.com>"`',
|
|
45
|
-
"Resolve the thread on GitHub: `pr-shepherd resolve 42 --resolve-thread-ids t1`",
|
|
46
|
-
"Push when ready: `git push` (or `git push --force-with-lease` after rebasing).",
|
|
47
|
-
],
|
|
48
|
-
};
|
|
49
|
-
// ---------------------------------------------------------------------------
|
|
50
|
-
// commit-suggestion dispatch
|
|
51
|
-
// ---------------------------------------------------------------------------
|
|
52
|
-
export function registerHooks() {
|
|
53
|
-
beforeEach(() => {
|
|
54
|
-
vi.clearAllMocks();
|
|
55
|
-
process.exitCode = undefined;
|
|
56
|
-
stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
|
57
|
-
stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
|
58
|
-
});
|
|
59
|
-
afterEach(() => {
|
|
60
|
-
process.exitCode = undefined;
|
|
61
|
-
stdoutSpy.mockRestore();
|
|
62
|
-
stderrSpy.mockRestore();
|
|
63
|
-
});
|
|
64
|
-
}
|
|
65
|
-
export { SUGGESTION_RESULT, getStdout, main, mockRunCommitSuggestion, runCommitSuggestion, stderrSpy, stdoutSpy, };
|
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
import { vi, beforeEach, afterEach } from "vitest";
|
|
2
|
-
vi.mock("./commands/check.mts", () => ({ runCheck: vi.fn() }));
|
|
3
|
-
vi.mock("./commands/resolve.mts", () => ({
|
|
4
|
-
runResolveFetch: vi.fn(),
|
|
5
|
-
runResolveMutate: vi.fn(),
|
|
6
|
-
}));
|
|
7
|
-
vi.mock("./commands/commit-suggestion.mts", () => ({
|
|
8
|
-
runCommitSuggestion: vi.fn(),
|
|
9
|
-
}));
|
|
10
|
-
vi.mock("./commands/iterate/index.mts", async (importOriginal) => {
|
|
11
|
-
const actual = await importOriginal();
|
|
12
|
-
return { ...actual, runIterate: vi.fn() };
|
|
13
|
-
});
|
|
14
|
-
vi.mock("./github/client.mts", () => ({
|
|
15
|
-
getRepoInfo: vi.fn().mockResolvedValue({ owner: "owner", name: "repo" }),
|
|
16
|
-
}));
|
|
17
|
-
import { main } from "./cli-parser.mjs";
|
|
18
|
-
import { runIterate } from "./commands/iterate/index.mjs";
|
|
19
|
-
import { makeIterateResult } from "./cli-parser.iterate-fixtures.mjs";
|
|
20
|
-
const mockRunIterate = vi.mocked(runIterate);
|
|
21
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
22
|
-
let stdoutSpy;
|
|
23
|
-
let stderrSpy;
|
|
24
|
-
function getStdout() {
|
|
25
|
-
return stdoutSpy.mock.calls.map((c) => c[0]).join("");
|
|
26
|
-
}
|
|
27
|
-
// ---------------------------------------------------------------------------
|
|
28
|
-
// formatIterateResult — fix_code actions and ## Checks section
|
|
29
|
-
// ---------------------------------------------------------------------------
|
|
30
|
-
export function registerHooks() {
|
|
31
|
-
beforeEach(() => {
|
|
32
|
-
vi.clearAllMocks();
|
|
33
|
-
process.exitCode = undefined;
|
|
34
|
-
stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
|
35
|
-
stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
|
36
|
-
});
|
|
37
|
-
afterEach(() => {
|
|
38
|
-
process.exitCode = undefined;
|
|
39
|
-
stdoutSpy.mockRestore();
|
|
40
|
-
stderrSpy.mockRestore();
|
|
41
|
-
});
|
|
42
|
-
}
|
|
43
|
-
export { getStdout, main, makeIterateResult, mockRunIterate, runIterate, stderrSpy, stdoutSpy };
|