pr-shepherd 0.7.1 → 0.8.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 (58) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +37 -303
  3. package/bin/checks/classify.mjs +5 -4
  4. package/bin/checks/triage.mjs +76 -62
  5. package/bin/cli/args.mjs +29 -61
  6. package/bin/cli/exit-codes.mjs +39 -0
  7. package/bin/cli/fix-formatter.mjs +76 -0
  8. package/bin/cli/formatters.mjs +108 -0
  9. package/bin/cli/handlers.mjs +138 -0
  10. package/bin/cli/iterate-formatter.mjs +78 -0
  11. package/bin/cli-parser.iterate-fixtures.mjs +65 -0
  12. package/bin/cli-parser.mjs +110 -0
  13. package/bin/commands/check-status.mjs +35 -0
  14. package/bin/commands/check.mjs +14 -61
  15. package/bin/commands/commit-suggestion.mjs +159 -0
  16. package/bin/commands/iterate/classify.mjs +77 -0
  17. package/bin/commands/iterate/escalate.mjs +124 -0
  18. package/bin/commands/iterate/fix-code.mjs +97 -0
  19. package/bin/commands/iterate/helpers.mjs +103 -0
  20. package/bin/commands/iterate/index.mjs +122 -0
  21. package/bin/commands/iterate/render.mjs +119 -0
  22. package/bin/commands/iterate/stall.mjs +65 -0
  23. package/bin/commands/iterate/steps.mjs +31 -0
  24. package/bin/commands/iterate.mjs +2 -628
  25. package/bin/commands/monitor.mjs +78 -0
  26. package/bin/commands/ready-delay.mjs +3 -4
  27. package/bin/commands/resolve-instructions.mjs +39 -0
  28. package/bin/commands/resolve.mjs +34 -3
  29. package/bin/commands/status.mjs +7 -0
  30. package/bin/comments/resolve.mjs +1 -1
  31. package/bin/config/load.mjs +17 -113
  32. package/bin/config.json +10 -22
  33. package/bin/github/batch-parsers.mjs +140 -0
  34. package/bin/github/batch-raw-types.mjs +2 -0
  35. package/bin/github/batch.mjs +34 -129
  36. package/bin/github/client.mjs +47 -9
  37. package/bin/github/gql/batch-pr.gql +20 -0
  38. package/bin/github/http.mjs +32 -30
  39. package/bin/index.mjs +15 -2
  40. package/bin/merge-status/derive.mjs +11 -11
  41. package/bin/reporters/agent.mjs +13 -4
  42. package/bin/reporters/check-instructions.mjs +65 -0
  43. package/bin/reporters/json.mjs +3 -2
  44. package/bin/reporters/text.mjs +108 -61
  45. package/bin/{cache → state}/fix-attempts.mjs +3 -3
  46. package/bin/state/iterate-stall.mjs +74 -0
  47. package/bin/suggestions/parse.mjs +119 -0
  48. package/bin/suggestions/patch.mjs +52 -0
  49. package/bin/types/github.mjs +2 -0
  50. package/bin/types/iterate.mjs +2 -0
  51. package/bin/types/report.mjs +2 -0
  52. package/bin/types.mjs +3 -1
  53. package/package.json +3 -3
  54. package/plugin/skills/check/SKILL.md +15 -48
  55. package/plugin/skills/monitor/SKILL.md +11 -64
  56. package/plugin/skills/resolve/SKILL.md +10 -76
  57. package/bin/cache/file-cache.mjs +0 -79
  58. package/bin/cli.mjs +0 -298
@@ -1,18 +1,11 @@
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
1
  import { graphql, graphqlWithRateLimit } from "./client.mjs";
10
2
  import { paginateForward, paginateBackward } from "./pagination.mjs";
11
3
  import { BATCH_PR_QUERY } from "./queries.mjs";
4
+ import { parseRawPr } from "./batch-parsers.mjs";
12
5
  /**
13
6
  * Fetch all PR data needed for a `shepherd check` in one (or a few, if paginating) GraphQL requests.
14
7
  */
