pr-shepherd 0.2.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 (46) hide show
  1. package/.claude-plugin/plugin.json +14 -0
  2. package/LICENSE +21 -0
  3. package/README.md +154 -0
  4. package/marketplace.json +8 -0
  5. package/package.json +62 -0
  6. package/skills/check/SKILL.md +70 -0
  7. package/skills/monitor/SKILL.md +108 -0
  8. package/skills/resolve/SKILL.md +85 -0
  9. package/src/cache/file-cache.mts +101 -0
  10. package/src/cache/file-cache.test.mts +91 -0
  11. package/src/cache/fix-attempts.mts +86 -0
  12. package/src/checks/classify.mts +80 -0
  13. package/src/checks/classify.test.mts +164 -0
  14. package/src/checks/triage.mock.test.mts +202 -0
  15. package/src/checks/triage.mts +88 -0
  16. package/src/cli.mts +423 -0
  17. package/src/commands/check.mts +188 -0
  18. package/src/commands/iterate.mock.test.mts +1111 -0
  19. package/src/commands/iterate.mts +371 -0
  20. package/src/commands/ready-delay.mts +117 -0
  21. package/src/commands/ready-delay.test.mts +116 -0
  22. package/src/commands/resolve.mts +92 -0
  23. package/src/commands/status.mts +173 -0
  24. package/src/comments/outdated.mts +18 -0
  25. package/src/comments/resolve.mts +179 -0
  26. package/src/config/load.mts +240 -0
  27. package/src/config.json +52 -0
  28. package/src/github/batch.mts +351 -0
  29. package/src/github/client.mts +207 -0
  30. package/src/github/client.test.mts +19 -0
  31. package/src/github/gql/batch-pr.gql +130 -0
  32. package/src/github/gql/dismiss-review.gql +7 -0
  33. package/src/github/gql/minimize-comment.gql +7 -0
  34. package/src/github/gql/multi-pr-status-paged.gql +31 -0
  35. package/src/github/gql/multi-pr-status.gql +32 -0
  36. package/src/github/gql/resolve-thread.gql +7 -0
  37. package/src/github/pagination.mts +86 -0
  38. package/src/github/pagination.test.mts +140 -0
  39. package/src/github/queries.mts +30 -0
  40. package/src/index.mts +17 -0
  41. package/src/merge-status/derive.mts +74 -0
  42. package/src/merge-status/derive.test.mts +130 -0
  43. package/src/reporters/json.mts +12 -0
  44. package/src/reporters/text.mts +140 -0
  45. package/src/types.mts +309 -0
  46. package/src/util/path-segment.mts +2 -0
