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,173 @@
1
+ /**
2
+ * `shepherd status PR1 [PR2 PR3 …]`
3
+ *
4
+ * Fetches readiness status for one or more PRs and prints a table.
5
+ * Uses a separate lightweight GraphQL query (MULTI_PR_STATUS_QUERY) per PR
6
+ * rather than the heavy batch query, since we only need summary data.
7
+ *
8
+ * Exit code: 0 if all PRs are READY, non-zero otherwise.
9
+ */
10
+
11
+ import { graphql, getRepoInfo } from "../github/client.mts";
12
+ import { MULTI_PR_STATUS_QUERY, MULTI_PR_STATUS_QUERY_WITH_CURSOR } from "../github/queries.mts";
13
+ import type { GlobalOptions } from "../types.mts";
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Public API
17
+ // ---------------------------------------------------------------------------
18
+
19
+ export interface PrSummary {
20
+ number: number;
21
+ title: string;
22
+ state: string;
23
+ isDraft: boolean;
24
+ mergeStateStatus: string;
25
+ reviewDecision: string | null;
26
+ unresolvedThreads: number;
27
+ ciState: string | null;
28
+ /**
29
+ * True when `reviewThreads` returned exactly 100 nodes but `totalCount` is higher —
30
+ * meaning the unresolved-thread count may be undercounted.
31
+ */
32
+ threadsTruncated: boolean;
33
+ }
34
+
35
+ export interface StatusCommandOptions extends GlobalOptions {
36
+ prNumbers: number[];
37
+ }
38
+
39
+ export async function runStatus(opts: StatusCommandOptions): Promise<PrSummary[]> {
40
+ const repo = await getRepoInfo();
41
+ const summaries = await Promise.all(
42
+ opts.prNumbers.map((pr) => fetchSummary(pr, repo.owner, repo.name)),
43
+ );
44
+ return summaries;
45
+ }
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Internal
49
+ // ---------------------------------------------------------------------------
50
+
51
+ async function fetchSummary(pr: number, owner: string, repo: string): Promise<PrSummary> {
52
+ const result = await graphql<RawStatusResponse>(MULTI_PR_STATUS_QUERY, {
53
+ owner,
54
+ repo,
55
+ pr,
56
+ });
57
+
58
+ const p = result.data.repository.pullRequest;
59
+ if (!p) {
60
+ throw new Error(`PR #${pr} not found in ${owner}/${repo}`);
61
+ }
62
+
63
+ let allNodes = p.reviewThreads.nodes;
64
+
65
+ // If the response was truncated, fetch additional pages to get the full count.
66
+ if (p.reviewThreads.totalCount > p.reviewThreads.nodes.length) {
67
+ // Fetch additional pages backward until we have all threads.
68
+ let cursor: string | null = p.reviewThreads.pageInfo?.startCursor ?? null;
69
+ while (cursor !== null) {
70
+ // eslint-disable-next-line no-await-in-loop
71
+ const extra = await graphql<RawStatusResponse>(MULTI_PR_STATUS_QUERY_WITH_CURSOR, {
72
+ owner,
73
+ repo,
74
+ pr,
75
+ cursor,
76
+ });
77
+ const p2 = extra.data.repository.pullRequest;
78
+ if (!p2) break;
79
+ allNodes = [...p2.reviewThreads.nodes, ...allNodes];
80
+ if (!p2.reviewThreads.pageInfo?.hasPreviousPage || !p2.reviewThreads.pageInfo.startCursor) {
81
+ break;
82
+ }
83
+ cursor = p2.reviewThreads.pageInfo.startCursor;
84
+ }
85
+ }
86
+
87
+ const unresolvedThreads = allNodes.filter((n) => !n.isResolved).length;
88
+ const ciState = p.commits.nodes[0]?.commit.statusCheckRollup?.state ?? null;
89
+ // If we still have fewer nodes than totalCount, report truncation.
90
+ const threadsTruncated = p.reviewThreads.totalCount > allNodes.length;
91
+
92
+ return {
93
+ number: p.number,
94
+ title: p.title,
95
+ state: p.state,
96
+ isDraft: p.isDraft,
97
+ mergeStateStatus: p.mergeStateStatus,
98
+ reviewDecision: p.reviewDecision,
99
+ unresolvedThreads,
100
+ ciState,
101
+ threadsTruncated,
102
+ };
103
+ }
104
+
105
+ // ---------------------------------------------------------------------------
106
+ // Output helpers
107
+ // ---------------------------------------------------------------------------
108
+
109
+ export function formatStatusTable(summaries: PrSummary[], repoFull: string): string {
110
+ const lines: string[] = [`\n# ${repoFull} — PR status (${summaries.length})\n`];
111
+
112
+ for (const s of summaries) {
113
+ const verdict = deriveVerdict(s);
114
+ const ciLabel = s.ciState ?? "—";
115
+ const title = s.title.slice(0, 50);
116
+ const truncNote = s.threadsTruncated
117
+ ? " (threads truncated — run shepherd check for full count)"
118
+ : "";
119
+ lines.push(
120
+ `PR #${String(s.number).padEnd(5)} ${title.padEnd(52)} ${verdict.padEnd(12)} ${ciLabel}${truncNote}`,
121
+ );
122
+ }
123
+
124
+ return lines.join("\n");
125
+ }
126
+
127
+ function deriveVerdict(s: PrSummary): string {
128
+ if (s.state === "MERGED") return "MERGED";
129
+ if (s.state === "CLOSED") return "CLOSED";
130
+ if (s.isDraft) return "DRAFT";
131
+ if (
132
+ s.mergeStateStatus === "CLEAN" &&
133
+ s.unresolvedThreads === 0 &&
134
+ s.ciState === "SUCCESS" &&
135
+ s.reviewDecision !== "CHANGES_REQUESTED"
136
+ ) {
137
+ return "READY";
138
+ }
139
+ if (s.mergeStateStatus === "BLOCKED") return "BLOCKED";
140
+ if (s.mergeStateStatus === "DIRTY") return "CONFLICTS";
141
+ if (s.ciState === "PENDING" || s.ciState === "EXPECTED") return "IN PROGRESS";
142
+ if (s.ciState === "FAILURE" || s.ciState === "ERROR") return "FAILING";
143
+ return s.mergeStateStatus;
144
+ }
145
+
146
+ // ---------------------------------------------------------------------------
147
+ // Raw GraphQL types
148
+ // ---------------------------------------------------------------------------
149
+
150
+ interface RawStatusResponse {
151
+ repository: {
152
+ pullRequest: {
153
+ number: number;
154
+ title: string;
155
+ state: string;
156
+ isDraft: boolean;
157
+ mergeStateStatus: string;
158
+ reviewDecision: string | null;
159
+ reviewThreads: {
160
+ totalCount: number;
161
+ pageInfo?: { hasPreviousPage: boolean; startCursor: string | null };
162
+ nodes: Array<{ isResolved: boolean }>;
163
+ };
164
+ commits: {
165
+ nodes: Array<{
166
+ commit: {
167
+ statusCheckRollup: { state: string } | null;
168
+ };
169
+ }>;
170
+ };
171
+ } | null;
172
+ };
173
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Determines which review threads should be auto-resolved as outdated.
3
+ *
4
+ * A thread is eligible for auto-resolution when:
5
+ * - `isOutdated == true` (GitHub marks these when the diff hunk changed), AND
6
+ * - `isResolved == false`.
7
+ *
8
+ * GitHub's `isOutdated` flag means the thread's referenced code has changed
9
+ * enough that the comment no longer points to a live diff line. These threads
10
+ * are visually collapsed on GitHub and are safe to resolve programmatically.
11
+ */
12
+
13
+ import type { ReviewThread } from "../types.mts";
14
+
15
+ /** Returns the subset of threads that should be auto-resolved as outdated. */
16
+ export function getOutdatedThreads(threads: ReviewThread[]): ReviewThread[] {
17
+ return threads.filter((t) => t.isOutdated && !t.isResolved);
18
+ }
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Batched mutations for resolving threads, minimizing comments, and dismissing reviews.
3
+ *
4
+ * The three mutation types (resolve / minimize / dismiss) are run sequentially so total
5
+ * in-flight mutations never exceed CONCURRENCY at once, keeping us well within GitHub's
6
+ * secondary rate-limit window.
7
+ *
8
+ * Push-before-resolve safety:
9
+ * When `requireSha` is set, shepherd verifies that GitHub has received that
10
+ * commit before issuing any resolve/dismiss mutations. It polls up to 20 seconds.
11
+ * If the push hasn't landed, shepherd throws rather than resolving prematurely
12
+ * (which could allow auto-merge before reviewers see the fix).
13
+ */
14
+
15
+ import { graphql, getPrHeadSha, type RepoInfo } from "../github/client.mts";
16
+ import {
17
+ RESOLVE_THREAD_MUTATION,
18
+ MINIMIZE_COMMENT_MUTATION,
19
+ DISMISS_REVIEW_MUTATION,
20
+ } from "../github/queries.mts";
21
+ import type { ResolveOptions } from "../types.mts";
22
+ import { loadConfig } from "../config/load.mts";
23
+
24
+ const config = loadConfig();
25
+ const CONCURRENCY = config.resolve.concurrency;
26
+ const SHA_POLL_INTERVAL_MS = config.resolve.shaPoll.intervalMs;
27
+ const SHA_POLL_MAX_ATTEMPTS = config.resolve.shaPoll.maxAttempts;
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Public API
31
+ // ---------------------------------------------------------------------------
32
+
33
+ export interface ResolveResult {
34
+ resolvedThreads: string[];
35
+ minimizedComments: string[];
36
+ dismissedReviews: string[];
37
+ errors: string[];
38
+ }
39
+
40
+ /**
41
+ * Execute all requested resolve/minimize/dismiss mutations.
42
+ *
43
+ * @throws Error if `requireSha` is set and GitHub hasn't received that commit
44
+ * within the polling window.
45
+ */
46
+ export async function applyResolveOptions(
47
+ pr: number,
48
+ repo: RepoInfo,
49
+ opts: ResolveOptions,
50
+ ): Promise<ResolveResult> {
51
+ // Require --message when dismissing reviews.
52
+ if ((opts.dismissReviewIds?.length ?? 0) > 0 && !opts.dismissMessage) {
53
+ throw new Error("--message is required when dismissing reviews");
54
+ }
55
+
56
+ // Safety check: verify the push landed before resolving.
57
+ if (opts.requireSha) {
58
+ await waitForSha(pr, repo, opts.requireSha);
59
+ }
60
+
61
+ const result: ResolveResult = {
62
+ resolvedThreads: [],
63
+ minimizedComments: [],
64
+ dismissedReviews: [],
65
+ errors: [],
66
+ };
67
+
68
+ await runBatched(
69
+ opts.resolveThreadIds ?? [],
70
+ (id) => resolveThread(id),
71
+ result.resolvedThreads,
72
+ result.errors,
73
+ );
74
+ await runBatched(
75
+ opts.minimizeCommentIds ?? [],
76
+ (id) => minimizeComment(id, "RESOLVED"),
77
+ result.minimizedComments,
78
+ result.errors,
79
+ );
80
+ await runBatched(
81
+ opts.dismissReviewIds ?? [],
82
+ (id) => dismissReview(id, opts.dismissMessage!),
83
+ result.dismissedReviews,
84
+ result.errors,
85
+ );
86
+
87
+ return result;
88
+ }
89
+
90
+ /**
91
+ * Auto-resolve a batch of outdated threads via the resolveReviewThread mutation.
92
+ */
93
+ export async function autoResolveOutdated(
94
+ threadIds: string[],
95
+ ): Promise<{ resolved: string[]; errors: string[] }> {
96
+ const resolved: string[] = [];
97
+ const errors: string[] = [];
98
+ await runBatched(threadIds, (id) => resolveThread(id), resolved, errors);
99
+ return { resolved, errors };
100
+ }
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // Mutation helpers
104
+ // ---------------------------------------------------------------------------
105
+
106
+ async function resolveThread(threadId: string): Promise<void> {
107
+ await graphql(RESOLVE_THREAD_MUTATION, { threadId });
108
+ }
109
+
110
+ async function minimizeComment(
111
+ commentId: string,
112
+ classifier: "RESOLVED" | "OFF_TOPIC",
113
+ ): Promise<void> {
114
+ await graphql(MINIMIZE_COMMENT_MUTATION, { commentId, classifier });
115
+ }
116
+
117
+ async function dismissReview(reviewId: string, message: string): Promise<void> {
118
+ await graphql(DISMISS_REVIEW_MUTATION, { reviewId, message });
119
+ }
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // Concurrency helper
123
+ // ---------------------------------------------------------------------------
124
+
125
+ async function runBatched(
126
+ ids: string[],
127
+ fn: (id: string) => Promise<void>,
128
+ successList: string[],
129
+ errorList: string[],
130
+ ): Promise<void> {
131
+ // Process in chunks of CONCURRENCY.
132
+ for (let i = 0; i < ids.length; i += CONCURRENCY) {
133
+ const chunk = ids.slice(i, i + CONCURRENCY);
134
+ // eslint-disable-next-line no-await-in-loop
135
+ await Promise.all(
136
+ chunk.map(async (id) => {
137
+ try {
138
+ await fn(id);
139
+ successList.push(id);
140
+ } catch (err) {
141
+ errorList.push(`${id}: ${err instanceof Error ? err.message : String(err)}`);
142
+ }
143
+ }),
144
+ );
145
+ }
146
+ }
147
+
148
+ // ---------------------------------------------------------------------------
149
+ // SHA polling
150
+ // ---------------------------------------------------------------------------
151
+
152
+ async function waitForSha(pr: number, repo: RepoInfo, expectedSha: string): Promise<void> {
153
+ for (let attempt = 0; attempt < SHA_POLL_MAX_ATTEMPTS; attempt++) {
154
+ try {
155
+ // eslint-disable-next-line no-await-in-loop
156
+ const currentSha = await getPrHeadSha(pr, repo.owner, repo.name);
157
+ if (currentSha === expectedSha) return;
158
+ } catch (err) {
159
+ // Transient network / 5xx error — keep polling unless this is the last attempt.
160
+ if (attempt === SHA_POLL_MAX_ATTEMPTS - 1) throw err;
161
+ }
162
+
163
+ if (attempt < SHA_POLL_MAX_ATTEMPTS - 1) {
164
+ // eslint-disable-next-line no-await-in-loop
165
+ await sleep(SHA_POLL_INTERVAL_MS);
166
+ }
167
+ }
168
+
169
+ // Total actual wait = (SHA_POLL_MAX_ATTEMPTS - 1) * SHA_POLL_INTERVAL_MS (no sleep after last poll).
170
+ throw new Error(
171
+ `Timeout: GitHub PR #${pr} head SHA has not updated to ${expectedSha} after ${
172
+ ((SHA_POLL_MAX_ATTEMPTS - 1) * SHA_POLL_INTERVAL_MS) / 1000
173
+ }s. Push may still be in transit — retry shortly.`,
174
+ );
175
+ }
176
+
177
+ function sleep(ms: number): Promise<void> {
178
+ return new Promise((resolve) => setTimeout(resolve, ms));
179
+ }
@@ -0,0 +1,240 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join, dirname } from "node:path";
3
+ import { homedir } from "node:os";
4
+ import { parse } from "yaml";
5
+ import builtins from "../config.json" with { type: "json" };
6
+
7
+ export interface PrShepherdConfig {
8
+ cache: {
9
+ ttlSeconds: number;
10
+ };
11
+ iterate: {
12
+ cooldownSeconds: number;
13
+ fixAttemptsPerThread: number;
14
+ };
15
+ watch: {
16
+ interval: string;
17
+ readyDelayMinutes: number;
18
+ expiresHours: number;
19
+ maxTurns: number;
20
+ };
21
+ resolve: {
22
+ concurrency: number;
23
+ shaPoll: {
24
+ intervalMs: number;
25
+ maxAttempts: number;
26
+ };
27
+ };
28
+ checks: {
29
+ ciTriggerEvents: string[];
30
+ timeoutPatterns: string[];
31
+ infraPatterns: string[];
32
+ logMaxLines: number;
33
+ logMaxChars: number;
34
+ };
35
+ mergeStatus: {
36
+ blockingReviewerLogins: string[];
37
+ };
38
+ execution: {
39
+ maxBufferMb: number;
40
+ triageLogBufferMb: number;
41
+ };
42
+ actions: {
43
+ autoResolveOutdated: boolean;
44
+ autoRebase: boolean;
45
+ autoMarkReady: boolean;
46
+ };
47
+ }
48
+
49
+ const RC_FILENAME = ".pr-shepherdrc.yml";
50
+
51
+ function findRcFile(startDir: string): string | null {
52
+ const home = homedir();
53
+ let current = startDir;
54
+ while (true) {
55
+ const candidate = join(current, RC_FILENAME);
56
+ try {
57
+ readFileSync(candidate);
58
+ return candidate;
59
+ } catch {
60
+ // not found here
61
+ }
62
+ if (current === home || current === dirname(current)) return null;
63
+ current = dirname(current);
64
+ }
65
+ }
66
+
67
+ function deepMerge(
68
+ base: Record<string, unknown>,
69
+ override: Record<string, unknown>,
70
+ ): Record<string, unknown> {
71
+ const result: Record<string, unknown> = { ...base };
72
+ for (const key of Object.keys(override)) {
73
+ const overVal = override[key];
74
+ const baseVal = base[key];
75
+ if (
76
+ overVal !== null &&
77
+ typeof overVal === "object" &&
78
+ !Array.isArray(overVal) &&
79
+ typeof baseVal === "object" &&
80
+ baseVal !== null &&
81
+ !Array.isArray(baseVal)
82
+ ) {
83
+ result[key] = deepMerge(
84
+ baseVal as Record<string, unknown>,
85
+ overVal as Record<string, unknown>,
86
+ );
87
+ } else if (overVal !== undefined) {
88
+ result[key] = overVal;
89
+ }
90
+ }
91
+ return result;
92
+ }
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // Compatibility shim — maps old RC keys to new ones and emits deprecation warnings
96
+ // ---------------------------------------------------------------------------
97
+
98
+ function applyCompat(raw: Record<string, unknown>): Record<string, unknown> {
99
+ const out = { ...raw };
100
+
101
+ // Removed keys — warn and strip.
102
+ for (const gone of ["baseBranch", "minimizeBots", "cancelCiOnFailure", "autoMinimize"]) {
103
+ if (gone in out) {
104
+ process.stderr.write(
105
+ `pr-shepherd: config key "${gone}" has been removed and has no effect.\n`,
106
+ );
107
+ delete out[gone];
108
+ }
109
+ }
110
+
111
+ // Renamed top-level section keys — iterate
112
+ const iterate = out["iterate"] as Record<string, unknown> | undefined;
113
+ if (iterate && "maxFixAttempts" in iterate) {
114
+ process.stderr.write(
115
+ `pr-shepherd: config key "iterate.maxFixAttempts" renamed to "iterate.fixAttemptsPerThread".\n`,
116
+ );
117
+ out["iterate"] = { fixAttemptsPerThread: iterate["maxFixAttempts"], ...iterate };
118
+ delete (out["iterate"] as Record<string, unknown>)["maxFixAttempts"];
119
+ }
120
+
121
+ // Renamed watch keys
122
+ const watch = out["watch"] as Record<string, unknown> | undefined;
123
+ if (watch) {
124
+ const watchOut = { ...watch };
125
+ if ("intervalDefault" in watch) {
126
+ process.stderr.write(
127
+ `pr-shepherd: config key "watch.intervalDefault" renamed to "watch.interval".\n`,
128
+ );
129
+ watchOut["interval"] = watch["intervalDefault"];
130
+ delete watchOut["intervalDefault"];
131
+ }
132
+ if ("readyDelayMinutesDefault" in watch) {
133
+ process.stderr.write(
134
+ `pr-shepherd: config key "watch.readyDelayMinutesDefault" renamed to "watch.readyDelayMinutes".\n`,
135
+ );
136
+ watchOut["readyDelayMinutes"] = watch["readyDelayMinutesDefault"];
137
+ delete watchOut["readyDelayMinutesDefault"];
138
+ }
139
+ if ("expiresHoursDefault" in watch) {
140
+ process.stderr.write(
141
+ `pr-shepherd: config key "watch.expiresHoursDefault" renamed to "watch.expiresHours".\n`,
142
+ );
143
+ watchOut["expiresHours"] = watch["expiresHoursDefault"];
144
+ delete watchOut["expiresHoursDefault"];
145
+ }
146
+ out["watch"] = watchOut;
147
+ }
148
+
149
+ // Renamed resolve keys (shaPollIntervalMs / shaPollMaxAttempts → shaPoll object)
150
+ const resolve = out["resolve"] as Record<string, unknown> | undefined;
151
+ if (resolve) {
152
+ const resolveOut = { ...resolve };
153
+ const shaPollOut: Record<string, unknown> = {};
154
+ let shaPollChanged = false;
155
+ if ("shaPollIntervalMs" in resolve) {
156
+ process.stderr.write(
157
+ `pr-shepherd: config key "resolve.shaPollIntervalMs" moved to "resolve.shaPoll.intervalMs".\n`,
158
+ );
159
+ shaPollOut["intervalMs"] = resolve["shaPollIntervalMs"];
160
+ delete resolveOut["shaPollIntervalMs"];
161
+ shaPollChanged = true;
162
+ }
163
+ if ("shaPollMaxAttempts" in resolve) {
164
+ process.stderr.write(
165
+ `pr-shepherd: config key "resolve.shaPollMaxAttempts" moved to "resolve.shaPoll.maxAttempts".\n`,
166
+ );
167
+ shaPollOut["maxAttempts"] = resolve["shaPollMaxAttempts"];
168
+ delete resolveOut["shaPollMaxAttempts"];
169
+ shaPollChanged = true;
170
+ }
171
+ if (shaPollChanged) {
172
+ resolveOut["shaPoll"] = {
173
+ ...(resolveOut["shaPoll"] as Record<string, unknown> | undefined),
174
+ ...shaPollOut,
175
+ };
176
+ }
177
+ out["resolve"] = resolveOut;
178
+ }
179
+
180
+ // Renamed checks keys
181
+ const checks = out["checks"] as Record<string, unknown> | undefined;
182
+ if (checks) {
183
+ const checksOut = { ...checks };
184
+ if ("relevantEvents" in checks) {
185
+ process.stderr.write(
186
+ `pr-shepherd: config key "checks.relevantEvents" renamed to "checks.ciTriggerEvents".\n`,
187
+ );
188
+ checksOut["ciTriggerEvents"] = checks["relevantEvents"];
189
+ delete checksOut["relevantEvents"];
190
+ }
191
+ if ("logLinesKept" in checks) {
192
+ process.stderr.write(
193
+ `pr-shepherd: config key "checks.logLinesKept" renamed to "checks.logMaxLines".\n`,
194
+ );
195
+ checksOut["logMaxLines"] = checks["logLinesKept"];
196
+ delete checksOut["logLinesKept"];
197
+ }
198
+ if ("logExcerptMaxChars" in checks) {
199
+ process.stderr.write(
200
+ `pr-shepherd: config key "checks.logExcerptMaxChars" renamed to "checks.logMaxChars".\n`,
201
+ );
202
+ checksOut["logMaxChars"] = checks["logExcerptMaxChars"];
203
+ delete checksOut["logExcerptMaxChars"];
204
+ }
205
+ out["checks"] = checksOut;
206
+ }
207
+
208
+ return out;
209
+ }
210
+
211
+ const defaults: PrShepherdConfig = builtins;
212
+
213
+ let cached: PrShepherdConfig | null = null;
214
+
215
+ export function loadConfig(): PrShepherdConfig {
216
+ if (cached) return cached;
217
+
218
+ const rcPath = findRcFile(process.cwd());
219
+ if (!rcPath) {
220
+ cached = defaults;
221
+ return cached;
222
+ }
223
+
224
+ try {
225
+ const raw = readFileSync(rcPath, "utf8");
226
+ const parsed = (parse(raw) ?? {}) as Record<string, unknown>;
227
+ const compat = applyCompat(parsed);
228
+ cached = deepMerge(
229
+ defaults as unknown as Record<string, unknown>,
230
+ compat,
231
+ ) as unknown as PrShepherdConfig;
232
+ return cached;
233
+ } catch (err) {
234
+ process.stderr.write(
235
+ `pr-shepherd: failed to parse ${rcPath}: ${err instanceof Error ? err.message : String(err)}\n`,
236
+ );
237
+ cached = { ...defaults };
238
+ return cached;
239
+ }
240
+ }
@@ -0,0 +1,52 @@
1
+ {
2
+ "cache": {
3
+ "ttlSeconds": 300
4
+ },
5
+ "iterate": {
6
+ "cooldownSeconds": 30,
7
+ "fixAttemptsPerThread": 3
8
+ },
9
+ "watch": {
10
+ "interval": "4m",
11
+ "readyDelayMinutes": 10,
12
+ "expiresHours": 8,
13
+ "maxTurns": 50
14
+ },
15
+ "resolve": {
16
+ "concurrency": 4,
17
+ "shaPoll": {
18
+ "intervalMs": 2000,
19
+ "maxAttempts": 10
20
+ }
21
+ },
22
+ "checks": {
23
+ "ciTriggerEvents": ["pull_request", "pull_request_target"],
24
+ "timeoutPatterns": [
25
+ "cancel timeout",
26
+ "exceeded the maximum execution time",
27
+ "job was cancelled"
28
+ ],
29
+ "infraPatterns": [
30
+ "runner error",
31
+ "service unavailable",
32
+ "ETIMEOUT",
33
+ "ECONNRESET",
34
+ "lost communication with the server",
35
+ "the hosted runner lost connection"
36
+ ],
37
+ "logMaxLines": 50,
38
+ "logMaxChars": 3000
39
+ },
40
+ "mergeStatus": {
41
+ "blockingReviewerLogins": ["copilot"]
42
+ },
43
+ "execution": {
44
+ "maxBufferMb": 10,
45
+ "triageLogBufferMb": 5
46
+ },
47
+ "actions": {
48
+ "autoResolveOutdated": true,
49
+ "autoRebase": true,
50
+ "autoMarkReady": true
51
+ }
52
+ }