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.
Files changed (64) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +87 -124
  3. package/bin/checks/triage.mjs +1 -0
  4. package/bin/cli/args.mjs +2 -0
  5. package/bin/cli/fix-formatter.mjs +38 -9
  6. package/bin/cli/formatters.mjs +3 -45
  7. package/bin/cli/handlers.mjs +22 -1
  8. package/bin/cli/help-command-pages.mjs +27 -2
  9. package/bin/cli/help-top-page.mjs +2 -0
  10. package/bin/cli/iterate-lean.mjs +4 -0
  11. package/bin/cli/list-formatters.mjs +55 -4
  12. package/bin/cli/mark-files-as-viewed-flags.mjs +34 -0
  13. package/bin/cli/mark-files-as-viewed-formatter.mjs +52 -0
  14. package/bin/cli/mutate-formatter.mjs +50 -0
  15. package/bin/cli-parser.mjs +11 -2
  16. package/bin/cli-parser.test-support.mjs +6 -1
  17. package/bin/commands/check-annotations.mjs +40 -0
  18. package/bin/commands/check.mjs +25 -57
  19. package/bin/commands/check.test-support.mjs +9 -2
  20. package/bin/commands/commit-suggestion-instruction.mjs +1 -1
  21. package/bin/commands/iterate/classify.mjs +25 -30
  22. package/bin/commands/iterate/escalate.mjs +16 -2
  23. package/bin/commands/iterate/fix-code.mjs +29 -18
  24. package/bin/commands/iterate/helpers.mjs +1 -0
  25. package/bin/commands/iterate/render.mjs +9 -3
  26. package/bin/commands/iterate/stall.mjs +43 -2
  27. package/bin/commands/iterate-test-support.mjs +1 -1
  28. package/bin/commands/iterate-thread-test-support.mjs +18 -0
  29. package/bin/commands/iterate.fix-code-in-progress.test-support.mjs +10 -1
  30. package/bin/commands/mark-files-as-viewed.mjs +220 -0
  31. package/bin/commands/resolve-mutate.mjs +41 -4
  32. package/bin/commands/resolve.mjs +16 -76
  33. package/bin/commands/resolve.test-support.mjs +2 -0
  34. package/bin/commands/shepherd-journal.mjs +1 -1
  35. package/bin/comments/authors.mjs +14 -0
  36. package/bin/comments/minimize-policy.mjs +6 -3
  37. package/bin/comments/pending-ops.mjs +6 -0
  38. package/bin/comments/resolve.mjs +28 -11
  39. package/bin/comments/resolve.test-support.mjs +6 -1
  40. package/bin/comments/review-visibility.mjs +14 -0
  41. package/bin/comments/thread-visibility.mjs +60 -0
  42. package/bin/comments/visible-comments.mjs +1 -1
  43. package/bin/config.json +1 -1
  44. package/bin/github/batch-parser-helpers.mjs +3 -4
  45. package/bin/github/batch-parsers.mjs +35 -8
  46. package/bin/github/batch.mjs +2 -0
  47. package/bin/github/check-annotations.mjs +75 -0
  48. package/bin/github/client.test-support.mjs +55 -0
  49. package/bin/github/gql/batch-pr.gql +15 -2
  50. package/bin/github/gql/check-run-annotations.gql +32 -0
  51. package/bin/github/gql/review-thread-comments.gql +27 -0
  52. package/bin/github/queries.mjs +4 -0
  53. package/bin/github/thread-comments.mjs +34 -0
  54. package/bin/reporters/agent.mjs +27 -0
  55. package/bin/state/seen-comments.test-support.mjs +19 -0
  56. package/bin/threads/transcript.mjs +31 -0
  57. package/bin/types/agent-thread.mjs +1 -0
  58. package/bin/types/check-annotations.mjs +1 -0
  59. package/bin/types/review-thread.mjs +1 -0
  60. package/bin/types.mjs +3 -0
  61. package/package.json +1 -1
  62. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  63. package/plugins/pr-shepherd/skills/mark-files-as-viewed/SKILL.md +31 -0
  64. package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +1 -1
@@ -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
- # first: 1 — we want the reviewer's original comment, not the latest reply.
71
- comments(first: 1) {
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
  }
