pr-shepherd 0.9.0 → 0.10.1

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 (52) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +2 -2
  3. package/bin/checks/triage.mjs +32 -40
  4. package/bin/cli/fence.mjs +4 -0
  5. package/bin/cli/fix-formatter.mjs +31 -25
  6. package/bin/cli/formatters.mjs +33 -51
  7. package/bin/cli/handlers.mjs +1 -1
  8. package/bin/cli/iterate-formatter.mjs +20 -35
  9. package/bin/cli/iterate-lean.mjs +3 -3
  10. package/bin/cli/list-formatters.mjs +32 -0
  11. package/bin/cli/suggestion-renderer.mjs +31 -0
  12. package/bin/cli-parser.iterate-fixtures.mjs +2 -2
  13. package/bin/cli-parser.mjs +27 -3
  14. package/bin/commands/check.mjs +7 -5
  15. package/bin/commands/commit-suggestion.mjs +12 -10
  16. package/bin/commands/iterate/classify.mjs +10 -32
  17. package/bin/commands/iterate/escalate.mjs +3 -12
  18. package/bin/commands/iterate/fix-code.mjs +7 -11
  19. package/bin/commands/iterate/index.mjs +2 -1
  20. package/bin/commands/iterate/render.mjs +30 -21
  21. package/bin/commands/log-file.mjs +7 -0
  22. package/bin/commands/monitor.mjs +7 -4
  23. package/bin/commands/ready-delay.mjs +2 -2
  24. package/bin/commands/resolve-instructions.mjs +5 -2
  25. package/bin/commands/resolve.mjs +1 -20
  26. package/bin/commands/status.mjs +40 -28
  27. package/bin/comments/resolve.mjs +75 -65
  28. package/bin/config.json +2 -2
  29. package/bin/github/batch-parsers.mjs +2 -0
  30. package/bin/github/client.mjs +11 -32
  31. package/bin/github/gql/batch-pr.gql +4 -0
  32. package/bin/github/gql/get-pr-head-sha.gql +7 -0
  33. package/bin/github/http.mjs +166 -11
  34. package/bin/github/queries.mjs +7 -11
  35. package/bin/log/log-file.mjs +88 -0
  36. package/bin/log/session.mjs +100 -0
  37. package/bin/log/setup.mjs +54 -0
  38. package/bin/reporters/agent.mjs +14 -1
  39. package/bin/reporters/text.mjs +82 -102
  40. package/bin/state/base.mjs +5 -0
  41. package/bin/state/fix-attempts.mjs +2 -2
  42. package/bin/state/iterate-stall.mjs +2 -2
  43. package/bin/state/seen-comments.mjs +2 -2
  44. package/bin/suggestions/extract.mjs +15 -0
  45. package/bin/suggestions/parse.mjs +1 -26
  46. package/bin/util/markdown.mjs +7 -0
  47. package/bin/util/worktree.mjs +23 -0
  48. package/package.json +6 -5
  49. package/bin/github/gql/dismiss-review.gql +0 -7
  50. package/bin/github/gql/minimize-comment.gql +0 -7
  51. package/bin/github/gql/multi-pr-status.gql +0 -32
  52. package/bin/github/gql/resolve-thread.gql +0 -7
@@ -1,32 +1,12 @@
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
1
  import { graphql, getPrHeadSha } from "../github/client.mjs";
15
- import { RESOLVE_THREAD_MUTATION, MINIMIZE_COMMENT_MUTATION, DISMISS_REVIEW_MUTATION, } from "../github/queries.mjs";
16
2
  import { loadConfig } from "../config/load.mjs";
