pr-shepherd 0.2.0 → 0.4.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/marketplace.json +18 -0
  2. package/.claude-plugin/plugin.json +8 -2
  3. package/README.md +128 -83
  4. package/bin/cache/file-cache.mjs +79 -0
  5. package/bin/cache/fix-attempts.mjs +67 -0
  6. package/bin/checks/classify.mjs +53 -0
  7. package/bin/checks/triage.mjs +77 -0
  8. package/bin/cli/args.mjs +173 -0
  9. package/bin/cli.mjs +204 -0
  10. package/bin/commands/check.mjs +140 -0
  11. package/bin/commands/iterate.mjs +301 -0
  12. package/bin/commands/ready-delay.mjs +87 -0
  13. package/bin/commands/resolve.mjs +64 -0
  14. package/bin/commands/status.mjs +107 -0
  15. package/{src/comments/outdated.mts → bin/comments/outdated.mjs} +2 -5
  16. package/bin/comments/resolve.mjs +111 -0
  17. package/bin/config/load.mjs +158 -0
  18. package/bin/github/batch.mjs +208 -0
  19. package/bin/github/client.mjs +152 -0
  20. package/{src/github/pagination.mts → bin/github/pagination.mjs} +26 -52
  21. package/{src/github/queries.mts → bin/github/queries.mjs} +1 -10
  22. package/{src/index.mts → bin/index.mjs} +3 -5
  23. package/bin/merge-status/derive.mjs +72 -0
  24. package/bin/pr-shepherd +2 -0
  25. package/bin/reporters/agent.mjs +41 -0
  26. package/{src/reporters/json.mts → bin/reporters/json.mjs} +2 -5
  27. package/bin/reporters/text.mjs +111 -0
  28. package/bin/types.mjs +2 -0
  29. package/package.json +9 -9
  30. package/skills/check/SKILL.md +12 -14
  31. package/skills/monitor/SKILL.md +9 -5
  32. package/src/cache/file-cache.mts +0 -101
  33. package/src/cache/file-cache.test.mts +0 -91
  34. package/src/cache/fix-attempts.mts +0 -86
  35. package/src/checks/classify.mts +0 -80
  36. package/src/checks/classify.test.mts +0 -164
  37. package/src/checks/triage.mock.test.mts +0 -202
  38. package/src/checks/triage.mts +0 -88
  39. package/src/cli.mts +0 -423
  40. package/src/commands/check.mts +0 -188
  41. package/src/commands/iterate.mock.test.mts +0 -1111
  42. package/src/commands/iterate.mts +0 -371
  43. package/src/commands/ready-delay.mts +0 -117
  44. package/src/commands/ready-delay.test.mts +0 -116
  45. package/src/commands/resolve.mts +0 -92
  46. package/src/commands/status.mts +0 -173
  47. package/src/comments/resolve.mts +0 -179
  48. package/src/config/load.mts +0 -240
  49. package/src/github/batch.mts +0 -351
  50. package/src/github/client.mts +0 -207
  51. package/src/github/client.test.mts +0 -19
  52. package/src/github/pagination.test.mts +0 -140
  53. package/src/merge-status/derive.mts +0 -74
  54. package/src/merge-status/derive.test.mts +0 -130
  55. package/src/reporters/text.mts +0 -140
  56. package/src/types.mts +0 -309
  57. /package/{src → bin}/config.json +0 -0
  58. /package/{src → bin}/github/gql/batch-pr.gql +0 -0
  59. /package/{src → bin}/github/gql/dismiss-review.gql +0 -0
  60. /package/{src → bin}/github/gql/minimize-comment.gql +0 -0
  61. /package/{src → bin}/github/gql/multi-pr-status-paged.gql +0 -0
  62. /package/{src → bin}/github/gql/multi-pr-status.gql +0 -0
  63. /package/{src → bin}/github/gql/resolve-thread.gql +0 -0
  64. /package/{src/util/path-segment.mts → bin/util/path-segment.mjs} +0 -0
