pr-shepherd 0.9.0 → 0.10.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 (51) 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 +21 -24
  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 +0 -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 +1 -2
  13. package/bin/cli-parser.mjs +27 -3
  14. package/bin/commands/check.mjs +1 -2
  15. package/bin/commands/commit-suggestion.mjs +12 -10
  16. package/bin/commands/iterate/classify.mjs +5 -29
  17. package/bin/commands/iterate/escalate.mjs +3 -12
  18. package/bin/commands/iterate/fix-code.mjs +4 -9
  19. package/bin/commands/iterate/render.mjs +9 -2
  20. package/bin/commands/log-file.mjs +7 -0
  21. package/bin/commands/monitor.mjs +7 -4
  22. package/bin/commands/ready-delay.mjs +2 -2
  23. package/bin/commands/resolve-instructions.mjs +5 -2
  24. package/bin/commands/resolve.mjs +1 -20
  25. package/bin/commands/status.mjs +40 -28
  26. package/bin/comments/resolve.mjs +75 -65
  27. package/bin/config.json +2 -2
  28. package/bin/github/batch-parsers.mjs +2 -0
  29. package/bin/github/client.mjs +11 -32
  30. package/bin/github/gql/batch-pr.gql +4 -0
  31. package/bin/github/gql/get-pr-head-sha.gql +7 -0
  32. package/bin/github/http.mjs +166 -11
  33. package/bin/github/queries.mjs +7 -11
  34. package/bin/log/log-file.mjs +88 -0
  35. package/bin/log/session.mjs +100 -0
  36. package/bin/log/setup.mjs +54 -0
  37. package/bin/reporters/agent.mjs +14 -1
  38. package/bin/reporters/text.mjs +81 -102
  39. package/bin/state/base.mjs +5 -0
  40. package/bin/state/fix-attempts.mjs +2 -2
  41. package/bin/state/iterate-stall.mjs +2 -2
  42. package/bin/state/seen-comments.mjs +2 -2
  43. package/bin/suggestions/extract.mjs +15 -0
  44. package/bin/suggestions/parse.mjs +1 -26
  45. package/bin/util/markdown.mjs +7 -0
  46. package/bin/util/worktree.mjs +23 -0
  47. package/package.json +2 -2
  48. package/bin/github/gql/dismiss-review.gql +0 -7
  49. package/bin/github/gql/minimize-comment.gql +0 -7
  50. package/bin/github/gql/multi-pr-status.gql +0 -32
  51. package/bin/github/gql/resolve-thread.gql +0 -7