@@ -0,0 +1,32 @@
1
+ query CheckRunAnnotations($id: ID!, $cursor: String) {
2
+ node(id: $id) {
3
+ __typename
4
+ ... on CheckRun {
5
+ annotations(first: 100, after: $cursor) {
6
+ pageInfo {
7
+ hasNextPage
8
+ endCursor
9
+ }
10
+ nodes {
11
+ fullDatabaseId
12
+ path
13
+ annotationLevel
14
+ title
15
+ message
16
+ rawDetails
17
+ blobUrl
18
+ location {
19
+ start {
20
+ line
21
+ column
22
+ }
23
+ end {
24
+ line
25
+ column
26
+ }
27
+ }
28
+ }
29
+ }
30
+ }
31
+ }
32
+ }
@@ -0,0 +1,27 @@
1
+ query ReviewThreadComments($threadId: ID!, $commentsCursor: String) {
2
+ node(id: $threadId) {
3
+ __typename
4
+ ... on PullRequestReviewThread {
5
+ comments(first: 100, after: $commentsCursor) {
6
+ pageInfo {
7
+ hasNextPage
8
+ endCursor
9
+ }
10
+ nodes {
11
+ id
12
+ isMinimized
13
+ url
14
+ author {
15
+ __typename
16
+ login
17
+ }
18
+ body
19
+ path
20
+ line
21
+ startLine
22
+ createdAt
23
+ }
24
+ }
25
+ }
26
+ }
27
+ }
@@ -11,6 +11,10 @@ import { join } from "node:path";
11
11
  const gql = (name) => readFileSync(join(import.meta.dirname, "gql", name), "utf8");
12
12
  /** The primary batch query that fetches CI + comments + merge status in one round-trip. */
13
13
  export const BATCH_PR_QUERY = gql("batch-pr.gql");
14
+ /** Fetches additional comments for a single review thread when its nested connection paginates. */
15
+ export const REVIEW_THREAD_COMMENTS_QUERY = gql("review-thread-comments.gql");
16
+ /** Fetch inline annotations for a single CheckRun by node ID. */
17
+ export const CHECK_RUN_ANNOTATIONS_QUERY = gql("check-run-annotations.gql");
14
18
  /** Returns the current head commit SHA for a PR. Used by waitForSha polling. */
15
19
  export const GET_PR_HEAD_SHA_QUERY = gql("get-pr-head-sha.gql");
16
20
  /** Look up PR number by branch name (for getCurrentPrNumber). */
@@ -0,0 +1,34 @@
1
+ import { graphql } from "./client.mjs";
2
+ import { paginateForward } from "./pagination.mjs";
3
+ import { REVIEW_THREAD_COMMENTS_QUERY } from "./queries.mjs";
4
+ export async function hydrateThreadCommentPages(threads) {
5
+ const hydrated = [];
6
+ for (const thread of threads) {
7
+ hydrated.push(await hydrateThreadCommentPage(thread));
8
+ }
9
+ return hydrated;
10
+ }
11
+ async function hydrateThreadCommentPage(thread) {
12
+ const pageInfo = thread.comments.pageInfo;
13
+ if (!pageInfo?.hasNextPage || !pageInfo.endCursor)
14
+ return thread;
15
+ const extra = await paginateForward(async (cursor) => {
16
+ const res = await graphql(REVIEW_THREAD_COMMENTS_QUERY, {
17
+ threadId: thread.id,
18
+ ...(cursor ? { commentsCursor: cursor } : {}),
19
+ });
20
+ const node = res.data.node;
21
+ if (!node?.comments) {
22
+ const nodeType = node?.__typename ?? "null";
23
+ throw new Error(`Review thread ${thread.id} did not resolve to PullRequestReviewThread while paginating comments (node type: ${nodeType})`);
24
+ }
25
+ return node.comments;
26
+ }, pageInfo.endCursor);
27
+ return {
28
+ ...thread,
29
+ comments: {
30
+ pageInfo: { hasNextPage: false, endCursor: null },
31
+ nodes: [...thread.comments.nodes, ...extra],
32
+ },
33
+ };
34
+ }
@@ -22,6 +22,16 @@ export function toAgentThread(t) {
22
22
  ...(t.authorType !== undefined && { authorType: t.authorType }),
23
23
  body: t.body,
24
24
  url: t.url,
25
+ ...(t.edited === true && { edited: true }),
26
+ ...(t.comments !== undefined && {
27
+ comments: t.comments.map((c) => ({
28
+ id: c.id,
29
+ author: c.author,
30
+ ...(c.authorType !== undefined && { authorType: c.authorType }),
31
+ body: c.body,
32
+ url: c.url,
33
+ })),
34
+ }),
25
35
  ...(suggestion !== undefined && { suggestion }),
26
36
  };
27
37
  }
