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,140 @@
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
+ }
package/src/types.mts ADDED
@@ -0,0 +1,309 @@
1
+ /** Shared type definitions for the shepherd CLI. */
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // GitHub primitives
5
+ // ---------------------------------------------------------------------------
6
+
7
+ export type CheckConclusion =
8
+ | "ACTION_REQUIRED"
9
+ | "CANCELLED"
10
+ | "FAILURE"
11
+ | "NEUTRAL"
12
+ | "SKIPPED"
13
+ | "STALE"
14
+ | "STARTUP_FAILURE"
15
+ | "SUCCESS"
16
+ | "TIMED_OUT"
17
+ | null;
18
+
19
+ export type CheckStatus =
20
+ | "COMPLETED"
21
+ | "IN_PROGRESS"
22
+ | "PENDING"
23
+ | "QUEUED"
24
+ | "REQUESTED"
25
+ | "WAITING";
26
+
27
+ export type MergeableState = "CONFLICTING" | "MERGEABLE" | "UNKNOWN";
28
+
29
+ export type MergeStateStatus =
30
+ | "BEHIND"
31
+ | "BLOCKED"
32
+ | "CLEAN"
33
+ | "DIRTY"
34
+ | "DRAFT"
35
+ | "HAS_HOOKS"
36
+ | "UNKNOWN"
37
+ | "UNSTABLE";
38
+
39
+ export type ReviewDecision = "APPROVED" | "CHANGES_REQUESTED" | "REVIEW_REQUIRED" | null;
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Check runs
43
+ // ---------------------------------------------------------------------------
44
+
45
+ export interface CheckRun {
46
+ name: string;
47
+ status: CheckStatus;
48
+ conclusion: CheckConclusion;
49
+ detailsUrl: string;
50
+ /** The workflow event that triggered this run (e.g. pull_request, push, schedule). */
51
+ event: string | null;
52
+ /** GitHub Actions run ID extracted from detailsUrl. */
53
+ runId: string | null;
54
+ }
55
+
56
+ export type CheckCategory = "passed" | "failing" | "in_progress" | "skipped" | "filtered";
57
+
58
+ export interface ClassifiedCheck extends CheckRun {
59
+ category: CheckCategory;
60
+ }
61
+
62
+ export type FailureKind = "timeout" | "infrastructure" | "actionable" | "flaky";
63
+
64
+ export interface TriagedCheck extends ClassifiedCheck {
65
+ failureKind?: FailureKind;
66
+ /** Log excerpt for actionable failures. */
67
+ logExcerpt?: string;
68
+ }
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // Review threads and comments
72
+ // ---------------------------------------------------------------------------
73
+
74
+ export interface ReviewThread {
75
+ id: string;
76
+ isResolved: boolean;
77
+ isOutdated: boolean;
78
+ path: string | null;
79
+ line: number | null;
80
+ author: string;
81
+ body: string;
82
+ createdAtUnix: number;
83
+ }
84
+
85
+ export interface PrComment {
86
+ id: string;
87
+ isMinimized: boolean;
88
+ author: string;
89
+ body: string;
90
+ createdAtUnix: number;
91
+ }
92
+
93
+ export interface Review {
94
+ id: string;
95
+ author: string;
96
+ body: string;
97
+ }
98
+
99
+ // ---------------------------------------------------------------------------
100
+ // Merge status
101
+ // ---------------------------------------------------------------------------
102
+
103
+ export type ShepherdMergeStatus =
104
+ | "CLEAN"
105
+ | "BEHIND"
106
+ | "CONFLICTS"
107
+ | "BLOCKED"
108
+ | "UNSTABLE"
109
+ | "DRAFT"
110
+ | "UNKNOWN";
111
+
112
+ export interface MergeStatusResult {
113
+ status: ShepherdMergeStatus;
114
+ state: "OPEN" | "CLOSED" | "MERGED";
115
+ isDraft: boolean;
116
+ mergeable: MergeableState;
117
+ reviewDecision: ReviewDecision;
118
+ copilotReviewInProgress: boolean;
119
+ mergeStateStatus: MergeStateStatus;
120
+ }
121
+
122
+ // ---------------------------------------------------------------------------
123
+ // Batch query response (the combined GraphQL shape)
124
+ // ---------------------------------------------------------------------------
125
+
126
+ export interface BatchPrData {
127
+ number: number;
128
+ state: "OPEN" | "CLOSED" | "MERGED";
129
+ isDraft: boolean;
130
+ mergeable: MergeableState;
131
+ mergeStateStatus: MergeStateStatus;
132
+ reviewDecision: ReviewDecision;
133
+ headRefOid: string;
134
+ reviewRequests: Array<{ login: string }>;
135
+ latestReviews: Array<{ login: string; state: string }>;
136
+ reviewThreads: ReviewThread[];
137
+ comments: PrComment[];
138
+ changesRequestedReviews: Review[];
139
+ checks: CheckRun[];
140
+ }
141
+
142
+ // ---------------------------------------------------------------------------
143
+ // Shepherd check report (output of the check command)
144
+ // ---------------------------------------------------------------------------
145
+
146
+ export type ShepherdStatus =
147
+ | "READY"
148
+ | "FAILING"
149
+ | "IN_PROGRESS"
150
+ | "UNRESOLVED_COMMENTS"
151
+ | "UNKNOWN";
152
+
153
+ export interface ShepherdReport {
154
+ pr: number;
155
+ repo: string;
156
+ status: ShepherdStatus;
157
+ mergeStatus: MergeStatusResult;
158
+ checks: {
159
+ passing: ClassifiedCheck[];
160
+ failing: TriagedCheck[];
161
+ inProgress: ClassifiedCheck[];
162
+ skipped: ClassifiedCheck[];
163
+ /** Checks filtered out because they were triggered by a non-PR event (push, schedule, etc.). */
164
+ filtered: ClassifiedCheck[];
165
+ filteredNames: string[];
166
+ blockedByFilteredCheck: boolean;
167
+ };
168
+ threads: {
169
+ actionable: ReviewThread[];
170
+ autoResolved: ReviewThread[];
171
+ autoResolveErrors: string[];
172
+ };
173
+ comments: {
174
+ actionable: PrComment[];
175
+ };
176
+ changesRequestedReviews: Review[];
177
+ lastPushTime?: number;
178
+ }
179
+
180
+ // ---------------------------------------------------------------------------
181
+ // Resolve command input
182
+ // ---------------------------------------------------------------------------
183
+
184
+ export interface ResolveOptions {
185
+ resolveThreadIds?: string[];
186
+ minimizeCommentIds?: string[];
187
+ dismissReviewIds?: string[];
188
+ dismissMessage?: string;
189
+ /** When set, shepherd verifies GitHub has received this commit before resolving. */
190
+ requireSha?: string;
191
+ }
192
+
193
+ // ---------------------------------------------------------------------------
194
+ // Iterate command types
195
+ // ---------------------------------------------------------------------------
196
+
197
+ export type ShepherdAction =
198
+ | "cooldown"
199
+ | "wait"
200
+ | "fix_code"
201
+ | "rerun_ci"
202
+ | "rebase"
203
+ | "mark_ready"
204
+ | "cancel"
205
+ | "escalate";
206
+
207
+ export interface EscalateDetails {
208
+ triggers: string[];
209
+ unresolvedThreads: ReviewThread[];
210
+ ambiguousComments: PrComment[];
211
+ changesRequestedReviews: Review[];
212
+ /** Populated when fix-thrash triggered — threads that have been attempted too many times. */
213
+ attemptHistory?: Array<{ threadId: string; attempts: number }>;
214
+ /** One-line hint for the human on what to do. */
215
+ suggestion: string;
216
+ }
217
+
218
+ export interface IterateResultSummary {
219
+ passing: number;
220
+ skipped: number;
221
+ filtered: number;
222
+ inProgress: number;
223
+ }
224
+
225
+ export interface IterateResultBase {
226
+ pr: number;
227
+ repo: string;
228
+ status: ShepherdStatus;
229
+ /** `'UNKNOWN'` during the cooldown early-return (no sweep has been run yet). */
230
+ state: "OPEN" | "CLOSED" | "MERGED" | "UNKNOWN";
231
+ mergeStateStatus: MergeStateStatus;
232
+ copilotReviewInProgress: boolean;
233
+ isDraft: boolean;
234
+ shouldCancel: boolean;
235
+ remainingSeconds: number;
236
+ summary: IterateResultSummary;
237
+ }
238
+
239
+ export interface IterateResultCooldown extends IterateResultBase {
240
+ action: "cooldown";
241
+ }
242
+
243
+ export interface IterateResultWait extends IterateResultBase {
244
+ action: "wait";
245
+ }
246
+
247
+ export interface IterateResultCancel extends IterateResultBase {
248
+ action: "cancel";
249
+ }
250
+
251
+ export interface IterateResultFixCode extends IterateResultBase {
252
+ action: "fix_code";
253
+ fix: {
254
+ threads: ReviewThread[];
255
+ comments: PrComment[];
256
+ checks: TriagedCheck[];
257
+ changesRequestedReviews: Review[];
258
+ };
259
+ cancelled: string[];
260
+ }
261
+
262
+ export interface IterateResultRerunCi extends IterateResultBase {
263
+ action: "rerun_ci";
264
+ reran: string[];
265
+ }
266
+
267
+ export interface IterateResultRebase extends IterateResultBase {
268
+ action: "rebase";
269
+ }
270
+
271
+ export interface IterateResultMarkReady extends IterateResultBase {
272
+ action: "mark_ready";
273
+ markedReady: boolean;
274
+ }
275
+
276
+ export interface IterateResultEscalate extends IterateResultBase {
277
+ action: "escalate";
278
+ escalate: EscalateDetails;
279
+ }
280
+
281
+ export type IterateResult =
282
+ | IterateResultCooldown
283
+ | IterateResultWait
284
+ | IterateResultCancel
285
+ | IterateResultFixCode
286
+ | IterateResultRerunCi
287
+ | IterateResultRebase
288
+ | IterateResultMarkReady
289
+ | IterateResultEscalate;
290
+
291
+ export interface IterateCommandOptions extends GlobalOptions {
292
+ cooldownSeconds?: number;
293
+ readyDelaySeconds?: number;
294
+ lastPushTime?: number;
295
+ noAutoRerun?: boolean;
296
+ noAutoMarkReady?: boolean;
297
+ noAutoCancelActionable?: boolean;
298
+ }
299
+
300
+ // ---------------------------------------------------------------------------
301
+ // CLI options
302
+ // ---------------------------------------------------------------------------
303
+
304
+ export interface GlobalOptions {
305
+ prNumber?: number;
306
+ format: "text" | "json";
307
+ noCache: boolean;
308
+ cacheTtlSeconds: number;
309
+ }
@@ -0,0 +1,2 @@
1
+ /** Allowed characters: alphanumeric, hyphen, underscore, dot. Prevents path traversal. */
2
+ export const SAFE_SEGMENT = /^[a-zA-Z0-9._-]+$/;