@@ -0,0 +1,7 @@
1
+ mutation DismissReview($reviewId: ID!, $message: String!) {
2
+ dismissPullRequestReview(input: { pullRequestReviewId: $reviewId, message: $message }) {
3
+ pullRequestReview {
4
+ state
5
+ }
6
+ }
7
+ }
@@ -0,0 +1,7 @@
1
+ mutation MinimizeComment($commentId: ID!, $classifier: ReportedContentClassifiers!) {
2
+ minimizeComment(input: { subjectId: $commentId, classifier: $classifier }) {
3
+ minimizedComment {
4
+ isMinimized
5
+ }
6
+ }
7
+ }
@@ -0,0 +1,31 @@
1
+ query MultiPrStatusPaged($owner: String!, $repo: String!, $pr: Int!, $cursor: String) {
2
+ repository(owner: $owner, name: $repo) {
3
+ pullRequest(number: $pr) {
4
+ number
5
+ title
6
+ state
7
+ isDraft
8
+ mergeStateStatus
9
+ reviewDecision
10
+ reviewThreads(last: 100, before: $cursor) {
11
+ totalCount
12
+ pageInfo {
13
+ hasPreviousPage
14
+ startCursor
15
+ }
16
+ nodes {
17
+ isResolved
18
+ }
19
+ }
20
+ commits(last: 1) {
21
+ nodes {
22
+ commit {
23
+ statusCheckRollup {
24
+ state
25
+ }
26
+ }
27
+ }
28
+ }
29
+ }
30
+ }
31
+ }
@@ -0,0 +1,32 @@
1
+ query MultiPrStatus($owner: String!, $repo: String!, $pr: Int!) {
2
+ repository(owner: $owner, name: $repo) {
3
+ pullRequest(number: $pr) {
4
+ number
5
+ state
6
+ isDraft
7
+ title
8
+ mergeStateStatus
9
+ reviewDecision
10
+ # totalCount lets callers detect truncation; pageInfo enables backward pagination.
11
+ reviewThreads(last: 100) {
12
+ totalCount
13
+ pageInfo {
14
+ hasPreviousPage
15
+ startCursor
16
+ }
17
+ nodes {
18
+ isResolved
19
+ }
20
+ }
21
+ commits(last: 1) {
22
+ nodes {
23
+ commit {
24
+ statusCheckRollup {
25
+ state
26
+ }
27
+ }
28
+ }
29
+ }
30
+ }
31
+ }
32
+ }
@@ -0,0 +1,7 @@
1
+ mutation ResolveThread($threadId: ID!) {
2
+ resolveReviewThread(input: { threadId: $threadId }) {
3
+ thread {
4
+ isResolved
5
+ }
6
+ }
7
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Generic GraphQL cursor-based paginator for shepherd.
3
+ *
4
+ * Both paginators accept a `fetchFn` instead of calling `graphql` directly,
5
+ * which makes them testable without any mocking — tests supply a pure function
6
+ * that returns pages of fake data.
7
+ *
8
+ * GitHub's GraphQL connections support two cursor directions:
9
+ * - Forward (`after` + `first`) — used by check contexts.
10
+ * - Backward (`before` + `last`) — used by reviewThreads (default GitHub order).
11
+ */
12
+
13
+ export interface PageInfo {
14
+ hasNextPage?: boolean;
15
+ hasPreviousPage?: boolean;
16
+ endCursor?: string | null;
17
+ startCursor?: string | null;
18
+ }
19
+
20
+ export interface Connection<T> {
21
+ pageInfo: PageInfo;
22
+ nodes: T[];
23
+ }
24
+
25
+ /**
26
+ * Paginate forward through a GraphQL connection (`first` / `after` cursors).
27
+ *
28
+ * @param fetchFn Called once per page. Receives the cursor (or null for
29
+ * the very first page) and returns a Connection<T>.
30
+ * @param initialCursor Start from this cursor instead of null. Pass the
31
+ * `endCursor` of an already-fetched page to fetch only
32
+ * the pages *after* it, avoiding a duplicate re-fetch.
33
+ */
34
+ export async function paginateForward<T>(
35
+ fetchFn: (cursor: string | null) => Promise<Connection<T>>,
36
+ initialCursor?: string | null,
37
+ ): Promise<T[]> {
38
+ const all: T[] = [];
39
+ let cursor: string | null = initialCursor ?? null;
40
+
41
+ for (;;) {
42
+ // eslint-disable-next-line no-await-in-loop
43
+ const conn = await fetchFn(cursor);
44
+
45
+ all.push(...conn.nodes);
46
+
47
+ if (!conn.pageInfo.hasNextPage || !conn.pageInfo.endCursor) break;
48
+ cursor = conn.pageInfo.endCursor;
49
+ }
50
+
51
+ return all;
52
+ }
53
+
54
+ /**
55
+ * Paginate backward through a GraphQL connection (`last` / `before` cursors).
56
+ *
57
+ * Used for `reviewThreads(last: 100, before: $before)`.
58
+ *
59
+ * @param fetchFn Called once per page. Receives the cursor (or null for
60
+ * the very first page). Returns a Connection<T> with
61
+ * `hasPreviousPage` and `startCursor` in pageInfo.
62
+ * @param initialCursor Start from this cursor instead of null. Pass the
63
+ * `startCursor` of an already-fetched page to avoid
64
+ * re-fetching it (fetch only the pages *before* it).
65
+ */
66
+ export async function paginateBackward<T>(
67
+ fetchFn: (cursor: string | null) => Promise<Connection<T>>,
68
+ initialCursor?: string | null,
69
+ ): Promise<T[]> {
70
+ const all: T[] = [];
71
+ let cursor: string | null = initialCursor ?? null;
72
+
73
+ for (;;) {
74
+ // eslint-disable-next-line no-await-in-loop
75
+ const conn = await fetchFn(cursor);
76
+
77
+ // Backward pagination returns items oldest-first within each page but pages
78
+ // go from newest-to-oldest. Prepend each page so final array is oldest-first.
79
+ all.unshift(...conn.nodes);
80
+
81
+ if (!conn.pageInfo.hasPreviousPage || !conn.pageInfo.startCursor) break;
82
+ cursor = conn.pageInfo.startCursor;
83
+ }
84
+
85
+ return all;
86
+ }
@@ -0,0 +1,140 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { paginateForward, paginateBackward, type Connection } from "./pagination.mts";
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // paginateForward
6
+ // ---------------------------------------------------------------------------
7
+
8
+ describe("paginateForward", () => {
9
+ it("collects all nodes across three pages", async () => {
10
+ const pages: Connection<string>[] = [
11
+ { pageInfo: { hasNextPage: true, endCursor: "cursor1" }, nodes: ["a", "b"] },
12
+ { pageInfo: { hasNextPage: true, endCursor: "cursor2" }, nodes: ["c"] },
13
+ { pageInfo: { hasNextPage: false, endCursor: null }, nodes: ["d", "e"] },
14
+ ];
15
+ const cursors: Array<string | null> = [];
16
+ let i = 0;
17
+
18
+ const result = await paginateForward((cursor) => {
19
+ cursors.push(cursor);
20
+ return Promise.resolve(pages[i++]!);
21
+ });
22
+
23
+ expect(result).toEqual(["a", "b", "c", "d", "e"]);
24
+ expect(cursors).toEqual([null, "cursor1", "cursor2"]);
25
+ });
26
+
27
+ it("stops after a single page when hasNextPage is false", async () => {
28
+ let calls = 0;
29
+ const result = await paginateForward(() => {
30
+ calls++;
31
+ return Promise.resolve({
32
+ pageInfo: { hasNextPage: false, endCursor: null },
33
+ nodes: ["x", "y"],
34
+ });
35
+ });
36
+
37
+ expect(result).toEqual(["x", "y"]);
38
+ expect(calls).toBe(1);
39
+ });
40
+
41
+ it("returns an empty array for an empty first page", async () => {
42
+ const result = await paginateForward(() =>
43
+ Promise.resolve({
44
+ pageInfo: { hasNextPage: false, endCursor: null },
45
+ nodes: [] as string[],
46
+ }),
47
+ );
48
+
49
+ expect(result).toEqual([]);
50
+ });
51
+
52
+ it("starts from initialCursor to avoid re-fetching the already-known page", async () => {
53
+ // Simulates the batch.mts use case: the initial query already returned page
54
+ // ending at 'cur-first'. paginateForward should start from that endCursor
55
+ // so it only fetches pages *after* it.
56
+ const pages: Record<string, Connection<string>> = {
57
+ "cur-first": {
58
+ pageInfo: { hasNextPage: true, endCursor: "cur-second" },
59
+ nodes: ["c", "d"],
60
+ },
61
+ "cur-second": {
62
+ pageInfo: { hasNextPage: false, endCursor: null },
63
+ nodes: ["e"],
64
+ },
65
+ };
66
+ const cursors: Array<string | null> = [];
67
+
68
+ const result = await paginateForward((cursor) => {
69
+ cursors.push(cursor);
70
+ return Promise.resolve(pages[cursor ?? ""]!);
71
+ }, "cur-first");
72
+
73
+ // Should fetch pages after 'cur-first', not re-fetch it.
74
+ expect(cursors).toEqual(["cur-first", "cur-second"]);
75
+ expect(result).toEqual(["c", "d", "e"]);
76
+ });
77
+ });
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // paginateBackward
81
+ // ---------------------------------------------------------------------------
82
+
83
+ describe("paginateBackward", () => {
84
+ it("collects nodes across pages and returns oldest-first", async () => {
85
+ // Backward pagination: newest page first, oldest last.
86
+ const pages: Connection<string>[] = [
87
+ { pageInfo: { hasPreviousPage: true, startCursor: "cur1" }, nodes: ["newer", "newest"] },
88
+ { pageInfo: { hasPreviousPage: true, startCursor: "cur2" }, nodes: ["older"] },
89
+ { pageInfo: { hasPreviousPage: false, startCursor: null }, nodes: ["oldest"] },
90
+ ];
91
+ const cursors: Array<string | null> = [];
92
+ let i = 0;
93
+
94
+ const result = await paginateBackward((cursor) => {
95
+ cursors.push(cursor);
96
+ return Promise.resolve(pages[i++]!);
97
+ });
98
+
99
+ // unshift inserts older pages at the front.
100
+ expect(result).toEqual(["oldest", "older", "newer", "newest"]);
101
+ expect(cursors).toEqual([null, "cur1", "cur2"]);
102
+ });
103
+
104
+ it("returns single-page nodes unchanged", async () => {
105
+ const result = await paginateBackward(() =>
106
+ Promise.resolve({
107
+ pageInfo: { hasPreviousPage: false, startCursor: null },
108
+ nodes: ["a", "b"],
109
+ }),
110
+ );
111
+
112
+ expect(result).toEqual(["a", "b"]);
113
+ });
114
+
115
+ it("starts from initialCursor to avoid re-fetching the already-known page", async () => {
116
+ // Simulates the batch.mts use case: the initial query already returned the
117
+ // "newest" page (cur-newest). paginateBackward should start from that
118
+ // startCursor so it only fetches pages *before* it.
119
+ const pages: Record<string, Connection<string>> = {
120
+ "cur-newest": {
121
+ pageInfo: { hasPreviousPage: true, startCursor: "cur-middle" },
122
+ nodes: ["middle"],
123
+ },
124
+ "cur-middle": {
125
+ pageInfo: { hasPreviousPage: false, startCursor: null },
126
+ nodes: ["oldest"],
127
+ },
128
+ };
129
+ const cursors: Array<string | null> = [];
130
+
131
+ const result = await paginateBackward((cursor) => {
132
+ cursors.push(cursor);
133
+ return Promise.resolve(pages[cursor ?? ""]!);
134
+ }, "cur-newest");
135
+
136
+ // Should fetch pages before 'cur-newest', not re-fetch 'cur-newest' itself.
137
+ expect(cursors).toEqual(["cur-newest", "cur-middle"]);
138
+ expect(result).toEqual(["oldest", "middle"]);
139
+ });
140
+ });
@@ -0,0 +1,30 @@
1
+ /**
2
+ * GraphQL query strings used by pr-shepherd.
3
+ *
4
+ * Query strings live in src/github/gql/*.gql.
5
+ * Never inline raw GraphQL strings in .ts source files.
6
+ */
7
+
8
+ import { readFileSync } from "node:fs";
9
+ import { join } from "node:path";
10
+
11
+ const gql = (name: string): string =>
12
+ readFileSync(join((import.meta as { dirname: string }).dirname, "gql", name), "utf8");
13
+
14
+ /** The primary batch query that fetches CI + comments + merge status in one round-trip. */
15
+ export const BATCH_PR_QUERY = gql("batch-pr.gql");
16
+
17
+ /** Resolve a single review thread. */
18
+ export const RESOLVE_THREAD_MUTATION = gql("resolve-thread.gql");
19
+
20
+ /** Minimize a PR comment (IssueComment). */
21
+ export const MINIMIZE_COMMENT_MUTATION = gql("minimize-comment.gql");
22
+
23
+ /** Dismiss a pull request review. */
24
+ export const DISMISS_REVIEW_MUTATION = gql("dismiss-review.gql");
25
+
26
+ /** Multi-PR status query for `shepherd status PR1 PR2 …`. */
27
+ export const MULTI_PR_STATUS_QUERY = gql("multi-pr-status.gql");
28
+
29
+ /** Paginated version — used when reviewThreads is truncated (totalCount > 100). */
30
+ export const MULTI_PR_STATUS_QUERY_WITH_CURSOR = gql("multi-pr-status-paged.gql");
package/src/index.mts ADDED
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * pr-shepherd — unified GitHub PR status + auto-resolve CLI
4
+ *
5
+ * Usage:
6
+ * pr-shepherd check [PR]
7
+ * pr-shepherd resolve [PR]
8
+ * pr-shepherd iterate [PR]
9
+ * pr-shepherd status PR1 [PR2 …]
10
+ */
11
+
12
+ import { main } from "./cli.mts";
13
+
14
+ main(process.argv).catch((err) => {
15
+ process.stderr.write(`pr-shepherd error: ${err instanceof Error ? err.message : String(err)}\n`);
16
+ process.exit(1);
17
+ });
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Derives a shepherd `MergeStatusResult` from raw PR data.
3
+ *
4
+ * `pr.state` is passed through unchanged; `iterate` handles the cancel action
5
+ * for non-OPEN (merged/closed) PRs — this function does not branch on it.
6
+ *
7
+ * Interpretation order for `status` — first match wins:
8
+ * 1. mergeable == CONFLICTING → CONFLICTS
9
+ * 2. mergeStateStatus DIRTY → CONFLICTS (GitHub merge conflicts)
10
+ * 3. copilotReviewInProgress → BLOCKED
11
+ * 4. mergeStateStatus BEHIND → BEHIND
12
+ * 5. mergeStateStatus BLOCKED / HAS_HOOKS → BLOCKED
13
+ * 6. mergeStateStatus UNSTABLE → UNSTABLE
14
+ * 7. isDraft → DRAFT
15
+ * 8. mergeStateStatus UNKNOWN → UNKNOWN
16
+ * 9. mergeStateStatus CLEAN → CLEAN
17
+ */
18
+
19
+ import type { BatchPrData, MergeStatusResult } from "../types.mts";
20
+ import { loadConfig } from "../config/load.mts";
21
+
22
+ export function deriveMergeStatus(pr: BatchPrData): MergeStatusResult {
23
+ const copilotReviewInProgress = detectCopilotReview(pr);
24
+
25
+ let status: MergeStatusResult["status"];
26
+
27
+ if (pr.mergeable === "CONFLICTING") {
28
+ status = "CONFLICTS";
29
+ } else if (copilotReviewInProgress) {
30
+ status = "BLOCKED";
31
+ } else if (pr.mergeStateStatus === "DIRTY") {
32
+ // DIRTY means GitHub detected merge conflicts in the branch.
33
+ status = "CONFLICTS";
34
+ } else if (pr.mergeStateStatus === "BEHIND") {
35
+ status = "BEHIND";
36
+ } else if (pr.mergeStateStatus === "BLOCKED" || pr.mergeStateStatus === "HAS_HOOKS") {
37
+ status = "BLOCKED";
38
+ } else if (pr.mergeStateStatus === "UNSTABLE") {
39
+ status = "UNSTABLE";
40
+ } else if (pr.isDraft || pr.mergeStateStatus === "DRAFT") {
41
+ status = "DRAFT";
42
+ } else if (pr.mergeStateStatus === "UNKNOWN") {
43
+ status = "UNKNOWN";
44
+ } else {
45
+ status = "CLEAN";
46
+ }
47
+
48
+ return {
49
+ status,
50
+ state: pr.state,
51
+ isDraft: pr.isDraft,
52
+ mergeable: pr.mergeable,
53
+ reviewDecision: pr.reviewDecision,
54
+ copilotReviewInProgress,
55
+ mergeStateStatus: pr.mergeStateStatus,
56
+ };
57
+ }
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // Copilot review detection
61
+ // ---------------------------------------------------------------------------
62
+
63
+ function detectCopilotReview(pr: BatchPrData): boolean {
64
+ // A blocking bot review is "in progress" when:
65
+ // 1. Any reviewRequest has a login starting with one of the configured prefixes, OR
66
+ // 2. Any latestReview has such a login AND state == "PENDING"
67
+ const prefixes = loadConfig().mergeStatus.blockingReviewerLogins.map((l) => l.toLowerCase());
68
+ const isBlocking = (login: string) => prefixes.some((p) => login.toLowerCase().startsWith(p));
69
+
70
+ const requested = pr.reviewRequests.some((r) => isBlocking(r.login));
71
+ const pendingReview = pr.latestReviews.some((r) => isBlocking(r.login) && r.state === "PENDING");
72
+
73
+ return requested || pendingReview;
74
+ }
@@ -0,0 +1,130 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { deriveMergeStatus } from "./derive.mts";
3
+ import type { BatchPrData } from "../types.mts";
4
+
5
+ function makePr(overrides: Partial<BatchPrData>): BatchPrData {
6
+ return {
7
+ number: 42,
8
+ state: "OPEN",
9
+ isDraft: false,
10
+ mergeable: "MERGEABLE",
11
+ mergeStateStatus: "CLEAN",
12
+ reviewDecision: null,
13
+ headRefOid: "abc123",
14
+ reviewRequests: [],
15
+ latestReviews: [],
16
+ reviewThreads: [],
17
+ comments: [],
18
+ changesRequestedReviews: [],
19
+ checks: [],
20
+ ...overrides,
21
+ };
22
+ }
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Interpretation order (first match wins)
26
+ // ---------------------------------------------------------------------------
27
+
28
+ describe("deriveMergeStatus", () => {
29
+ it("CONFLICTING mergeable → CONFLICTS", () => {
30
+ const result = deriveMergeStatus(makePr({ mergeable: "CONFLICTING" }));
31
+ expect(result.status).toBe("CONFLICTS");
32
+ });
33
+
34
+ it("Copilot review requested → BLOCKED", () => {
35
+ const result = deriveMergeStatus(
36
+ makePr({ reviewRequests: [{ login: "copilot-pull-request-reviewer[bot]" }] }),
37
+ );
38
+ expect(result.status).toBe("BLOCKED");
39
+ expect(result.copilotReviewInProgress).toBe(true);
40
+ });
41
+
42
+ it("Copilot review PENDING in latestReviews → BLOCKED", () => {
43
+ const result = deriveMergeStatus(
44
+ makePr({
45
+ latestReviews: [{ login: "copilot[bot]", state: "PENDING" }],
46
+ }),
47
+ );
48
+ expect(result.status).toBe("BLOCKED");
49
+ expect(result.copilotReviewInProgress).toBe(true);
50
+ });
51
+
52
+ it("Copilot review APPROVED in latestReviews → not copilotInProgress", () => {
53
+ const result = deriveMergeStatus(
54
+ makePr({
55
+ latestReviews: [{ login: "copilot[bot]", state: "APPROVED" }],
56
+ }),
57
+ );
58
+ expect(result.copilotReviewInProgress).toBe(false);
59
+ });
60
+
61
+ it("CONFLICTING takes priority over copilot blocked", () => {
62
+ const result = deriveMergeStatus(
63
+ makePr({
64
+ mergeable: "CONFLICTING",
65
+ reviewRequests: [{ login: "copilot[bot]" }],
66
+ }),
67
+ );
68
+ expect(result.status).toBe("CONFLICTS");
69
+ });
70
+
71
+ it("BEHIND mergeStateStatus → BEHIND", () => {
72
+ const result = deriveMergeStatus(makePr({ mergeStateStatus: "BEHIND" }));
73
+ expect(result.status).toBe("BEHIND");
74
+ });
75
+
76
+ it("BLOCKED mergeStateStatus → BLOCKED", () => {
77
+ const result = deriveMergeStatus(makePr({ mergeStateStatus: "BLOCKED" }));
78
+ expect(result.status).toBe("BLOCKED");
79
+ });
80
+
81
+ it("UNSTABLE mergeStateStatus → UNSTABLE", () => {
82
+ const result = deriveMergeStatus(makePr({ mergeStateStatus: "UNSTABLE" }));
83
+ expect(result.status).toBe("UNSTABLE");
84
+ });
85
+
86
+ it("isDraft → DRAFT", () => {
87
+ const result = deriveMergeStatus(makePr({ isDraft: true }));
88
+ expect(result.status).toBe("DRAFT");
89
+ });
90
+
91
+ it("UNKNOWN mergeStateStatus → UNKNOWN", () => {
92
+ const result = deriveMergeStatus(makePr({ mergeStateStatus: "UNKNOWN" }));
93
+ expect(result.status).toBe("UNKNOWN");
94
+ });
95
+
96
+ it("CLEAN mergeStateStatus → CLEAN", () => {
97
+ const result = deriveMergeStatus(makePr({ mergeStateStatus: "CLEAN" }));
98
+ expect(result.status).toBe("CLEAN");
99
+ });
100
+
101
+ it("includes full detail fields in result", () => {
102
+ const result = deriveMergeStatus(
103
+ makePr({ reviewDecision: "CHANGES_REQUESTED", isDraft: false }),
104
+ );
105
+ expect(result.reviewDecision).toBe("CHANGES_REQUESTED");
106
+ expect(result.isDraft).toBe(false);
107
+ expect(result.mergeable).toBe("MERGEABLE");
108
+ });
109
+ });
110
+
111
+ describe("deriveMergeStatus — state pass-through", () => {
112
+ it("passes OPEN state through", () => {
113
+ const result = deriveMergeStatus(makePr({ state: "OPEN" }));
114
+ expect(result.state).toBe("OPEN");
115
+ });
116
+
117
+ it("passes MERGED state through", () => {
118
+ const result = deriveMergeStatus(
119
+ makePr({ state: "MERGED", mergeable: "UNKNOWN", mergeStateStatus: "UNKNOWN" }),
120
+ );
121
+ expect(result.state).toBe("MERGED");
122
+ });
123
+
124
+ it("passes CLOSED state through", () => {
125
+ const result = deriveMergeStatus(
126
+ makePr({ state: "CLOSED", mergeable: "UNKNOWN", mergeStateStatus: "UNKNOWN" }),
127
+ );
128
+ expect(result.state).toBe("CLOSED");
129
+ });
130
+ });
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Machine-readable JSON reporter.
3
+ *
4
+ * Slash commands parse this output to extract IDs, status, and actionable items
5
+ * without string-scraping the human-readable text reporter.
6
+ */
7
+
8
+ import type { ShepherdReport } from "../types.mts";
9
+
10
+ export function formatJson(report: ShepherdReport): string {
11
+ return JSON.stringify(report, null, 2);
12
+ }