@open-mercato/cezar 0.8.0-pr550.472 → 0.8.0-pr552.477

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 (33) hide show
  1. package/dist/server/forge/github.d.ts +244 -1
  2. package/dist/server/forge/github.js +310 -4
  3. package/dist/server/forge/github.js.map +1 -1
  4. package/dist/server/forge/types.d.ts +48 -1
  5. package/dist/server/github.d.ts +1 -1
  6. package/package.json +1 -1
  7. package/web/dist/assets/{chunk-BO2N2NFS-BN7h-q1E.js → chunk-BO2N2NFS-DtrdTCWa.js} +4 -4
  8. package/web/dist/assets/{compare-variants-DsOgM5ib.js → compare-variants-D7O7BVMV.js} +1 -1
  9. package/web/dist/assets/{diff-view-CquZs1fE.js → diff-view-B-49MZ8r.js} +1 -1
  10. package/web/dist/assets/{git-toolbar-Do9adSg-.js → git-toolbar-DFNvlSQN.js} +1 -1
  11. package/web/dist/assets/github-sfT3Xs-a.js +1 -0
  12. package/web/dist/assets/{highlighted-body-OFNGDK62-CJIHtxqW.js → highlighted-body-OFNGDK62-BuFJwunK.js} +1 -1
  13. package/web/dist/assets/{index-CieIT6P1.js → index-D9M-o4Ya.js} +3 -3
  14. package/web/dist/assets/index-kt0DXo2U.css +2 -0
  15. package/web/dist/assets/markdown-CAqR0DUp.js +2 -0
  16. package/web/dist/assets/mermaid-GHXKKRXX-CN2zQue0.js +1 -0
  17. package/web/dist/assets/{prompt-templates-kzhWan9W.js → prompt-templates-Bknb2TI0.js} +1 -1
  18. package/web/dist/assets/{repo-git-BIevPDKj.js → repo-git-CyyVXZpk.js} +1 -1
  19. package/web/dist/assets/{run-diff-9gl2gO8Y.js → run-diff-DttEgfs8.js} +1 -1
  20. package/web/dist/assets/{run-header-R9nC4EQa.js → run-header-BsSusQCM.js} +1 -1
  21. package/web/dist/assets/{skill-detail-BOxhR_E1.js → skill-detail-D_Jsx47i.js} +1 -1
  22. package/web/dist/assets/{skills-D7qavwOV.js → skills-B3fYhNam.js} +1 -1
  23. package/web/dist/assets/{task-changes-Cw7ikBog.js → task-changes-BGFBSp00.js} +1 -1
  24. package/web/dist/assets/{task-commits-B0Xu6PX5.js → task-commits-D1Az5aYZ.js} +1 -1
  25. package/web/dist/assets/{task-files-B6Jt73fx.js → task-files-Cfro5YPa.js} +1 -1
  26. package/web/dist/assets/{task-thread-DfMbZqxH.js → task-thread-CraQE9L5.js} +1 -1
  27. package/web/dist/assets/{use-desktop-C2q3znqh.js → use-desktop-B-Ye1WHY.js} +2 -2
  28. package/web/dist/assets/{workflows-BO8d6uJJ.js → workflows-PsYykOjX.js} +1 -1
  29. package/web/dist/index.html +6 -6
  30. package/web/dist/assets/github-C0x8j0WW.js +0 -1
  31. package/web/dist/assets/index-KeezEgXG.css +0 -2
  32. package/web/dist/assets/markdown-DxmCMr30.js +0 -1
  33. package/web/dist/assets/mermaid-GHXKKRXX-DghlHZS5.js +0 -1
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import type { DraftPrInput, DraftPrOutcome, ForgeAvailability, ForgeComment, ForgeCommentsData, ForgeDriver, ForgeItem } from './types.js';
2
+ import type { DraftPrInput, DraftPrOutcome, ForgeAvailability, ForgeComment, ForgeCommentsData, ForgeDriver, ForgeItem, ForgeTimelineEvent, ForgeTimelineEventKind } from './types.js';
3
3
  /** One GitHub issue or pull request, flattened for the cockpit's GitHub tab. */
4
4
  export type GithubItem = ForgeItem;
