@humanbased/crosscheck 1.6.0-beta.5 → 1.6.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 +2 -0
- package/crosscheck.config.example.yml +17 -4
- package/dist/__tests__/board.test.js +332 -33
- package/dist/__tests__/board.test.js.map +1 -1
- package/dist/__tests__/lock-ownership.test.d.ts +2 -0
- package/dist/__tests__/lock-ownership.test.d.ts.map +1 -0
- package/dist/__tests__/lock-ownership.test.js +167 -0
- package/dist/__tests__/lock-ownership.test.js.map +1 -0
- package/dist/__tests__/optimize.test.js +4 -3
- package/dist/__tests__/optimize.test.js.map +1 -1
- package/dist/__tests__/repository-cache.test.d.ts +2 -0
- package/dist/__tests__/repository-cache.test.d.ts.map +1 -0
- package/dist/__tests__/repository-cache.test.js +91 -0
- package/dist/__tests__/repository-cache.test.js.map +1 -0
- package/dist/__tests__/repository-guidance.test.js +1 -1
- package/dist/__tests__/repository-guidance.test.js.map +1 -1
- package/dist/__tests__/review-command-strategy.test.d.ts +2 -0
- package/dist/__tests__/review-command-strategy.test.d.ts.map +1 -0
- package/dist/__tests__/review-command-strategy.test.js +194 -0
- package/dist/__tests__/review-command-strategy.test.js.map +1 -0
- package/dist/__tests__/review-efficiency-config.test.d.ts +2 -0
- package/dist/__tests__/review-efficiency-config.test.d.ts.map +1 -0
- package/dist/__tests__/review-efficiency-config.test.js +16 -0
- package/dist/__tests__/review-efficiency-config.test.js.map +1 -0
- package/dist/__tests__/review-memory.test.d.ts +2 -0
- package/dist/__tests__/review-memory.test.d.ts.map +1 -0
- package/dist/__tests__/review-memory.test.js +252 -0
- package/dist/__tests__/review-memory.test.js.map +1 -0
- package/dist/__tests__/review-models.test.js +1 -0
- package/dist/__tests__/review-models.test.js.map +1 -1
- package/dist/__tests__/review-strategy.test.js +78 -3
- package/dist/__tests__/review-strategy.test.js.map +1 -1
- package/dist/__tests__/vendor-error-summary.test.d.ts +2 -0
- package/dist/__tests__/vendor-error-summary.test.d.ts.map +1 -0
- package/dist/__tests__/vendor-error-summary.test.js +74 -0
- package/dist/__tests__/vendor-error-summary.test.js.map +1 -0
- package/dist/__tests__/watch-concurrency.test.d.ts +2 -0
- package/dist/__tests__/watch-concurrency.test.d.ts.map +1 -0
- package/dist/__tests__/watch-concurrency.test.js +53 -0
- package/dist/__tests__/watch-concurrency.test.js.map +1 -0
- package/dist/cli.js +18 -2
- package/dist/cli.js.map +1 -1
- package/dist/commands/onboard.js +2 -2
- package/dist/commands/onboard.js.map +1 -1
- package/dist/commands/review.d.ts +3 -1
- package/dist/commands/review.d.ts.map +1 -1
- package/dist/commands/review.js +146 -7
- package/dist/commands/review.js.map +1 -1
- package/dist/commands/run.d.ts.map +1 -1
- package/dist/commands/run.js +24 -6
- package/dist/commands/run.js.map +1 -1
- package/dist/commands/watch.d.ts +3 -0
- package/dist/commands/watch.d.ts.map +1 -1
- package/dist/commands/watch.js +170 -25
- package/dist/commands/watch.js.map +1 -1
- package/dist/config/schema.d.ts +27 -0
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/config/schema.js +12 -1
- package/dist/config/schema.js.map +1 -1
- package/dist/github/review-status.d.ts +33 -0
- package/dist/github/review-status.d.ts.map +1 -1
- package/dist/github/review-status.js +94 -9
- package/dist/github/review-status.js.map +1 -1
- package/dist/lib/board.d.ts +32 -1
- package/dist/lib/board.d.ts.map +1 -1
- package/dist/lib/board.js +169 -50
- package/dist/lib/board.js.map +1 -1
- package/dist/lib/clone.d.ts +3 -0
- package/dist/lib/clone.d.ts.map +1 -1
- package/dist/lib/clone.js +21 -1
- package/dist/lib/clone.js.map +1 -1
- package/dist/lib/pr-lock.d.ts +37 -1
- package/dist/lib/pr-lock.d.ts.map +1 -1
- package/dist/lib/pr-lock.js +176 -29
- package/dist/lib/pr-lock.js.map +1 -1
- package/dist/lib/pr-workflow-state.d.ts +2 -0
- package/dist/lib/pr-workflow-state.d.ts.map +1 -1
- package/dist/lib/pr-workflow-state.js +15 -0
- package/dist/lib/pr-workflow-state.js.map +1 -1
- package/dist/lib/repository-cache.d.ts +18 -0
- package/dist/lib/repository-cache.d.ts.map +1 -0
- package/dist/lib/repository-cache.js +125 -0
- package/dist/lib/repository-cache.js.map +1 -0
- package/dist/lib/review-memory.d.ts +212 -0
- package/dist/lib/review-memory.d.ts.map +1 -0
- package/dist/lib/review-memory.js +305 -0
- package/dist/lib/review-memory.js.map +1 -0
- package/dist/lib/review-models.d.ts +2 -2
- package/dist/lib/review-models.js +2 -2
- package/dist/lib/runner.d.ts +37 -2
- package/dist/lib/runner.d.ts.map +1 -1
- package/dist/lib/runner.js +81 -28
- package/dist/lib/runner.js.map +1 -1
- package/dist/lib/tips.js +1 -1
- package/dist/lib/tips.js.map +1 -1
- package/dist/lib/vendor-error-summary.d.ts +19 -0
- package/dist/lib/vendor-error-summary.d.ts.map +1 -0
- package/dist/lib/vendor-error-summary.js +95 -0
- package/dist/lib/vendor-error-summary.js.map +1 -0
- package/dist/reviewers/claude.d.ts.map +1 -1
- package/dist/reviewers/claude.js +2 -1
- package/dist/reviewers/claude.js.map +1 -1
- package/dist/reviewers/codex.d.ts.map +1 -1
- package/dist/reviewers/codex.js +2 -1
- package/dist/reviewers/codex.js.map +1 -1
- package/docs/dynamic-thoroughness.md +7 -4
- package/get-started.md +83 -9
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -257,6 +257,8 @@ Classification may set a **floor**, or promote on **consequence** — a security
|
|
|
257
257
|
|
|
258
258
|
Class tier, effort, **and** step set are all applied. The class is resolved once per workflow, not per step — the fix step pushes commits, so re-classifying could make the review and recheck comments cite different tiers for the same PR.
|
|
259
259
|
|
|
260
|
+
`crosscheck review <pr>` classifies the same way and runs at the class's tier and effort. It is an explicit request for one review, so the step set does not apply, and a class that would skip the PR is overridden: the review runs at the configured `quality.tier`, as `crosscheck run --steps review` does.
|
|
261
|
+
|
|
260
262
|
The step set **narrows** the configured pipeline and never widens it: a repo pinned to review-only with `crosscheck alter` stays review-only whatever the class says.
|
|
261
263
|
|
|
262
264
|
Rounds beyond the first escalate on measured non-convergence rather than prediction — effort rises where the model supports it, the tier is promoted where it does not, and the model never weakens.
|
|
@@ -21,6 +21,8 @@ mode: cross-vendor
|
|
|
21
21
|
# Pick https if you have a multi-account SSH setup or your default SSH key
|
|
22
22
|
# cannot access target repos. Independent of `gh config get git_protocol`.
|
|
23
23
|
clone_protocol: ssh
|
|
24
|
+
# Reuse fetched Git objects; set false to always clone from scratch.
|
|
25
|
+
repository_cache: true
|
|
24
26
|
|
|
25
27
|
# ─── Vendors ─────────────────────────────────────────────────────────────────
|
|
26
28
|
vendors:
|
|
@@ -39,6 +41,8 @@ vendors:
|
|
|
39
41
|
|
|
40
42
|
# ─── Quality & Focus ─────────────────────────────────────────────────────────
|
|
41
43
|
quality:
|
|
44
|
+
# Structured findings and incremental follow-ups; set false for legacy prose reviews.
|
|
45
|
+
review_memory: true
|
|
42
46
|
mode: smart # smart (default) | fixed
|
|
43
47
|
#
|
|
44
48
|
# smart - dynamically adjust model + effort based on task type. The PR is
|
|
@@ -67,7 +71,9 @@ quality:
|
|
|
67
71
|
tier: balanced # fast | balanced | thorough
|
|
68
72
|
#
|
|
69
73
|
# Under `fixed` this is the tier for every call. Under `smart` it is the
|
|
70
|
-
# fallback used when a PR's file list cannot be read
|
|
74
|
+
# fallback used when a PR's file list cannot be read, and when an explicit
|
|
75
|
+
# request (`crosscheck review`, `run --steps`) overrides a class that would
|
|
76
|
+
# skip the PR.
|
|
71
77
|
#
|
|
72
78
|
# Cost is output-token cost at 48k output tokens, the measured median for one
|
|
73
79
|
# review. A review is an agentic session, so it takes 10-16 minutes of wall
|
|
@@ -254,12 +260,19 @@ post_review:
|
|
|
254
260
|
# timeout_min: 30 # minutes of no PR activity before the idle prompt fires (min: 5)
|
|
255
261
|
|
|
256
262
|
# ─── Backtrace ───────────────────────────────────────────────────────────────
|
|
257
|
-
#
|
|
258
|
-
#
|
|
263
|
+
# Scan all open PRs in the monitored scope and review any that have never
|
|
264
|
+
# received a [crosscheck] comment. Ensures coverage for PRs opened while
|
|
259
265
|
# crosscheck was offline or before it was first set up.
|
|
260
266
|
# Off by default — enable with the config below or pass --backtrace at startup.
|
|
267
|
+
#
|
|
268
|
+
# Webhook delivery is best-effort, so the scan is also the recovery path for PRs
|
|
269
|
+
# no future event will mention: opened while watch was down, an org hook that
|
|
270
|
+
# failed to register, a smee reconnect gap, or a push dropped while the PR was
|
|
271
|
+
# locked. Set interval_min to re-run it on a cadence, not just at startup.
|
|
261
272
|
# backtrace:
|
|
262
|
-
# enabled: true
|
|
273
|
+
# enabled: true # set to true to enable the scan (or pass --backtrace)
|
|
274
|
+
# interval_min: 30 # re-scan every N minutes while watch is up; 0 = startup only
|
|
275
|
+
# concurrency: 2 # max reviews the scan starts at once (default 2)
|
|
263
276
|
|
|
264
277
|
# ─── Issue enrichment ────────────────────────────────────────────────────────
|
|
265
278
|
# Recover the linked tracker issue (e.g. IN-2017) from the PR title / branch /
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
2
|
import { EventEmitter } from 'events';
|
|
3
|
-
import
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import { PRBoard, fmtEnteredAt, fmtTokens, pageKeyAction, isNoticeLine, distribute, fmtUptime } from '../lib/board.js';
|
|
4
5
|
describe('fmtTokens', () => {
|
|
5
6
|
it('returns empty string for undefined', () => {
|
|
6
7
|
expect(fmtTokens(undefined)).toBe('');
|
|
@@ -87,6 +88,14 @@ describe('PRBoard — TTY workspace retention', () => {
|
|
|
87
88
|
const invokeRender = () => board.render();
|
|
88
89
|
const invokeFolded = (key) => board.renderPRSlotFolded(slots().get(key));
|
|
89
90
|
const superseded = (key) => slots().get(key)?.superseded === true;
|
|
91
|
+
// The PR rows only. Layout is config │ stats │ workspace │ footer, separated by
|
|
92
|
+
// rules, so the third block is the workspace. Assertions about which PRs are on
|
|
93
|
+
// screen scope to this: the stats panel names verdicts too (outcome shares), so
|
|
94
|
+
// searching the whole frame for "BLOCK" no longer answers "is that row shown?".
|
|
95
|
+
const workspaceOf = (output) => {
|
|
96
|
+
const blocks = output.split(/^─+$/m);
|
|
97
|
+
return blocks[2] ?? '';
|
|
98
|
+
};
|
|
90
99
|
it('keeps completed slot in the workspace (no auto-clear)', () => {
|
|
91
100
|
board.addPR('k1', 1, 'a/b', 'main');
|
|
92
101
|
board.completePR('k1', { elapsedMs: 1000, url: 'https://github.com/a/b/pull/1' });
|
|
@@ -167,9 +176,9 @@ describe('PRBoard — TTY workspace retention', () => {
|
|
|
167
176
|
expect(slots().has('k1@sha2')).toBe(true); // new round present
|
|
168
177
|
board.updatePR('k1@sha2', { recheckVerdict: 'APPROVE' });
|
|
169
178
|
board.completePR('k1@sha2', { elapsedMs: 362_000, url: 'https://github.com/owner/repo/pull/214' });
|
|
170
|
-
const
|
|
171
|
-
expect(
|
|
172
|
-
expect(
|
|
179
|
+
const workspace = workspaceOf(stripAnsi(invokeRender()));
|
|
180
|
+
expect(workspace).not.toContain('BLOCK');
|
|
181
|
+
expect(workspace).toContain('APPROVE');
|
|
173
182
|
});
|
|
174
183
|
it('does not supersede active slots when round 2 starts', () => {
|
|
175
184
|
// Active round 1 for a different PR — must not be touched
|
|
@@ -319,6 +328,27 @@ describe('PRBoard — viewport height fitting', () => {
|
|
|
319
328
|
const residue = scrollback.filter(l => liveOnlyMarkers.some(m => l.includes(m)));
|
|
320
329
|
expect(residue).toEqual([]);
|
|
321
330
|
});
|
|
331
|
+
// failPR settles into the live block now rather than printing a static line
|
|
332
|
+
// per failure. This pins that: a long run of failures on a short viewport must
|
|
333
|
+
// stay a redraw, never scrollback. (It is an invariant guard, not a reproducer
|
|
334
|
+
// — the doubled header operators report comes from a viewport resize, which
|
|
335
|
+
// strands rows above the cursor-up clamp regardless of what wrote them.)
|
|
336
|
+
it('keeps a long run of failures in the live block, not scrollback', () => {
|
|
337
|
+
const ROWS = 14, COLS = 100;
|
|
338
|
+
setViewport(ROWS, COLS);
|
|
339
|
+
board.setTunnel('smee', 'https://smee.io/test', true);
|
|
340
|
+
invokeRedraw();
|
|
341
|
+
for (let i = 0; i < 20; i++) {
|
|
342
|
+
board.addPR(`k${i}`, 4600 + i, 'humanbased-ai/monorepo', `codex/branch-${i}`);
|
|
343
|
+
invokeRedraw();
|
|
344
|
+
board.failPR(`k${i}`, 'codex: timed out after 1200s (retried once) — PR diff may be too large');
|
|
345
|
+
invokeRedraw();
|
|
346
|
+
}
|
|
347
|
+
const { scrollback } = emulateVT(captured.join(''), ROWS, COLS);
|
|
348
|
+
const liveOnlyMarkers = ['crosscheck', 'workflow:', 'vendors:', 'PRs:', 'tunnel:'];
|
|
349
|
+
const residue = scrollback.filter(l => liveOnlyMarkers.some(m => l.includes(m)));
|
|
350
|
+
expect(residue).toEqual([]);
|
|
351
|
+
});
|
|
322
352
|
});
|
|
323
353
|
// ── History pagination ────────────────────────────────────────────────────────
|
|
324
354
|
//
|
|
@@ -353,6 +383,7 @@ describe('PRBoard — history pagination', () => {
|
|
|
353
383
|
const page = () => board.page;
|
|
354
384
|
const pageCount = () => board.pageCount;
|
|
355
385
|
// The footer is the last line — the tip line also names the page keys.
|
|
386
|
+
const slotsOf = (b) => b.slots;
|
|
356
387
|
const footerOf = (content) => stripAnsi(content).split('\n').at(-1) ?? '';
|
|
357
388
|
const addCompleted = (n) => {
|
|
358
389
|
for (let i = 0; i < n; i++) {
|
|
@@ -402,7 +433,7 @@ describe('PRBoard — history pagination', () => {
|
|
|
402
433
|
board.pageOlder();
|
|
403
434
|
const older = footerOf(invokeRender());
|
|
404
435
|
expect(older).toContain('history · page 2/');
|
|
405
|
-
expect(older).toContain('
|
|
436
|
+
expect(older).toContain('→');
|
|
406
437
|
});
|
|
407
438
|
it('keeps a history page inside the viewport', () => {
|
|
408
439
|
addCompleted(60);
|
|
@@ -412,11 +443,50 @@ describe('PRBoard — history pagination', () => {
|
|
|
412
443
|
.reduce((sum, l) => sum + Math.max(1, Math.ceil(l.length / 120)), 0);
|
|
413
444
|
expect(rows).toBeLessThanOrEqual(19);
|
|
414
445
|
});
|
|
446
|
+
it('mutes the page key that has nowhere to go', () => {
|
|
447
|
+
const level = chalk.level;
|
|
448
|
+
chalk.level = 3;
|
|
449
|
+
try {
|
|
450
|
+
addCompleted(60);
|
|
451
|
+
const rawFooter = () => invokeRender().split('\n').at(-1) ?? '';
|
|
452
|
+
const muted = (k) => chalk.gray.dim(k);
|
|
453
|
+
// Live page: nothing newer, so ← fades; → still leads somewhere.
|
|
454
|
+
expect(rawFooter()).toContain(muted('← newer'));
|
|
455
|
+
expect(rawFooter()).not.toContain(muted('→ older'));
|
|
456
|
+
board.pageOlder();
|
|
457
|
+
expect(rawFooter()).not.toContain(muted('← newer'));
|
|
458
|
+
expect(rawFooter()).not.toContain(muted('→ older'));
|
|
459
|
+
for (let i = 0; i < 50; i++)
|
|
460
|
+
board.pageOlder();
|
|
461
|
+
expect(rawFooter()).toContain(muted('→ older'));
|
|
462
|
+
expect(rawFooter()).not.toContain(muted('← newer'));
|
|
463
|
+
}
|
|
464
|
+
finally {
|
|
465
|
+
chalk.level = level;
|
|
466
|
+
}
|
|
467
|
+
});
|
|
468
|
+
it('shows when each PR entered, in a column after the PR number', () => {
|
|
469
|
+
board.addPR('a', 7, 'acme/api', 'branch-a');
|
|
470
|
+
board.addPR('b', 4880, 'acme/api', 'branch-b');
|
|
471
|
+
for (const k of ['a', 'b']) {
|
|
472
|
+
board.updatePR(k, { verdict: 'APPROVE', commentCount: 0 });
|
|
473
|
+
board.completePR(k, { elapsedMs: 60_000, url: `https://github.com/acme/api/pull/${k}` });
|
|
474
|
+
}
|
|
475
|
+
const rows = stripAnsi(invokeRender()).split('\n').filter(l => l.includes('branch-'));
|
|
476
|
+
expect(rows).toHaveLength(2);
|
|
477
|
+
const stampOf = (k) => fmtEnteredAt(slotsOf(board).get(k).startedAt);
|
|
478
|
+
const [sa, sb] = [stampOf('a'), stampOf('b')];
|
|
479
|
+
expect(rows[0]).toContain(sa);
|
|
480
|
+
expect(rows[1]).toContain(sb);
|
|
481
|
+
// #7 is padded to #4880's width, so the timestamp starts in the same column.
|
|
482
|
+
expect(rows[0].indexOf(sa)).toBe(rows[1].indexOf(sb));
|
|
483
|
+
expect(rows[0].indexOf('#7')).toBeLessThan(rows[0].indexOf(sa));
|
|
484
|
+
});
|
|
415
485
|
it('has one page when everything fits on the live page', () => {
|
|
416
486
|
addCompleted(2);
|
|
417
487
|
invokeRender();
|
|
418
488
|
expect(pageCount()).toBe(1);
|
|
419
|
-
expect(footerOf(invokeRender())).not.toContain('
|
|
489
|
+
expect(footerOf(invokeRender())).not.toContain('older'); // no keys offered with nowhere to go
|
|
420
490
|
expect(footerOf(invokeRender())).toContain('showing 2 of 2');
|
|
421
491
|
});
|
|
422
492
|
});
|
|
@@ -477,16 +547,16 @@ describe('PRBoard — key input', () => {
|
|
|
477
547
|
fillHistory();
|
|
478
548
|
board.start();
|
|
479
549
|
expect(fake.isRaw).toBe(true);
|
|
480
|
-
fake.emit('data', Buffer.from('
|
|
550
|
+
fake.emit('data', Buffer.from('\u001b[C'));
|
|
481
551
|
expect(page()).toBe(1);
|
|
482
|
-
fake.emit('data', Buffer.from('
|
|
552
|
+
fake.emit('data', Buffer.from('\u001b[C'));
|
|
483
553
|
expect(page()).toBe(2);
|
|
484
|
-
fake.emit('data', Buffer.from('
|
|
554
|
+
fake.emit('data', Buffer.from('\u001b[D'));
|
|
485
555
|
expect(page()).toBe(1);
|
|
486
556
|
board.stop();
|
|
487
557
|
expect(fake.isRaw).toBe(false); // terminal handed back
|
|
488
558
|
expect(fake.listenerCount('data')).toBe(0);
|
|
489
|
-
fake.emit('data', Buffer.from('
|
|
559
|
+
fake.emit('data', Buffer.from('\u001b[C'));
|
|
490
560
|
expect(page()).toBe(1); // no longer listening
|
|
491
561
|
});
|
|
492
562
|
it('raises SIGINT itself, since raw mode suppresses the terminal ctrl-c', () => {
|
|
@@ -505,29 +575,17 @@ describe('PRBoard — key input', () => {
|
|
|
505
575
|
});
|
|
506
576
|
});
|
|
507
577
|
describe('pageKeyAction', () => {
|
|
508
|
-
it('maps
|
|
509
|
-
expect(pageKeyAction('
|
|
510
|
-
expect(pageKeyAction('
|
|
511
|
-
expect(pageKeyAction('
|
|
512
|
-
expect(pageKeyAction('
|
|
513
|
-
});
|
|
514
|
-
it('
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
});
|
|
520
|
-
it('maps plain and modified arrows', () => {
|
|
521
|
-
expect(pageKeyAction('\u001b[D')).toBe('older');
|
|
522
|
-
expect(pageKeyAction('\u001b[C')).toBe('newer');
|
|
523
|
-
expect(pageKeyAction('\u001b[1;5D')).toBe('older');
|
|
524
|
-
expect(pageKeyAction('\u001b[1;5C')).toBe('newer');
|
|
525
|
-
});
|
|
526
|
-
it('ignores everything else', () => {
|
|
527
|
-
expect(pageKeyAction('a')).toBe(null);
|
|
528
|
-
expect(pageKeyAction('\u0003')).toBe(null);
|
|
529
|
-
expect(pageKeyAction('\u001b[A')).toBe(null);
|
|
530
|
-
expect(pageKeyAction('\u001b[48;5u')).toBe(null);
|
|
578
|
+
it('maps the left and right arrows, in normal and application cursor mode', () => {
|
|
579
|
+
expect(pageKeyAction('\u001b[D')).toBe('newer');
|
|
580
|
+
expect(pageKeyAction('\u001b[C')).toBe('older');
|
|
581
|
+
expect(pageKeyAction('\u001bOD')).toBe('newer');
|
|
582
|
+
expect(pageKeyAction('\u001bOC')).toBe('older');
|
|
583
|
+
});
|
|
584
|
+
it('ignores every other key, including the retired < > , . and modified-arrow bindings', () => {
|
|
585
|
+
for (const seq of ['<', '>', ',', '.', '\u001b[1;5D', '\u001b[1;5C', '\u001bb', '\u001bf',
|
|
586
|
+
'\u001b\u001b[D', '\u001b[44;5u', '\u001b[5~', '\u001b[6~', 'a', '\u0003', '\u001b[A']) {
|
|
587
|
+
expect(pageKeyAction(seq)).toBe(null);
|
|
588
|
+
}
|
|
531
589
|
});
|
|
532
590
|
});
|
|
533
591
|
describe('isNoticeLine', () => {
|
|
@@ -544,6 +602,247 @@ describe('isNoticeLine', () => {
|
|
|
544
602
|
expect(isNoticeLine(' skills: typescript')).toBe(false);
|
|
545
603
|
});
|
|
546
604
|
});
|
|
605
|
+
describe('distribute', () => {
|
|
606
|
+
it('returns all zeroes when nothing has been counted', () => {
|
|
607
|
+
expect(distribute([0, 0, 0])).toEqual([0, 0, 0]);
|
|
608
|
+
});
|
|
609
|
+
it('sums to exactly 100 where plain rounding would not', () => {
|
|
610
|
+
// Three equal shares floor to 33 each; the leftover point goes to a remainder.
|
|
611
|
+
const out = distribute([1, 1, 1]);
|
|
612
|
+
expect(out.reduce((a, b) => a + b, 0)).toBe(100);
|
|
613
|
+
expect(out.sort((a, b) => a - b)).toEqual([33, 33, 34]);
|
|
614
|
+
});
|
|
615
|
+
it('sums to 100 across a spread of awkward splits', () => {
|
|
616
|
+
const cases = [[1, 2], [1, 1, 1, 1, 1, 1], [7, 11, 13], [95, 1, 1, 1, 1, 1], [1, 999]];
|
|
617
|
+
for (const c of cases) {
|
|
618
|
+
expect(distribute(c).reduce((a, b) => a + b, 0)).toBe(100);
|
|
619
|
+
}
|
|
620
|
+
});
|
|
621
|
+
it('gives a lone outcome the whole 100%', () => {
|
|
622
|
+
expect(distribute([43, 0, 0])).toEqual([100, 0, 0]);
|
|
623
|
+
});
|
|
624
|
+
it('reports 0 for a share too small to earn a point, so callers can say "<1%"', () => {
|
|
625
|
+
const out = distribute([999, 1]);
|
|
626
|
+
expect(out[1]).toBe(0);
|
|
627
|
+
expect(out.reduce((a, b) => a + b, 0)).toBe(100);
|
|
628
|
+
});
|
|
629
|
+
});
|
|
630
|
+
describe('fmtUptime', () => {
|
|
631
|
+
it('renders minutes only under an hour', () => {
|
|
632
|
+
expect(fmtUptime(0)).toBe('0m');
|
|
633
|
+
expect(fmtUptime(59_000)).toBe('0m');
|
|
634
|
+
expect(fmtUptime(12 * 60_000)).toBe('12m');
|
|
635
|
+
});
|
|
636
|
+
it('renders hours and zero-padded minutes past the hour', () => {
|
|
637
|
+
expect(fmtUptime(60 * 60_000)).toBe('1h00m');
|
|
638
|
+
expect(fmtUptime((3 * 60 + 32) * 60_000)).toBe('3h32m');
|
|
639
|
+
expect(fmtUptime((11 * 60 + 57) * 60_000)).toBe('11h57m');
|
|
640
|
+
});
|
|
641
|
+
it('never renders a negative age if the clock steps backwards', () => {
|
|
642
|
+
expect(fmtUptime(-5000)).toBe('0m');
|
|
643
|
+
});
|
|
644
|
+
});
|
|
645
|
+
describe('PRBoard \u2014 failed PRs stay in the workspace', () => {
|
|
646
|
+
let board;
|
|
647
|
+
let originalIsTTY;
|
|
648
|
+
let originalRows;
|
|
649
|
+
let originalWrite;
|
|
650
|
+
let written;
|
|
651
|
+
beforeEach(() => {
|
|
652
|
+
originalIsTTY = process.stdout.isTTY;
|
|
653
|
+
originalRows = process.stdout.rows;
|
|
654
|
+
Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true });
|
|
655
|
+
Object.defineProperty(process.stdout, 'rows', { value: 200, configurable: true });
|
|
656
|
+
originalWrite = process.stdout.write.bind(process.stdout);
|
|
657
|
+
written = [];
|
|
658
|
+
process.stdout.write = ((chunk) => { written.push(String(chunk)); return true; });
|
|
659
|
+
board = new PRBoard();
|
|
660
|
+
board.setConfig(baseConfig, [reviewStep]);
|
|
661
|
+
});
|
|
662
|
+
afterEach(() => {
|
|
663
|
+
board.stop();
|
|
664
|
+
process.stdout.write = originalWrite;
|
|
665
|
+
Object.defineProperty(process.stdout, 'isTTY', { value: originalIsTTY, configurable: true });
|
|
666
|
+
Object.defineProperty(process.stdout, 'rows', { value: originalRows, configurable: true });
|
|
667
|
+
});
|
|
668
|
+
const slots = () => board.slots;
|
|
669
|
+
const invokeRender = () => board.render();
|
|
670
|
+
it('keeps the slot instead of deleting it', () => {
|
|
671
|
+
board.addPR('k1', 4641, 'a/b', 'feat/x');
|
|
672
|
+
board.failPR('k1', 'codex: timed out after 600s');
|
|
673
|
+
expect(slots().size).toBe(1);
|
|
674
|
+
expect(slots().has('k1')).toBe(true);
|
|
675
|
+
});
|
|
676
|
+
it('renders the failed PR as a row carrying its error, not as scrollback', () => {
|
|
677
|
+
board.addPR('k1', 4641, 'a/b', 'feat/x');
|
|
678
|
+
board.failPR('k1', 'codex: timed out after 600s');
|
|
679
|
+
const output = stripAnsi(invokeRender());
|
|
680
|
+
expect(output).toContain('#4641');
|
|
681
|
+
expect(output).toContain('codex: timed out after 600s');
|
|
682
|
+
// The regression: the row went to scrollback and the table read "no PRs yet".
|
|
683
|
+
expect(output).not.toContain('no PRs yet');
|
|
684
|
+
expect(written.join('')).not.toContain('codex: timed out after 600s');
|
|
685
|
+
});
|
|
686
|
+
it('folds a failed row even when it is the only one on the page', () => {
|
|
687
|
+
board.addPR('k1', 4641, 'a/b', 'feat/x');
|
|
688
|
+
board.failPR('k1', 'codex: timed out after 600s');
|
|
689
|
+
const output = stripAnsi(invokeRender());
|
|
690
|
+
// Folded rows carry no pipeline bars; an expanded row would show "CR" queued
|
|
691
|
+
// against work the failed run will never do.
|
|
692
|
+
expect(output).not.toMatch(/CR [\u2588\u2591]/);
|
|
693
|
+
expect(output).toContain('\u2717 #4641');
|
|
694
|
+
});
|
|
695
|
+
it('flattens a multi-line subprocess dump into a single row', () => {
|
|
696
|
+
board.addPR('k1', 4823, 'a/b', 'feat/x');
|
|
697
|
+
board.failPR('k1', 'claude: Command failed with exit code 1: claude --print\n\n{"is_error":true,\n"result":"limit reached"}');
|
|
698
|
+
const rows = stripAnsi(invokeRender()).split('\n').filter(l => l.includes('#4823'));
|
|
699
|
+
// One row per folded slot is what history pagination sizes a page by.
|
|
700
|
+
expect(rows).toHaveLength(1);
|
|
701
|
+
expect(rows[0]).not.toContain('{');
|
|
702
|
+
expect(rows[0]).toContain('claude: Command failed');
|
|
703
|
+
});
|
|
704
|
+
it('counts a failure once, toward errors and the outcome split', () => {
|
|
705
|
+
board.addPR('k1', 1, 'a/b', 'feat/x');
|
|
706
|
+
board.failPR('k1', 'boom');
|
|
707
|
+
const stats = board.stats;
|
|
708
|
+
expect(stats.errorsOccurred).toBe(1);
|
|
709
|
+
expect(stats.outcomes.error).toBe(1);
|
|
710
|
+
});
|
|
711
|
+
it('still counts the error when the slot is already gone', () => {
|
|
712
|
+
board.failPR('never-added', 'boom');
|
|
713
|
+
const stats = board.stats;
|
|
714
|
+
expect(stats.errorsOccurred).toBe(1);
|
|
715
|
+
// No slot means no row, so nothing should be added to the distribution.
|
|
716
|
+
expect(stats.outcomes.error).toBe(0);
|
|
717
|
+
});
|
|
718
|
+
it('pages failed rows into history like any other settled row', () => {
|
|
719
|
+
for (let i = 0; i < 40; i++) {
|
|
720
|
+
board.addPR(`k${i}`, 4600 + i, 'a/b', `feat/${i}`);
|
|
721
|
+
board.failPR(`k${i}`, 'codex: timed out after 600s');
|
|
722
|
+
}
|
|
723
|
+
invokeRender();
|
|
724
|
+
expect(slots().size).toBe(40);
|
|
725
|
+
const pageCount = board.pageCount;
|
|
726
|
+
expect(pageCount).toBeGreaterThan(0);
|
|
727
|
+
});
|
|
728
|
+
it('emits the folded line to scrollback and drops the slot when not a TTY', () => {
|
|
729
|
+
Object.defineProperty(process.stdout, 'isTTY', { value: false, configurable: true });
|
|
730
|
+
const nonTty = new PRBoard();
|
|
731
|
+
nonTty.setConfig(baseConfig, [reviewStep]);
|
|
732
|
+
nonTty.addPR('k1', 4641, 'a/b', 'feat/x');
|
|
733
|
+
nonTty.failPR('k1', 'codex: timed out after 600s');
|
|
734
|
+
expect(stripAnsi(written.join(''))).toContain('codex: timed out after 600s');
|
|
735
|
+
expect(nonTty.slots.size).toBe(0);
|
|
736
|
+
});
|
|
737
|
+
});
|
|
738
|
+
describe('PRBoard \u2014 session stats panel', () => {
|
|
739
|
+
let board;
|
|
740
|
+
let originalIsTTY;
|
|
741
|
+
let originalRows;
|
|
742
|
+
let originalWrite;
|
|
743
|
+
beforeEach(() => {
|
|
744
|
+
originalIsTTY = process.stdout.isTTY;
|
|
745
|
+
originalRows = process.stdout.rows;
|
|
746
|
+
Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true });
|
|
747
|
+
Object.defineProperty(process.stdout, 'rows', { value: 200, configurable: true });
|
|
748
|
+
originalWrite = process.stdout.write.bind(process.stdout);
|
|
749
|
+
process.stdout.write = (() => true);
|
|
750
|
+
board = new PRBoard();
|
|
751
|
+
board.setConfig(baseConfig, [reviewStep]);
|
|
752
|
+
});
|
|
753
|
+
afterEach(() => {
|
|
754
|
+
board.stop();
|
|
755
|
+
process.stdout.write = originalWrite;
|
|
756
|
+
Object.defineProperty(process.stdout, 'isTTY', { value: originalIsTTY, configurable: true });
|
|
757
|
+
Object.defineProperty(process.stdout, 'rows', { value: originalRows, configurable: true });
|
|
758
|
+
});
|
|
759
|
+
const invokeRender = () => board.render();
|
|
760
|
+
const setStart = (ms) => (board.stats.sessionStart = ms);
|
|
761
|
+
it('shows the local start time and the session age', () => {
|
|
762
|
+
const start = Date.now() - (3 * 60 + 32) * 60_000;
|
|
763
|
+
setStart(start);
|
|
764
|
+
const output = stripAnsi(invokeRender());
|
|
765
|
+
const expected = new Date(start).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: true });
|
|
766
|
+
expect(output).toContain(`since ${expected}`);
|
|
767
|
+
expect(output).toContain('up 3h32m');
|
|
768
|
+
});
|
|
769
|
+
it('omits the outcome split until something settles', () => {
|
|
770
|
+
const output = stripAnsi(invokeRender());
|
|
771
|
+
expect(output).toContain('since');
|
|
772
|
+
expect(output).not.toContain('%');
|
|
773
|
+
});
|
|
774
|
+
it('reports the outcome split as percentages that sum to 100', () => {
|
|
775
|
+
const verdicts = ['APPROVE', 'APPROVE', 'BLOCK', 'NEEDS WORK'];
|
|
776
|
+
verdicts.forEach((v, i) => {
|
|
777
|
+
board.addPR(`k${i}`, i, 'a/b', `b${i}`);
|
|
778
|
+
board.updatePR(`k${i}`, { verdict: v });
|
|
779
|
+
board.completePR(`k${i}`, { elapsedMs: 1000, url: `u${i}` });
|
|
780
|
+
});
|
|
781
|
+
const output = stripAnsi(invokeRender());
|
|
782
|
+
expect(output).toContain('APPROVE 50%');
|
|
783
|
+
expect(output).toContain('BLOCK 25%');
|
|
784
|
+
expect(output).toContain('NEEDS WORK 25%');
|
|
785
|
+
});
|
|
786
|
+
it('counts a failure in the split alongside verdicts', () => {
|
|
787
|
+
board.addPR('ok', 1, 'a/b', 'b1');
|
|
788
|
+
board.updatePR('ok', { verdict: 'APPROVE' });
|
|
789
|
+
board.completePR('ok', { elapsedMs: 1000, url: 'u1' });
|
|
790
|
+
board.addPR('bad', 2, 'a/b', 'b2');
|
|
791
|
+
board.failPR('bad', 'codex: timed out after 600s');
|
|
792
|
+
const output = stripAnsi(invokeRender());
|
|
793
|
+
expect(output).toContain('APPROVE 50%');
|
|
794
|
+
expect(output).toContain('error 50%');
|
|
795
|
+
});
|
|
796
|
+
it('takes the recheck verdict as the outcome when a recheck ran', () => {
|
|
797
|
+
board.addPR('k1', 1, 'a/b', 'b1');
|
|
798
|
+
board.updatePR('k1', { verdict: 'BLOCK', recheckVerdict: 'APPROVE' });
|
|
799
|
+
board.completePR('k1', { elapsedMs: 1000, url: 'u1' });
|
|
800
|
+
// Scoped to the stats block: the PR row still shows the round's own BLOCK in
|
|
801
|
+
// its CR bar, which is correct — only the split should read APPROVE.
|
|
802
|
+
const stats = stripAnsi(invokeRender()).split(/^─+$/m)[1] ?? '';
|
|
803
|
+
expect(stats).toContain('APPROVE 100%');
|
|
804
|
+
expect(stats).not.toContain('BLOCK');
|
|
805
|
+
});
|
|
806
|
+
it('separates a reviewer that returned nothing from one that never ran', () => {
|
|
807
|
+
board.addPR('noverdict', 1, 'a/b', 'b1');
|
|
808
|
+
board.updatePR('noverdict', { verdict: null });
|
|
809
|
+
board.completePR('noverdict', { elapsedMs: 1000, url: 'u1' });
|
|
810
|
+
board.addPR('skipped', 2, 'a/b', 'b2');
|
|
811
|
+
board.completePR('skipped', { elapsedMs: 900, url: 'u2', label: 'skipped \u00b7 generated' });
|
|
812
|
+
const output = stripAnsi(invokeRender());
|
|
813
|
+
expect(output).toContain('no verdict 50%');
|
|
814
|
+
expect(output).toContain('skipped 50%');
|
|
815
|
+
});
|
|
816
|
+
it('never pairs a "<1%" outcome with a flat 100%', () => {
|
|
817
|
+
for (let i = 0; i < 999; i++) {
|
|
818
|
+
board.addPR(`e${i}`, i, 'a/b', 'x');
|
|
819
|
+
board.failPR(`e${i}`, 'boom');
|
|
820
|
+
}
|
|
821
|
+
board.addPR('ok', 9999, 'a/b', 'y');
|
|
822
|
+
board.updatePR('ok', { verdict: 'APPROVE' });
|
|
823
|
+
board.completePR('ok', { elapsedMs: 1, url: 'u' });
|
|
824
|
+
const output = stripAnsi(invokeRender());
|
|
825
|
+
expect(output).toContain('APPROVE <1%');
|
|
826
|
+
expect(output).toContain('error >99%');
|
|
827
|
+
expect(output).not.toContain('error 100%');
|
|
828
|
+
});
|
|
829
|
+
it('still reads a clean 100% when one outcome is the only one', () => {
|
|
830
|
+
for (let i = 0; i < 5; i++) {
|
|
831
|
+
board.addPR(`e${i}`, i, 'a/b', 'x');
|
|
832
|
+
board.failPR(`e${i}`, 'boom');
|
|
833
|
+
}
|
|
834
|
+
expect(stripAnsi(invokeRender())).toContain('error 100%');
|
|
835
|
+
});
|
|
836
|
+
it('keeps the cumulative split after the slot history cap drops old rows', () => {
|
|
837
|
+
const stats = board.stats;
|
|
838
|
+
board.addPR('k1', 1, 'a/b', 'b1');
|
|
839
|
+
board.updatePR('k1', { verdict: 'APPROVE' });
|
|
840
|
+
board.completePR('k1', { elapsedMs: 1000, url: 'u1' });
|
|
841
|
+
board.slots.clear();
|
|
842
|
+
expect(stats.outcomes.APPROVE).toBe(1);
|
|
843
|
+
expect(stripAnsi(invokeRender())).toContain('APPROVE 100%');
|
|
844
|
+
});
|
|
845
|
+
});
|
|
547
846
|
// Minimal VT emulator: LF scrolls at the bottom row, CUU clamps at the viewport
|
|
548
847
|
// top, ED-0J clears cursor→end-of-screen, autowrap is deferred at the last
|
|
549
848
|
// column. Rows pushed off the top are collected as scrollback.
|