pr-shepherd 0.2.0 → 0.3.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 (62) hide show
  1. package/.claude-plugin/plugin.json +8 -2
  2. package/README.md +126 -83
  3. package/dist/cache/file-cache.mjs +78 -0
  4. package/dist/cache/fix-attempts.mjs +67 -0
  5. package/dist/checks/classify.mjs +53 -0
  6. package/dist/checks/triage.mjs +77 -0
  7. package/dist/cli/args.mjs +153 -0
  8. package/dist/cli.mjs +203 -0
  9. package/dist/commands/check.mjs +140 -0
  10. package/dist/commands/iterate.mjs +295 -0
  11. package/dist/commands/ready-delay.mjs +87 -0
  12. package/dist/commands/resolve.mjs +64 -0
  13. package/dist/commands/status.mjs +107 -0
  14. package/{src/comments/outdated.mts → dist/comments/outdated.mjs} +2 -5
  15. package/dist/comments/resolve.mjs +113 -0
  16. package/dist/config/load.mjs +154 -0
  17. package/dist/github/batch.mjs +208 -0
  18. package/dist/github/client.mjs +153 -0
  19. package/{src/github/pagination.mts → dist/github/pagination.mjs} +26 -52
  20. package/{src/github/queries.mts → dist/github/queries.mjs} +1 -10
  21. package/{src/index.mts → dist/index.mjs} +3 -5
  22. package/dist/merge-status/derive.mjs +72 -0
  23. package/dist/reporters/agent.mjs +41 -0
  24. package/{src/reporters/json.mts → dist/reporters/json.mjs} +2 -5
  25. package/dist/reporters/text.mjs +111 -0
  26. package/dist/types.mjs +2 -0
  27. package/package.json +6 -6
  28. package/skills/check/SKILL.md +1 -1
  29. package/skills/monitor/SKILL.md +9 -5
  30. package/src/cache/file-cache.mts +0 -101
  31. package/src/cache/file-cache.test.mts +0 -91
  32. package/src/cache/fix-attempts.mts +0 -86
  33. package/src/checks/classify.mts +0 -80
  34. package/src/checks/classify.test.mts +0 -164
  35. package/src/checks/triage.mock.test.mts +0 -202
  36. package/src/checks/triage.mts +0 -88
  37. package/src/cli.mts +0 -423
  38. package/src/commands/check.mts +0 -188
  39. package/src/commands/iterate.mock.test.mts +0 -1111
  40. package/src/commands/iterate.mts +0 -371
  41. package/src/commands/ready-delay.mts +0 -117
  42. package/src/commands/ready-delay.test.mts +0 -116
  43. package/src/commands/resolve.mts +0 -92
  44. package/src/commands/status.mts +0 -173
  45. package/src/comments/resolve.mts +0 -179
  46. package/src/config/load.mts +0 -240
  47. package/src/github/batch.mts +0 -351
  48. package/src/github/client.mts +0 -207
  49. package/src/github/client.test.mts +0 -19
  50. package/src/github/pagination.test.mts +0 -140
  51. package/src/merge-status/derive.mts +0 -74
  52. package/src/merge-status/derive.test.mts +0 -130
  53. package/src/reporters/text.mts +0 -140
  54. package/src/types.mts +0 -309
  55. /package/{src → dist}/config.json +0 -0
  56. /package/{src → dist}/github/gql/batch-pr.gql +0 -0
  57. /package/{src → dist}/github/gql/dismiss-review.gql +0 -0
  58. /package/{src → dist}/github/gql/minimize-comment.gql +0 -0
  59. /package/{src → dist}/github/gql/multi-pr-status-paged.gql +0 -0
  60. /package/{src → dist}/github/gql/multi-pr-status.gql +0 -0
  61. /package/{src → dist}/github/gql/resolve-thread.gql +0 -0
  62. /package/{src/util/path-segment.mts → dist/util/path-segment.mjs} +0 -0
