pr-shepherd 0.8.1 → 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 (60) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +123 -21
  3. package/bin/checks/classify.mjs +2 -1
  4. package/bin/checks/triage.mjs +63 -61
  5. package/bin/cli/args.mjs +9 -3
  6. package/bin/cli/fence.mjs +4 -0
  7. package/bin/cli/fix-formatter.mjs +45 -19
  8. package/bin/cli/formatters.mjs +61 -44
  9. package/bin/cli/handlers.mjs +5 -4
  10. package/bin/cli/iterate-formatter.mjs +51 -45
  11. package/bin/cli/iterate-lean.mjs +95 -0
  12. package/bin/cli/list-formatters.mjs +32 -0
  13. package/bin/cli/suggestion-renderer.mjs +31 -0
  14. package/bin/cli-parser.iterate-fixtures.mjs +6 -5
  15. package/bin/cli-parser.mjs +27 -3
  16. package/bin/commands/check-status.mjs +7 -4
  17. package/bin/commands/check.mjs +39 -3
  18. package/bin/commands/commit-suggestion.mjs +12 -10
  19. package/bin/commands/iterate/classify.mjs +10 -54
  20. package/bin/commands/iterate/escalate.mjs +5 -14
  21. package/bin/commands/iterate/fix-code.mjs +15 -16
  22. package/bin/commands/iterate/helpers.mjs +8 -5
  23. package/bin/commands/iterate/index.mjs +18 -12
  24. package/bin/commands/iterate/render.mjs +28 -12
  25. package/bin/commands/iterate/stall.mjs +1 -1
  26. package/bin/commands/log-file.mjs +7 -0
  27. package/bin/commands/monitor.mjs +7 -4
  28. package/bin/commands/ready-delay.mjs +2 -2
  29. package/bin/commands/resolve-instructions.mjs +14 -5
  30. package/bin/commands/resolve.mjs +38 -37
  31. package/bin/commands/status.mjs +41 -29
  32. package/bin/comments/resolve.mjs +75 -65
  33. package/bin/config.json +4 -7
  34. package/bin/github/batch-parsers.mjs +4 -0
  35. package/bin/github/client.mjs +11 -32
  36. package/bin/github/gql/batch-pr.gql +6 -0
  37. package/bin/github/gql/get-pr-head-sha.gql +7 -0
  38. package/bin/github/http.mjs +166 -11
  39. package/bin/github/queries.mjs +7 -11
  40. package/bin/log/log-file.mjs +88 -0
  41. package/bin/log/session.mjs +100 -0
  42. package/bin/log/setup.mjs +54 -0
  43. package/bin/reporters/agent.mjs +21 -13
  44. package/bin/reporters/check-instructions.mjs +9 -14
  45. package/bin/reporters/text.mjs +93 -105
  46. package/bin/state/base.mjs +5 -0
  47. package/bin/state/fix-attempts.mjs +2 -2
  48. package/bin/state/iterate-stall.mjs +2 -2
  49. package/bin/state/seen-comments.mjs +76 -0
  50. package/bin/suggestions/extract.mjs +15 -0
  51. package/bin/suggestions/parse.mjs +1 -26
  52. package/bin/types/report.mjs +0 -1
  53. package/bin/util/markdown.mjs +7 -0
  54. package/bin/util/worktree.mjs +23 -0
  55. package/package.json +2 -2
  56. package/bin/commands/iterate/steps.mjs +0 -31
  57. package/bin/github/gql/dismiss-review.gql +0 -7
  58. package/bin/github/gql/minimize-comment.gql +0 -7
  59. package/bin/github/gql/multi-pr-status.gql +0 -32
  60. package/bin/github/gql/resolve-thread.gql +0 -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,39 +7,47 @@
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 };
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
- return { id: c.id, author: c.author, body: c.body };
27
+ return { id: c.id, author: c.author, body: c.body, url: c.url };
15
28
  }
16
29
  export function toAgentCheck(c) {
17
30
  return {
18
31
  name: c.name,
19
32
  runId: c.runId,
20
33
  detailsUrl: c.detailsUrl,
21
- failureKind: c.failureKind,
22
34
  ...(c.workflowName !== undefined && { workflowName: c.workflowName }),
35
+ ...(c.jobName !== undefined && { jobName: c.jobName }),
23
36
  ...(c.failedStep !== undefined && { failedStep: c.failedStep }),
24
37
  ...(c.summary !== undefined && { summary: c.summary }),
38
+ ...(c.logTail !== undefined && { logTail: c.logTail }),
25
39
  };
26
40
  }
27
41
  /**
28
- * Project and deduplicate checks so the agent makes one `gh run view` call
29
- * per run (dedup by runId) and skips duplicate external status checks (dedup
30
- * by name when runId is null).
42
+ * Project failing checks for the agent. Deduplicates only null-runId external
43
+ * checks by name — when runId is present each check may have a distinct job and
44
+ * log tail, so they are all kept.
31
45
  */
32
46
  export function toAgentChecks(checks) {
33
- const seenRunIds = new Set();
34
47
  const seenNames = new Set();
35
48
  const result = [];
36
49
  for (const c of checks) {
37
- if (c.runId !== null) {
38
- if (seenRunIds.has(c.runId))
39
- continue;
40
- seenRunIds.add(c.runId);
41
- }
42
- else {
50
+ if (c.runId === null) {
43
51
  if (seenNames.has(c.name))
44
52
  continue;
45
53
  seenNames.add(c.name);
@@ -26,20 +26,15 @@ export function buildCheckInstructions(report) {
26
26
  }
27
27
  // 3. CI budget policy — one instruction per failing check
28
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
- }
29
+ const stepHint = c.failedStep ? ` (failed step: \`${c.failedStep}\`)` : "";
30
+ const diagnosisHint = c.runId
31
+ ? c.logTail !== undefined
32
+ ? `examine the log tail${stepHint} — if transient, run \`gh run rerun ${c.runId} --failed\`; otherwise apply a fix`
33
+ : `run \`gh run view ${c.runId} --log-failed\`${stepHint} to diagnose — if transient, rerun with \`gh run rerun ${c.runId} --failed\`; otherwise apply a fix`
34
+ : c.detailsUrl
35
+ ? `open the check details (${c.detailsUrl}) to diagnose the failure`
36
+ : `no run or details URL available — escalate to a human`;
37
+ instructions.push(`Failing check: \`${c.name}\` — ${diagnosisHint}.`);
43
38
  }
44
39
  // 4. Ready-to-merge gate
45
40
  const isClean = mergeStatus.mergeStateStatus === "CLEAN";