Skip to content

Commit 1bf4a64

Browse files
committed
cli(doctor --issue): cap body at GitHub's 64KB limit + dedicated errors section
GitHub issue bodies have a hard ~64KB limit. The pre-fix bundlers dumped the full 400-line log tail unconditionally, so long-running sessions produced reports that exceeded the limit. Worse, when an issue body was over budget the GitHub API rejected the whole payload — the bundler completed, but the issue couldn't be opened. Two changes: 1. New "## Recent errors (last 20, sanitized)" section above the main log tail. The body cap (point 2 below) only shrinks the main log block, so promoting recent errors into their own section guarantees they survive truncation. We scan the last 4000 log lines for ERROR-shaped patterns: - `failed:` (Magic Context's sessionLog convention) - `Error:` / `TypeError:` / typed-error rendering - `EMERGENCY` (95% abort marker) - `exception` (generic throw text) - V8/JSC stack-trace frames (` at SomeFn (file:line:col)`) The `failed:` colon suffix is deliberate — it avoids the common false positive where a sessionLog message includes "failed" as past-tense status (e.g. `historian: 12 published; 0 failed` is telemetry, not an error). Stack frames are kept so the agent reading the issue sees enough context to identify the call site. 2. New `capBodyToGithubLimit(body, maxBytes = 60_000)` helper that enforces the byte budget. Budget is 60KB (4KB headroom below GH's 64KB cap for URL encoding via `gh issue create --web` and future minor section growth). When the body exceeds budget, the helper rewrites the main `## Log (last N lines, sanitized)` fenced block — drops oldest lines first (keeps newest) until the body fits, prefixes a visible `[truncated for GitHub 64KB limit — older log lines dropped]` marker. Diagnostics, configuration, historian failure signals, and recent-errors sections are preserved intact. UTF-8 byte length is the budget. The fallback path (no log heading found) uses a code-point-aware byte truncator so cutting mid-character doesn't produce U+FFFD replacement bytes that push the output back over budget. Both helpers live in a new shared `issue-body.ts` so OpenCode and Pi share the same budget, same truncation marker, and same precision/false-positive tradeoff on what counts as an error line. Tests: 14 new in `issue-body.test.ts` covering: - Documented sessionLog shapes + "0 failed" false-positive - V8 stack-trace frame matching - Chronological-order output / newest-first selection - Limit cap behavior - Pass-through under budget - Truncation when over budget (correct marker, oldest dropped first) - Recent errors section survives truncation - Description/Environment sections preserved - Default MAX_GITHUB_BODY_BYTES applied - UTF-8-safe fallback truncation CLI suite: 138 pass / 2 skip / 0 fail (+14 from 124 baseline). Typecheck, lint, format, build all clean across all 3 packages.
1 parent aa1356b commit 1bf4a64

4 files changed

Lines changed: 457 additions & 2 deletions

File tree

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
import { describe, expect, it } from "bun:test";
2+
import { capBodyToGithubLimit, extractRecentErrors, MAX_GITHUB_BODY_BYTES } from "./issue-body";
3+
4+
describe("extractRecentErrors", () => {
5+
it("matches the documented sessionLog error shapes", () => {
6+
const log = [
7+
"2026-05-20 12:00:00 [INFO] transform completed in 42ms",
8+
"2026-05-20 12:00:01 [INFO] historian: 12 compartments published; 0 failed", // telemetry, NOT an error
9+
"2026-05-20 12:00:02 transform failed: SQLITE_BUSY",
10+
"2026-05-20 12:00:03 historian prompt failed: connection refused",
11+
"2026-05-20 12:00:04 Error: Connection reset",
12+
"2026-05-20 12:00:05 TypeError: cannot read property 'foo' of undefined",
13+
"2026-05-20 12:00:06 EMERGENCY: aborting session ses_abc",
14+
"2026-05-20 12:00:07 some other info line",
15+
"2026-05-20 12:00:08 caught exception during cleanup",
16+
].join("\n");
17+
18+
const matches = extractRecentErrors(log, 20);
19+
20+
// Should include real errors but NOT the past-tense "0 failed" telemetry.
21+
expect(matches).toContain("2026-05-20 12:00:02 transform failed: SQLITE_BUSY");
22+
expect(matches).toContain(
23+
"2026-05-20 12:00:03 historian prompt failed: connection refused",
24+
);
25+
expect(matches).toContain("2026-05-20 12:00:04 Error: Connection reset");
26+
expect(matches).toContain(
27+
"2026-05-20 12:00:05 TypeError: cannot read property 'foo' of undefined",
28+
);
29+
expect(matches).toContain("2026-05-20 12:00:06 EMERGENCY: aborting session ses_abc");
30+
expect(matches).toContain("2026-05-20 12:00:08 caught exception during cleanup");
31+
expect(matches).not.toContain(
32+
"2026-05-20 12:00:01 [INFO] historian: 12 compartments published; 0 failed",
33+
);
34+
expect(matches).not.toContain("2026-05-20 12:00:07 some other info line");
35+
});
36+
37+
it("matches V8 stack-trace frames", () => {
38+
const log = [
39+
"Error: thing broke",
40+
" at SomeFn (file:///foo.ts:42:5)",
41+
" at processTransform (file:///bar.ts:13:9)",
42+
" at file:///baz.ts:7:1",
43+
].join("\n");
44+
45+
const matches = extractRecentErrors(log, 20);
46+
// All four lines qualify — the Error and three stack frames.
47+
expect(matches.length).toBe(4);
48+
});
49+
50+
it("returns matches in chronological order", () => {
51+
const log = [
52+
"transform failed: first error",
53+
"info noise",
54+
"transform failed: second error",
55+
"info noise",
56+
"transform failed: third error",
57+
].join("\n");
58+
59+
const matches = extractRecentErrors(log, 10);
60+
expect(matches).toEqual([
61+
"transform failed: first error",
62+
"transform failed: second error",
63+
"transform failed: third error",
64+
]);
65+
});
66+
67+
it("caps at the requested limit (newest-first selection, oldest-first output)", () => {
68+
const lines: string[] = [];
69+
for (let i = 0; i < 50; i += 1) {
70+
lines.push(`transform failed: error ${i}`);
71+
}
72+
const matches = extractRecentErrors(lines.join("\n"), 5);
73+
// We asked for 5; the 5 NEWEST errors should be returned, in
74+
// chronological (oldest-first) order: 45, 46, 47, 48, 49.
75+
expect(matches.length).toBe(5);
76+
expect(matches[0]).toBe("transform failed: error 45");
77+
expect(matches[4]).toBe("transform failed: error 49");
78+
});
79+
80+
it("returns empty array when no errors found", () => {
81+
const log = ["info line 1", "info line 2", "transform completed in 42ms"].join("\n");
82+
expect(extractRecentErrors(log, 20)).toEqual([]);
83+
});
84+
85+
it("handles empty input gracefully", () => {
86+
expect(extractRecentErrors("", 20)).toEqual([]);
87+
});
88+
});
89+
90+
describe("capBodyToGithubLimit", () => {
91+
/**
92+
* Build a synthetic issue body shaped like the real bundlers produce —
93+
* a few small sections followed by a giant `## Log (last N lines,
94+
* sanitized)` fenced block. We make the log section large enough to
95+
* exceed the requested budget.
96+
*/
97+
function makeBody(opts: { logLineCount: number; lineSize?: number }): string {
98+
const lineSize = opts.lineSize ?? 80;
99+
const logLines: string[] = [];
100+
for (let i = 0; i < opts.logLineCount; i += 1) {
101+
// Each line is prefixed with its index so we can verify which
102+
// ones get dropped vs kept after truncation.
103+
const prefix = `LINE${String(i).padStart(6, "0")}: `;
104+
const padding = "x".repeat(Math.max(0, lineSize - prefix.length));
105+
logLines.push(prefix + padding);
106+
}
107+
108+
return [
109+
"## Description",
110+
"Test description for the cap helper.",
111+
"",
112+
"## Environment",
113+
"- Plugin: v0.21.5",
114+
"",
115+
"## Recent errors (last 20, sanitized)",
116+
"```",
117+
"transform failed: critical error 1",
118+
"transform failed: critical error 2",
119+
"```",
120+
"",
121+
"## Log (last 400 lines, sanitized)",
122+
"```",
123+
logLines.join("\n"),
124+
"```",
125+
].join("\n");
126+
}
127+
128+
it("returns body unchanged when already within budget", () => {
129+
const body = makeBody({ logLineCount: 20 });
130+
const capped = capBodyToGithubLimit(body, 100_000);
131+
expect(capped).toBe(body);
132+
});
133+
134+
it("truncates the main log section when body exceeds budget", () => {
135+
const body = makeBody({ logLineCount: 5000, lineSize: 200 });
136+
const originalBytes = Buffer.byteLength(body, "utf8");
137+
138+
const capped = capBodyToGithubLimit(body, 60_000);
139+
const cappedBytes = Buffer.byteLength(capped, "utf8");
140+
141+
// The body must now fit the budget.
142+
expect(cappedBytes).toBeLessThanOrEqual(60_000);
143+
// And it must actually be smaller than the input (proving truncation
144+
// happened, not just trivially passed-through).
145+
expect(cappedBytes).toBeLessThan(originalBytes);
146+
});
147+
148+
it("preserves the Recent errors section after truncation", () => {
149+
const body = makeBody({ logLineCount: 5000, lineSize: 200 });
150+
const capped = capBodyToGithubLimit(body, 60_000);
151+
152+
// The errors section MUST survive truncation — that's the whole
153+
// point of separating it from the main log block.
154+
expect(capped).toContain("## Recent errors (last 20, sanitized)");
155+
expect(capped).toContain("transform failed: critical error 1");
156+
expect(capped).toContain("transform failed: critical error 2");
157+
});
158+
159+
it("inserts the truncation marker when log lines are dropped", () => {
160+
const body = makeBody({ logLineCount: 5000, lineSize: 200 });
161+
const capped = capBodyToGithubLimit(body, 60_000);
162+
163+
expect(capped).toContain("[truncated for GitHub 64KB limit");
164+
});
165+
166+
it("drops oldest log lines first (keeps newest)", () => {
167+
const body = makeBody({ logLineCount: 5000, lineSize: 200 });
168+
const capped = capBodyToGithubLimit(body, 60_000);
169+
170+
// The last log line (LINE004999) should be preserved — it's the
171+
// newest and the most relevant.
172+
expect(capped).toContain("LINE004999:");
173+
174+
// The first log line (LINE000000) should be gone — it's the oldest.
175+
expect(capped).not.toContain("LINE000000:");
176+
});
177+
178+
it("preserves the Description and Environment sections", () => {
179+
const body = makeBody({ logLineCount: 5000, lineSize: 200 });
180+
const capped = capBodyToGithubLimit(body, 60_000);
181+
182+
expect(capped).toContain("## Description");
183+
expect(capped).toContain("Test description for the cap helper.");
184+
expect(capped).toContain("## Environment");
185+
expect(capped).toContain("- Plugin: v0.21.5");
186+
});
187+
188+
it("uses MAX_GITHUB_BODY_BYTES as the default budget", () => {
189+
// Building a body well past 60KB to force the default-budget path
190+
// to engage. 80 chars * 5000 lines = ~400KB just for the log block.
191+
const body = makeBody({ logLineCount: 5000, lineSize: 80 });
192+
const capped = capBodyToGithubLimit(body);
193+
expect(Buffer.byteLength(capped, "utf8")).toBeLessThanOrEqual(MAX_GITHUB_BODY_BYTES);
194+
});
195+
196+
it("falls back to raw byte truncation when log heading is missing", () => {
197+
// Synthetic input with no `## Log (last` heading — exercises the
198+
// defensive fallback path. We pad with non-ASCII to ensure UTF-8
199+
// boundary handling doesn't corrupt the slice.
200+
const body = `## Other\n${"ü".repeat(50_000)}\n## End`;
201+
const capped = capBodyToGithubLimit(body, 10_000);
202+
expect(Buffer.byteLength(capped, "utf8")).toBeLessThanOrEqual(10_000);
203+
expect(capped).toContain("[truncated for GitHub 64KB limit]");
204+
});
205+
});

0 commit comments

Comments
 (0)