5
5
  export interface GithubData {
@@ -71,6 +71,192 @@ export declare function parseOwnerName(nameWithOwner: string): {
71
71
  } | null;
72
72
  export declare const GH_MAX_LIMIT = 1000;
73
73
  export declare function fetchGithub(repoRoot: string, refresh?: boolean, limit?: number): Promise<GithubData>;
74
+ /** The event kinds rendered in v1 — an allowlist, so a new GitHub event type is dropped rather
75
+ * than rendered and can never crash or clutter the thread. Real timelines carry plenty that
76
+ * github.com itself doesn't surface (`subscribed`, `mentioned`, `review_requested`).
77
+ *
78
+ * `reviewed` is deliberately absent: timeline `reviewed` rows DO carry a body and would work,
79
+ * but `/pulls/{n}/reviews` is already normalized, chipped and empty-body-filtered, so sourcing
80
+ * both would render every review twice. */
81
+ export declare const TIMELINE_EVENT_KINDS: Set<ForgeTimelineEventKind>;
82
+ /** Events get their OWN cap, independent of `THREAD_ENTRY_CAP`. A combined cap would mean a
83
+ * thread with 150 comments and 100 events returns ~120 comments — silently removing contents
84
+ * from a §2-protected response. */
85
+ export declare const TIMELINE_EVENT_CAP = 200;
86
+ /** `gh api --paginate` has no page limit, so the timeline fetch hand-rolls a bounded loop. */
87
+ export declare const TIMELINE_MAX_PAGES = 10;
88
+ /** ONE budget shared by every page. `gh()`'s timeout is per invocation, so ten pages at the 15 s
89
+ * default would put the ceiling at 150 s — an order of magnitude worse than the single
90
+ * `--paginate` spawn this replaces. The loop tracks a deadline and passes what's left. */
91
+ export declare const TIMELINE_BUDGET_MS = 15000;
92
+ /** Never spawn a page that cannot finish. A bare `remaining <= 0` guard catches only the exact
93
+ * boundary; the realistic case is 300 ms left, which spawns `gh` with a 300 ms timeout, throws,
94
+ * and is indistinguishable from a real endpoint failure. */
95
+ export declare const TIMELINE_MIN_PAGE_MS = 2000;
96
+ /** One timeline row. `event` stays a loose `z.string()` so unknown kinds parse and are dropped by
97
+ * the allowlist rather than throwing the whole page. Extras are stripped by default — notably
98
+ * `author.email`, which is read for nothing and must not reach the wire type. */
99
+ export declare const ghTimelineEventSchema: z.ZodObject<{
100
+ event: z.ZodString;
101
+ id: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
102
+ node_id: z.ZodOptional<z.ZodNullable<z.ZodString>>;
103
+ created_at: z.ZodOptional<z.ZodNullable<z.ZodString>>;
104
+ actor: z.ZodOptional<z.ZodNullable<z.ZodObject<{
105
+ login: z.ZodString;
106
+ avatar_url: z.ZodOptional<z.ZodNullable<z.ZodString>>;
107
+ }, "strip", z.ZodTypeAny, {
108
+ login: string;
109
+ avatar_url?: string | null | undefined;
110
+ }, {
111
+ login: string;
112
+ avatar_url?: string | null | undefined;
113
+ }>>>;
114
+ url: z.ZodOptional<z.ZodNullable<z.ZodString>>;
115
+ html_url: z.ZodOptional<z.ZodNullable<z.ZodString>>;
116
+ sha: z.ZodOptional<z.ZodNullable<z.ZodString>>;
117
+ message: z.ZodOptional<z.ZodNullable<z.ZodString>>;
118
+ author: z.ZodOptional<z.ZodNullable<z.ZodObject<{
119
+ name: z.ZodOptional<z.ZodNullable<z.ZodString>>;
120
+ date: z.ZodOptional<z.ZodNullable<z.ZodString>>;
121
+ }, "strip", z.ZodTypeAny, {
122
+ name?: string | null | undefined;
123
+ date?: string | null | undefined;
124
+ }, {
125
+ name?: string | null | undefined;
126
+ date?: string | null | undefined;
127
+ }>>>;
128
+ label: z.ZodOptional<z.ZodNullable<z.ZodObject<{
129
+ name: z.ZodString;
130
+ color: z.ZodOptional<z.ZodNullable<z.ZodString>>;
131
+ }, "strip", z.ZodTypeAny, {
132
+ name: string;
133
+ color?: string | null | undefined;
134
+ }, {
135
+ name: string;
136
+ color?: string | null | undefined;
137
+ }>>>;
138
+ assignee: z.ZodOptional<z.ZodNullable<z.ZodObject<{
139
+ login: z.ZodString;
140
+ }, "strip", z.ZodTypeAny, {
141
+ login: string;
142
+ }, {
143
+ login: string;
144
+ }>>>;
145
+ rename: z.ZodOptional<z.ZodNullable<z.ZodObject<{
146
+ from: z.ZodOptional<z.ZodNullable<z.ZodString>>;
147
+ to: z.ZodOptional<z.ZodNullable<z.ZodString>>;
148
+ }, "strip", z.ZodTypeAny, {
149
+ from?: string | null | undefined;
150
+ to?: string | null | undefined;
151
+ }, {
152
+ from?: string | null | undefined;
153
+ to?: string | null | undefined;
154
+ }>>>;
155
+ source: z.ZodOptional<z.ZodNullable<z.ZodObject<{
156
+ issue: z.ZodOptional<z.ZodNullable<z.ZodObject<{
157
+ number: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
158
+ title: z.ZodOptional<z.ZodNullable<z.ZodString>>;
159
+ html_url: z.ZodOptional<z.ZodNullable<z.ZodString>>;
160
+ pull_request: z.ZodOptional<z.ZodNullable<z.ZodUnknown>>;
161
+ }, "strip", z.ZodTypeAny, {
162
+ number?: number | null | undefined;
163
+ title?: string | null | undefined;
164
+ html_url?: string | null | undefined;
165
+ pull_request?: unknown;
166
+ }, {
167
+ number?: number | null | undefined;
168
+ title?: string | null | undefined;
169
+ html_url?: string | null | undefined;
170
+ pull_request?: unknown;
171
+ }>>>;
172
+ }, "strip", z.ZodTypeAny, {
173
+ issue?: {
174
+ number?: number | null | undefined;
175
+ title?: string | null | undefined;
176
+ html_url?: string | null | undefined;
177
+ pull_request?: unknown;
178
+ } | null | undefined;
179
+ }, {
180
+ issue?: {
181
+ number?: number | null | undefined;
182
+ title?: string | null | undefined;
183
+ html_url?: string | null | undefined;
184
+ pull_request?: unknown;
185
+ } | null | undefined;
186
+ }>>>;
187
+ }, "strip", z.ZodTypeAny, {
188
+ event: string;
189
+ message?: string | null | undefined;
190
+ author?: {
191
+ name?: string | null | undefined;
192
+ date?: string | null | undefined;
193
+ } | null | undefined;
194
+ id?: number | null | undefined;
195
+ label?: {
196
+ name: string;
197
+ color?: string | null | undefined;
198
+ } | null | undefined;
199
+ url?: string | null | undefined;
200
+ rename?: {
201
+ from?: string | null | undefined;
202
+ to?: string | null | undefined;
203
+ } | null | undefined;
204
+ source?: {
205
+ issue?: {
206
+ number?: number | null | undefined;
207
+ title?: string | null | undefined;
208
+ html_url?: string | null | undefined;
209
+ pull_request?: unknown;
210
+ } | null | undefined;
211
+ } | null | undefined;
212
+ sha?: string | null | undefined;
213
+ created_at?: string | null | undefined;
214
+ html_url?: string | null | undefined;
215
+ node_id?: string | null | undefined;
216
+ actor?: {
217
+ login: string;
218
+ avatar_url?: string | null | undefined;
219
+ } | null | undefined;
220
+ assignee?: {
221
+ login: string;
222
+ } | null | undefined;
223
+ }, {
224
+ event: string;
225
+ message?: string | null | undefined;
226
+ author?: {
227
+ name?: string | null | undefined;
228
+ date?: string | null | undefined;
229
+ } | null | undefined;
230
+ id?: number | null | undefined;
231
+ label?: {
232
+ name: string;
233
+ color?: string | null | undefined;
234
+ } | null | undefined;
235
+ url?: string | null | undefined;
236
+ rename?: {
237
+ from?: string | null | undefined;
238
+ to?: string | null | undefined;
239
+ } | null | undefined;
240
+ source?: {
241
+ issue?: {
242
+ number?: number | null | undefined;
243
+ title?: string | null | undefined;
244
+ html_url?: string | null | undefined;
245
+ pull_request?: unknown;
246
+ } | null | undefined;
247
+ } | null | undefined;
248
+ sha?: string | null | undefined;
249
+ created_at?: string | null | undefined;
250
+ html_url?: string | null | undefined;
251
+ node_id?: string | null | undefined;
252
+ actor?: {
253
+ login: string;
254
+ avatar_url?: string | null | undefined;
255
+ } | null | undefined;
256
+ assignee?: {
257
+ login: string;
258
+ } | null | undefined;
259
+ }>;
74
260
  /** First 200 thread entries, then `truncated`; each body sliced to 8 000 chars (same cap as
75
261
  * item bodies). */
76
262
  export declare const THREAD_ENTRY_CAP = 200;
@@ -80,6 +266,29 @@ export declare function normalizeComments(raw: unknown): ForgeComment[];
80
266
  * COMMENTED/PENDING carry no signal in a flat thread and are dropped; the rest map to
81
267
  * `kind: 'review'` (Q4). Exported for unit tests. */
82
268
  export declare function normalizeReviews(raw: unknown): ForgeComment[];
269
+ /**
270
+ * `gh api …/issues/{n}/timeline` JSON → `ForgeTimelineEvent[]`, plus whether the cap fired.
271
+ *
272
+ * Returns `truncated` rather than just the array because the caller has no other way to learn it:
273
+ * `events.length === TIMELINE_EVENT_CAP` is ambiguous on a thread with exactly that many.
274
+ *
275
+ * Three details here are load-bearing and were verified against a real timeline, not assumed:
276
+ *
277
+ * 1. **`committed` rows return `created_at: null`** — the real timestamp is at `author.date`.
278
+ * Mapping `created_at` naively yields `createdAt: null` on every commit, which string-sorts to
279
+ * the top and silently reorders the entire thread.
280
+ * 2. **`committed` carries a git author, not a GitHub actor** — a name, no login, no avatar.
281
+ * 3. **The cap keeps the NEWEST events — `slice(-cap)`**, the opposite of the neighbouring
282
+ * `mergeThread`, which head-slices. The timeline arrives oldest-first, so `slice(0, cap)` would
283
+ * retain 200 stale day-one `labeled` rows and discard the merge and the recent commits — the
284
+ * exact rows #525 asks for.
285
+ *
286
+ * Exported for unit tests.
287
+ */
288
+ export declare function normalizeEvents(raw: unknown, cap?: number): {
289
+ events: ForgeTimelineEvent[];
290
+ truncated: boolean;
291
+ };
83
292
  /** Merge comment/review lists chronologically (oldest first) and apply the entry cap. Exported
84
293
  * for unit tests. */
85
294
  export declare function mergeThread(parts: ForgeComment[][], cap?: number): {
@@ -88,10 +297,44 @@ export declare function mergeThread(parts: ForgeComment[][], cap?: number): {
88
297
  };
89
298
  /** Test-only: drop the per-thread cache so cases don't leak state into each other. */
90
299
  export declare function __clearCommentsCacheForTests(): void;
300
+ /** What the bounded timeline page loop returns. `stoppedShort` means "the timeline may have more
301
+ * rows than we fetched" and has exactly three causes — the page cap, the budget floor, and a
302
+ * failure on page ≥ 2. All three shorten the `commented` stream the same way, so all three feed
303
+ * `truncated` and arm the comments top-up; the cause does not change the remedy. A short page is
304
+ * the one exit that does NOT set it: that is the timeline genuinely ending. */
305
+ type TimelinePages = {
306
+ rows: unknown[];
307
+ stoppedShort: boolean;
308
+ };
309
+ /**
310
+ * Walk `/issues/{n}/timeline` under ONE shared time budget.
311
+ *
312
+ * `gh api --paginate` is not used here: it pages *"until there are no more pages of results"* and
313
+ * exposes no page-limit flag, so the only way to bound the walk is to hand-roll it. The cap that
314
+ * `paginateCounts` gets comes from being a JS cursor loop, not from a `gh` flag.
315
+ *
316
+ * The budget is a **total**, not a per-page allowance. `gh()` takes its timeout per invocation, so
317
+ * ten sequential spawns at the 15 s default would put the ceiling at 150 s — an order of magnitude
318
+ * worse than the single `--paginate` spawn this replaces. The loop tracks a deadline and passes
319
+ * each page whatever remains.
320
+ *
321
+ * Exported for unit tests; `run` is injected so the loop is testable without shelling out.
322
+ */
323
+ export declare function fetchTimelinePages(run: (page: number, timeoutMs: number) => Promise<string>, opts?: {
324
+ maxPages?: number;
325
+ budgetMs?: number;
326
+ minPageMs?: number;
327
+ now?: () => number;
328
+ }): Promise<TimelinePages>;
91
329
  /**
92
330
  * The conversation thread for one issue/PR, lazily. `{owner}`/`{repo}` in the gh api paths are
93
331
  * filled from the worktree's remote by gh itself, so no extra handle lookup. Everything degrades:
94
332
  * gh missing/offline → `{ available: false, reason }`, a 404 → a "not found" hint — never a throw.
333
+ *
334
+ * Since #525 the thread is sourced from `/issues/{n}/timeline`, which returns comments AND events
335
+ * in one stream: `commented` rows go through the unchanged `normalizeComments`, the rest through
336
+ * `normalizeEvents`. `comments[]` therefore keeps its exact pre-#525 shape, contents and cap
337
+ * (BACKWARD_COMPATIBILITY.md §2) — see the top-up below for the one case that needed defending.
95
338
  */
96
339
  export declare function fetchGithubComments(repoRoot: string, kind: 'issue' | 'pr', number: number, refresh?: boolean): Promise<ForgeCommentsData>;
97
340
  export declare function createDraftPr(input: DraftPrInput): Promise<DraftPrOutcome>;
@@ -288,6 +288,82 @@ const ghReviewSchema = z.object({
288
288
  submitted_at: z.string().nullish(),
289
289
  html_url: z.string(),
290
290
  });
291
+ // ---- timeline events (#525) -------------------------------------------------
292
+ // The thread's non-comment history — commits, label changes, assignments, merges, force-pushes,
293
+ // cross-references. Sourced from `/issues/{n}/timeline`, which returns comments AND events in one
294
+ // chronological stream, so the `commented` rows keep flowing through `normalizeComments` unchanged
295
+ // and `comments[]` stays exactly what BACKWARD_COMPATIBILITY.md §2 promises.
296
+ /** The event kinds rendered in v1 — an allowlist, so a new GitHub event type is dropped rather
297
+ * than rendered and can never crash or clutter the thread. Real timelines carry plenty that
298
+ * github.com itself doesn't surface (`subscribed`, `mentioned`, `review_requested`).
299
+ *
300
+ * `reviewed` is deliberately absent: timeline `reviewed` rows DO carry a body and would work,
301
+ * but `/pulls/{n}/reviews` is already normalized, chipped and empty-body-filtered, so sourcing
302
+ * both would render every review twice. */
303
+ export const TIMELINE_EVENT_KINDS = new Set([
304
+ 'committed',
305
+ 'labeled',
306
+ 'unlabeled',
307
+ 'assigned',
308
+ 'unassigned',
309
+ 'merged',
310
+ 'closed',
311
+ 'reopened',
312
+ 'head_ref_force_pushed',
313
+ 'cross-referenced',
314
+ 'renamed',
315
+ ]);
316
+ /** Events get their OWN cap, independent of `THREAD_ENTRY_CAP`. A combined cap would mean a
317
+ * thread with 150 comments and 100 events returns ~120 comments — silently removing contents
318
+ * from a §2-protected response. */
319
+ export const TIMELINE_EVENT_CAP = 200;
320
+ /** `gh api --paginate` has no page limit, so the timeline fetch hand-rolls a bounded loop. */
321
+ export const TIMELINE_MAX_PAGES = 10;
322
+ /** ONE budget shared by every page. `gh()`'s timeout is per invocation, so ten pages at the 15 s
323
+ * default would put the ceiling at 150 s — an order of magnitude worse than the single
324
+ * `--paginate` spawn this replaces. The loop tracks a deadline and passes what's left. */
325
+ export const TIMELINE_BUDGET_MS = 15_000;
326
+ /** Never spawn a page that cannot finish. A bare `remaining <= 0` guard catches only the exact
327
+ * boundary; the realistic case is 300 ms left, which spawns `gh` with a 300 ms timeout, throws,
328
+ * and is indistinguishable from a real endpoint failure. */
329
+ export const TIMELINE_MIN_PAGE_MS = 2_000;
330
+ /** `committed` messages are trimmed to their first line, then this. */
331
+ const COMMIT_MESSAGE_CAP = 120;
332
+ /** One timeline row. `event` stays a loose `z.string()` so unknown kinds parse and are dropped by
333
+ * the allowlist rather than throwing the whole page. Extras are stripped by default — notably
334
+ * `author.email`, which is read for nothing and must not reach the wire type. */
335
+ export const ghTimelineEventSchema = z.object({
336
+ event: z.string(),
337
+ id: z.number().nullish(),
338
+ node_id: z.string().nullish(),
339
+ created_at: z.string().nullish(),
340
+ actor: z.object({ login: z.string(), avatar_url: z.string().nullish() }).nullish(),
341
+ url: z.string().nullish(),
342
+ html_url: z.string().nullish(),
343
+ // `committed`
344
+ sha: z.string().nullish(),
345
+ message: z.string().nullish(),
346
+ author: z.object({ name: z.string().nullish(), date: z.string().nullish() }).nullish(),
347
+ // `labeled` / `unlabeled`
348
+ label: z.object({ name: z.string(), color: z.string().nullish() }).nullish(),
349
+ // `assigned` / `unassigned`
350
+ assignee: z.object({ login: z.string() }).nullish(),
351
+ // `renamed`
352
+ rename: z.object({ from: z.string().nullish(), to: z.string().nullish() }).nullish(),
353
+ // `cross-referenced`
354
+ source: z
355
+ .object({
356
+ issue: z
357
+ .object({
358
+ number: z.number().nullish(),
359
+ title: z.string().nullish(),
360
+ html_url: z.string().nullish(),
361
+ pull_request: z.unknown().nullish(),
362
+ })
363
+ .nullish(),
364
+ })
365
+ .nullish(),
366
+ });
291
367
  const REVIEW_STATE = {
292
368
  APPROVED: 'approved',
293
369
  CHANGES_REQUESTED: 'changes_requested',
@@ -333,6 +409,111 @@ export function normalizeReviews(raw) {
333
409
  url: r.html_url,
334
410
  }));
335
411
  }