@@ -48,6 +58,23 @@ export function toAgentCheck(c) {
48
58
  ...(c.jobName !== undefined && { jobName: c.jobName }),
49
59
  ...(c.failedStep !== undefined && { failedStep: c.failedStep }),
50
60
  ...(c.summary !== undefined && { summary: c.summary }),
61
+ ...(c.annotations !== undefined && { annotations: c.annotations }),
62
+ };
63
+ }
64
+ export function toAgentStalledCheck(c, nowSeconds) {
65
+ const createdAtUnix = c.createdAtUnix ?? nowSeconds;
66
+ const activityAtUnix = c.updatedAtUnix ?? createdAtUnix;
67
+ return {
68
+ name: c.name,
69
+ status: c.status,
70
+ source: c.source ?? "check_run",
71
+ runId: c.runId,
72
+ detailsUrl: c.detailsUrl || null,
73
+ ...(c.createdAtUnix !== undefined && { createdAtUnix: c.createdAtUnix }),
74
+ ...(c.startedAtUnix !== undefined && { startedAtUnix: c.startedAtUnix }),
75
+ ...(c.updatedAtUnix !== undefined && { updatedAtUnix: c.updatedAtUnix }),
76
+ ageSeconds: Math.max(0, nowSeconds - activityAtUnix),
77
+ ...(c.summary !== undefined && { summary: c.summary }),
51
78
  };
52
79
  }
53
80
  /**
@@ -0,0 +1,19 @@
1
+ import { beforeEach, afterEach } from "vitest";
2
+ import { randomBytes, createHash } from "node:crypto";
3
+ import { rm } from "node:fs/promises";
4
+ export function idToFilename(id) {
5
+ return createHash("sha256").update(id, "utf8").digest("hex") + ".json";
6
+ }
7
+ export const testKey = { owner: "test-owner", repo: "test-repo", pr: 123 };
8
+ export const testId = "PRRT_kwDOTest123";
9
+ export let testStateDir;
10
+ export function registerHooks() {
11
+ beforeEach(() => {
12
+ testStateDir = `${process.env["TMPDIR"] ?? "/tmp"}/shepherd-seen-test-${randomBytes(4).toString("hex")}`;
13
+ process.env["PR_SHEPHERD_STATE_DIR"] = testStateDir;
14
+ });
15
+ afterEach(async () => {
16
+ delete process.env["PR_SHEPHERD_STATE_DIR"];
17
+ await rm(testStateDir, { recursive: true, force: true });
18
+ });
19
+ }
@@ -0,0 +1,31 @@
1
+ export function threadComments(thread) {
2
+ if (thread.comments && thread.comments.length > 0) {
3
+ return thread.comments.map((c) => ({
4
+ id: c.id,
5
+ isMinimized: c.isMinimized ?? false,
6
+ author: c.author,
7
+ authorType: c.authorType ?? "Unknown",
8
+ body: c.body,
9
+ url: c.url,
10
+ createdAtUnix: c.createdAtUnix ?? 0,
11
+ }));
12
+ }
13
+ return [
14
+ {
15
+ id: "",
16
+ isMinimized: false,
17
+ author: thread.author,
18
+ authorType: thread.authorType ?? "Unknown",
19
+ body: thread.body,
20
+ url: thread.url ?? "",
21
+ createdAtUnix: thread.createdAtUnix ?? 0,
22
+ },
23
+ ];
24
+ }
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
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
package/bin/types.mjs CHANGED
@@ -1,4 +1,7 @@
1
1
  /** Shared type definitions for the shepherd CLI. */
2
2
  export * from "./types/github.mjs";
3
+ export * from "./types/review-thread.mjs";
4
+ export * from "./types/agent-thread.mjs";
5
+ export * from "./types/check-annotations.mjs";
3
6
  export * from "./types/report.mjs";
4
7
  export * from "./types/iterate.mjs";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
- "version": "0.21.0",
3
+ "version": "0.23.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",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
- "version": "0.21.0",
3
+ "version": "0.23.0",
4
4
  "description": "Autonomous PR CI monitor and review-comment resolver for Codex.",
5
5
  "author": {
6
6
  "name": "Jonathan Ong",
@@ -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.
@@ -39,5 +39,5 @@ Poll dispatcher for iterating a PR to completion.
39
39
 
40
40
  4. **Stop conditions (terminal states):**
41
41
  - Stop when the CLI emits `[CANCEL]` (ready-delay completed, or PR merged/closed).
42
- - Stop when the CLI emits `[ESCALATE]`, including `stall-timeout` for repeated unchanged CI failures.
42
+ - Stop when the CLI emits `[ESCALATE]`, including `stall-timeout` for repeated unchanged CI failures or CI that never starts.
43
43
  - **Do NOT merge the pull request** unless the human has explicitly requested or allowed it.