revspec 0.5.0 → 0.7.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.
- package/README.md +84 -67
- package/bin/revspec.ts +4 -38
- package/package.json +20 -3
- package/skills/revspec/SKILL.md +38 -31
- package/src/cli/reply.ts +1 -1
- package/src/cli/watch.ts +69 -41
- package/src/protocol/live-events.ts +6 -16
- package/src/state/review-state.ts +37 -24
- package/src/tui/app.ts +168 -107
- package/src/tui/comment-input.ts +21 -14
- package/src/tui/confirm.ts +4 -6
- package/src/tui/help.ts +77 -20
- package/src/tui/pager.ts +4 -2
- package/src/tui/search.ts +9 -4
- package/src/tui/spinner.ts +81 -0
- package/src/tui/status-bar.ts +9 -8
- package/src/tui/thread-list.ts +62 -22
- package/src/tui/ui/keymap.ts +55 -0
- package/.github/workflows/ci.yml +0 -18
- package/CLAUDE.md +0 -27
- package/bun.lock +0 -213
- package/docs/superpowers/plans/2026-03-14-live-ai-integration.md +0 -1877
- package/docs/superpowers/plans/2026-03-14-spectral-v1-implementation.md +0 -2139
- package/docs/superpowers/plans/2026-03-15-ui-refactor.md +0 -1025
- package/docs/superpowers/specs/2026-03-14-live-ai-integration-design.md +0 -518
- package/docs/superpowers/specs/2026-03-14-live-ai-integration-design.review.json +0 -65
- package/docs/superpowers/specs/2026-03-14-spec-review-tool-design.md +0 -331
- package/docs/superpowers/specs/2026-03-14-spec-review-tool-design.review.json +0 -141
- package/docs/superpowers/specs/claude-code-integration-notes.md +0 -26
- package/scripts/install-skill.sh +0 -20
- package/scripts/release.sh +0 -52
- package/test/cli-reply.test.ts +0 -140
- package/test/cli-watch.test.ts +0 -216
- package/test/cli.test.ts +0 -160
- package/test/e2e-live.test.ts +0 -171
- package/test/live-interaction.test.ts +0 -398
- package/test/opentui-smoke.test.ts +0 -12
- package/test/protocol/live-events.test.ts +0 -509
- package/test/protocol/live-merge.test.ts +0 -167
- package/test/protocol/merge.test.ts +0 -100
- package/test/protocol/read.test.ts +0 -92
- package/test/protocol/types.test.ts +0 -95
- package/test/protocol/write.test.ts +0 -72
- package/test/state/review-state.test.ts +0 -399
- package/test/tui/pager.test.ts +0 -159
- package/test/tui/ui/keybinds.test.ts +0 -71
- package/tsconfig.json +0 -14
|
@@ -1,509 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
|
2
|
-
import { mkdtempSync, rmSync, writeFileSync } from "fs";
|
|
3
|
-
import { join } from "path";
|
|
4
|
-
import { tmpdir } from "os";
|
|
5
|
-
import {
|
|
6
|
-
isValidLiveEvent,
|
|
7
|
-
appendEvent,
|
|
8
|
-
readEventsFromOffset,
|
|
9
|
-
replayEventsToThreads,
|
|
10
|
-
type LiveEvent,
|
|
11
|
-
} from "../../src/protocol/live-events";
|
|
12
|
-
|
|
13
|
-
function tmpDir() {
|
|
14
|
-
return mkdtempSync(join(tmpdir(), "revspec-test-"));
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
describe("isValidLiveEvent", () => {
|
|
18
|
-
it("accepts a valid comment event", () => {
|
|
19
|
-
const event: unknown = {
|
|
20
|
-
type: "comment",
|
|
21
|
-
threadId: "t1",
|
|
22
|
-
line: 5,
|
|
23
|
-
author: "reviewer",
|
|
24
|
-
text: "Hello",
|
|
25
|
-
ts: 1000,
|
|
26
|
-
};
|
|
27
|
-
expect(isValidLiveEvent(event)).toBe(true);
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
it("accepts a valid reply event", () => {
|
|
31
|
-
const event: unknown = {
|
|
32
|
-
type: "reply",
|
|
33
|
-
threadId: "t1",
|
|
34
|
-
author: "owner",
|
|
35
|
-
text: "Thanks",
|
|
36
|
-
ts: 2000,
|
|
37
|
-
};
|
|
38
|
-
expect(isValidLiveEvent(event)).toBe(true);
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
it("accepts a valid resolve event", () => {
|
|
42
|
-
const event: unknown = {
|
|
43
|
-
type: "resolve",
|
|
44
|
-
threadId: "t1",
|
|
45
|
-
author: "reviewer",
|
|
46
|
-
ts: 3000,
|
|
47
|
-
};
|
|
48
|
-
expect(isValidLiveEvent(event)).toBe(true);
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
it("accepts a valid approve event", () => {
|
|
52
|
-
const event: unknown = {
|
|
53
|
-
type: "approve",
|
|
54
|
-
author: "reviewer",
|
|
55
|
-
ts: 4000,
|
|
56
|
-
};
|
|
57
|
-
expect(isValidLiveEvent(event)).toBe(true);
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
it("accepts a valid round event", () => {
|
|
61
|
-
const event: unknown = {
|
|
62
|
-
type: "round",
|
|
63
|
-
author: "owner",
|
|
64
|
-
round: 2,
|
|
65
|
-
ts: 5000,
|
|
66
|
-
};
|
|
67
|
-
expect(isValidLiveEvent(event)).toBe(true);
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
it("accepts a valid delete event", () => {
|
|
71
|
-
const event: unknown = {
|
|
72
|
-
type: "delete",
|
|
73
|
-
threadId: "t1",
|
|
74
|
-
author: "reviewer",
|
|
75
|
-
ts: 6000,
|
|
76
|
-
};
|
|
77
|
-
expect(isValidLiveEvent(event)).toBe(true);
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
it("accepts a valid unresolve event", () => {
|
|
81
|
-
const event: unknown = {
|
|
82
|
-
type: "unresolve",
|
|
83
|
-
threadId: "t1",
|
|
84
|
-
author: "reviewer",
|
|
85
|
-
ts: 7000,
|
|
86
|
-
};
|
|
87
|
-
expect(isValidLiveEvent(event)).toBe(true);
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
it("rejects event missing type", () => {
|
|
91
|
-
expect(isValidLiveEvent({ author: "reviewer", ts: 1000 })).toBe(false);
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
it("rejects event with invalid type", () => {
|
|
95
|
-
expect(
|
|
96
|
-
isValidLiveEvent({ type: "invalid", author: "reviewer", ts: 1000 })
|
|
97
|
-
).toBe(false);
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
it("rejects comment missing text", () => {
|
|
101
|
-
expect(
|
|
102
|
-
isValidLiveEvent({
|
|
103
|
-
type: "comment",
|
|
104
|
-
threadId: "t1",
|
|
105
|
-
line: 5,
|
|
106
|
-
author: "reviewer",
|
|
107
|
-
ts: 1000,
|
|
108
|
-
})
|
|
109
|
-
).toBe(false);
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
it("rejects comment missing line", () => {
|
|
113
|
-
expect(
|
|
114
|
-
isValidLiveEvent({
|
|
115
|
-
type: "comment",
|
|
116
|
-
threadId: "t1",
|
|
117
|
-
author: "reviewer",
|
|
118
|
-
text: "Hello",
|
|
119
|
-
ts: 1000,
|
|
120
|
-
})
|
|
121
|
-
).toBe(false);
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
it("rejects event missing ts", () => {
|
|
125
|
-
expect(
|
|
126
|
-
isValidLiveEvent({
|
|
127
|
-
type: "approve",
|
|
128
|
-
author: "reviewer",
|
|
129
|
-
})
|
|
130
|
-
).toBe(false);
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
it("rejects non-object", () => {
|
|
134
|
-
expect(isValidLiveEvent(null)).toBe(false);
|
|
135
|
-
expect(isValidLiveEvent("string")).toBe(false);
|
|
136
|
-
expect(isValidLiveEvent(42)).toBe(false);
|
|
137
|
-
});
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
describe("appendEvent + readEventsFromOffset", () => {
|
|
141
|
-
let dir: string;
|
|
142
|
-
|
|
143
|
-
beforeEach(() => {
|
|
144
|
-
dir = tmpDir();
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
afterEach(() => {
|
|
148
|
-
rmSync(dir, { recursive: true });
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
it("appends and reads back events", () => {
|
|
152
|
-
const path = join(dir, "events.jsonl");
|
|
153
|
-
const event: LiveEvent = {
|
|
154
|
-
type: "comment",
|
|
155
|
-
threadId: "t1",
|
|
156
|
-
line: 3,
|
|
157
|
-
author: "reviewer",
|
|
158
|
-
text: "hello",
|
|
159
|
-
ts: 1000,
|
|
160
|
-
};
|
|
161
|
-
appendEvent(path, event);
|
|
162
|
-
const { events, newOffset } = readEventsFromOffset(path, 0);
|
|
163
|
-
expect(events).toHaveLength(1);
|
|
164
|
-
expect(events[0]).toEqual(event);
|
|
165
|
-
expect(newOffset).toBeGreaterThan(0);
|
|
166
|
-
});
|
|
167
|
-
|
|
168
|
-
it("reads only new events from offset", () => {
|
|
169
|
-
const path = join(dir, "events.jsonl");
|
|
170
|
-
const event1: LiveEvent = {
|
|
171
|
-
type: "comment",
|
|
172
|
-
threadId: "t1",
|
|
173
|
-
line: 1,
|
|
174
|
-
author: "reviewer",
|
|
175
|
-
text: "first",
|
|
176
|
-
ts: 1000,
|
|
177
|
-
};
|
|
178
|
-
const event2: LiveEvent = {
|
|
179
|
-
type: "reply",
|
|
180
|
-
threadId: "t1",
|
|
181
|
-
author: "owner",
|
|
182
|
-
text: "second",
|
|
183
|
-
ts: 2000,
|
|
184
|
-
};
|
|
185
|
-
appendEvent(path, event1);
|
|
186
|
-
const { newOffset: offset1 } = readEventsFromOffset(path, 0);
|
|
187
|
-
appendEvent(path, event2);
|
|
188
|
-
const { events, newOffset: offset2 } = readEventsFromOffset(path, offset1);
|
|
189
|
-
expect(events).toHaveLength(1);
|
|
190
|
-
expect(events[0]).toEqual(event2);
|
|
191
|
-
expect(offset2).toBeGreaterThan(offset1);
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
it("returns empty for non-existent file", () => {
|
|
195
|
-
const { events, newOffset } = readEventsFromOffset(
|
|
196
|
-
join(dir, "nonexistent.jsonl"),
|
|
197
|
-
0
|
|
198
|
-
);
|
|
199
|
-
expect(events).toHaveLength(0);
|
|
200
|
-
expect(newOffset).toBe(0);
|
|
201
|
-
});
|
|
202
|
-
|
|
203
|
-
it("discards malformed lines", () => {
|
|
204
|
-
const path = join(dir, "events.jsonl");
|
|
205
|
-
const validEvent: LiveEvent = {
|
|
206
|
-
type: "approve",
|
|
207
|
-
author: "reviewer",
|
|
208
|
-
ts: 1000,
|
|
209
|
-
};
|
|
210
|
-
appendEvent(path, validEvent);
|
|
211
|
-
// Manually append a malformed line
|
|
212
|
-
const Bun_appendFile = require("fs").appendFileSync;
|
|
213
|
-
Bun_appendFile(path, "not valid json\n");
|
|
214
|
-
appendEvent(path, validEvent);
|
|
215
|
-
|
|
216
|
-
const { events } = readEventsFromOffset(path, 0);
|
|
217
|
-
expect(events).toHaveLength(2);
|
|
218
|
-
expect(events[0]).toEqual(validEvent);
|
|
219
|
-
expect(events[1]).toEqual(validEvent);
|
|
220
|
-
});
|
|
221
|
-
|
|
222
|
-
it("handles mid-line byte offset alignment", () => {
|
|
223
|
-
const path = join(dir, "events.jsonl");
|
|
224
|
-
const event: LiveEvent = {
|
|
225
|
-
type: "comment",
|
|
226
|
-
threadId: "t1",
|
|
227
|
-
line: 5,
|
|
228
|
-
author: "reviewer",
|
|
229
|
-
text: "test",
|
|
230
|
-
ts: 1000,
|
|
231
|
-
};
|
|
232
|
-
appendEvent(path, event);
|
|
233
|
-
// Start mid-line (offset 3 is in the middle of first line)
|
|
234
|
-
const { events } = readEventsFromOffset(path, 3);
|
|
235
|
-
// Should skip to next newline and not crash; may return empty or partial depending on alignment
|
|
236
|
-
expect(Array.isArray(events)).toBe(true);
|
|
237
|
-
});
|
|
238
|
-
});
|
|
239
|
-
|
|
240
|
-
describe("replayEventsToThreads", () => {
|
|
241
|
-
it("creates threads from comment events", () => {
|
|
242
|
-
const events: LiveEvent[] = [
|
|
243
|
-
{
|
|
244
|
-
type: "comment",
|
|
245
|
-
threadId: "t1",
|
|
246
|
-
line: 3,
|
|
247
|
-
author: "reviewer",
|
|
248
|
-
text: "First comment",
|
|
249
|
-
ts: 1000,
|
|
250
|
-
},
|
|
251
|
-
{
|
|
252
|
-
type: "comment",
|
|
253
|
-
threadId: "t2",
|
|
254
|
-
line: 7,
|
|
255
|
-
author: "reviewer",
|
|
256
|
-
text: "Second comment",
|
|
257
|
-
ts: 2000,
|
|
258
|
-
},
|
|
259
|
-
];
|
|
260
|
-
const threads = replayEventsToThreads(events);
|
|
261
|
-
expect(threads).toHaveLength(2);
|
|
262
|
-
expect(threads[0].id).toBe("t1");
|
|
263
|
-
expect(threads[0].line).toBe(3);
|
|
264
|
-
expect(threads[0].messages[0].text).toBe("First comment");
|
|
265
|
-
expect(threads[1].id).toBe("t2");
|
|
266
|
-
});
|
|
267
|
-
|
|
268
|
-
it("appends replies to threads", () => {
|
|
269
|
-
const events: LiveEvent[] = [
|
|
270
|
-
{
|
|
271
|
-
type: "comment",
|
|
272
|
-
threadId: "t1",
|
|
273
|
-
line: 3,
|
|
274
|
-
author: "reviewer",
|
|
275
|
-
text: "Question",
|
|
276
|
-
ts: 1000,
|
|
277
|
-
},
|
|
278
|
-
{
|
|
279
|
-
type: "reply",
|
|
280
|
-
threadId: "t1",
|
|
281
|
-
author: "owner",
|
|
282
|
-
text: "Answer",
|
|
283
|
-
ts: 2000,
|
|
284
|
-
},
|
|
285
|
-
];
|
|
286
|
-
const threads = replayEventsToThreads(events);
|
|
287
|
-
expect(threads).toHaveLength(1);
|
|
288
|
-
expect(threads[0].messages).toHaveLength(2);
|
|
289
|
-
expect(threads[0].messages[1].text).toBe("Answer");
|
|
290
|
-
});
|
|
291
|
-
|
|
292
|
-
it("sets status to pending after owner reply", () => {
|
|
293
|
-
const events: LiveEvent[] = [
|
|
294
|
-
{
|
|
295
|
-
type: "comment",
|
|
296
|
-
threadId: "t1",
|
|
297
|
-
line: 3,
|
|
298
|
-
author: "reviewer",
|
|
299
|
-
text: "Question",
|
|
300
|
-
ts: 1000,
|
|
301
|
-
},
|
|
302
|
-
{
|
|
303
|
-
type: "reply",
|
|
304
|
-
threadId: "t1",
|
|
305
|
-
author: "owner",
|
|
306
|
-
text: "Answer",
|
|
307
|
-
ts: 2000,
|
|
308
|
-
},
|
|
309
|
-
];
|
|
310
|
-
const threads = replayEventsToThreads(events);
|
|
311
|
-
expect(threads[0].status).toBe("pending");
|
|
312
|
-
});
|
|
313
|
-
|
|
314
|
-
it("sets status to open after reviewer reply", () => {
|
|
315
|
-
const events: LiveEvent[] = [
|
|
316
|
-
{
|
|
317
|
-
type: "comment",
|
|
318
|
-
threadId: "t1",
|
|
319
|
-
line: 3,
|
|
320
|
-
author: "reviewer",
|
|
321
|
-
text: "Question",
|
|
322
|
-
ts: 1000,
|
|
323
|
-
},
|
|
324
|
-
{
|
|
325
|
-
type: "reply",
|
|
326
|
-
threadId: "t1",
|
|
327
|
-
author: "owner",
|
|
328
|
-
text: "Answer",
|
|
329
|
-
ts: 2000,
|
|
330
|
-
},
|
|
331
|
-
{
|
|
332
|
-
type: "reply",
|
|
333
|
-
threadId: "t1",
|
|
334
|
-
author: "reviewer",
|
|
335
|
-
text: "Follow up",
|
|
336
|
-
ts: 3000,
|
|
337
|
-
},
|
|
338
|
-
];
|
|
339
|
-
const threads = replayEventsToThreads(events);
|
|
340
|
-
expect(threads[0].status).toBe("open");
|
|
341
|
-
});
|
|
342
|
-
|
|
343
|
-
it("resolves thread on resolve event", () => {
|
|
344
|
-
const events: LiveEvent[] = [
|
|
345
|
-
{
|
|
346
|
-
type: "comment",
|
|
347
|
-
threadId: "t1",
|
|
348
|
-
line: 3,
|
|
349
|
-
author: "reviewer",
|
|
350
|
-
text: "Issue",
|
|
351
|
-
ts: 1000,
|
|
352
|
-
},
|
|
353
|
-
{
|
|
354
|
-
type: "resolve",
|
|
355
|
-
threadId: "t1",
|
|
356
|
-
author: "reviewer",
|
|
357
|
-
ts: 2000,
|
|
358
|
-
},
|
|
359
|
-
];
|
|
360
|
-
const threads = replayEventsToThreads(events);
|
|
361
|
-
expect(threads[0].status).toBe("resolved");
|
|
362
|
-
});
|
|
363
|
-
|
|
364
|
-
it("unresolved thread on unresolve event", () => {
|
|
365
|
-
const events: LiveEvent[] = [
|
|
366
|
-
{
|
|
367
|
-
type: "comment",
|
|
368
|
-
threadId: "t1",
|
|
369
|
-
line: 3,
|
|
370
|
-
author: "reviewer",
|
|
371
|
-
text: "Issue",
|
|
372
|
-
ts: 1000,
|
|
373
|
-
},
|
|
374
|
-
{
|
|
375
|
-
type: "resolve",
|
|
376
|
-
threadId: "t1",
|
|
377
|
-
author: "reviewer",
|
|
378
|
-
ts: 2000,
|
|
379
|
-
},
|
|
380
|
-
{
|
|
381
|
-
type: "unresolve",
|
|
382
|
-
threadId: "t1",
|
|
383
|
-
author: "reviewer",
|
|
384
|
-
ts: 3000,
|
|
385
|
-
},
|
|
386
|
-
];
|
|
387
|
-
const threads = replayEventsToThreads(events);
|
|
388
|
-
expect(threads[0].status).toBe("open");
|
|
389
|
-
});
|
|
390
|
-
|
|
391
|
-
it("delete removes last reviewer message", () => {
|
|
392
|
-
const events: LiveEvent[] = [
|
|
393
|
-
{
|
|
394
|
-
type: "comment",
|
|
395
|
-
threadId: "t1",
|
|
396
|
-
line: 3,
|
|
397
|
-
author: "reviewer",
|
|
398
|
-
text: "First",
|
|
399
|
-
ts: 1000,
|
|
400
|
-
},
|
|
401
|
-
{
|
|
402
|
-
type: "reply",
|
|
403
|
-
threadId: "t1",
|
|
404
|
-
author: "reviewer",
|
|
405
|
-
text: "Second",
|
|
406
|
-
ts: 2000,
|
|
407
|
-
},
|
|
408
|
-
{
|
|
409
|
-
type: "delete",
|
|
410
|
-
threadId: "t1",
|
|
411
|
-
author: "reviewer",
|
|
412
|
-
ts: 3000,
|
|
413
|
-
},
|
|
414
|
-
];
|
|
415
|
-
const threads = replayEventsToThreads(events);
|
|
416
|
-
expect(threads).toHaveLength(1);
|
|
417
|
-
expect(threads[0].messages).toHaveLength(1);
|
|
418
|
-
expect(threads[0].messages[0].text).toBe("First");
|
|
419
|
-
});
|
|
420
|
-
|
|
421
|
-
it("delete re-derives status from remaining messages", () => {
|
|
422
|
-
const events: LiveEvent[] = [
|
|
423
|
-
{ type: "comment", threadId: "t1", line: 3, author: "reviewer", text: "comment", ts: 1000 },
|
|
424
|
-
{ type: "reply", threadId: "t1", author: "owner", text: "response", ts: 1001 },
|
|
425
|
-
{ type: "reply", threadId: "t1", author: "reviewer", text: "follow up", ts: 1002 },
|
|
426
|
-
{ type: "delete", threadId: "t1", author: "reviewer", ts: 1003 },
|
|
427
|
-
];
|
|
428
|
-
const threads = replayEventsToThreads(events);
|
|
429
|
-
expect(threads).toHaveLength(1);
|
|
430
|
-
// After deleting the reviewer reply, last message is owner reply → pending
|
|
431
|
-
expect(threads[0].status).toBe("pending");
|
|
432
|
-
expect(threads[0].messages).toHaveLength(2);
|
|
433
|
-
});
|
|
434
|
-
|
|
435
|
-
it("excludes empty threads after all messages deleted", () => {
|
|
436
|
-
const events: LiveEvent[] = [
|
|
437
|
-
{
|
|
438
|
-
type: "comment",
|
|
439
|
-
threadId: "t1",
|
|
440
|
-
line: 3,
|
|
441
|
-
author: "reviewer",
|
|
442
|
-
text: "Only message",
|
|
443
|
-
ts: 1000,
|
|
444
|
-
},
|
|
445
|
-
{
|
|
446
|
-
type: "delete",
|
|
447
|
-
threadId: "t1",
|
|
448
|
-
author: "reviewer",
|
|
449
|
-
ts: 2000,
|
|
450
|
-
},
|
|
451
|
-
];
|
|
452
|
-
const threads = replayEventsToThreads(events);
|
|
453
|
-
expect(threads).toHaveLength(0);
|
|
454
|
-
});
|
|
455
|
-
|
|
456
|
-
it("skips approve events (does not create threads)", () => {
|
|
457
|
-
const events: LiveEvent[] = [
|
|
458
|
-
{
|
|
459
|
-
type: "approve",
|
|
460
|
-
author: "reviewer",
|
|
461
|
-
ts: 1000,
|
|
462
|
-
},
|
|
463
|
-
];
|
|
464
|
-
const threads = replayEventsToThreads(events);
|
|
465
|
-
expect(threads).toHaveLength(0);
|
|
466
|
-
});
|
|
467
|
-
|
|
468
|
-
it("skips round events", () => {
|
|
469
|
-
const events: LiveEvent[] = [
|
|
470
|
-
{
|
|
471
|
-
type: "round",
|
|
472
|
-
author: "owner",
|
|
473
|
-
round: 2,
|
|
474
|
-
ts: 1000,
|
|
475
|
-
},
|
|
476
|
-
];
|
|
477
|
-
const threads = replayEventsToThreads(events);
|
|
478
|
-
expect(threads).toHaveLength(0);
|
|
479
|
-
});
|
|
480
|
-
|
|
481
|
-
it("preserves timestamps on messages", () => {
|
|
482
|
-
const events: LiveEvent[] = [
|
|
483
|
-
{
|
|
484
|
-
type: "comment",
|
|
485
|
-
threadId: "t1",
|
|
486
|
-
line: 3,
|
|
487
|
-
author: "reviewer",
|
|
488
|
-
text: "With ts",
|
|
489
|
-
ts: 9999,
|
|
490
|
-
},
|
|
491
|
-
];
|
|
492
|
-
const threads = replayEventsToThreads(events);
|
|
493
|
-
expect(threads[0].messages[0].ts).toBe(9999);
|
|
494
|
-
});
|
|
495
|
-
|
|
496
|
-
it("ignores replies to unknown threads", () => {
|
|
497
|
-
const events: LiveEvent[] = [
|
|
498
|
-
{
|
|
499
|
-
type: "reply",
|
|
500
|
-
threadId: "unknown-id",
|
|
501
|
-
author: "owner",
|
|
502
|
-
text: "Reply to nothing",
|
|
503
|
-
ts: 1000,
|
|
504
|
-
},
|
|
505
|
-
];
|
|
506
|
-
const threads = replayEventsToThreads(events);
|
|
507
|
-
expect(threads).toHaveLength(0);
|
|
508
|
-
});
|
|
509
|
-
});
|
|
@@ -1,167 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
|
2
|
-
import { mkdtempSync, rmSync } from "fs";
|
|
3
|
-
import { join } from "path";
|
|
4
|
-
import { tmpdir } from "os";
|
|
5
|
-
import { mergeJsonlIntoReview } from "../../src/protocol/live-merge";
|
|
6
|
-
import { appendEvent } from "../../src/protocol/live-events";
|
|
7
|
-
import type { ReviewFile } from "../../src/protocol/types";
|
|
8
|
-
|
|
9
|
-
function tmpDir() {
|
|
10
|
-
return mkdtempSync(join(tmpdir(), "revspec-test-"));
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
describe("mergeJsonlIntoReview", () => {
|
|
14
|
-
let dir: string;
|
|
15
|
-
|
|
16
|
-
beforeEach(() => {
|
|
17
|
-
dir = tmpDir();
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
afterEach(() => {
|
|
21
|
-
rmSync(dir, { recursive: true });
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
it("creates a new review from JSONL events", () => {
|
|
25
|
-
const jsonlPath = join(dir, "events.jsonl");
|
|
26
|
-
appendEvent(jsonlPath, {
|
|
27
|
-
type: "comment",
|
|
28
|
-
threadId: "t1",
|
|
29
|
-
line: 3,
|
|
30
|
-
author: "reviewer",
|
|
31
|
-
text: "First comment",
|
|
32
|
-
ts: 1000,
|
|
33
|
-
});
|
|
34
|
-
appendEvent(jsonlPath, {
|
|
35
|
-
type: "comment",
|
|
36
|
-
threadId: "t2",
|
|
37
|
-
line: 7,
|
|
38
|
-
author: "reviewer",
|
|
39
|
-
text: "Second comment",
|
|
40
|
-
ts: 2000,
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
const result = mergeJsonlIntoReview(jsonlPath, null, "spec.md");
|
|
44
|
-
expect(result.file).toBe("spec.md");
|
|
45
|
-
expect(result.threads).toHaveLength(2);
|
|
46
|
-
expect(result.threads[0].id).toBe("t1");
|
|
47
|
-
expect(result.threads[1].id).toBe("t2");
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
it("merges with existing review threads (preserves old, adds new)", () => {
|
|
51
|
-
const jsonlPath = join(dir, "events.jsonl");
|
|
52
|
-
appendEvent(jsonlPath, {
|
|
53
|
-
type: "comment",
|
|
54
|
-
threadId: "t3",
|
|
55
|
-
line: 10,
|
|
56
|
-
author: "reviewer",
|
|
57
|
-
text: "New from JSONL",
|
|
58
|
-
ts: 1000,
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
const existingReview: ReviewFile = {
|
|
62
|
-
file: "spec.md",
|
|
63
|
-
threads: [
|
|
64
|
-
{
|
|
65
|
-
id: "t1",
|
|
66
|
-
line: 1,
|
|
67
|
-
status: "open",
|
|
68
|
-
messages: [{ author: "reviewer", text: "Existing comment" }],
|
|
69
|
-
},
|
|
70
|
-
],
|
|
71
|
-
};
|
|
72
|
-
|
|
73
|
-
const result = mergeJsonlIntoReview(jsonlPath, existingReview, "spec.md");
|
|
74
|
-
expect(result.threads).toHaveLength(2);
|
|
75
|
-
// Existing thread preserved
|
|
76
|
-
expect(result.threads.find((t) => t.id === "t1")).toBeDefined();
|
|
77
|
-
// New thread from JSONL added
|
|
78
|
-
expect(result.threads.find((t) => t.id === "t3")).toBeDefined();
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
it("appends new messages to existing thread with same ID", () => {
|
|
82
|
-
const jsonlPath = join(dir, "events.jsonl");
|
|
83
|
-
// JSONL has comment + reply for t1
|
|
84
|
-
appendEvent(jsonlPath, {
|
|
85
|
-
type: "comment",
|
|
86
|
-
threadId: "t1",
|
|
87
|
-
line: 5,
|
|
88
|
-
author: "reviewer",
|
|
89
|
-
text: "Original",
|
|
90
|
-
ts: 1000,
|
|
91
|
-
});
|
|
92
|
-
appendEvent(jsonlPath, {
|
|
93
|
-
type: "reply",
|
|
94
|
-
threadId: "t1",
|
|
95
|
-
author: "owner",
|
|
96
|
-
text: "Owner replied",
|
|
97
|
-
ts: 2000,
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
const existingReview: ReviewFile = {
|
|
101
|
-
file: "spec.md",
|
|
102
|
-
threads: [
|
|
103
|
-
{
|
|
104
|
-
id: "t1",
|
|
105
|
-
line: 5,
|
|
106
|
-
status: "open",
|
|
107
|
-
messages: [{ author: "reviewer", text: "Original" }],
|
|
108
|
-
},
|
|
109
|
-
],
|
|
110
|
-
};
|
|
111
|
-
|
|
112
|
-
const result = mergeJsonlIntoReview(jsonlPath, existingReview, "spec.md");
|
|
113
|
-
expect(result.threads).toHaveLength(1);
|
|
114
|
-
expect(result.threads[0].messages).toHaveLength(2);
|
|
115
|
-
expect(result.threads[0].messages[1].text).toBe("Owner replied");
|
|
116
|
-
expect(result.threads[0].status).toBe("pending");
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
it("returns existing review unchanged if JSONL is empty", () => {
|
|
120
|
-
const jsonlPath = join(dir, "events.jsonl");
|
|
121
|
-
// Create empty JSONL file
|
|
122
|
-
const { appendFileSync } = require("fs");
|
|
123
|
-
appendFileSync(jsonlPath, "");
|
|
124
|
-
|
|
125
|
-
const existingReview: ReviewFile = {
|
|
126
|
-
file: "spec.md",
|
|
127
|
-
threads: [
|
|
128
|
-
{
|
|
129
|
-
id: "t1",
|
|
130
|
-
line: 1,
|
|
131
|
-
status: "resolved",
|
|
132
|
-
messages: [{ author: "reviewer", text: "Done" }],
|
|
133
|
-
},
|
|
134
|
-
],
|
|
135
|
-
};
|
|
136
|
-
|
|
137
|
-
const result = mergeJsonlIntoReview(jsonlPath, existingReview, "spec.md");
|
|
138
|
-
expect(result.threads).toHaveLength(1);
|
|
139
|
-
expect(result.threads[0].id).toBe("t1");
|
|
140
|
-
expect(result.threads[0].status).toBe("resolved");
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
it("returns a new review with empty threads if JSONL is missing and no existing review", () => {
|
|
144
|
-
const jsonlPath = join(dir, "nonexistent.jsonl");
|
|
145
|
-
const result = mergeJsonlIntoReview(jsonlPath, null, "spec.md");
|
|
146
|
-
expect(result.file).toBe("spec.md");
|
|
147
|
-
expect(result.threads).toHaveLength(0);
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
it("is idempotent — replaying from byte 0 each time", () => {
|
|
151
|
-
const jsonlPath = join(dir, "events.jsonl");
|
|
152
|
-
appendEvent(jsonlPath, {
|
|
153
|
-
type: "comment",
|
|
154
|
-
threadId: "t1",
|
|
155
|
-
line: 3,
|
|
156
|
-
author: "reviewer",
|
|
157
|
-
text: "Hello",
|
|
158
|
-
ts: 1000,
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
const result1 = mergeJsonlIntoReview(jsonlPath, null, "spec.md");
|
|
162
|
-
const result2 = mergeJsonlIntoReview(jsonlPath, null, "spec.md");
|
|
163
|
-
|
|
164
|
-
expect(result1).toEqual(result2);
|
|
165
|
-
expect(result1.threads).toHaveLength(1);
|
|
166
|
-
});
|
|
167
|
-
});
|