@@ -0,0 +1,154 @@
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
+ const RC_FILENAME = ".pr-shepherdrc.yml";
7
+ function findRcFile(startDir) {
8
+ const home = homedir();
9
+ let current = startDir;
10
+ while (true) {
11
+ const candidate = join(current, RC_FILENAME);
12
+ try {
13
+ readFileSync(candidate);
14
+ return candidate;
15
+ }
16
+ catch {
17
+ // not found here
18
+ }
19
+ if (current === home || current === dirname(current))
20
+ return null;
21
+ current = dirname(current);
22
+ }
23
+ }
24
+ function deepMerge(base, override) {
25
+ const result = { ...base };
26
+ for (const key of Object.keys(override)) {
27
+ const overVal = override[key];
28
+ const baseVal = base[key];
29
+ if (overVal !== null &&
30
+ typeof overVal === "object" &&
31
+ !Array.isArray(overVal) &&
32
+ typeof baseVal === "object" &&
33
+ baseVal !== null &&
34
+ !Array.isArray(baseVal)) {
35
+ result[key] = deepMerge(baseVal, overVal);
36
+ }
37
+ else if (overVal !== undefined) {
38
+ result[key] = overVal;
39
+ }
40
+ }
41
+ return result;
42
+ }
43
+ // ---------------------------------------------------------------------------
44
+ // Compatibility shim — maps old RC keys to new ones and emits deprecation warnings
45
+ // ---------------------------------------------------------------------------
46
+ function applyCompat(raw) {
47
+ const out = { ...raw };
48
+ // Removed keys — warn and strip.
49
+ for (const gone of ["baseBranch", "minimizeBots", "cancelCiOnFailure", "autoMinimize"]) {
50
+ if (gone in out) {
51
+ process.stderr.write(`pr-shepherd: config key "${gone}" has been removed and has no effect.\n`);
52
+ delete out[gone];
53
+ }
54
+ }
55
+ // Renamed top-level section keys — iterate
56
+ const iterate = out["iterate"];
57
+ if (iterate && "maxFixAttempts" in iterate) {
58
+ process.stderr.write(`pr-shepherd: config key "iterate.maxFixAttempts" renamed to "iterate.fixAttemptsPerThread".\n`);
59
+ out["iterate"] = { fixAttemptsPerThread: iterate["maxFixAttempts"], ...iterate };
60
+ delete out["iterate"]["maxFixAttempts"];
61
+ }
62
+ // Renamed watch keys
63
+ const watch = out["watch"];
64
+ if (watch) {
65
+ const watchOut = { ...watch };
66
+ if ("intervalDefault" in watch) {
67
+ process.stderr.write(`pr-shepherd: config key "watch.intervalDefault" renamed to "watch.interval".\n`);
68
+ watchOut["interval"] = watch["intervalDefault"];
69
+ delete watchOut["intervalDefault"];
70
+ }
71
+ if ("readyDelayMinutesDefault" in watch) {
72
+ process.stderr.write(`pr-shepherd: config key "watch.readyDelayMinutesDefault" renamed to "watch.readyDelayMinutes".\n`);
73
+ watchOut["readyDelayMinutes"] = watch["readyDelayMinutesDefault"];
74
+ delete watchOut["readyDelayMinutesDefault"];
75
+ }
76
+ if ("expiresHoursDefault" in watch) {
77
+ process.stderr.write(`pr-shepherd: config key "watch.expiresHoursDefault" renamed to "watch.expiresHours".\n`);
78
+ watchOut["expiresHours"] = watch["expiresHoursDefault"];
79
+ delete watchOut["expiresHoursDefault"];
80
+ }
81
+ out["watch"] = watchOut;
82
+ }
83
+ // Renamed resolve keys (shaPollIntervalMs / shaPollMaxAttempts → shaPoll object)
84
+ const resolve = out["resolve"];
85
+ if (resolve) {
86
+ const resolveOut = { ...resolve };
87
+ const shaPollOut = {};
88
+ let shaPollChanged = false;
89
+ if ("shaPollIntervalMs" in resolve) {
90
+ process.stderr.write(`pr-shepherd: config key "resolve.shaPollIntervalMs" moved to "resolve.shaPoll.intervalMs".\n`);
91
+ shaPollOut["intervalMs"] = resolve["shaPollIntervalMs"];
92
+ delete resolveOut["shaPollIntervalMs"];
93
+ shaPollChanged = true;
94
+ }
95
+ if ("shaPollMaxAttempts" in resolve) {
96
+ process.stderr.write(`pr-shepherd: config key "resolve.shaPollMaxAttempts" moved to "resolve.shaPoll.maxAttempts".\n`);
97
+ shaPollOut["maxAttempts"] = resolve["shaPollMaxAttempts"];
98
+ delete resolveOut["shaPollMaxAttempts"];
99
+ shaPollChanged = true;
100
+ }
101
+ if (shaPollChanged) {
102
+ resolveOut["shaPoll"] = {
103
+ ...resolveOut["shaPoll"],
104
+ ...shaPollOut,
105
+ };
106
+ }
107
+ out["resolve"] = resolveOut;
108
+ }
109
+ // Renamed checks keys
110
+ const checks = out["checks"];
111
+ if (checks) {
112
+ const checksOut = { ...checks };
113
+ if ("relevantEvents" in checks) {
114
+ process.stderr.write(`pr-shepherd: config key "checks.relevantEvents" renamed to "checks.ciTriggerEvents".\n`);
115
+ checksOut["ciTriggerEvents"] = checks["relevantEvents"];
116
+ delete checksOut["relevantEvents"];
117
+ }
118
+ if ("logLinesKept" in checks) {
119
+ process.stderr.write(`pr-shepherd: config key "checks.logLinesKept" renamed to "checks.logMaxLines".\n`);
120
+ checksOut["logMaxLines"] = checks["logLinesKept"];
121
+ delete checksOut["logLinesKept"];
122
+ }
123
+ if ("logExcerptMaxChars" in checks) {
124
+ process.stderr.write(`pr-shepherd: config key "checks.logExcerptMaxChars" renamed to "checks.logMaxChars".\n`);
125
+ checksOut["logMaxChars"] = checks["logExcerptMaxChars"];
126
+ delete checksOut["logExcerptMaxChars"];
127
+ }
128
+ out["checks"] = checksOut;
129
+ }
130
+ return out;
131
+ }
132
+ const defaults = builtins;
133
+ let cached = null;
134
+ export function loadConfig() {
135
+ if (cached)
136
+ return cached;
137
+ const rcPath = findRcFile(process.cwd());
138
+ if (!rcPath) {
139
+ cached = defaults;
140
+ return cached;
141
+ }
142
+ try {
143
+ const raw = readFileSync(rcPath, "utf8");
144
+ const parsed = (parse(raw) ?? {});
145
+ const compat = applyCompat(parsed);
146
+ cached = deepMerge(defaults, compat);
147
+ return cached;
148
+ }
149
+ catch (err) {
150
+ process.stderr.write(`pr-shepherd: failed to parse ${rcPath}: ${err instanceof Error ? err.message : String(err)}\n`);
151
+ cached = { ...defaults };
152
+ return cached;
153
+ }
154
+ }
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Executes the primary batch GraphQL query and parses the raw GitHub response
3
+ * into shepherd's typed `BatchPrData` shape.
4
+ *
5
+ * The batch query fetches CI checks + review threads + PR comments + merge
6
+ * status in a single network round-trip, drastically reducing API call counts
7
+ * compared to the previous per-agent approach.
8
+ */
9
+ import { graphql, graphqlWithRateLimit } from "./client.mjs";
10
+ import { paginateForward, paginateBackward } from "./pagination.mjs";
11
+ import { BATCH_PR_QUERY } from "./queries.mjs";
12
+ /**
13
+ * Fetch all PR data needed for a `shepherd check` in one (or a few, if paginating) GraphQL requests.
14
+ */
15
+ export async function fetchPrBatch(pr, repo) {
16
+ // First page: no cursor variables.
17
+ const result = await graphqlWithRateLimit(BATCH_PR_QUERY, {
18
+ owner: repo.owner,
19
+ repo: repo.name,
20
+ pr,
21
+ });
22
+ const raw = result.data.repository.pullRequest;
23
+ if (!raw) {
24
+ throw new Error(`PR #${pr} not found`);
25
+ }
26
+ // Paginate reviewThreads backward if the first page is incomplete.
27
+ let rawThreadPages = raw.reviewThreads.nodes;
28
+ if (raw.reviewThreads.pageInfo.hasPreviousPage && raw.reviewThreads.pageInfo.startCursor) {
29
+ // Pass startCursor so paginateBackward fetches pages *before* the already-
30
+ // fetched first page instead of re-fetching it from the start.
31
+ const extra = await paginateBackward(async (cursor) => {
32
+ const res = await graphql(BATCH_PR_QUERY, {
33
+ owner: repo.owner,
34
+ repo: repo.name,
35
+ pr,
36
+ ...(cursor ? { threadsCursor: cursor } : {}),
37
+ });
38
+ const pr2 = res.data.repository.pullRequest;
39
+ if (!pr2)
40
+ throw new Error(`PR #${pr} not found`);
41
+ return pr2.reviewThreads;
42
+ }, raw.reviewThreads.pageInfo.startCursor);
43
+ // extra contains pages before the first page.
44
+ rawThreadPages = [...extra, ...rawThreadPages];
45
+ }
46
+ // Paginate comments backward if the first page is incomplete.
47
+ let rawCommentNodes = raw.comments.nodes;
48
+ if (raw.comments.pageInfo.hasPreviousPage && raw.comments.pageInfo.startCursor) {
49
+ const extra = await paginateBackward(async (cursor) => {
50
+ const res = await graphql(BATCH_PR_QUERY, {
51
+ owner: repo.owner,
52
+ repo: repo.name,
53
+ pr,
54
+ ...(cursor ? { commentsCursor: cursor } : {}),
55
+ });
56
+ const pr2 = res.data.repository.pullRequest;
57
+ if (!pr2)
58
+ throw new Error(`PR #${pr} not found`);
59
+ return pr2.comments;
60
+ }, raw.comments.pageInfo.startCursor);
61
+ rawCommentNodes = [...extra, ...rawCommentNodes];
62
+ }
63
+ // Paginate reviews backward if the first page is incomplete.
64
+ let rawReviewNodes = raw.reviews.nodes;
65
+ if (raw.reviews.pageInfo.hasPreviousPage && raw.reviews.pageInfo.startCursor) {
66
+ const extra = await paginateBackward(async (cursor) => {
67
+ const res = await graphql(BATCH_PR_QUERY, {
68
+ owner: repo.owner,
69
+ repo: repo.name,
70
+ pr,
71
+ ...(cursor ? { reviewsCursor: cursor } : {}),
72
+ });
73
+ const pr2 = res.data.repository.pullRequest;
74
+ if (!pr2)
75
+ throw new Error(`PR #${pr} not found`);
76
+ return pr2.reviews;
77
+ }, raw.reviews.pageInfo.startCursor);
78
+ rawReviewNodes = [...extra, ...rawReviewNodes];
79
+ }
80
+ // Paginate check contexts forward if the first page is incomplete.
81
+ let rawCheckNodes = raw.commits.nodes[0]?.commit.statusCheckRollup?.contexts.nodes ?? [];
82
+ const checksPageInfo = raw.commits.nodes[0]?.commit.statusCheckRollup?.contexts.pageInfo;
83
+ if (checksPageInfo?.hasNextPage && checksPageInfo.endCursor) {
84
+ // Pass endCursor so paginateForward fetches pages *after* the already-
85
+ // fetched first page instead of re-fetching it from the start.
86
+ const extra = await paginateForward(async (cursor) => {
87
+ const res = await graphql(BATCH_PR_QUERY, {
88
+ owner: repo.owner,
89
+ repo: repo.name,
90
+ pr,
91
+ ...(cursor ? { checksCursor: cursor } : {}),
92
+ });
93
+ const pr2 = res.data.repository.pullRequest;
94
+ const ctxs = pr2?.commits.nodes[0]?.commit.statusCheckRollup?.contexts;
95
+ return ctxs ?? { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] };
96
+ }, checksPageInfo.endCursor);
97
+ rawCheckNodes = [...rawCheckNodes, ...extra];
98
+ }
99
+ const data = parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes, rawCheckNodes);
100
+ return { data, rateLimit: result.rateLimit };
101
+ }
102
+ // ---------------------------------------------------------------------------
103
+ // Parsers
104
+ // ---------------------------------------------------------------------------
105
+ function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes, rawCheckNodes) {
106
+ const reviewRequests = (raw.reviewRequests?.nodes ?? []).flatMap((n) => {
107
+ const login = n.requestedReviewer?.login ?? n.requestedReviewer?.name;
108
+ return login ? [{ login }] : [];
109
+ });
110
+ const latestReviews = (raw.latestReviews?.nodes ?? []).map((n) => ({
111
+ login: n.author?.login ?? "unknown",
112
+ state: n.state,
113
+ }));
114
+ const reviewThreads = rawThreadPages.map((t) => {
115
+ const comment = t.comments.nodes[0];
116
+ return {
117
+ id: t.id,
118
+ isResolved: t.isResolved,
119
+ isOutdated: t.isOutdated,
120
+ path: comment?.path ?? null,
121
+ line: comment?.line ?? null,
122
+ author: comment?.author?.login ?? "unknown",
123
+ body: comment?.body ?? "",
124
+ createdAtUnix: comment ? parseCreatedAt(comment.createdAt) : 0,
125
+ };
126
+ });
127
+ const comments = rawCommentNodes.map((c) => ({
128
+ id: c.id,
129
+ isMinimized: c.isMinimized,
130
+ author: c.author?.login ?? "unknown",
131
+ body: c.body,
132
+ createdAtUnix: parseCreatedAt(c.createdAt),
133
+ }));
134
+ const changesRequestedReviews = rawReviewNodes.map((r) => ({
135
+ id: r.id,
136
+ author: r.author?.login ?? "unknown",
137
+ body: r.body,
138
+ }));
139
+ const checks = rawCheckNodes.flatMap((node) => {
140
+ if (node.__typename === "CheckRun") {
141
+ const event = node.checkSuite?.workflowRun?.event ?? null;
142
+ const runId = extractRunId(node.detailsUrl);
143
+ return [
144
+ {
145
+ name: node.name,
146
+ status: node.status,
147
+ conclusion: node.conclusion,
148
+ detailsUrl: node.detailsUrl ?? "",
149
+ event,
150
+ runId,
151
+ },
152
+ ];
153
+ }
154
+ if (node.__typename === "StatusContext") {
155
+ const { status, conclusion } = mapStatusContextState(node.state);
156
+ return [
157
+ {
158
+ name: node.context,
159
+ status,
160
+ conclusion,
161
+ detailsUrl: node.targetUrl ?? "",
162
+ event: null,
163
+ runId: null,
164
+ },
165
+ ];
166
+ }
167
+ return [];
168
+ });
169
+ return {
170
+ number: raw.number,
171
+ state: raw.state,
172
+ isDraft: raw.isDraft,
173
+ mergeable: raw.mergeable,
174
+ mergeStateStatus: raw.mergeStateStatus,
175
+ reviewDecision: (raw.reviewDecision ?? null),
176
+ headRefOid: raw.headRefOid,
177
+ reviewRequests,
178
+ latestReviews,
179
+ reviewThreads,
180
+ comments,
181
+ changesRequestedReviews,
182
+ checks,
183
+ };
184
+ }
185
+ function parseCreatedAt(iso) {
186
+ const ms = new Date(iso).getTime();
187
+ return Number.isFinite(ms) ? Math.floor(ms / 1000) : 0;
188
+ }
189
+ function extractRunId(url) {
190
+ if (!url)
191
+ return null;
192
+ const m = /\/runs\/(\d+)/.exec(url);
193
+ return m ? (m[1] ?? null) : null;
194
+ }
195
+ /** Maps a GitHub commit status `state` to CheckRun-compatible status + conclusion. */
196
+ function mapStatusContextState(state) {
197
+ switch (state) {
198
+ case "SUCCESS":
199
+ return { status: "COMPLETED", conclusion: "SUCCESS" };
200
+ case "FAILURE":
201
+ case "ERROR":
202
+ return { status: "COMPLETED", conclusion: "FAILURE" };
203
+ case "PENDING":
204
+ case "EXPECTED":
205
+ default:
206
+ return { status: "IN_PROGRESS", conclusion: null };
207
+ }
208
+ }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Thin wrapper around the `gh` CLI for GraphQL and REST calls.
3
+ */
4
+ import { execFile as execFileCb } from "node:child_process";
5
+ import { promisify } from "node:util";
6
+ import { loadConfig } from "../config/load.mjs";
7
+ const execFile = promisify(execFileCb);
8
+ // ---------------------------------------------------------------------------
9
+ // GraphQL
10
+ // ---------------------------------------------------------------------------
11
+ /**
12
+ * Execute a GraphQL query via `gh api graphql`.
13
+ *
14
+ * @param query The full GraphQL query/mutation string.
15
+ * @param vars Key-value pairs forwarded as `-f key=value` or `-F key=value`.
16
+ * Numeric values are passed with `-F`; everything else with `-f`.
17
+ */
18
+ export async function graphql(query, vars = {}) {
19
+ const args = buildGraphqlArgs(query, vars);
20
+ const raw = await runGh(args);
21
+ const parsed = JSON.parse(raw);
22
+ if (parsed.errors?.length) {
23
+ const messages = parsed.errors.map((e) => e.message).join("; ");
24
+ throw new Error(`GitHub GraphQL error: ${messages}`);
25
+ }
26
+ return { data: parsed.data };
27
+ }
28
+ /** Like {@link graphql} but also returns the `x-ratelimit-remaining` header. */
29
+ export async function graphqlWithRateLimit(query, vars = {}) {
30
+ // --include must come after 'api' (it's a flag for `gh api`, not for `gh`).
31
+ const [api, ...extraArgs] = buildGraphqlArgs(query, vars);
32
+ const args = [api, "--include", ...extraArgs];
33
+ const raw = await runGh(args);
34
+ // `gh api -i` prepends HTTP headers before the JSON body.
35
+ // Handle both CRLF (\r\n\r\n) and LF-only (\n\n) header separators.
36
+ const crlfEnd = raw.indexOf("\r\n\r\n");
37
+ const lfEnd = raw.indexOf("\n\n");
38
+ const headerEnd = crlfEnd >= 0 ? crlfEnd : lfEnd;
39
+ const headerSection = headerEnd >= 0 ? raw.slice(0, headerEnd) : "";
40
+ const body = headerEnd >= 0 ? raw.slice(headerEnd + (crlfEnd >= 0 ? 4 : 2)) : raw;
41
+ const remaining = parseHeaderNumber(headerSection, "x-ratelimit-remaining");
42
+ const limit = parseHeaderNumber(headerSection, "x-ratelimit-limit");
43
+ const resetAt = parseHeaderNumber(headerSection, "x-ratelimit-reset");
44
+ const parsed = JSON.parse(body);
45
+ if (parsed.errors?.length) {
46
+ const messages = parsed.errors.map((e) => e.message).join("; ");
47
+ throw new Error(`GitHub GraphQL error: ${messages}`);
48
+ }
49
+ return {
50
+ data: parsed.data,
51
+ rateLimit: remaining !== null && limit !== null && resetAt !== null
52
+ ? { remaining, limit, resetAt }
53
+ : undefined,
54
+ };
55
+ }
56
+ /** Returns the current repo's owner and name from `gh repo view`. */
57
+ export async function getRepoInfo() {
58
+ const raw = await runGh(["repo", "view", "--json", "owner,name"]);
59
+ const parsed = JSON.parse(raw);
60
+ return { owner: parsed.owner.login, name: parsed.name };
61
+ }
62
+ /**
63
+ * Derives the PR number for the current HEAD branch.
64
+ * Returns null if no open PR is found.
65
+ */
66
+ export async function getCurrentPrNumber() {
67
+ try {
68
+ const branch = await getCurrentBranch();
69
+ // In detached HEAD state git returns "HEAD" — no branch name to look up.
70
+ if (branch === "HEAD")
71
+ return null;
72
+ const raw = await runGh([
73
+ "pr",
74
+ "list",
75
+ "--head",
76
+ branch,
77
+ "--json",
78
+ "number",
79
+ "--jq",
80
+ ".[0].number",
81
+ ]);
82
+ const trimmed = raw.trim();
83
+ if (!trimmed || trimmed === "null")
84
+ return null;
85
+ return parseInt(trimmed, 10);
86
+ }
87
+ catch {
88
+ return null;
89
+ }
90
+ }
91
+ async function getCurrentBranch() {
92
+ await runGh(["--version"]); // warm up gh CLI before git call
93
+ // Use git directly for branch name — gh doesn't expose it.
94
+ const { stdout } = await execFile("git", ["rev-parse", "--abbrev-ref", "HEAD"]);
95
+ return stdout.trim();
96
+ }
97
+ /** Returns the `headRefOid` (commit SHA) of the given PR as reported by GitHub. */
98
+ export async function getPrHeadSha(pr, owner, name) {
99
+ const raw = await runGh(["api", `repos/${owner}/${name}/pulls/${pr}`, "--jq", ".head.sha"]);
100
+ return raw.trim();
101
+ }
102
+ /**
103
+ * Fetches `mergeable` and `mergeStateStatus` via the REST API (`gh pr view`).
104
+ *
105
+ * Used as a fallback when the GraphQL API returns `UNKNOWN` for these fields —
106
+ * a known GitHub quirk where GraphQL lags behind the REST layer.
107
+ */
108
+ export async function getMergeableState(pr, owner, repo) {
109
+ const raw = await runGh([
110
+ "pr",
111
+ "view",
112
+ String(pr),
113
+ "--repo",
114
+ `${owner}/${repo}`,
115
+ "--json",
116
+ "mergeable,mergeStateStatus",
117
+ ]);
118
+ const parsed = JSON.parse(raw);
119
+ return parsed;
120
+ }
121
+ // ---------------------------------------------------------------------------
122
+ // Internal helpers
123
+ // ---------------------------------------------------------------------------
124
+ function buildGraphqlArgs(query, vars) {
125
+ const args = ["api", "graphql", "-f", `query=${query}`];
126
+ for (const [k, v] of Object.entries(vars)) {
127
+ if (typeof v === "number" || typeof v === "boolean") {
128
+ args.push("-F", `${k}=${String(v)}`);
129
+ }
130
+ else {
131
+ args.push("-f", `${k}=${v}`);
132
+ }
133
+ }
134
+ return args;
135
+ }
136
+ async function runGh(args) {
137
+ try {
138
+ const { stdout } = await execFile("gh", args, {
139
+ maxBuffer: loadConfig().execution.maxBufferMb * 1024 * 1024,
140
+ });
141
+ return stdout;
142
+ }
143
+ catch (err) {
144
+ // Re-throw with a more useful message
145
+ const msg = err instanceof Error ? err.message : String(err);
146
+ throw new Error(`gh ${args[0] ?? ""} failed: ${msg}`, { cause: err });
147
+ }
148
+ }
149
+ function parseHeaderNumber(headers, name) {
150
+ const re = new RegExp(`^${name}:\\s*(\\d+)`, "im");
151
+ const m = re.exec(headers);
152
+ return m ? parseInt(m[1], 10) : null;
153
+ }
@@ -9,19 +9,6 @@
9
9
  * - Forward (`after` + `first`) — used by check contexts.