15
- export async function fetchPrBatch(pr, repo) {
8
+ export async function fetchPrBatch(pr, repo, opts = {}) {
16
9
  // First page: no cursor variables.
17
10
  const result = await graphqlWithRateLimit(BATCH_PR_QUERY, {
18
11
  owner: repo.owner,
@@ -95,12 +88,34 @@ export async function fetchPrBatch(pr, repo) {
95
88
  }, raw.reviewSummaries.pageInfo.startCursor);
96
89
  rawReviewSummaryNodes = [...extra, ...rawReviewSummaryNodes];
97
90
  }
91
+ // Paginate APPROVED reviews backward if the first page is incomplete — gated behind
92
+ // `paginateApprovedReviews` because approvals minimization is opt-in. See FetchPrBatchOptions.
93
+ let rawApprovedReviewNodes = raw.approvedReviews.nodes;
94
+ if (opts.paginateApprovedReviews &&
95
+ raw.approvedReviews.pageInfo.hasPreviousPage &&
96
+ raw.approvedReviews.pageInfo.startCursor) {
97
+ const extra = await paginateBackward(async (cursor) => {
98
+ const res = await graphql(BATCH_PR_QUERY, {
99
+ owner: repo.owner,
100
+ repo: repo.name,
101
+ pr,
102
+ ...(cursor ? { approvedReviewsCursor: cursor } : {}),
103
+ });
104
+ const pr2 = res.data.repository.pullRequest;
105
+ if (!pr2)
106
+ throw new Error(`PR #${pr} not found`);
107
+ return pr2.approvedReviews;
108
+ }, raw.approvedReviews.pageInfo.startCursor);
109
+ rawApprovedReviewNodes = [...extra, ...rawApprovedReviewNodes];
110
+ }
98
111
  // Paginate check contexts forward if the first page is incomplete.
99
112
  let rawCheckNodes = raw.commits.nodes[0]?.commit.statusCheckRollup?.contexts.nodes ?? [];
100
113
  const checksPageInfo = raw.commits.nodes[0]?.commit.statusCheckRollup?.contexts.pageInfo;
114
+ const firstOid = raw.commits.nodes[0]?.commit.oid;
101
115
  if (checksPageInfo?.hasNextPage && checksPageInfo.endCursor) {
102
116
  // Pass endCursor so paginateForward fetches pages *after* the already-
103
117
  // fetched first page instead of re-fetching it from the start.
118
+ let pageCount = 0;
104
119
  const extra = await paginateForward(async (cursor) => {
105
120
  const res = await graphql(BATCH_PR_QUERY, {
106
121
  owner: repo.owner,
@@ -109,129 +124,19 @@ export async function fetchPrBatch(pr, repo) {
109
124
  ...(cursor ? { checksCursor: cursor } : {}),
110
125
  });
111
126
  const pr2 = res.data.repository.pullRequest;
112
- const ctxs = pr2?.commits.nodes[0]?.commit.statusCheckRollup?.contexts;
127
+ if (!pr2?.commits.nodes[0]?.commit.statusCheckRollup) {
128
+ throw new Error(`Check-context pagination interrupted: statusCheckRollup disappeared on page ${pageCount + 2} (possible force-push race). Retry after the push stabilizes.`);
129
+ }
130
+ const currentOid = pr2.commits.nodes[0]?.commit.oid;
131
+ if (firstOid !== undefined && currentOid !== undefined && currentOid !== firstOid) {
132
+ throw new Error(`Check-context pagination interrupted: head commit changed from ${firstOid} to ${currentOid} between pages (force-push race). Retry.`);
133
+ }
134
+ pageCount++;
135
+ const ctxs = pr2.commits.nodes[0]?.commit.statusCheckRollup?.contexts;
113
136
  return ctxs ?? { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] };
114
137
  }, checksPageInfo.endCursor);
115
138
  rawCheckNodes = [...rawCheckNodes, ...extra];
116
139
  }
117
- const data = parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes, rawReviewSummaryNodes, rawCheckNodes);
140
+ const data = parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes, rawReviewSummaryNodes, rawApprovedReviewNodes, rawCheckNodes);
118
141
  return { data, rateLimit: result.rateLimit };
119
142
  }