412
+ /**
413
+ * `gh api …/issues/{n}/timeline` JSON → `ForgeTimelineEvent[]`, plus whether the cap fired.
414
+ *
415
+ * Returns `truncated` rather than just the array because the caller has no other way to learn it:
416
+ * `events.length === TIMELINE_EVENT_CAP` is ambiguous on a thread with exactly that many.
417
+ *
418
+ * Three details here are load-bearing and were verified against a real timeline, not assumed:
419
+ *
420
+ * 1. **`committed` rows return `created_at: null`** — the real timestamp is at `author.date`.
421
+ * Mapping `created_at` naively yields `createdAt: null` on every commit, which string-sorts to
422
+ * the top and silently reorders the entire thread.
423
+ * 2. **`committed` carries a git author, not a GitHub actor** — a name, no login, no avatar.
424
+ * 3. **The cap keeps the NEWEST events — `slice(-cap)`**, the opposite of the neighbouring
425
+ * `mergeThread`, which head-slices. The timeline arrives oldest-first, so `slice(0, cap)` would
426
+ * retain 200 stale day-one `labeled` rows and discard the merge and the recent commits — the
427
+ * exact rows #525 asks for.
428
+ *
429
+ * Exported for unit tests.
430
+ */
431
+ export function normalizeEvents(raw, cap = TIMELINE_EVENT_CAP) {
432
+ const rows = z.array(ghTimelineEventSchema).parse(raw);
433
+ const mapped = [];
434
+ rows.forEach((row, index) => {
435
+ const kind = row.event;
436
+ if (!TIMELINE_EVENT_KINDS.has(kind))
437
+ return; // unknown//noise → dropped, never rendered
438
+ // Per-kind timestamp resolution — see (1) above.
439
+ const rawAt = kind === 'committed' ? row.author?.date : row.created_at;
440
+ if (!rawAt)
441
+ return; // no resolvable timestamp → drop, rather than merge at an arbitrary spot
442
+ // `author.date` arrives with a numeric offset (`+02:00`); normalize so the string compare the
443
+ // thread sorts by stays correct across zones.
444
+ const parsedAt = new Date(rawAt);
445
+ if (Number.isNaN(parsedAt.getTime()))
446
+ return;
447
+ const createdAt = parsedAt.toISOString();
448
+ // Per-kind actor resolution — see (2) above.
449
+ const actor = (kind === 'committed' ? row.author?.name : row.actor?.login) ?? '?';
450
+ // Identity: `sha` sits ahead of `node_id` deliberately. `committed` rows carry both, and a
451
+ // node_id-first order would key commits by an opaque `C_kwDO…` blob instead of the SHA, which
452
+ // is the natural, debuggable identifier and is already the rollup key. The bare-index fallback
453
+ // reaches only `cross-referenced`, the one kind with no identity at all — as a general scheme
454
+ // it would be wrong, since the id becomes the React key and an index over the post-sort array
455
+ // shifts for every row below an insertion, remounting them on each 60 s refetch.
456
+ const identity = row.id ?? row.sha ?? row.node_id ?? index;
457
+ const event = {
458
+ id: `evt-${identity}`,
459
+ kind,
460
+ actor,
461
+ createdAt,
462
+ };
463
+ // A git author has no avatar, so `committed` deliberately carries none.
464
+ if (kind !== 'committed' && row.actor?.avatar_url)
465
+ event.avatarUrl = row.actor.avatar_url;
466
+ switch (kind) {
467
+ case 'committed': {
468
+ if (row.sha)
469
+ event.sha = row.sha;
470
+ if (row.message)
471
+ event.message = (row.message.split('\n')[0] ?? '').slice(0, COMMIT_MESSAGE_CAP);
472
+ if (row.html_url)
473
+ event.url = row.html_url;
474
+ break;
475
+ }
476
+ case 'labeled':
477
+ case 'unlabeled': {
478
+ if (row.label) {
479
+ event.label = { name: row.label.name };
480
+ if (row.label.color)
481
+ event.label.color = row.label.color;
482
+ }
483
+ break;
484
+ }
485
+ case 'assigned':
486
+ case 'unassigned': {
487
+ if (row.assignee?.login)
488
+ event.subject = row.assignee.login;
489
+ break;
490
+ }
491
+ case 'renamed': {
492
+ if (row.rename?.to)
493
+ event.subject = row.rename.to;
494
+ break;
495
+ }
496
+ case 'cross-referenced': {
497
+ const issue = row.source?.issue;
498
+ if (issue?.number != null)
499
+ event.refNumber = issue.number;
500
+ if (issue?.title)
501
+ event.refTitle = issue.title;
502
+ if (issue)
503
+ event.refIsPr = Boolean(issue.pull_request);
504
+ if (issue?.html_url)
505
+ event.url = issue.html_url;
506
+ break;
507
+ }
508
+ default:
509
+ break;
510
+ }
511
+ mapped.push(event);
512
+ });
513
+ const truncated = mapped.length > cap;
514
+ // slice(-cap), NOT slice(0, cap) — see (3) above.
515
+ return { events: truncated ? mapped.slice(-cap) : mapped, truncated };
516
+ }
336
517
  /** Merge comment/review lists chronologically (oldest first) and apply the entry cap. Exported
337
518
  * for unit tests. */
