@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.
- package/dist/server/forge/github.d.ts +244 -1
- package/dist/server/forge/github.js +310 -4
- package/dist/server/forge/github.js.map +1 -1
- package/dist/server/forge/types.d.ts +48 -1
- package/dist/server/github.d.ts +1 -1
- package/package.json +1 -1
- package/web/dist/assets/{chunk-BO2N2NFS-BN7h-q1E.js → chunk-BO2N2NFS-DtrdTCWa.js} +4 -4
- package/web/dist/assets/{compare-variants-DsOgM5ib.js → compare-variants-D7O7BVMV.js} +1 -1
- package/web/dist/assets/{diff-view-CquZs1fE.js → diff-view-B-49MZ8r.js} +1 -1
- package/web/dist/assets/{git-toolbar-Do9adSg-.js → git-toolbar-DFNvlSQN.js} +1 -1
- package/web/dist/assets/github-sfT3Xs-a.js +1 -0
- package/web/dist/assets/{highlighted-body-OFNGDK62-CJIHtxqW.js → highlighted-body-OFNGDK62-BuFJwunK.js} +1 -1
- package/web/dist/assets/{index-CieIT6P1.js → index-D9M-o4Ya.js} +3 -3
- package/web/dist/assets/index-kt0DXo2U.css +2 -0
- package/web/dist/assets/markdown-CAqR0DUp.js +2 -0
- package/web/dist/assets/mermaid-GHXKKRXX-CN2zQue0.js +1 -0
- package/web/dist/assets/{prompt-templates-kzhWan9W.js → prompt-templates-Bknb2TI0.js} +1 -1
- package/web/dist/assets/{repo-git-BIevPDKj.js → repo-git-CyyVXZpk.js} +1 -1
- package/web/dist/assets/{run-diff-9gl2gO8Y.js → run-diff-DttEgfs8.js} +1 -1
- package/web/dist/assets/{run-header-R9nC4EQa.js → run-header-BsSusQCM.js} +1 -1
- package/web/dist/assets/{skill-detail-BOxhR_E1.js → skill-detail-D_Jsx47i.js} +1 -1
- package/web/dist/assets/{skills-D7qavwOV.js → skills-B3fYhNam.js} +1 -1
- package/web/dist/assets/{task-changes-Cw7ikBog.js → task-changes-BGFBSp00.js} +1 -1
- package/web/dist/assets/{task-commits-B0Xu6PX5.js → task-commits-D1Az5aYZ.js} +1 -1
- package/web/dist/assets/{task-files-B6Jt73fx.js → task-files-Cfro5YPa.js} +1 -1
- package/web/dist/assets/{task-thread-DfMbZqxH.js → task-thread-CraQE9L5.js} +1 -1
- package/web/dist/assets/{use-desktop-C2q3znqh.js → use-desktop-B-Ye1WHY.js} +2 -2
- package/web/dist/assets/{workflows-BO8d6uJJ.js → workflows-PsYykOjX.js} +1 -1
- package/web/dist/index.html +6 -6
- package/web/dist/assets/github-C0x8j0WW.js +0 -1
- package/web/dist/assets/index-KeezEgXG.css +0 -2
- package/web/dist/assets/markdown-DxmCMr30.js +0 -1
- 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
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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 = {
|
|
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
|
}
|