120
- // ---------------------------------------------------------------------------
121
- // Parsers
122
- // ---------------------------------------------------------------------------
123
- function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes, rawReviewSummaryNodes, rawCheckNodes) {
124
- const reviewRequests = (raw.reviewRequests?.nodes ?? []).flatMap((n) => {
125
- const login = n.requestedReviewer?.login ?? n.requestedReviewer?.name;
126
- return login ? [{ login }] : [];
127
- });
128
- const latestReviews = (raw.latestReviews?.nodes ?? []).map((n) => ({
129
- login: n.author?.login ?? "unknown",
130
- state: n.state,
131
- }));
132
- const reviewThreads = rawThreadPages.map((t) => {
133
- const comment = t.comments.nodes[0];
134
- return {
135
- id: t.id,
136
- isResolved: t.isResolved,
137
- isOutdated: t.isOutdated,
138
- isMinimized: comment?.isMinimized ?? false,
139
- path: comment?.path ?? null,
140
- line: comment?.line ?? null,
141
- author: comment?.author?.login ?? "unknown",
142
- body: comment?.body ?? "",
143
- createdAtUnix: comment ? parseCreatedAt(comment.createdAt) : 0,
144
- };
145
- });
146
- const comments = rawCommentNodes.map((c) => ({
147
- id: c.id,
148
- isMinimized: c.isMinimized,
149
- author: c.author?.login ?? "unknown",
150
- body: c.body,
151
- createdAtUnix: parseCreatedAt(c.createdAt),
152
- }));
153
- const changesRequestedReviews = rawReviewNodes.map((r) => ({
154
- id: r.id,
155
- author: r.author?.login ?? "unknown",
156
- body: r.body,
157
- }));
158
- const reviewSummaries = rawReviewSummaryNodes
159
- .filter((r) => !r.isMinimized && r.body.trim() !== "")
160
- .map((r) => ({
161
- id: r.id,
162
- author: r.author?.login ?? "unknown",
163
- body: r.body,
164
- }));
165
- const checks = rawCheckNodes.flatMap((node) => {
166
- if (node.__typename === "CheckRun") {
167
- const event = node.checkSuite?.workflowRun?.event ?? null;
168
- const runId = extractRunId(node.detailsUrl);
169
- return [
170
- {
171
- name: node.name,
172
- status: node.status,
173
- conclusion: node.conclusion,
174
- detailsUrl: node.detailsUrl ?? "",
175
- event,
176
- runId,
177
- },
178
- ];
179
- }
180
- if (node.__typename === "StatusContext") {
181
- const { status, conclusion } = mapStatusContextState(node.state);
182
- return [
183
- {
184
- name: node.context,
185
- status,
186
- conclusion,
187
- detailsUrl: node.targetUrl ?? "",
188
- event: null,
189
- runId: null,
190
- },
191
- ];
192
- }
193
- return [];
194
- });
195
- return {
196
- nodeId: raw.id,
197
- number: raw.number,
198
- state: raw.state,
199
- isDraft: raw.isDraft,
200
- mergeable: raw.mergeable,
201
- mergeStateStatus: raw.mergeStateStatus,
202
- reviewDecision: (raw.reviewDecision ?? null),
203
- headRefOid: raw.headRefOid,
204
- baseRefName: raw.baseRefName,
205
- reviewRequests,
206
- latestReviews,
207
- reviewThreads,
208
- comments,
209
- changesRequestedReviews,
210
- reviewSummaries,
211
- checks,
212
- };
213
- }
214
- function parseCreatedAt(iso) {
215
- const ms = new Date(iso).getTime();
216
- return Number.isFinite(ms) ? Math.floor(ms / 1000) : 0;
217
- }
218
- function extractRunId(url) {
219
- if (!url)
220
- return null;
221
- const m = /\/runs\/(\d+)/.exec(url);
222
- return m ? (m[1] ?? null) : null;
223
- }
224
- /** Maps a GitHub commit status `state` to CheckRun-compatible status + conclusion. */
225
- function mapStatusContextState(state) {
226
- switch (state) {
227
- case "SUCCESS":
228
- return { status: "COMPLETED", conclusion: "SUCCESS" };
229
- case "FAILURE":
230
- case "ERROR":
231
- return { status: "COMPLETED", conclusion: "FAILURE" };
232
- case "PENDING":
233
- case "EXPECTED":
234
- default:
235
- return { status: "IN_PROGRESS", conclusion: null };
236
- }
237
- }
@@ -45,6 +45,36 @@ export async function getPrHeadSha(pr, owner, name) {
45
45
  const data = await rest("GET", `/repos/${owner}/${name}/pulls/${pr}`);
46
46
  return data.head.sha;
47
47
  }