17
- /**
18
- * Execute all requested resolve/minimize/dismiss mutations.
19
- *
20
- * @throws Error if `requireSha` is set and GitHub hasn't received that commit
21
- * within the polling window.
22
- */
23
3
  export async function applyResolveOptions(pr, repo, opts) {
24
- // Require --message when dismissing reviews.
25
4
  if ((opts.dismissReviewIds?.length ?? 0) > 0 && !opts.dismissMessage) {
26
5
  throw new Error("--message is required when dismissing reviews");
27
6
  }
28
- // Safety check: verify the push landed before resolving.
29
7
  if (opts.requireSha) {
8
+ // Verify GitHub received the commit before resolving — prevents auto-merge
9
+ // before reviewers see the fix.
30
10
  await waitForSha(pr, repo, opts.requireSha);
31
11
  }
32
12
  const result = {
@@ -35,55 +15,87 @@ export async function applyResolveOptions(pr, repo, opts) {
35
15
  dismissedReviews: [],
36
16
  errors: [],
37
17
  };
38
- await runBatched(opts.resolveThreadIds ?? [], (id) => resolveThread(id), result.resolvedThreads, result.errors);
39
- await runBatched(opts.minimizeCommentIds ?? [], (id) => minimizeComment(id, "RESOLVED"), result.minimizedComments, result.errors);
40
- await runBatched(opts.dismissReviewIds ?? [], (id) => dismissReview(id, opts.dismissMessage ?? ""), result.dismissedReviews, result.errors);
18
+ await bulkApply(opts.resolveThreadIds ?? [], opts.minimizeCommentIds ?? [], opts.dismissReviewIds ?? [], opts.dismissMessage ?? "", result);
41
19
  return result;
42
20
  }
43
- /**
44
- * Auto-resolve a batch of outdated threads via the resolveReviewThread mutation.
45
- */
46
21
  export async function autoResolveOutdated(threadIds) {
47
- const resolved = [];
48
- const errors = [];
49
- await runBatched(threadIds, (id) => resolveThread(id), resolved, errors);
50
- return { resolved, errors };
51
- }
52
- // ---------------------------------------------------------------------------
53
- // Mutation helpers
54
- // ---------------------------------------------------------------------------
55
- async function resolveThread(threadId) {
56
- await graphql(RESOLVE_THREAD_MUTATION, { threadId });
57
- }
58
- async function minimizeComment(commentId, classifier) {
59
- await graphql(MINIMIZE_COMMENT_MUTATION, { commentId, classifier });
22
+ const result = {
23
+ resolvedThreads: [],
24
+ minimizedComments: [],
25
+ dismissedReviews: [],
26
+ errors: [],
27
+ };
28
+ await bulkApply(threadIds, [], [], "", result);
29
+ return { resolved: result.resolvedThreads, errors: result.errors };
60
30
  }
61
- async function dismissReview(reviewId, message) {
62
- await graphql(DISMISS_REVIEW_MUTATION, { reviewId, message });
31
+ // Chunk at 50 so a single oversized list never fails the entire call.
32
+ const BULK_CHUNK_SIZE = 50;
33
+ function buildBulkMutation(resolveIds, minimizeIds, dismissIds, dismissMessage) {
34
+ const ops = [];
35
+ for (let i = 0; i < resolveIds.length; i++) {
36
+ ops.push(` r${i}: resolveReviewThread(input: { threadId: ${JSON.stringify(resolveIds[i])} }) { thread { isResolved } }`);
37
+ }
38
+ for (let i = 0; i < minimizeIds.length; i++) {
39
+ ops.push(` m${i}: minimizeComment(input: { subjectId: ${JSON.stringify(minimizeIds[i])}, classifier: RESOLVED }) { minimizedComment { isMinimized } }`);
40
+ }
41
+ for (let i = 0; i < dismissIds.length; i++) {
42
+ ops.push(` d${i}: dismissPullRequestReview(input: { pullRequestReviewId: ${JSON.stringify(dismissIds[i])}, message: ${JSON.stringify(dismissMessage)} }) { pullRequestReview { state } }`);
43
+ }
44
+ return `mutation BulkApply {\n${ops.join("\n")}\n}`;
63
45
  }
64
- // ---------------------------------------------------------------------------
65
- // Concurrency helper
66
- // ---------------------------------------------------------------------------
67
- async function runBatched(ids, fn, successList, errorList) {
68
- const CONCURRENCY = loadConfig().resolve.concurrency;
69
- // Process in chunks of CONCURRENCY.
70
- for (let i = 0; i < ids.length; i += CONCURRENCY) {
71
- const chunk = ids.slice(i, i + CONCURRENCY);
46
+ async function bulkApply(resolveIds, minimizeIds, dismissIds, dismissMessage, result) {
47
+ const allOps = [
48
+ ...resolveIds.map((id) => ({ kind: "r", id })),
49
+ ...minimizeIds.map((id) => ({ kind: "m", id })),
50
+ ...dismissIds.map((id) => ({ kind: "d", id })),
51
+ ];
52
+ for (let i = 0; i < allOps.length; i += BULK_CHUNK_SIZE) {
53
+ const chunk = allOps.slice(i, i + BULK_CHUNK_SIZE);
72
54
  // eslint-disable-next-line no-await-in-loop
73
- await Promise.all(chunk.map(async (id) => {
74
- try {
75
- await fn(id);
76
- successList.push(id);
77
- }
78
- catch (err) {
79
- errorList.push(`${id}: ${err instanceof Error ? err.message : String(err)}`);
80
- }
81
- }));
55
+ await bulkApplyChunk(chunk.filter((o) => o.kind === "r").map((o) => o.id), chunk.filter((o) => o.kind === "m").map((o) => o.id), chunk.filter((o) => o.kind === "d").map((o) => o.id), dismissMessage, result);
56
+ }
57
+ }
58
+ async function bulkApplyChunk(resolveIds, minimizeIds, dismissIds, dismissMessage, result) {
59
+ if (resolveIds.length === 0 && minimizeIds.length === 0 && dismissIds.length === 0)
60
+ return;
61
+ const doc = buildBulkMutation(resolveIds, minimizeIds, dismissIds, dismissMessage);
62
+ let data;
63
+ try {
64
+ const resp = await graphql(doc, {});
65
+ data = resp.data;
66
+ }
67
+ catch (err) {
68
+ const msg = err instanceof Error ? err.message : String(err);
69
+ for (const id of resolveIds)
70
+ result.errors.push(`${id}: ${msg}`);
71
+ for (const id of minimizeIds)
72
+ result.errors.push(`${id}: ${msg}`);
73
+ for (const id of dismissIds)
74
+ result.errors.push(`${id}: ${msg}`);
75
+ return;
76
+ }
77
+ for (let i = 0; i < resolveIds.length; i++) {
78
+ const r = data[`r${i}`];
79
+ if (r?.thread?.isResolved === true)
80
+ result.resolvedThreads.push(resolveIds[i]);
81
+ else
82
+ result.errors.push(`${resolveIds[i]}: resolve returned null or thread not resolved`);
83
+ }
84
+ for (let i = 0; i < minimizeIds.length; i++) {
85
+ const m = data[`m${i}`];
86
+ if (m?.minimizedComment?.isMinimized === true)
87
+ result.minimizedComments.push(minimizeIds[i]);
88
+ else
89
+ result.errors.push(`${minimizeIds[i]}: minimize returned null or comment not minimized`);
90
+ }
91
+ for (let i = 0; i < dismissIds.length; i++) {
92
+ const d = data[`d${i}`];
93
+ if (d?.pullRequestReview != null)
94
+ result.dismissedReviews.push(dismissIds[i]);
95
+ else
96
+ result.errors.push(`${dismissIds[i]}: dismiss returned null`);
82
97
  }
83
98
  }
84
- // ---------------------------------------------------------------------------
85
- // SHA polling
86
- // ---------------------------------------------------------------------------
87
99
  async function waitForSha(pr, repo, expectedSha) {
88
100
  const { intervalMs: SHA_POLL_INTERVAL_MS, maxAttempts: SHA_POLL_MAX_ATTEMPTS } = loadConfig().resolve.shaPoll;
89
101
  for (let attempt = 0; attempt < SHA_POLL_MAX_ATTEMPTS; attempt++) {
@@ -94,7 +106,6 @@ async function waitForSha(pr, repo, expectedSha) {
94
106
  return;
95
107
  }
96
108
  catch (err) {
97
- // Transient network / 5xx error — keep polling unless this is the last attempt.
98
109
  if (attempt === SHA_POLL_MAX_ATTEMPTS - 1)
99
110
  throw err;
100
111
  }
@@ -103,7 +114,6 @@ async function waitForSha(pr, repo, expectedSha) {
103
114
  await sleep(SHA_POLL_INTERVAL_MS);
104
115
  }
105
116
  }
106
- // Total actual wait = (SHA_POLL_MAX_ATTEMPTS - 1) * SHA_POLL_INTERVAL_MS (no sleep after last poll).
107
117
  throw new Error(`Timeout: GitHub PR #${pr} head SHA has not updated to ${expectedSha} after ${((SHA_POLL_MAX_ATTEMPTS - 1) * SHA_POLL_INTERVAL_MS) / 1000}s. Push may still be in transit — retry shortly.`);
108
118
  }
109
119
  function sleep(ms) {
package/bin/config.json CHANGED
@@ -12,7 +12,6 @@
12
12
  "maxTurns": 50
13
13
  },
14
14
  "resolve": {
15
- "concurrency": 4,
16
15
  "shaPoll": {
17
16
  "intervalMs": 2000,
18
17
  "maxAttempts": 10
@@ -21,7 +20,8 @@
21
20
  },
22
21
  "checks": {
23
22
  "ciTriggerEvents": ["pull_request", "pull_request_target"],
24
- "logTailLines": 200
23
+ "logTailLines": 5,
24
+ "logTailChars": 200
25
25
  },
26
26
  "mergeStatus": {
27
27
  "blockingReviewerLogins": ["copilot"]
@@ -96,6 +96,8 @@ export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes,
96
96
  mergeStateStatus: raw.mergeStateStatus,
97
97
  reviewDecision: (raw.reviewDecision ?? null),
98
98
  headRefOid: raw.headRefOid,
99
+ headRefName: raw.headRefName,
100
+ headRepoWithOwner: raw.headRepository?.nameWithOwner ?? null,
99
101
  baseRefName: raw.baseRefName,
100
102
  reviewRequests,
101
103
  latestReviews,
@@ -8,7 +8,7 @@
8
8
  import { execFile as execFileCb } from "node:child_process";
9
9
  import { promisify } from "node:util";
10
10
  import { graphql as httpGraphql, rest } from "./http.mjs";
11
- import { PR_NUMBER_BY_BRANCH_QUERY } from "./queries.mjs";
11
+ import { PR_NUMBER_BY_BRANCH_QUERY, GET_PR_HEAD_SHA_QUERY } from "./queries.mjs";
12
12
  const execFile = promisify(execFileCb);
13
13
  // ---------------------------------------------------------------------------
14
14
  // GraphQL — thin re-exports so callers don't need to import http.mts directly
@@ -42,38 +42,17 @@ export async function getCurrentPrNumber() {
42
42
  }
43
43
  /** Returns the `headRefOid` (commit SHA) of the given PR as reported by GitHub. */
44
44
  export async function getPrHeadSha(pr, owner, name) {
45
- const data = await rest("GET", `/repos/${owner}/${name}/pulls/${pr}`);
46
- return data.head.sha;
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"}).`);
45
+ const result = await httpGraphql(GET_PR_HEAD_SHA_QUERY, { owner, repo: name, pr });
46
+ const sha = result.data.repository?.pullRequest?.headRefOid;
47
+ if (!sha) {
48
+ const detail = !result.data.repository
49
+ ? "repository not found or access denied"
50
+ : !result.data.repository.pullRequest
51
+ ? "PR not found or access denied"
52
+ : "headRefOid missing";
53
+ throw new Error(`Could not resolve head SHA for ${owner}/${name} PR #${pr}: ${detail}`);
71
54
  }
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("/");
55
+ return sha;
77
56
  }
78
57
  /**
79
58
  * Fetches `mergeable` and `mergeStateStatus` via the REST API.
@@ -19,6 +19,10 @@ query BatchPr(
19
19
  mergeStateStatus
20
20
  reviewDecision
21
21
  headRefOid
22
+ headRefName
23
+ headRepository {
24
+ nameWithOwner
25
+ }
22
26
  baseRefName
23
27
  # Not paginated — capped at 50; PRs with more pending reviewers truncate silently.
24
28
  reviewRequests(last: 50) {
@@ -0,0 +1,7 @@
1
+ query GetPrHeadSha($owner: String!, $repo: String!, $pr: Int!) {
2
+ repository(owner: $owner, name: $repo) {
3
+ pullRequest(number: $pr) {
4
+ headRefOid
5
+ }
6
+ }
7
+ }
@@ -1,5 +1,7 @@
1
1
  import { execFile as execFileCb } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
+ import { appendEntry, nextEntry } from "../log/log-file.mjs";
4
+ import { formatRequestEntry, formatResponseEntry } from "../log/session.mjs";
3
5
  const execFile = promisify(execFileCb);
4
6
  const BASE_URL = "https://api.github.com";
5
7
  // ---------------------------------------------------------------------------
@@ -42,33 +44,87 @@ async function makeHeaders() {
42
44
  function sanitizeBody(body) {
43
45
  return body.replace(/Bearer\s+\S+/gi, "[REDACTED]").slice(0, 200);
44
46
  }
45
- async function requestWithTokenRetry(fn) {
47
+ function redactToken(body) {
48
+ return body.replace(/Bearer\s+\S+/gi, "[REDACTED]");
49
+ }
50
+ function redactUrl(url) {
51
+ try {
52
+ const u = new URL(url);
53
+ return `${u.origin}${u.pathname}`;
54
+ }
55
+ catch {
56
+ return url;
57
+ }
58
+ }
59
+ async function requestWithTokenRetry(fn, t0, onIntermediate) {
46
60
  const res = await fn();
47
61
  if (res.status === 401 && _token !== undefined) {
62
+ onIntermediate?.(401, Math.round(performance.now() - t0));
48
63
  try {
49
64
  await res.arrayBuffer();
50
65
  }
51
66
  catch { }
52
67
  _token = undefined;
53
- return fn();
68
+ const retryT0 = performance.now();
69
+ return { res: await fn(), attempt: 2, retryT0 };
54
70
  }
55
- return res;
71
+ return { res, attempt: 1, retryT0: t0 };
56
72
  }
57
73
  // ---------------------------------------------------------------------------
58
74
  // GraphQL
59
75
  // ---------------------------------------------------------------------------
60
76
  async function graphqlInner(query, vars) {
61
- const res = await requestWithTokenRetry(async () => fetch(`${BASE_URL}/graphql`, {
77
+ const url = `${BASE_URL}/graphql`;
78
+ const n = nextEntry();
79
+ appendEntry(formatRequestEntry({
80
+ n,
81
+ kind: "GraphQL",
82
+ method: "POST",
83
+ url,
84
+ body: { query, variables: vars },
85
+ }));
86
+ const t0 = performance.now();
87
+ const { res, attempt, retryT0 } = await requestWithTokenRetry(async () => fetch(url, {
62
88
  method: "POST",
63
89
  headers: await makeHeaders(),
64
90
  body: JSON.stringify({ query, variables: vars }),
65
- }));
91
+ }), t0, (status, firstDurationMs) => {
92
+ appendEntry(formatResponseEntry({
93
+ n,
94
+ kind: "GraphQL",
95
+ method: "POST",
96
+ url,
97
+ status,
98
+ durationMs: firstDurationMs,
99
+ }));
100
+ });
101
+ const durationMs = Math.round(performance.now() - retryT0);
66
102
  const rateLimit = parseRateLimit(res.headers);
67
103
  if (!res.ok) {
68
104
  const body = await res.text();
105
+ appendEntry(formatResponseEntry({
106
+ n,
107
+ kind: "GraphQL",
108
+ method: "POST",
109
+ url,
110
+ status: res.status,
111
+ durationMs,
112
+ textBody: redactToken(body),
113
+ attempt: attempt > 1 ? attempt : undefined,
114
+ }));
69
115
  throw new Error(`GitHub GraphQL request failed: ${res.status} ${sanitizeBody(body)}`);
70
116
  }
71
117
  const parsed = (await res.json());
118
+ appendEntry(formatResponseEntry({
119
+ n,
120
+ kind: "GraphQL",
121
+ method: "POST",
122
+ url,
123
+ status: res.status,
124
+ durationMs,
125
+ body: parsed,
126
+ attempt: attempt > 1 ? attempt : undefined,
127
+ }));
72
128
  if (parsed.data == null) {
73
129
  const messages = (parsed.errors ?? []).map((e) => e.message).join("; ");
74
130
  throw new Error(`GitHub GraphQL error (no data): ${messages}`);
@@ -91,31 +147,109 @@ export async function graphqlWithRateLimit(query, vars = {}) {
91
147
  // REST
92
148
  // ---------------------------------------------------------------------------
93
149
  export async function rest(method, path, body) {
94
- const res = await requestWithTokenRetry(async () => fetch(`${BASE_URL}${path}`, {
150
+ const url = `${BASE_URL}${path}`;
151
+ const n = nextEntry();
152
+ appendEntry(formatRequestEntry({ n, kind: "REST", method, url, body }));
153
+ const t0 = performance.now();
154
+ const { res, attempt, retryT0 } = await requestWithTokenRetry(async () => fetch(url, {
95
155
  method,
96
156
  headers: await makeHeaders(),
97
157
  body: body !== undefined ? JSON.stringify(body) : undefined,
98
- }));
158
+ }), t0, (status, firstDurationMs) => {
159
+ appendEntry(formatResponseEntry({ n, kind: "REST", method, url, status, durationMs: firstDurationMs }));
160
+ });
161
+ const durationMs = Math.round(performance.now() - retryT0);
162
+ const ct = res.headers.get("content-type") ?? "";
99
163
  if (!res.ok) {
100
164
  const text = await res.text();
165
+ appendEntry(formatResponseEntry({
166
+ n,
167
+ kind: "REST",
168
+ method,
169
+ url,
170
+ status: res.status,
171
+ durationMs,
172
+ textBody: redactToken(text),
173
+ attempt: attempt > 1 ? attempt : undefined,
174
+ }));
101
175
  throw new Error(`GitHub REST ${method} ${path} failed: ${res.status} ${sanitizeBody(text)}`);
102
176
  }
103
- const ct = res.headers.get("content-type") ?? "";
104
177
  if (ct.includes("application/json")) {
105
- return res.json();
178
+ const json = (await res.json());
179
+ appendEntry(formatResponseEntry({
180
+ n,
181
+ kind: "REST",
182
+ method,
183
+ url,
184
+ status: res.status,
185
+ durationMs,
186
+ contentType: ct,
187
+ body: json,
188
+ attempt: attempt > 1 ? attempt : undefined,
189
+ }));
190
+ return json;
106
191
  }
192
+ appendEntry(formatResponseEntry({
193
+ n,
194
+ kind: "REST",
195
+ method,
196
+ url,
197
+ status: res.status,
198
+ durationMs,
199
+ contentType: ct || undefined,
200
+ attempt: attempt > 1 ? attempt : undefined,
201
+ }));
107
202
  return undefined;
108
203
  }
109
204
  export async function restText(path) {
110
- const res = await requestWithTokenRetry(async () => fetch(`${BASE_URL}${path}`, {
205
+ const url = `${BASE_URL}${path}`;
206
+ const n = nextEntry();
207
+ appendEntry(formatRequestEntry({ n, kind: "restText", method: "GET", url }));
208
+ const t0 = performance.now();
209
+ const { res, attempt, retryT0 } = await requestWithTokenRetry(async () => fetch(url, {
111
210
  method: "GET",
112
211
  headers: await makeHeaders(),
113
212
  redirect: "manual",
114
- }));
213
+ }), t0, (status, firstDurationMs) => {
214
+ appendEntry(formatResponseEntry({
215
+ n,
216
+ kind: "restText",
217
+ method: "GET",
218
+ url,
219
+ status,
220
+ durationMs: firstDurationMs,
221
+ }));
222
+ });
223
+ const durationMs = Math.round(performance.now() - retryT0);
115
224
  if (res.status === 301 || res.status === 302 || res.status === 307 || res.status === 308) {
225
+ appendEntry(formatResponseEntry({
226
+ n,
227
+ kind: "restText",
228
+ method: "GET",
229
+ url,
230
+ status: res.status,
231
+ durationMs,
232
+ attempt: attempt > 1 ? attempt : undefined,
233
+ }));
116
234
  const location = res.headers.get("location");
117
235
  if (location) {
236
+ const n2 = nextEntry();
237
+ const logUrl = redactUrl(location);
238
+ appendEntry(formatRequestEntry({ n: n2, kind: "restText", method: "GET", url: logUrl }));
239
+ const t1 = performance.now();
118
240
  const redirectRes = await fetch(location);
241
+ const duration2 = Math.round(performance.now() - t1);
242
+ const clRaw = redirectRes.headers.get("content-length");
243
+ const contentLength = clRaw !== null && Number.isFinite(Number(clRaw)) ? Number(clRaw) : undefined;
244
+ appendEntry(formatResponseEntry({
245
+ n: n2,
246
+ kind: "restText",
247
+ method: "GET",
248
+ url: logUrl,
249
+ status: redirectRes.status,
250
+ durationMs: duration2,
251
+ contentLength,
252
+ }));
119
253
  if (!redirectRes.ok) {
120
254
  throw new Error(`redirect target ${location} failed: ${redirectRes.status}`);
121
255
  }
@@ -124,8 +258,29 @@ export async function restText(path) {
124
258
  }
125
259
  if (!res.ok) {
126
260
  const text = await res.text();
261
+ appendEntry(formatResponseEntry({
262
+ n,
263
+ kind: "restText",
264
+ method: "GET",
265
+ url,
266
+ status: res.status,
267
+ durationMs,
268
+ attempt: attempt > 1 ? attempt : undefined,
269
+ }));
127
270
  throw new Error(`GitHub REST GET ${path} failed: ${res.status} ${sanitizeBody(text)}`);
128
271
  }
272
+ const clRaw = res.headers.get("content-length");
273
+ const contentLength = clRaw !== null && Number.isFinite(Number(clRaw)) ? Number(clRaw) : undefined;
274
+ appendEntry(formatResponseEntry({
275
+ n,
276
+ kind: "restText",
277
+ method: "GET",
278
+ url,
279
+ status: res.status,
280
+ durationMs,
281
+ contentLength,
282
+ attempt: attempt > 1 ? attempt : undefined,
283
+ }));
129
284
  return res.text();
130
285
  }
131
286
  // ---------------------------------------------------------------------------
@@ -1,23 +1,19 @@
1
1
  /**
2
2
  * GraphQL query strings used by pr-shepherd.
3
3
  *
4
- * Query strings live in src/github/gql/*.gql.
5
- * Never inline raw GraphQL strings in .ts source files.
4
+ * Static query strings live in src/github/gql/*.gql and are loaded here.
5
+ * Dynamic documents whose content varies per call (e.g. BulkApply in
6
+ * src/comments/resolve.mts) are built at runtime and are exempt — they
7
+ * cannot be expressed as static files.
6
8
  */
7
9
  import { readFileSync } from "node:fs";
8
10
  import { join } from "node:path";
9
11
  const gql = (name) => readFileSync(join(import.meta.dirname, "gql", name), "utf8");
10
12
  /** The primary batch query that fetches CI + comments + merge status in one round-trip. */
11
13
  export const BATCH_PR_QUERY = gql("batch-pr.gql");
12
- /** Resolve a single review thread. */
13
- export const RESOLVE_THREAD_MUTATION = gql("resolve-thread.gql");
14
- /** Minimize a PR comment (IssueComment). */
15
- export const MINIMIZE_COMMENT_MUTATION = gql("minimize-comment.gql");
16
- /** Dismiss a pull request review. */
17
- export const DISMISS_REVIEW_MUTATION = gql("dismiss-review.gql");
18
- /** Multi-PR status query for `shepherd status PR1 PR2 …`. */
19
- export const MULTI_PR_STATUS_QUERY = gql("multi-pr-status.gql");
20
- /** Paginated version — used when reviewThreads is truncated (totalCount > 100). */
14
+ /** Returns the current head commit SHA for a PR. Used by waitForSha polling. */
15
+ export const GET_PR_HEAD_SHA_QUERY = gql("get-pr-head-sha.gql");
16
+ /** Paginated follow-up for `shepherd status` — used when reviewThreads is truncated (totalCount > 100). */
21
17
  export const MULTI_PR_STATUS_QUERY_WITH_CURSOR = gql("multi-pr-status-paged.gql");
22
18
  /** Look up PR number by branch name (for getCurrentPrNumber). */
23
19
  export const PR_NUMBER_BY_BRANCH_QUERY = gql("pr-number-by-branch.gql");
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Append-only per-worktree markdown log.
3
+ *
4
+ * Log path: $PR_SHEPHERD_STATE_DIR/<owner>-<repo>/worktrees/<basename>-<sha8>.md
5
+ *
6
+ * Always-on by default. Set PR_SHEPHERD_LOG_DISABLED=1 or CI=true to disable.
7
+ * Write failures flip an internal disabled flag so the CLI never crashes because
8
+ * logging failed.
9
+ */
10
+ import { appendFileSync, mkdirSync } from "node:fs";
11
+ import { dirname, join } from "node:path";
12
+ import { resolveStateBase } from "../state/base.mjs";
13
+ import { SAFE_SEGMENT } from "../util/path-segment.mjs";
14
+ import { getWorktreeKey } from "../util/worktree.mjs";
15
+ function computeDisabled() {
16
+ if (process.env["PR_SHEPHERD_LOG_DISABLED"] === "1")
17
+ return true;
18
+ const ci = process.env["CI"];
19
+ return ci !== undefined && ci !== "" && ci !== "0" && ci !== "false";
20
+ }
21
+ let _disabled = computeDisabled();
22
+ let _logPath = null;
23
+ let _entryCounter = 0;
24
+ /** Returns the next monotonically-increasing entry number for the current session. */
25
+ export function nextEntry() {
26
+ return ++_entryCounter;
27
+ }
28
+ export function getLogFilePath(key) {
29
+ const { owner, repo } = key;
30
+ if (!SAFE_SEGMENT.test(owner) || !SAFE_SEGMENT.test(repo)) {
31
+ throw new Error(`Invalid repo key segments: ${owner}/${repo}`);
32
+ }
33
+ const base = resolveStateBase();
34
+ // Worktree key injected at init time; fall back to "unknown" if not yet set.
35
+ const wkey = _worktreeKey ?? "unknown";
36
+ return join(base, `${owner}-${repo}`, "worktrees", `${wkey}.md`);
37
+ }
38
+ let _worktreeKey = null;
39
+ /**
40
+ * Initialize the log for this process. Must be called before appendEntry().
41
+ * Silently disables logging on any error (no git repo, bad repo name, etc.).
42
+ */
43
+ export async function initLog(repoKey) {
44
+ if (_disabled)
45
+ return null;
46
+ try {
47
+ const { owner, repo } = repoKey;
48
+ if (!SAFE_SEGMENT.test(owner) || !SAFE_SEGMENT.test(repo))
49
+ return null;
50
+ _worktreeKey = await getWorktreeKey();
51
+ const path = getLogFilePath(repoKey);
52
+ mkdirSync(dirname(path), { recursive: true });
53
+ _logPath = path;
54
+ return path;
55
+ }
56
+ catch {
57
+ _disabled = true;
58
+ return null;
59
+ }
60
+ }
61
+ /** Append a pre-formatted markdown chunk to the log. No-op if disabled. */
62
+ export function appendEntry(markdown) {
63
+ if (_disabled || _logPath === null)
64
+ return;
65
+ try {
66
+ appendFileSync(_logPath, markdown);
67
+ }
68
+ catch (e) {
69
+ process.stderr.write(`pr-shepherd: log write failed (disabling log): ${String(e)}\n`);
70
+ _disabled = true;
71
+ }
72
+ }
73
+ /** Resolve the log path without initializing (for the log-file subcommand). */
74
+ export async function resolveLogPath(repoKey) {
75
+ if (!SAFE_SEGMENT.test(repoKey.owner) || !SAFE_SEGMENT.test(repoKey.repo)) {
76
+ throw new Error(`Invalid repo key segments: ${repoKey.owner}/${repoKey.repo}`);
77
+ }
78
+ const wkey = await getWorktreeKey();
79
+ const base = resolveStateBase();
80
+ return join(base, `${repoKey.owner}-${repoKey.repo}`, "worktrees", `${wkey}.md`);
81
+ }
82
+ /** Exposed for tests to reset module state. */
83
+ export function _resetLogState() {
84
+ _disabled = computeDisabled();
85
+ _logPath = null;
86
+ _worktreeKey = null;
87
+ _entryCounter = 0;
88
+ }