10
10
  * - Backward (`before` + `last`) — used by reviewThreads (default GitHub order).
11
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
12
  /**
26
13
  * Paginate forward through a GraphQL connection (`first` / `after` cursors).
27
14
  *
@@ -31,26 +18,19 @@ export interface Connection<T> {
31
18
  * `endCursor` of an already-fetched page to fetch only
32
19
  * the pages *after* it, avoiding a duplicate re-fetch.
33
20
  */
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;
21
+ export async function paginateForward(fetchFn, initialCursor) {
22
+ const all = [];
23
+ let cursor = initialCursor ?? null;
24
+ for (;;) {
25
+ // eslint-disable-next-line no-await-in-loop
26
+ const conn = await fetchFn(cursor);
27
+ all.push(...conn.nodes);
28
+ if (!conn.pageInfo.hasNextPage || !conn.pageInfo.endCursor)
29
+ break;
30
+ cursor = conn.pageInfo.endCursor;
31
+ }
32
+ return all;
52
33
  }
53
-
54
34
  /**
55
35
  * Paginate backward through a GraphQL connection (`last` / `before` cursors).
56
36
  *
@@ -63,24 +43,18 @@ export async function paginateForward<T>(
63
43
  * `startCursor` of an already-fetched page to avoid
64
44
  * re-fetching it (fetch only the pages *before* it).
65
45
  */
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;
46
+ export async function paginateBackward(fetchFn, initialCursor) {
47
+ const all = [];
48
+ let cursor = initialCursor ?? null;
49
+ for (;;) {
50
+ // eslint-disable-next-line no-await-in-loop
51
+ const conn = await fetchFn(cursor);
52
+ // Backward pagination returns items oldest-first within each page but pages
53
+ // go from newest-to-oldest. Prepend each page so final array is oldest-first.
54
+ all.unshift(...conn.nodes);
55
+ if (!conn.pageInfo.hasPreviousPage || !conn.pageInfo.startCursor)
56
+ break;
57
+ cursor = conn.pageInfo.startCursor;
58
+ }
59
+ return all;
86
60
  }