48
+ /**
49
+ * Full head info for a PR — sha, branch name, and the head repository's full name.
50
+ * Needed by commit-suggestions to target the correct branch via `createCommitOnBranch`.
51
+ */
52
+ export async function getPrHead(pr, owner, name) {
53
+ const data = await rest("GET", `/repos/${owner}/${name}/pulls/${pr}`);
54
+ if (!data.head.repo) {
55
+ throw new Error(`PR #${pr} head repository is unavailable (fork may have been deleted).`);
56
+ }
57
+ return {
58
+ sha: data.head.sha,
59
+ ref: data.head.ref,
60
+ repoWithOwner: data.head.repo.full_name,
61
+ };
62
+ }
63
+ /**
64
+ * Fetch a file's raw text content at a given ref. Uses the REST contents endpoint,
65
+ * which returns base64 for files under 1MB. Throws for binary / oversize files.
66
+ */
67
+ export async function getFileContents(repoWithOwner, path, ref) {
68
+ const data = await rest("GET", `/repos/${repoWithOwner}/contents/${encodePathForApi(path)}?ref=${encodeURIComponent(ref)}`);
69
+ if (!data.content || data.encoding !== "base64") {
70
+ throw new Error(`File ${path} could not be read as text (encoding=${data.encoding ?? "n/a"}).`);
71
+ }
72
+ return Buffer.from(data.content, "base64").toString("utf8");
73
+ }
74
+ // Encode every path segment but preserve the slashes between them.
75
+ function encodePathForApi(path) {
76
+ return path.split("/").map(encodeURIComponent).join("/");
77
+ }
48
78
  /**
49
79
  * Fetches `mergeable` and `mergeStateStatus` via the REST API.
50
80
  *
@@ -60,22 +90,30 @@ export async function getMergeableState(pr, owner, repo) {
60
90
  // ---------------------------------------------------------------------------
61
91
  // Internal helpers
62
92
  // ---------------------------------------------------------------------------
63
- async function getCurrentBranch() {
93
+ export async function getCurrentBranch() {
64
94
  const { stdout } = await execFile("git", ["rev-parse", "--abbrev-ref", "HEAD"]);
65
95
  return stdout.trim();
66
96
  }
67
97
  function parseRemoteUrl(url) {
68
- // Strip trailing .git and trailing slash
69
- const stripped = url.replace(/\.git$/, "").replace(/\/$/, "");
70
- // ssh: git@host:owner/repo
71
- const sshMatch = /^git@[^:]+:([^/]+)\/(.+)$/.exec(stripped);
98
+ const trimmed = url.trim();
99
+ // ssh: git@host:owner/repo[.git]
100
+ const sshMatch = /^git@[^:]+:([^/]+)\/([^/]+?)(?:\.git)?$/.exec(trimmed);
72
101
  if (sshMatch) {
73
102
  return { owner: sshMatch[1], name: sshMatch[2] };
74
103
  }
75
- // https or ssh://: https://host/owner/repo or ssh://git@host/owner/repo
76
- const httpsMatch = /^(?:https?|ssh):\/\/[^/]+\/([^/]+)\/(.+)$/.exec(stripped);
77
- if (httpsMatch) {
78
- return { owner: httpsMatch[1], name: httpsMatch[2] };
104
+ // https or ssh://: parse via URL and require exactly /<owner>/<repo>[.git]
105
+ if (/^(?:https?|ssh):\/\//.test(trimmed)) {
106
+ try {
107
+ const parsed = new URL(trimmed);
108
+ const pathname = parsed.pathname.replace(/\.git\/?$/, "").replace(/\/$/, "");
109
+ const parts = pathname.split("/").filter(Boolean);
110
+ if (parts.length === 2) {
111
+ return { owner: parts[0], name: parts[1] };
112
+ }
113
+ }
114
+ catch {
115
+ // fall through to error
116
+ }
79
117
  }
80
118
  throw new Error(`Cannot parse GitHub remote URL: ${url}`);
81
119
  }
@@ -7,6 +7,7 @@ query BatchPr(
7
7
  $commentsCursor: String
8
8
  $changesRequestedCursor: String
9
9
  $reviewSummariesCursor: String
10
+ $approvedReviewsCursor: String
10
11
  ) {
11
12
  repository(owner: $owner, name: $repo) {
12
13
  pullRequest(number: $pr) {
@@ -63,6 +64,7 @@ query BatchPr(
63
64
  body
64
65
  path
65
66
  line
67
+ startLine
66
68
  createdAt
67
69
  }
68
70
  }
@@ -114,9 +116,24 @@ query BatchPr(
114
116
  body
115
117
  }
116
118
  }
119
+ approvedReviews: reviews(states: APPROVED, last: 50, before: $approvedReviewsCursor) {
120
+ pageInfo {
121
+ hasPreviousPage
122
+ startCursor
123
+ }
124
+ nodes {
125
+ id
126
+ isMinimized
127
+ author {
128
+ login
129
+ }
130
+ body
131
+ }
132
+ }
117
133
  commits(last: 1) {
118
134
  nodes {
119
135
  commit {
136
+ oid
120
137
  statusCheckRollup {
121
138
  contexts(first: 100, after: $checksCursor) {
122
139
  pageInfo {
@@ -130,6 +147,8 @@ query BatchPr(
130
147
  status
131
148
  conclusion
132
149
  detailsUrl
150
+ title
151
+ summary
133
152
  checkSuite {
134
153
  workflowRun {
135
154
  event
@@ -140,6 +159,7 @@ query BatchPr(
140
159
  context
141
160
  state
142
161
  targetUrl
162
+ description
143
163
  }
144
164
  }
145
165
  }
@@ -1,12 +1,3 @@
1
- /**
2
- * Thin native-fetch HTTP client for the GitHub API.
3
- * Replaces the previous `gh` CLI shell-out on every code path.
4
- *
5
- * Token resolution order:
6
- * 1. GH_TOKEN env
7
- * 2. GITHUB_TOKEN env
8
- * 3. `gh auth token` (fallback for users who have run `gh auth login`)
9
- */
10
1
  import { execFile as execFileCb } from "node:child_process";