@@ -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
+ }
@@ -0,0 +1,100 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { fileURLToPath } from "node:url";
3
+ function readVersion() {
4
+ const pkgUrl = new URL("../../package.json", import.meta.url);
5
+ const pkg = JSON.parse(readFileSync(fileURLToPath(pkgUrl), "utf8"));
6
+ return pkg.version;
7
+ }
8
+ /** Builds the session header markdown block. */
9
+ export function buildSessionHeader(argv) {
10
+ const ts = new Date().toISOString();
11
+ const cmd = argv.slice(2).join(" ") || "(no args)";
12
+ const markdown = `## ${ts} — pr-shepherd ${cmd}\n\n` + `pid: ${process.pid} · version: ${readVersion()}\n\n`;
13
+ return { markdown };
14
+ }
15
+ const _maxBodyRaw = Number(process.env["PR_SHEPHERD_LOG_MAX_BODY"]);
16
+ const MAX_BODY = Number.isFinite(_maxBodyRaw) && _maxBodyRaw > 0 ? _maxBodyRaw : 256 * 1024;
17
+ function truncate(s) {
18
+ if (s.length <= MAX_BODY)
19
+ return s;
20
+ return `${s.slice(0, MAX_BODY)}\n...[truncated ${s.length - MAX_BODY} characters]`;
21
+ }
22
+ function fenceBody(body, lang) {
23
+ const raw = typeof body === "string" ? body : JSON.stringify(body);
24
+ return `\`\`\`${lang}\n${truncate(raw)}\n\`\`\`\n`;
25
+ }
26
+ function extractOperationName(query) {
27
+ const match = /^\s*(?:query|mutation|subscription)\s+(\w+)/m.exec(query);
28
+ return match?.[1] ?? "(anonymous)";
29
+ }
30
+ export function formatRequestEntry(entry) {
31
+ const ts = new Date().toISOString();
32
+ const label = entry.kind === "GraphQL"
33
+ ? `GraphQL request — POST ${entry.url}`
34
+ : entry.kind === "restText"
35
+ ? `restText request — GET ${entry.url}`
36
+ : `REST request — ${entry.method} ${entry.url}`;
37
+ let out = `### #${entry.n} ${label} · ${ts}\n\n`;
38
+ if (entry.kind === "restText") {
39
+ out += `(body omitted: log artifact)\n\n`;
40
+ return out;
41
+ }
42
+ if (entry.body !== undefined && entry.kind === "GraphQL") {
43
+ const { query, variables } = entry.body;
44
+ out += `operation: \`${extractOperationName(query)}\`\n`;
45
+ if (variables && Object.keys(variables).length > 0) {
46
+ out += `variables:\n${fenceBody(variables, "json")}`;
47
+ }
48
+ else {
49
+ // For dynamic documents (e.g. BulkApply) the IDs are inlined as aliases.
50
+ // Count the aliases so the log shows how many operations were batched.
51
+ const aliasCount = (query.match(/^\s+[_A-Za-z]\w*:/gm) ?? []).length;
52
+ if (aliasCount > 0)
53
+ out += `aliases: ${aliasCount}\n`;
54
+ }
55
+ }
56
+ else if (entry.body !== undefined) {
57
+ out += fenceBody(entry.body, "json");
58
+ }
59
+ else {
60
+ out += `(no body)\n`;
61
+ }
62
+ return out + "\n";
63
+ }
64
+ export function formatResponseEntry(entry) {
65
+ const ts = new Date().toISOString();
66
+ const attempt = entry.attempt !== undefined ? ` (attempt ${entry.attempt}/2 after 401)` : "";
67
+ const label = entry.kind === "GraphQL"
68
+ ? `GraphQL response — ${entry.status}${attempt} · ${entry.durationMs}ms`
69
+ : entry.kind === "restText"
70
+ ? `restText response — ${entry.status}${attempt} · ${entry.durationMs}ms`
71
+ : `REST response — ${entry.status}${attempt} · ${entry.durationMs}ms`;
72
+ let out = `### #${entry.n} ${label} · ${ts}\n\n`;
73
+ if (entry.kind === "restText") {
74
+ if (entry.contentLength !== undefined) {
75
+ out += `content-length: ${entry.contentLength} bytes (body not logged)\n\n`;
76
+ }
77
+ else {
78
+ out += `(body not logged)\n\n`;
79
+ }
80
+ return out;
81
+ }
82
+ if (entry.contentType !== undefined) {
83
+ out += `content-type: ${entry.contentType}`;
84
+ if (entry.contentLength !== undefined)
85
+ out += ` · ${entry.contentLength} bytes`;
86
+ out += "\n";
87
+ }
88
+ if (entry.body !== undefined) {
89
+ out += fenceBody(entry.body, "json");
90
+ }
91
+ else if (entry.textBody !== undefined) {
92
+ out += fenceBody(entry.textBody, "");
93
+ }
94
+ return out + "\n";
95
+ }
96
+ export function formatOutputEntry(text, format) {
97
+ const ts = new Date().toISOString();
98
+ const lang = format === "json" ? "json" : "";
99
+ return `### Output (${format}) · ${ts}\n\n${fenceBody(text.trimEnd(), lang)}\n`;
100
+ }
@@ -0,0 +1,54 @@
1
+ import { initLog, appendEntry } from "./log-file.mjs";
2
+ import { buildSessionHeader, formatOutputEntry } from "./session.mjs";
3
+ import { getRepoInfo } from "../github/client.mjs";
4
+ let _done = false;
5
+ function detectFormat(argv) {
6
+ for (let i = 0; i < argv.length; i++) {
7
+ if (argv[i] === "--format=json")
8
+ return "json";
9
+ if (argv[i] === "--format" && argv[i + 1] === "json")
10
+ return "json";
11
+ }
12
+ return "text";
13
+ }
14
+ /**
15
+ * Initialize the per-worktree log, write the session header, and install a
16
+ * stdout tee that routes all CLI output to the log. No-op after the first call.
17
+ * Silently skips logging when not in a git repo or on any other error.
18
+ */
19
+ export async function setupLog(argv) {
20
+ if (_done)
21
+ return;
22
+ _done = true;
23
+ try {
24
+ const { owner, name } = await getRepoInfo();
25
+ const log = await initLog({ owner, repo: name });
26
+ if (!log)
27
+ return;
28
+ }
29
+ catch {
30
+ return;
31
+ }
32
+ try {
33
+ const { markdown: header } = buildSessionHeader(argv);
34
+ appendEntry(header);
35
+ const format = detectFormat(argv);
36
+ const origWrite = process.stdout.write.bind(process.stdout);
37
+ process.stdout.write = (chunk, encodingOrCb, cb) => {
38
+ try {
39
+ const text = typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
40
+ if (text.length > 0)
41
+ appendEntry(formatOutputEntry(text, format));
42
+ }
43
+ catch {
44
+ // Best-effort: logging must never interfere with CLI output.
45
+ }
46
+ return typeof encodingOrCb === "function"
47
+ ? origWrite(chunk, encodingOrCb)
48
+ : origWrite(chunk, encodingOrCb, cb);
49
+ };
50
+ }
51
+ catch {
52
+ // Best-effort: logging setup must never prevent the CLI from running.
53
+ }
54
+ }
@@ -7,8 +7,21 @@
7
7
  * detailsUrl is preserved in AgentCheck as a fallback for external status checks.
8
8
  * The original domain types are preserved for check command output.
9
9
  */
10
+ import { extractSuggestion } from "../suggestions/extract.mjs";
10
11
  export function toAgentThread(t) {
11
- return { id: t.id, path: t.path, line: t.line, author: t.author, body: t.body, url: t.url };
12
+ const suggestion = extractSuggestion(t) ?? undefined;
13
+ return {
14
+ id: t.id,
15
+ path: t.path,
16
+ line: t.line,
17
+ ...(t.line !== null &&
18
+ t.startLine !== null &&
19
+ t.startLine !== t.line && { startLine: t.startLine }),
20
+ author: t.author,
21
+ body: t.body,
22
+ url: t.url,
23
+ ...(suggestion !== undefined && { suggestion }),
24
+ };
12
25
  }
13
26
  export function toAgentComment(c) {
14
27
  return { id: c.id, author: c.author, body: c.body, url: c.url };