@@ -1,140 +0,0 @@
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
- });
@@ -1,74 +0,0 @@
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
- }
@@ -1,130 +0,0 @@
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
- });
@@ -1,140 +0,0 @@
1
- /**
2
- * Human-readable text reporter for shepherd check output.
3
- */
4
-
5
- import type { ShepherdReport, TriagedCheck } from "../types.mts";
6
-
7
- export function formatText(report: ShepherdReport): string {
8
- const lines: string[] = [];
9
-
10
- // Header
11
- lines.push(`\nPR #${report.pr} — ${report.repo}`);
12
- lines.push(`Status: ${report.status}`);
13
- lines.push("");
14
-
15
- // Merge status
16
- const ms = report.mergeStatus;
17
- lines.push(`Merge Status: ${ms.status}`);
18
- lines.push(` mergeStateStatus: ${ms.mergeStateStatus}`);
19
- lines.push(` mergeable: ${ms.mergeable}`);
20
- lines.push(` reviewDecision: ${ms.reviewDecision ?? "(none)"}`);
21
- lines.push(` isDraft: ${ms.isDraft}`);
22
- lines.push(` copilotReviewInProgress:${ms.copilotReviewInProgress}`);
23
- lines.push("");
24
-
25
- // CI checks
26
- const { passing, failing, inProgress, skipped } = report.checks;
27
- const total = passing.length + failing.length + inProgress.length + skipped.length;
28
- lines.push(`CI Checks: ${passing.length}/${total} passed`);
29
-
30
- if (failing.length > 0) {
31
- lines.push(`\nFailed Checks (${failing.length}):`);
32
- for (const c of failing) {
33
- const triaged = c as TriagedCheck;
34
- const kind = triaged.failureKind ? ` [${triaged.failureKind}]` : "";
35
- lines.push(` - ${c.name}${kind}: ${c.conclusion ?? c.status}`);
36
- if (triaged.logExcerpt) {
37
- lines.push(indent(triaged.logExcerpt.split("\n").slice(-10).join("\n"), " "));
38
- }
39
- }
40
- }
41
-
42
- if (inProgress.length > 0) {
43
- lines.push(`\nIn Progress (${inProgress.length}):`);
44
- for (const c of inProgress) {
45
- lines.push(` - ${c.name}: ${c.status}`);
46
- }
47
- }
48
-
49
- if (skipped.length > 0) {
50
- lines.push(`\nSkipped (${skipped.length}): ${skipped.map((c) => c.name).join(", ")}`);
51
- }
52
-
53
- if (report.checks.filtered.length > 0) {
54
- lines.push(
55
- `\nFiltered (non-PR-trigger) (${report.checks.filtered.length}): ${report.checks.filtered.map((c) => c.name).join(", ")}`,
56
- );
57
- if (report.checks.blockedByFilteredCheck) {
58
- lines.push(
59
- " Note: PR is BLOCKED and all filtered checks are non-PR-trigger — one of these filtered checks may be a required status check blocking merge.",
60
- );
61
- } else if (report.mergeStatus.status === "BLOCKED") {
62
- lines.push(
63
- " Note: one or more of these filtered checks may be a required status check blocking merge.",
64
- );
65
- }
66
- }
67
-
68
- lines.push("");
69
-
70
- // Review threads
71
- const { actionable: actionableThreads, autoResolved, autoResolveErrors } = report.threads;
72
- if (autoResolved.length > 0) {
73
- lines.push(`Auto-resolved outdated threads (${autoResolved.length}):`);
74
- for (const t of autoResolved) {
75
- lines.push(` - threadId=${t.id} ${t.path ?? ""}:${t.line ?? "?"} (@${t.author})`);
76
- }
77
- lines.push("");
78
- }
79
-
80
- if (autoResolveErrors.length > 0) {
81
- lines.push(`Auto-resolve errors (${autoResolveErrors.length}):`);
82
- for (const e of autoResolveErrors) {
83
- lines.push(` - ${e}`);
84
- }
85
- lines.push("");
86
- }
87
-
88
- if (actionableThreads.length > 0) {
89
- lines.push(`Actionable Review Threads (${actionableThreads.length}):`);
90
- for (const t of actionableThreads) {
91
- const label = t.path ? `${t.path}:${t.line ?? "?"}` : "(general)";
92
- lines.push(` - threadId=${t.id} ${label} (@${t.author})`);
93
- lines.push(` ${firstLine(t.body)}`);
94
- }
95
- lines.push("");
96
- }
97
-
98
- // PR comments
99
- const { actionable: actionableComments } = report.comments;
100
- if (actionableComments.length > 0) {
101
- lines.push(`Actionable PR Comments (${actionableComments.length}):`);
102
- for (const c of actionableComments) {
103
- lines.push(` - commentId=${c.id} (@${c.author}): ${firstLine(c.body)}`);
104
- }
105
- lines.push("");
106
- }
107
-
108
- // CHANGES_REQUESTED reviews
109
- if (report.changesRequestedReviews.length > 0) {
110
- lines.push(`Pending CHANGES_REQUESTED reviews (${report.changesRequestedReviews.length}):`);
111
- for (const r of report.changesRequestedReviews) {
112
- lines.push(` - reviewId=${r.id} (@${r.author}): ${firstLine(r.body)}`);
113
- }
114
- lines.push("");
115
- }
116
-
117
- // Summary
118
- const totalActionable =
119
- actionableThreads.length + actionableComments.length + report.changesRequestedReviews.length;
120
- lines.push(
121
- `Summary: ${totalActionable === 0 ? "0 actionable — all threads resolved/minimized" : `${totalActionable} actionable item(s) remaining`}`,
122
- );
123
-
124
- return lines.join("\n");
125
- }
126
-
127
- // ---------------------------------------------------------------------------
128
- // Helpers
129
- // ---------------------------------------------------------------------------
130
-
131
- function firstLine(text: string): string {
132
- return (text.split("\n")[0] ?? "").trim().slice(0, 120);
133
- }
134
-
135
- function indent(text: string, prefix: string): string {
136
- return text
137
- .split("\n")
138
- .map((l) => prefix + l)
139
- .join("\n");
140
- }