11
2
  import { promisify } from "node:util";
12
3
  const execFile = promisify(execFileCb);
@@ -21,12 +12,9 @@ export function _resetTokenCache() {
21
12
  async function resolveToken() {
22
13
  if (_token)
23
14
  return _token;
24
- if (process.env["GH_TOKEN"]) {
25
- _token = process.env["GH_TOKEN"];
26
- return _token;
27
- }
28
- if (process.env["GITHUB_TOKEN"]) {
29
- _token = process.env["GITHUB_TOKEN"];
15
+ const envToken = process.env["GH_TOKEN"] ?? process.env["GITHUB_TOKEN"];
16
+ if (envToken) {
17
+ _token = envToken;
30
18
  return _token;
31
19
  }
32
20
  try {
@@ -51,24 +39,43 @@ async function makeHeaders() {
51
39
  "Content-Type": "application/json",
52
40
  };
53
41
  }
42
+ function sanitizeBody(body) {
43
+ return body.replace(/Bearer\s+\S+/gi, "[REDACTED]").slice(0, 200);
44
+ }
45
+ async function requestWithTokenRetry(fn) {
46
+ const res = await fn();
47
+ if (res.status === 401 && _token !== undefined) {
48
+ try {
49
+ await res.arrayBuffer();
50
+ }
51
+ catch { }
52
+ _token = undefined;
53
+ return fn();
54
+ }
55
+ return res;
56
+ }
54
57
  // ---------------------------------------------------------------------------
55
58
  // GraphQL
56
59
  // ---------------------------------------------------------------------------
57
60
  async function graphqlInner(query, vars) {
58
- const res = await fetch(`${BASE_URL}/graphql`, {
61
+ const res = await requestWithTokenRetry(async () => fetch(`${BASE_URL}/graphql`, {
59
62
  method: "POST",
60
63
  headers: await makeHeaders(),
61
64
  body: JSON.stringify({ query, variables: vars }),
62
- });
65
+ }));
63
66
  const rateLimit = parseRateLimit(res.headers);
64
67
  if (!res.ok) {
65
68
  const body = await res.text();
66
- throw new Error(`GitHub GraphQL request failed: ${res.status} ${body.slice(0, 300)}`);
69
+ throw new Error(`GitHub GraphQL request failed: ${res.status} ${sanitizeBody(body)}`);
67
70
  }
68
71
  const parsed = (await res.json());
72
+ if (parsed.data == null) {
73
+ const messages = (parsed.errors ?? []).map((e) => e.message).join("; ");
74
+ throw new Error(`GitHub GraphQL error (no data): ${messages}`);
75
+ }
69
76
  if (parsed.errors?.length) {
70
77
  const messages = parsed.errors.map((e) => e.message).join("; ");
71
- throw new Error(`GitHub GraphQL error: ${messages}`);
78
+ process.stderr.write(`pr-shepherd: GraphQL non-fatal errors: ${messages}\n`);
72
79
  }
73
80
  return { data: parsed.data, rateLimit };
74
81
  }
@@ -84,14 +91,14 @@ export async function graphqlWithRateLimit(query, vars = {}) {
84
91
  // REST
85
92
  // ---------------------------------------------------------------------------
86
93
  export async function rest(method, path, body) {
87
- const res = await fetch(`${BASE_URL}${path}`, {
94
+ const res = await requestWithTokenRetry(async () => fetch(`${BASE_URL}${path}`, {
88
95
  method,
89
96
  headers: await makeHeaders(),
90
97
  body: body !== undefined ? JSON.stringify(body) : undefined,
91
- });
98
+ }));
92
99
  if (!res.ok) {
93
100
  const text = await res.text();
94
- throw new Error(`GitHub REST ${method} ${path} failed: ${res.status} ${text.slice(0, 300)}`);
101
+ throw new Error(`GitHub REST ${method} ${path} failed: ${res.status} ${sanitizeBody(text)}`);
95
102
  }
96
103
  const ct = res.headers.get("content-type") ?? "";
97
104
  if (ct.includes("application/json")) {
@@ -99,17 +106,12 @@ export async function rest(method, path, body) {
99
106
  }
100
107
  return undefined;
101
108
  }
102
- /**
103
- * GET request that returns plain text.
104
- * Handles the 302 redirect pattern used by the GitHub Actions job-logs endpoint —
105
- * the redirect target (a signed storage URL) is fetched without auth headers.
106
- */
107
109
  export async function restText(path) {
108
- const res = await fetch(`${BASE_URL}${path}`, {
110
+ const res = await requestWithTokenRetry(async () => fetch(`${BASE_URL}${path}`, {
109
111
  method: "GET",
110
112
  headers: await makeHeaders(),
111
113
  redirect: "manual",
112
- });
114
+ }));
113
115
  if (res.status === 301 || res.status === 302 || res.status === 307 || res.status === 308) {
114
116
  const location = res.headers.get("location");
115
117
  if (location) {
@@ -122,7 +124,7 @@ export async function restText(path) {
122
124
  }
123
125
  if (!res.ok) {
124
126
  const text = await res.text();
125
- throw new Error(`GitHub REST GET ${path} failed: ${res.status} ${text.slice(0, 300)}`);
127
+ throw new Error(`GitHub REST GET ${path} failed: ${res.status} ${sanitizeBody(text)}`);
126
128
  }
127
129
  return res.text();
128
130
  }
package/bin/index.mjs CHANGED
@@ -8,8 +8,21 @@
8
8
  * pr-shepherd iterate [PR]
9
9
  * pr-shepherd status PR1 [PR2 …]
10
10
  */
11
- import { main } from "./cli.mjs";
11
+ import { main } from "./cli-parser.mjs";
12
+ function formatCause(cause, seen = new Set(), depth = 0) {
13
+ if (depth > 5 || seen.has(cause))
14
+ return "[circular or deep cause chain]";
15
+ seen.add(cause);
16
+ if (cause instanceof Error) {
17
+ const stack = cause.stack ?? `${cause.message}`;
18
+ const nested = cause.cause != null ? `\n caused by: ${formatCause(cause.cause, seen, depth + 1)}` : "";
19
+ return `${stack}${nested}`;
20
+ }
21
+ return String(cause);
22
+ }
12
23
  main(process.argv).catch((err) => {
13
- process.stderr.write(`pr-shepherd error: ${err instanceof Error ? err.message : String(err)}\n`);
24
+ const msg = err instanceof Error ? err.message : String(err);
25
+ const causeStr = err instanceof Error && err.cause != null ? formatCause(err.cause) : null;
26
+ process.stderr.write(`pr-shepherd error: ${msg}${causeStr !== null ? ` (cause: ${causeStr})` : ""}\n`);
14
27
  process.exit(1);
15
28
  });
@@ -5,13 +5,13 @@
5
5
  * for non-OPEN (merged/closed) PRs — this function does not branch on it.
6
6
  *
7
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
8
+ * 1. mergeable == CONFLICTING → CONFLICTS (hard conflict even for drafts)
9
+ * 2. copilotReviewInProgress BLOCKED
10
+ * 3. mergeStateStatus DIRTY CONFLICTS (GitHub merge conflicts, even for drafts)
11
+ * 4. isDraft DRAFT
12
+ * 5. mergeStateStatus BEHIND BEHIND
13
+ * 6. mergeStateStatus BLOCKED / HAS_HOOKS BLOCKED
14
+ * 7. mergeStateStatus UNSTABLE UNSTABLE
15
15
  * 8. mergeStateStatus UNKNOWN → UNKNOWN
16
16
  * 9. mergeStateStatus CLEAN → CLEAN
17
17
  */
@@ -26,9 +26,12 @@ export function deriveMergeStatus(pr) {
26
26
  status = "BLOCKED";
27
27
  }
28
28
  else if (pr.mergeStateStatus === "DIRTY") {
29
- // DIRTY means GitHub detected merge conflicts in the branch.
29
+ // DIRTY means GitHub detected merge conflicts surface as CONFLICTS even for drafts.
30
30
  status = "CONFLICTS";
31
31
  }
32
+ else if (pr.isDraft || pr.mergeStateStatus === "DRAFT") {
33
+ status = "DRAFT";
34
+ }
32
35
  else if (pr.mergeStateStatus === "BEHIND") {
33
36
  status = "BEHIND";
34
37
  }
@@ -38,9 +41,6 @@ export function deriveMergeStatus(pr) {
38
41
  else if (pr.mergeStateStatus === "UNSTABLE") {
39
42
  status = "UNSTABLE";
40
43
  }
41
- else if (pr.isDraft || pr.mergeStateStatus === "DRAFT") {
42
- status = "DRAFT";
43
- }
44
44
  else if (pr.mergeStateStatus === "UNKNOWN") {
45
45
  status = "UNKNOWN";
46
46
  }
@@ -2,9 +2,10 @@
2
2
  * Projections for the agent-facing iterate output.
3
3
  *
4
4
  * These strip fields that are always-false by the time items reach iterate
5
- * (isResolved, isOutdated, isMinimized, createdAtUnix) and metadata that the
6
- * monitor prompt never reads (detailsUrl, event, status, conclusion, category,
7
- * logExcerpt). The original domain types are preserved for check command output.
5
+ * (isResolved, isOutdated, isMinimized, createdAtUnix) and check metadata the
6
+ * monitor prompt never reads (event, status, conclusion, category).
7
+ * detailsUrl is preserved in AgentCheck as a fallback for external status checks.
8
+ * The original domain types are preserved for check command output.
8
9
  */
9
10
  export function toAgentThread(t) {
10
11
  return { id: t.id, path: t.path, line: t.line, author: t.author, body: t.body };
@@ -13,7 +14,15 @@ export function toAgentComment(c) {
13
14
  return { id: c.id, author: c.author, body: c.body };
14
15
  }
15
16
  export function toAgentCheck(c) {
16
- return { name: c.name, runId: c.runId, detailsUrl: c.detailsUrl, failureKind: c.failureKind };
17
+ return {
18
+ name: c.name,
19
+ runId: c.runId,
20
+ detailsUrl: c.detailsUrl,
21
+ failureKind: c.failureKind,
22
+ ...(c.workflowName !== undefined && { workflowName: c.workflowName }),
23
+ ...(c.failedStep !== undefined && { failedStep: c.failedStep }),
24
+ ...(c.summary !== undefined && { summary: c.summary }),
25
+ };
17
26
  }
18
27
  /**
19
28
  * Project and deduplicate checks so the agent makes one `gh run view` call
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Build the numbered instruction steps for the agent to follow after a `check` run.
3
+ * All rebase policy, CI budget policy, and ready-to-merge gating live here so the
4
+ * skill stays a thin dispatcher and these rules co-evolve with the CLI data model.
5
+ */
6
+ export function buildCheckInstructions(report) {
7
+ const { mergeStatus, checks, threads, comments, changesRequestedReviews, status } = report;
8
+ const instructions = [];
9
+ // 1. Summary
10
+ const totalActionable = threads.actionable.length + comments.actionable.length + changesRequestedReviews.length;
11
+ const total = checks.passing.length +
12
+ checks.failing.length +
13
+ checks.inProgress.length +
14
+ checks.skipped.length;
15
+ const copilotNote = mergeStatus.copilotReviewInProgress ? " (Copilot review in progress)" : "";
16
+ instructions.push(`Report: merge status is ${mergeStatus.status}${copilotNote}, CI ${checks.passing.length}/${total} passed` +
17
+ (checks.failing.length > 0 ? ` (${checks.failing.length} failing)` : "") +
18
+ (checks.inProgress.length > 0 ? ` (${checks.inProgress.length} in progress)` : "") +
19
+ `, ${totalActionable} actionable review item(s).`);
20
+ // 2. Rebase policy (only emit when relevant)
21
+ if (mergeStatus.status === "CONFLICTS") {
22
+ instructions.push("Rebase required: the branch has merge conflicts that must be resolved before this PR can land.");
23
+ }
24
+ else if (mergeStatus.status === "BEHIND") {
25
+ instructions.push("The PR is behind the base branch. A rebase is optional if all CI checks pass.");
26
+ }
27
+ // 3. CI budget policy — one instruction per failing check
28
+ for (const c of checks.failing) {
29
+ if (c.failureKind === "actionable") {
30
+ const diagnosisHint = c.failedStep
31
+ ? `the failure was in step \`${c.failedStep}\` — fetch the run log with \`gh run view ${c.runId ?? "<runId>"} --log-failed\` to diagnose`
32
+ : c.runId
33
+ ? `fetch the run log with \`gh run view ${c.runId} --log-failed\` to diagnose the failure`
34
+ : `open the check details (${c.detailsUrl}) to diagnose the failure`;
35
+ instructions.push(`Fix code failure: \`${c.name}\` (actionable) — ${diagnosisHint} and apply a fix.`);
36
+ }
37
+ else if (c.failureKind === "cancelled" || c.failureKind === "timeout") {
38
+ const rerunCmd = c.runId
39
+ ? `gh run rerun ${c.runId} --failed`
40
+ : `gh run rerun <runId> --failed`;
41
+ instructions.push(`Re-run transient failure: \`${c.name}\` [${c.failureKind}] — run \`${rerunCmd}\`.`);
42
+ }
43
+ }
44
+ // 4. Ready-to-merge gate
45
+ const isClean = mergeStatus.mergeStateStatus === "CLEAN";
46
+ const isReady = isClean && status === "READY" && !mergeStatus.copilotReviewInProgress;
47
+ if (isReady) {
48
+ instructions.push("This PR is ready to merge: mergeStateStatus is CLEAN, status is READY, and no Copilot review is in progress.");
49
+ }
50
+ else {
51
+ const blockers = [];
52
+ if (!isClean)
53
+ blockers.push(`mergeStateStatus is ${mergeStatus.mergeStateStatus} (not CLEAN)`);
54
+ if (status !== "READY")
55
+ blockers.push(`status is ${status} (not READY)`);
56
+ if (mergeStatus.copilotReviewInProgress)
57
+ blockers.push("Copilot review is still in progress");
58
+ instructions.push(`Do not declare this PR ready to merge: ${blockers.join("; ")}.`);
59
+ }
60
+ // 5. Continuous monitoring pointer (suppressed only when truly ready to merge)
61
+ if (!isReady) {
62
+ instructions.push("This is a one-shot check. For continuous monitoring that acts on these signals automatically, use `/pr-shepherd:monitor`.");
63
+ }
64
+ return instructions;
65
+ }
@@ -1,9 +1,10 @@
1
1
  /**
2
- * Machine-readable JSON reporter.
2
+ * Machine-readable JSON reporter for shepherd check output.
3
3
  *
4
4
  * Slash commands parse this output to extract IDs, status, and actionable items
5
5
  * without string-scraping the human-readable text reporter.
6
6
  */
7
+ import { buildCheckInstructions } from "./check-instructions.mjs";
7
8
  export function formatJson(report) {
8
- return JSON.stringify(report, null, 2);
9
+ return JSON.stringify({ ...report, instructions: buildCheckInstructions(report) }, null, 2);
9
10
  }