338
519
  export function mergeThread(parts, cap = THREAD_ENTRY_CAP) {
@@ -358,10 +539,70 @@ function cacheComments(key, data) {
358
539
  export function __clearCommentsCacheForTests() {
359
540
  commentsCache.clear();
360
541
  }
542
+ const TIMELINE_PER_PAGE = 100;
543
+ /**
544
+ * Walk `/issues/{n}/timeline` under ONE shared time budget.
545
+ *
546
+ * `gh api --paginate` is not used here: it pages *"until there are no more pages of results"* and
547
+ * exposes no page-limit flag, so the only way to bound the walk is to hand-roll it. The cap that
548
+ * `paginateCounts` gets comes from being a JS cursor loop, not from a `gh` flag.
549
+ *
550
+ * The budget is a **total**, not a per-page allowance. `gh()` takes its timeout per invocation, so
551
+ * ten sequential spawns at the 15 s default would put the ceiling at 150 s — an order of magnitude
552
+ * worse than the single `--paginate` spawn this replaces. The loop tracks a deadline and passes
553
+ * each page whatever remains.
554
+ *
555
+ * Exported for unit tests; `run` is injected so the loop is testable without shelling out.
556
+ */
557
+ export async function fetchTimelinePages(run, opts = {}) {
558
+ const maxPages = opts.maxPages ?? TIMELINE_MAX_PAGES;
559
+ const budgetMs = opts.budgetMs ?? TIMELINE_BUDGET_MS;
560
+ const minPageMs = opts.minPageMs ?? TIMELINE_MIN_PAGE_MS;
561
+ const now = opts.now ?? Date.now;
562
+ const deadline = now() + budgetMs;
563
+ const rows = [];
564
+ let stoppedShort = false;
565
+ let page = 1;
566
+ for (; page <= maxPages; page++) {
567
+ const remaining = deadline - now();
568
+ // Never spawn a page that cannot finish. A bare `remaining <= 0` guard catches only the exact
569
+ // boundary; the realistic case is 300 ms left, which spawns gh with a 300 ms timeout, throws,
570
+ // and looks indistinguishable from a real endpoint failure.
571
+ if (remaining < minPageMs) {
572
+ stoppedShort = true;
573
+ break;
574
+ }
575
+ let parsed;
576
+ try {
577
+ parsed = z.array(z.unknown()).parse(JSON.parse(await run(page, remaining)));
578
+ }
579
+ catch (err) {
580
+ // Page 1 rethrows so the caller's inner catch can decide whether substitution helps —
581
+ // nothing was fetched, so there is nothing to lose. A failure on any later page keeps the
582
+ // pages already in hand: discarding nine good pages to re-fetch comments-only is strictly
583
+ // worse than what the loop already holds.
584
+ if (page === 1)
585
+ throw err;
586
+ stoppedShort = true;
587
+ break;
588
+ }
589
+ rows.push(...parsed);
590
+ if (parsed.length < TIMELINE_PER_PAGE)
591
+ break; // short page — the real end of the timeline
592
+ }
593
+ if (page > maxPages)
594
+ stoppedShort = true; // fell out on the page cap
595
+ return { rows, stoppedShort };
596
+ }
361
597
  /**
362
598
  * The conversation thread for one issue/PR, lazily. `{owner}`/`{repo}` in the gh api paths are
363
599
  * filled from the worktree's remote by gh itself, so no extra handle lookup. Everything degrades:
364
600
  * gh missing/offline → `{ available: false, reason }`, a 404 → a "not found" hint — never a throw.
601
+ *
602
+ * Since #525 the thread is sourced from `/issues/{n}/timeline`, which returns comments AND events
603
+ * in one stream: `commented` rows go through the unchanged `normalizeComments`, the rest through
604
+ * `normalizeEvents`. `comments[]` therefore keeps its exact pre-#525 shape, contents and cap
605
+ * (BACKWARD_COMPATIBILITY.md §2) — see the top-up below for the one case that needed defending.
365
606
  */
366
607
  export async function fetchGithubComments(repoRoot, kind, number, refresh = false) {
367
608
  if (process.env.CEZ_DRY_RUN === '1')
@@ -370,16 +611,81 @@ export async function fetchGithubComments(repoRoot, kind, number, refresh = fals
370
611
  const hit = commentsCache.get(key);
371
612
  if (!refresh && hit && Date.now() - hit.at < CACHE_MS)
372
613
  return hit.data;
614
+ // PR conversation comments live on the issues endpoint too — always use it for the body thread.
615
+ const legacyComments = () => gh(repoRoot, ['api', `repos/{owner}/{repo}/issues/${number}/comments`, '--paginate']);
373
616
  try {
374
- // PR conversation comments live on the issues endpoint too — always use it for the body thread.
375
- const commentsOut = await gh(repoRoot, ['api', `repos/{owner}/{repo}/issues/${number}/comments`, '--paginate']);
376
- const parts = [normalizeComments(JSON.parse(commentsOut))];
617
+ let commentRows = [];
618
+ let events;
619
+ let eventsTruncated = false;
620
+ let stoppedShort = false;
621
+ try {
622
+ const pages = await fetchTimelinePages((page, timeoutMs) => gh(repoRoot, [
623
+ 'api',
624
+ '-H',
625
+ 'Accept: application/vnd.github+json',
626
+ `repos/{owner}/{repo}/issues/${number}/timeline?per_page=${TIMELINE_PER_PAGE}&page=${page}`,
627
+ ], timeoutMs));
628
+ stoppedShort = pages.stoppedShort;
629
+ commentRows = pages.rows.filter((r) => r?.event === 'commented');
630
+ const normalized = normalizeEvents(pages.rows);
631
+ events = normalized.events;
632
+ eventsTruncated = normalized.truncated;
633
+ // Comments top-up. The two ENTRY caps are independent, but the FETCH budget is not: today's
634
+ // call is an unbounded --paginate over a comments-only endpoint, so every comment is fetched
635
+ // and mergeThread picks the oldest 200 from the complete set. Under the timeline those same
636
+ // 200 slots are filled from at most 1000 rows in which events compete with comments — so on
637
+ // a 1500-row thread with 250 interleaved comments, comments[] would return ~167 where today
638
+ // it returns 200. Same §2 defect class as a combined entry cap, arriving via the source set.
639
+ //
640
+ // The trigger deliberately over-fires: it cannot distinguish "comments were cut off" from
641
+ // "this thread just has few comments", because the second is only knowable by fetching them.
642
+ // So on an event-heavy thread with 30 comments it re-fetches 30 complete comments. Accepted
643
+ // — it fires only past ~1000 timeline rows, behind the 60 s LRU, and the alternative is a §2
644
+ // regression. In the other direction the threshold is provably sound: with ≥200 commented
645
+ // rows already in the oldest-first prefix, mergeThread's oldest-200 cut lies inside it.
646
+ if (stoppedShort && commentRows.length < THREAD_ENTRY_CAP) {
647
+ // Its OWN nested catch. The inner handler's remedy is the fallback — which is this very
648
+ // call — and the outer handler returns { available: false, comments: [] }, emptying a
649
+ // thread that was about to render. So a thrown top-up is swallowed: keep the commented
650
+ // rows the timeline already returned and carry on. That is at worst the short list the
651
+ // safeguard existed to avoid, never worse than not having attempted it.
652
+ try {
653
+ commentRows = z.array(z.unknown()).parse(JSON.parse(await legacyComments()));
654
+ }
655
+ catch {
656
+ // keep the timeline's own commented rows
657
+ }
658
+ }
659
+ }
660
+ catch (timelineErr) {
661
+ // Scoped INSIDE the existing outer catch on purpose: the outer handler's /404|not found/i
662
+ // branch would otherwise turn a timeline 404 into an empty thread. ENOENT is the one failure
663
+ // the fallback cannot rescue — nothing will work, and a second spawn fails identically.
664
+ const message = timelineErr instanceof Error ? timelineErr.message : String(timelineErr);
665
+ if (/ENOENT/.test(message))
666
+ throw timelineErr;
667
+ // Every other endpoint-level failure substitutes the legacy comments call. It is a
668
+ // substitution, not a retry — a different endpoint, which typically still answers. (A 403
669
+ // attaches to the token rather than the endpoint, so it cannot succeed either; not
670
+ // special-cased, since rate-limit replies are immediate and it costs one fast spawn.)
671
+ commentRows = z.array(z.unknown()).parse(JSON.parse(await legacyComments()));
672
+ events = undefined;
673
+ }
674
+ const parts = [normalizeComments(commentRows)];
377
675
  if (kind === 'pr') {
378
676
  const reviewsOut = await gh(repoRoot, ['api', `repos/{owner}/{repo}/pulls/${number}/reviews`, '--paginate']);
379
677
  parts.push(normalizeReviews(JSON.parse(reviewsOut)));
380
678
  }
381
679
  const { comments, truncated } = mergeThread(parts);
382
- const data = { available: true, comments, truncated: truncated || undefined };
680
+ const data = {
681
+ available: true,
682
+ comments,
683
+ // OR-folded exactly as before, so the pre-existing >200-comments trigger is preserved
684
+ // rather than replaced.
685
+ truncated: truncated || eventsTruncated || stoppedShort || undefined,
686
+ };
687
+ if (events)
688
+ data.events = events;
383
689
  cacheComments(key, data);
384
690
  return data;
385
691
  }