@@ -4,27 +4,18 @@
4
4
  * Query strings live in src/github/gql/*.gql.
5
5
  * Never inline raw GraphQL strings in .ts source files.
6
6
  */
7
-
8
7
  import { readFileSync } from "node:fs";
9
8
  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
-
9
+ const gql = (name) => readFileSync(join(import.meta.dirname, "gql", name), "utf8");
14
10
  /** The primary batch query that fetches CI + comments + merge status in one round-trip. */
15
11
  export const BATCH_PR_QUERY = gql("batch-pr.gql");
16
-
17
12
  /** Resolve a single review thread. */
18
13
  export const RESOLVE_THREAD_MUTATION = gql("resolve-thread.gql");
19
-
20
14
  /** Minimize a PR comment (IssueComment). */
21
15
  export const MINIMIZE_COMMENT_MUTATION = gql("minimize-comment.gql");
22
-
23
16
  /** Dismiss a pull request review. */
24
17
  export const DISMISS_REVIEW_MUTATION = gql("dismiss-review.gql");
25
-
26
18
  /** Multi-PR status query for `shepherd status PR1 PR2 …`. */
27
19
  export const MULTI_PR_STATUS_QUERY = gql("multi-pr-status.gql");
28
-
29
20
  /** Paginated version — used when reviewThreads is truncated (totalCount > 100). */
30
21
  export const MULTI_PR_STATUS_QUERY_WITH_CURSOR = gql("multi-pr-status-paged.gql");
@@ -8,10 +8,8 @@
8
8
  * pr-shepherd iterate [PR]
9
9
  * pr-shepherd status PR1 [PR2 …]
10
10
  */
11
-
12
- import { main } from "./cli.mts";
13
-
11
+ import { main } from "./cli.mjs";
14
12
  main(process.argv).catch((err) => {
15
- process.stderr.write(`pr-shepherd error: ${err instanceof Error ? err.message : String(err)}\n`);
16
- process.exit(1);
13
+ process.stderr.write(`pr-shepherd error: ${err instanceof Error ? err.message : String(err)}\n`);
14
+ process.exit(1);
17
15
  });