@humanbased/crosscheck 1.3.1-beta.7 → 1.6.0-beta.11
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 +16 -1
- package/assets/demo-block-to-approve.gif +0 -0
- package/assets/demo-block-to-approve.mp4 +0 -0
- package/dist/__tests__/base-ref-recovery.test.d.ts +2 -0
- package/dist/__tests__/base-ref-recovery.test.d.ts.map +1 -0
- package/dist/__tests__/base-ref-recovery.test.js +102 -0
- package/dist/__tests__/base-ref-recovery.test.js.map +1 -0
- package/dist/__tests__/board.test.js +510 -20
- package/dist/__tests__/board.test.js.map +1 -1
- package/dist/__tests__/codex-env.test.js +4 -0
- package/dist/__tests__/codex-env.test.js.map +1 -1
- package/dist/__tests__/credential-free-origin.test.js +134 -2
- package/dist/__tests__/credential-free-origin.test.js.map +1 -1
- package/dist/__tests__/demo-arc.test.d.ts +2 -0
- package/dist/__tests__/demo-arc.test.d.ts.map +1 -0
- package/dist/__tests__/demo-arc.test.js +73 -0
- package/dist/__tests__/demo-arc.test.js.map +1 -0
- package/dist/__tests__/doc-only-verdict.test.d.ts +2 -0
- package/dist/__tests__/doc-only-verdict.test.d.ts.map +1 -0
- package/dist/__tests__/doc-only-verdict.test.js +79 -0
- package/dist/__tests__/doc-only-verdict.test.js.map +1 -0
- package/dist/__tests__/error-classification.test.js +14 -0
- package/dist/__tests__/error-classification.test.js.map +1 -1
- package/dist/__tests__/fix-requires-recheck.test.d.ts +2 -0
- package/dist/__tests__/fix-requires-recheck.test.d.ts.map +1 -0
- package/dist/__tests__/fix-requires-recheck.test.js +56 -0
- package/dist/__tests__/fix-requires-recheck.test.js.map +1 -0
- package/dist/__tests__/human-feedback.test.d.ts +2 -0
- package/dist/__tests__/human-feedback.test.d.ts.map +1 -0
- package/dist/__tests__/human-feedback.test.js +132 -0
- package/dist/__tests__/human-feedback.test.js.map +1 -0
- package/dist/__tests__/inconclusive-review.test.d.ts +2 -0
- package/dist/__tests__/inconclusive-review.test.d.ts.map +1 -0
- package/dist/__tests__/inconclusive-review.test.js +110 -0
- package/dist/__tests__/inconclusive-review.test.js.map +1 -0
- 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__/merge-gate.test.d.ts +2 -0
- package/dist/__tests__/merge-gate.test.d.ts.map +1 -0
- package/dist/__tests__/merge-gate.test.js +220 -0
- package/dist/__tests__/merge-gate.test.js.map +1 -0
- package/dist/__tests__/no-verdict.test.d.ts +2 -0
- package/dist/__tests__/no-verdict.test.d.ts.map +1 -0
- package/dist/__tests__/no-verdict.test.js +415 -0
- package/dist/__tests__/no-verdict.test.js.map +1 -0
- package/dist/__tests__/pr-spec.test.js +37 -0
- package/dist/__tests__/pr-spec.test.js.map +1 -1
- package/dist/__tests__/run.test.js +50 -1
- package/dist/__tests__/run.test.js.map +1 -1
- package/dist/__tests__/runner.test.js +82 -1
- package/dist/__tests__/runner.test.js.map +1 -1
- package/dist/__tests__/standing-verdict-history.test.d.ts +2 -0
- package/dist/__tests__/standing-verdict-history.test.d.ts.map +1 -0
- package/dist/__tests__/standing-verdict-history.test.js +109 -0
- package/dist/__tests__/standing-verdict-history.test.js.map +1 -0
- package/dist/__tests__/step-plan.test.d.ts +2 -0
- package/dist/__tests__/step-plan.test.d.ts.map +1 -0
- package/dist/__tests__/step-plan.test.js +129 -0
- package/dist/__tests__/step-plan.test.js.map +1 -0
- package/dist/cli.js +14 -1
- package/dist/cli.js.map +1 -1
- package/dist/commands/merge.d.ts +11 -0
- package/dist/commands/merge.d.ts.map +1 -0
- package/dist/commands/merge.js +188 -0
- package/dist/commands/merge.js.map +1 -0
- package/dist/commands/review.d.ts +3 -1
- package/dist/commands/review.d.ts.map +1 -1
- package/dist/commands/review.js +117 -9
- package/dist/commands/review.js.map +1 -1
- package/dist/commands/run.d.ts +53 -0
- package/dist/commands/run.d.ts.map +1 -1
- package/dist/commands/run.js +218 -36
- package/dist/commands/run.js.map +1 -1
- package/dist/commands/watch.d.ts.map +1 -1
- package/dist/commands/watch.js +41 -26
- package/dist/commands/watch.js.map +1 -1
- package/dist/config/loader.d.ts +4 -0
- package/dist/config/loader.d.ts.map +1 -1
- package/dist/config/loader.js +8 -0
- package/dist/config/loader.js.map +1 -1
- package/dist/config/review-strategy.json +5 -5
- package/dist/github/client.d.ts +18 -1
- package/dist/github/client.d.ts.map +1 -1
- package/dist/github/client.js +19 -3
- package/dist/github/client.js.map +1 -1
- package/dist/github/merge.d.ts +28 -0
- package/dist/github/merge.d.ts.map +1 -1
- package/dist/github/merge.js +41 -0
- package/dist/github/merge.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/github/webhook.d.ts +1 -0
- package/dist/github/webhook.d.ts.map +1 -1
- package/dist/github/webhook.js.map +1 -1
- package/dist/lib/board.d.ts +60 -4
- package/dist/lib/board.d.ts.map +1 -1
- package/dist/lib/board.js +357 -65
- package/dist/lib/board.js.map +1 -1
- package/dist/lib/clone.d.ts +65 -1
- package/dist/lib/clone.d.ts.map +1 -1
- package/dist/lib/clone.js +252 -16
- package/dist/lib/clone.js.map +1 -1
- package/dist/lib/human-feedback.d.ts +9 -0
- package/dist/lib/human-feedback.d.ts.map +1 -0
- package/dist/lib/human-feedback.js +83 -0
- package/dist/lib/human-feedback.js.map +1 -0
- package/dist/lib/logger.d.ts +1 -1
- package/dist/lib/logger.d.ts.map +1 -1
- package/dist/lib/logger.js +6 -0
- package/dist/lib/logger.js.map +1 -1
- package/dist/lib/merge-gate.d.ts +70 -0
- package/dist/lib/merge-gate.d.ts.map +1 -0
- package/dist/lib/merge-gate.js +138 -0
- package/dist/lib/merge-gate.js.map +1 -0
- package/dist/lib/no-verdict.d.ts +104 -0
- package/dist/lib/no-verdict.d.ts.map +1 -0
- package/dist/lib/no-verdict.js +262 -0
- package/dist/lib/no-verdict.js.map +1 -0
- 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-spec.d.ts +7 -0
- package/dist/lib/pr-spec.d.ts.map +1 -1
- package/dist/lib/pr-spec.js +12 -1
- package/dist/lib/pr-spec.js.map +1 -1
- package/dist/lib/pr-workflow-state.d.ts +39 -0
- package/dist/lib/pr-workflow-state.d.ts.map +1 -1
- package/dist/lib/pr-workflow-state.js +73 -1
- package/dist/lib/pr-workflow-state.js.map +1 -1
- package/dist/lib/review-strategy.d.ts +14 -0
- package/dist/lib/review-strategy.d.ts.map +1 -1
- package/dist/lib/review-strategy.js +32 -1
- package/dist/lib/review-strategy.js.map +1 -1
- package/dist/lib/runner.d.ts +51 -2
- package/dist/lib/runner.d.ts.map +1 -1
- package/dist/lib/runner.js +211 -105
- package/dist/lib/runner.js.map +1 -1
- package/dist/lib/tips.d.ts.map +1 -1
- package/dist/lib/tips.js +1 -0
- package/dist/lib/tips.js.map +1 -1
- package/dist/lib/verdict.d.ts +30 -1
- package/dist/lib/verdict.d.ts.map +1 -1
- package/dist/lib/verdict.js +94 -9
- package/dist/lib/verdict.js.map +1 -1
- package/dist/reviewers/codex-env.d.ts.map +1 -1
- package/dist/reviewers/codex-env.js +2 -0
- package/dist/reviewers/codex-env.js.map +1 -1
- package/dist/reviewers/codex.d.ts.map +1 -1
- package/dist/reviewers/codex.js +3 -1
- package/dist/reviewers/codex.js.map +1 -1
- package/dist/reviewers/fix.d.ts +2 -2
- package/dist/reviewers/fix.d.ts.map +1 -1
- package/dist/reviewers/fix.js +10 -4
- package/dist/reviewers/fix.js.map +1 -1
- package/docs/growth/launch/README.md +59 -0
- package/docs/growth/launch/essay-stop-letting-one-agent-review-its-own-code.md +118 -0
- package/docs/growth/launch/posts.md +175 -0
- package/docs/trust.md +16 -1
- package/get-started.md +17 -0
- package/package.json +11 -3
package/dist/lib/board.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
import { selectTip } from './tips.js';
|
|
3
|
+
const OUTCOME_ORDER = ['APPROVE', 'NEEDS WORK', 'BLOCK', 'no verdict', 'skipped', 'error'];
|
|
3
4
|
// ── Constants ─────────────────────────────────────────────────────────────────
|
|
4
5
|
const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
5
6
|
const BAR_FILLED = '█';
|
|
@@ -27,6 +28,44 @@ function fmtDuration(ms) {
|
|
|
27
28
|
function fmtStartTime(epochMs) {
|
|
28
29
|
return new Date(epochMs).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: true });
|
|
29
30
|
}
|
|
31
|
+
// Session age in hours and minutes: "3h32m", or "12m" under the hour. Deliberately
|
|
32
|
+
// not fmtDuration: that renders MM:SS shaped output under an hour, which reads as
|
|
33
|
+
// hours:minutes on a long-running watch and understates the session by 60x.
|
|
34
|
+
export function fmtUptime(ms) {
|
|
35
|
+
const totalMin = Math.max(0, Math.floor(ms / 60_000));
|
|
36
|
+
const h = Math.floor(totalMin / 60);
|
|
37
|
+
const m = totalMin % 60;
|
|
38
|
+
return h > 0 ? `${h}h${String(m).padStart(2, '0')}m` : `${m}m`;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Whole percentages summing to exactly 100, by the largest-remainder method.
|
|
42
|
+
* Plain rounding drifts — three equal shares render as 33/33/33 — and a
|
|
43
|
+
* distribution that visibly fails to add up reads as a bug in the numbers.
|
|
44
|
+
*
|
|
45
|
+
* A non-zero count too small to earn a point comes back as 0; the caller
|
|
46
|
+
* renders those as "<1%" rather than claiming the outcome never happened.
|
|
47
|
+
*/
|
|
48
|
+
export function distribute(counts) {
|
|
49
|
+
const total = counts.reduce((a, b) => a + b, 0);
|
|
50
|
+
if (total === 0)
|
|
51
|
+
return counts.map(() => 0);
|
|
52
|
+
const exact = counts.map(c => (c * 100) / total);
|
|
53
|
+
const out = exact.map(Math.floor);
|
|
54
|
+
// Exact shares sum to 100, so the floors leave fewer whole points than there
|
|
55
|
+
// are entries: one pass in remainder order always places every last one.
|
|
56
|
+
let remaining = 100 - out.reduce((a, b) => a + b, 0);
|
|
57
|
+
const byRemainder = exact
|
|
58
|
+
.map((e, i) => ({ i, rem: e - Math.floor(e) }))
|
|
59
|
+
.filter(({ i }) => counts[i] > 0)
|
|
60
|
+
.sort((a, b) => b.rem - a.rem);
|
|
61
|
+
for (const { i } of byRemainder) {
|
|
62
|
+
if (remaining <= 0)
|
|
63
|
+
break;
|
|
64
|
+
out[i]++;
|
|
65
|
+
remaining--;
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
30
69
|
// Format token count as a compact suffix: "(900)", "(1.2K)", "(1.5M)". Returns '' when undefined.
|
|
31
70
|
export function fmtTokens(n) {
|
|
32
71
|
if (n == null)
|
|
@@ -38,7 +77,7 @@ export function fmtTokens(n) {
|
|
|
38
77
|
return `(${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M)`;
|
|
39
78
|
}
|
|
40
79
|
// Raw token count without surrounding parens: "900", "1.2K", "1.5M". Returns '' when undefined.
|
|
41
|
-
function fmtTokensRaw(n) {
|
|
80
|
+
export function fmtTokensRaw(n) {
|
|
42
81
|
if (n == null)
|
|
43
82
|
return '';
|
|
44
83
|
if (n < 1_000)
|
|
@@ -55,6 +94,104 @@ function stripAnsi(s) {
|
|
|
55
94
|
function truncate(s, max) {
|
|
56
95
|
return s.length <= max ? s : s.slice(0, max - 1) + '…';
|
|
57
96
|
}
|
|
97
|
+
// Cut a rendered line to `max` visible columns, keeping the ANSI sequences it
|
|
98
|
+
// passes over (they have no width) and closing with a reset so a cut mid-colour
|
|
99
|
+
// cannot bleed into the next line. Folded rows are clamped with this so they
|
|
100
|
+
// always occupy exactly one terminal row — history pagination counts on it.
|
|
101
|
+
function truncateVisible(s, max) {
|
|
102
|
+
if (max <= 0)
|
|
103
|
+
return '';
|
|
104
|
+
if (stripAnsi(s).length <= max)
|
|
105
|
+
return s;
|
|
106
|
+
let out = '';
|
|
107
|
+
let width = 0;
|
|
108
|
+
for (let i = 0; i < s.length; i++) {
|
|
109
|
+
const ch = s[i];
|
|
110
|
+
if (ch === '\x1B') {
|
|
111
|
+
const end = s.indexOf('m', i);
|
|
112
|
+
if (end === -1)
|
|
113
|
+
break;
|
|
114
|
+
out += s.slice(i, end + 1);
|
|
115
|
+
i = end;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (width >= max - 1)
|
|
119
|
+
break;
|
|
120
|
+
out += ch;
|
|
121
|
+
width++;
|
|
122
|
+
}
|
|
123
|
+
return out + '…\x1B[0m';
|
|
124
|
+
}
|
|
125
|
+
// Lines the runner hands to its log callback are prefixed ⚠ or ✗ when they
|
|
126
|
+
// report something an operator must act on. Everything else is progress
|
|
127
|
+
// narration that the board's own PR rows already carry, and a multi-line
|
|
128
|
+
// message is a dump (an unposted review, a dry-run comment) that always
|
|
129
|
+
// follows one of those notices. Only these earn a line of terminal scrollback
|
|
130
|
+
// beside the board; the rest goes to the file log.
|
|
131
|
+
// eslint-disable-next-line no-control-regex -- matching the ESC byte is the point
|
|
132
|
+
const NOTICE_PREFIX = /^(?:\x1B\[[0-9;]*m)*\s*[⚠✗]/;
|
|
133
|
+
export function isNoticeLine(msg) {
|
|
134
|
+
return NOTICE_PREFIX.test(msg) || msg.includes('\n');
|
|
135
|
+
}
|
|
136
|
+
// Bare `<`/`>` (and their unshifted `,`/`.`) always work. Terminals cannot agree
|
|
137
|
+
// on how to report ctrl/cmd with a punctuation key, so every encoding they do
|
|
138
|
+
// emit is accepted: meta-prefixed, CSI-u (kitty / xterm modifyOtherKeys) and
|
|
139
|
+
// modified arrows. macOS Terminal swallows cmd entirely — there the bare keys,
|
|
140
|
+
// or ctrl+arrow, are the way through.
|
|
141
|
+
const PAGE_OLDER_KEYS = new Set(['<', ',', '\u001b<', '\u001b,', '\u001b[D', '\u001b[5~']);
|
|
142
|
+
const PAGE_NEWER_KEYS = new Set(['>', '.', '\u001b>', '\u001b.', '\u001b[C', '\u001b[6~']);
|
|
143
|
+
// eslint-disable-next-line no-control-regex -- the ESC byte is the sequence
|
|
144
|
+
const CSI_U = /^\u001b\[(\d+);\d+u$/; // ESC [ <codepoint> ; <modifiers> u
|
|
145
|
+
// eslint-disable-next-line no-control-regex -- the ESC byte is the sequence
|
|
146
|
+
const CSI_ARROW = /^\u001b\[1;\d+([DC])$/; // ESC [ 1 ; <modifiers> D|C
|
|
147
|
+
/** Map a raw stdin key sequence to a page direction, or null when it is not a page key. */
|
|
148
|
+
export function pageKeyAction(seq) {
|
|
149
|
+
if (PAGE_OLDER_KEYS.has(seq))
|
|
150
|
+
return 'older';
|
|
151
|
+
if (PAGE_NEWER_KEYS.has(seq))
|
|
152
|
+
return 'newer';
|
|
153
|
+
const csiU = CSI_U.exec(seq);
|
|
154
|
+
if (csiU) {
|
|
155
|
+
const codepoint = Number(csiU[1]);
|
|
156
|
+
if (codepoint === 44 || codepoint === 60)
|
|
157
|
+
return 'older'; // , <
|
|
158
|
+
if (codepoint === 46 || codepoint === 62)
|
|
159
|
+
return 'newer'; // . >
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
const arrow = CSI_ARROW.exec(seq);
|
|
163
|
+
if (arrow)
|
|
164
|
+
return arrow[1] === 'D' ? 'older' : 'newer';
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
// Keep every active slot plus the newest `budget` settled ones. Anything older
|
|
168
|
+
// cannot fit the live page (one row minimum per slot), so the fitting loop need
|
|
169
|
+
// never consider it.
|
|
170
|
+
function trimToBudget(pool, budget) {
|
|
171
|
+
const settled = pool.filter(s => s.completedAt !== undefined);
|
|
172
|
+
if (settled.length <= budget)
|
|
173
|
+
return pool.slice();
|
|
174
|
+
const keep = new Set(settled.slice(settled.length - budget));
|
|
175
|
+
return pool.filter(s => s.completedAt === undefined || keep.has(s));
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* The one outcome a settled slot counts toward. A recheck verdict supersedes
|
|
179
|
+
* the review verdict — it is the later word on the same PR — and `null` from
|
|
180
|
+
* either step means a reviewer ran but returned nothing parseable, which is a
|
|
181
|
+
* different event from never having run at all.
|
|
182
|
+
*/
|
|
183
|
+
function outcomeOf(slot) {
|
|
184
|
+
if (slot.error !== undefined)
|
|
185
|
+
return 'error';
|
|
186
|
+
const final = typeof slot.recheckVerdict === 'string'
|
|
187
|
+
? slot.recheckVerdict
|
|
188
|
+
: typeof slot.verdict === 'string' ? slot.verdict : null;
|
|
189
|
+
if (final === 'APPROVE' || final === 'NEEDS WORK' || final === 'BLOCK')
|
|
190
|
+
return final;
|
|
191
|
+
if (slot.recheckVerdict === null || slot.verdict === null)
|
|
192
|
+
return 'no verdict';
|
|
193
|
+
return 'skipped';
|
|
194
|
+
}
|
|
58
195
|
function makeBar(filled, total, fillFn, emptyFn) {
|
|
59
196
|
const f = Math.max(0, Math.min(total, Math.round(filled)));
|
|
60
197
|
return fillFn(BAR_FILLED.repeat(f)) + emptyFn(BAR_EMPTY.repeat(total - f));
|
|
@@ -154,7 +291,7 @@ function buildTheme(cfg) {
|
|
|
154
291
|
const CONN_LOG_MAX = 6; // max connectivity log lines kept in memory
|
|
155
292
|
const COMPACT_CONN_LOG_LINES = 2; // conn log lines shown when the live block must shrink to fit the viewport
|
|
156
293
|
const FOLD_THRESHOLD = 3; // when completed count exceeds this, fold all completed PRs to 1 line
|
|
157
|
-
const
|
|
294
|
+
const HISTORY_MAX = 2000; // hard cap on retained slots — oldest settled rows drop out beyond it
|
|
158
295
|
const LAYOUT_NORMAL = { foldAll: false, connLogLines: CONN_LOG_MAX, showTip: true };
|
|
159
296
|
const LAYOUT_COMPACT = { foldAll: true, connLogLines: COMPACT_CONN_LOG_LINES, showTip: false };
|
|
160
297
|
export class PRBoard {
|
|
@@ -165,6 +302,10 @@ export class PRBoard {
|
|
|
165
302
|
liveContent = '';
|
|
166
303
|
isTTY = Boolean(process.stdout.isTTY);
|
|
167
304
|
connLog = [];
|
|
305
|
+
page = 0; // 0 = live page (newest); higher = further back in history
|
|
306
|
+
pageCount = 1; // recomputed every render; page keys clamp against it
|
|
307
|
+
keyHandler = null;
|
|
308
|
+
stdinWasRaw = false;
|
|
168
309
|
stats = {
|
|
169
310
|
prsReceived: 0,
|
|
170
311
|
crsCompleted: 0,
|
|
@@ -172,6 +313,7 @@ export class PRBoard {
|
|
|
172
313
|
errorsOccurred: 0,
|
|
173
314
|
crTotalMs: 0,
|
|
174
315
|
sessionStart: Date.now(),
|
|
316
|
+
outcomes: { 'APPROVE': 0, 'NEEDS WORK': 0, 'BLOCK': 0, 'no verdict': 0, 'skipped': 0, 'error': 0 },
|
|
175
317
|
};
|
|
176
318
|
tunnel = {
|
|
177
319
|
type: 'none', url: null, alive: false,
|
|
@@ -195,6 +337,7 @@ export class PRBoard {
|
|
|
195
337
|
start() {
|
|
196
338
|
if (!this.isTTY)
|
|
197
339
|
return;
|
|
340
|
+
this.attachKeys();
|
|
198
341
|
this.timer = setInterval(() => {
|
|
199
342
|
this.frameIdx = (this.frameIdx + 1) % FRAMES.length;
|
|
200
343
|
this.redraw();
|
|
@@ -205,18 +348,34 @@ export class PRBoard {
|
|
|
205
348
|
clearInterval(this.timer);
|
|
206
349
|
this.timer = null;
|
|
207
350
|
}
|
|
351
|
+
this.detachKeys();
|
|
208
352
|
this.eraseLive();
|
|
209
353
|
}
|
|
354
|
+
/** Flip one page toward older history. No-op when already at the oldest page. */
|
|
355
|
+
pageOlder() {
|
|
356
|
+
const next = Math.min(this.page + 1, Math.max(0, this.pageCount - 1));
|
|
357
|
+
if (next === this.page)
|
|
358
|
+
return;
|
|
359
|
+
this.page = next;
|
|
360
|
+
this.redraw();
|
|
361
|
+
}
|
|
362
|
+
/** Flip one page toward the live page. No-op when already on it. */
|
|
363
|
+
pageNewer() {
|
|
364
|
+
if (this.page === 0)
|
|
365
|
+
return;
|
|
366
|
+
this.page--;
|
|
367
|
+
this.redraw();
|
|
368
|
+
}
|
|
210
369
|
addPR(key, prNumber, repo, branch, round) {
|
|
211
370
|
// When a new round starts for a PR that already has a completed slot in the
|
|
212
|
-
// workspace,
|
|
213
|
-
//
|
|
214
|
-
//
|
|
371
|
+
// workspace, mark the prior-round slot superseded so only the current round
|
|
372
|
+
// shows on the live page. It stays in the map: history pages still carry it.
|
|
373
|
+
// Prior-round slots always have a different key (different SHA suffix) but
|
|
374
|
+
// the same prNumber + repo combination.
|
|
215
375
|
if ((round ?? 1) >= 2) {
|
|
216
376
|
for (const [existingKey, slot] of this.slots) {
|
|
217
377
|
if (existingKey !== key && slot.prNumber === prNumber && slot.repo === repo && slot.completedAt !== undefined) {
|
|
218
|
-
|
|
219
|
-
this.slots.delete(existingKey);
|
|
378
|
+
slot.superseded = true;
|
|
220
379
|
}
|
|
221
380
|
}
|
|
222
381
|
}
|
|
@@ -271,19 +430,32 @@ export class PRBoard {
|
|
|
271
430
|
}
|
|
272
431
|
if (fixCount !== undefined && fixCount > 0)
|
|
273
432
|
this.stats.fixesApplied++;
|
|
433
|
+
this.stats.outcomes[outcomeOf(slot)]++;
|
|
274
434
|
// Non-TTY has no live block to re-render — emit the folded line to scrollback and drop the slot.
|
|
275
435
|
if (!this.isTTY) {
|
|
276
436
|
process.stdout.write(this.renderPRSlotFolded(slot) + '\n');
|
|
277
437
|
this.slots.delete(key);
|
|
278
438
|
}
|
|
279
439
|
}
|
|
440
|
+
/**
|
|
441
|
+
* Settle a slot that ended in an error. The slot stays in the workspace as a
|
|
442
|
+
* settled row, the same as a completed one: a failed run is a session record,
|
|
443
|
+
* and deleting it here was what pushed reviewer timeouts out of the table and
|
|
444
|
+
* into raw scrollback, where a long watch session showed 43 errors above an
|
|
445
|
+
* empty workspace reading "no PRs yet".
|
|
446
|
+
*/
|
|
280
447
|
failPR(key, error) {
|
|
281
448
|
const slot = this.slots.get(key);
|
|
282
|
-
this.slots.delete(key);
|
|
283
449
|
this.stats.errorsOccurred++;
|
|
284
|
-
if (slot)
|
|
285
|
-
|
|
286
|
-
|
|
450
|
+
if (!slot || slot.completedAt !== undefined)
|
|
451
|
+
return;
|
|
452
|
+
slot.completedAt = Date.now();
|
|
453
|
+
slot.error = error;
|
|
454
|
+
slot.label = 'failed';
|
|
455
|
+
this.stats.outcomes.error++;
|
|
456
|
+
if (!this.isTTY) {
|
|
457
|
+
process.stdout.write(this.renderPRSlotFolded(slot) + '\n');
|
|
458
|
+
this.slots.delete(key);
|
|
287
459
|
}
|
|
288
460
|
}
|
|
289
461
|
/** Print 1–2 static lines to scrollback (above the live block). */
|
|
@@ -353,14 +525,43 @@ export class PRBoard {
|
|
|
353
525
|
return t.barCRNeedsWork;
|
|
354
526
|
}
|
|
355
527
|
// ── Private: render ────────────────────────────────────────────────────────
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
const
|
|
359
|
-
const
|
|
360
|
-
const
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
528
|
+
/** "since 08:25 PM · up 3h32m" — when watch started, and how long it has run. */
|
|
529
|
+
sessionRow() {
|
|
530
|
+
const t = this.theme;
|
|
531
|
+
const started = fmtStartTime(this.stats.sessionStart);
|
|
532
|
+
const age = fmtUptime(Date.now() - this.stats.sessionStart);
|
|
533
|
+
return `${t.dim('since')} ${started} ${t.dim('·')} ${t.dim('up')} ${age}`;
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* Outcome shares across every PR that settled this session, e.g.
|
|
537
|
+
* "APPROVE 45% · BLOCK 30% · error 25%". Empty when nothing has settled yet.
|
|
538
|
+
*/
|
|
539
|
+
outcomeRow() {
|
|
540
|
+
const t = this.theme;
|
|
541
|
+
const counts = OUTCOME_ORDER.map(o => this.stats.outcomes[o]);
|
|
542
|
+
const total = counts.reduce((a, b) => a + b, 0);
|
|
543
|
+
if (total === 0)
|
|
544
|
+
return '';
|
|
545
|
+
const pcts = distribute(counts);
|
|
546
|
+
const mixed = counts.filter(c => c > 0).length > 1;
|
|
547
|
+
const parts = OUTCOME_ORDER.map((outcome, i) => {
|
|
548
|
+
if (counts[i] === 0)
|
|
549
|
+
return null;
|
|
550
|
+
// A share too small to earn a whole point still happened; "<1%" says so
|
|
551
|
+
// where "0%" would read as never. Its complement is then not a whole 100
|
|
552
|
+
// either — "error 100%" beside "APPROVE <1%" contradicts itself — so the
|
|
553
|
+
// rounded-up bucket reads ">99%" whenever some other outcome is non-zero.
|
|
554
|
+
const pct = pcts[i] === 0 ? '<1%'
|
|
555
|
+
: pcts[i] === 100 && mixed ? '>99%'
|
|
556
|
+
: `${pcts[i]}%`;
|
|
557
|
+
const paint = outcome === 'APPROVE' ? t.barCRApprove
|
|
558
|
+
: outcome === 'BLOCK' ? t.barCRBlock
|
|
559
|
+
: outcome === 'NEEDS WORK' ? t.barCRNeedsWork
|
|
560
|
+
: outcome === 'error' ? t.error
|
|
561
|
+
: t.dim;
|
|
562
|
+
return `${paint(outcome)} ${t.dim(pct)}`;
|
|
563
|
+
}).filter((p) => p !== null);
|
|
564
|
+
return parts.join(t.dim(' · '));
|
|
364
565
|
}
|
|
365
566
|
statsRow() {
|
|
366
567
|
const { prsReceived, crsCompleted, fixesApplied, errorsOccurred, crTotalMs } = this.stats;
|
|
@@ -523,29 +724,62 @@ export class PRBoard {
|
|
|
523
724
|
render() {
|
|
524
725
|
if (!this.config)
|
|
525
726
|
return '';
|
|
526
|
-
|
|
527
|
-
this.evictOverflow();
|
|
727
|
+
this.trimHistory();
|
|
528
728
|
const w = process.stdout.columns || 80;
|
|
529
729
|
// The live block must never be taller than the viewport: lines that scroll
|
|
530
730
|
// out the top cannot be erased on the next frame (cursor-up clamps at the
|
|
531
731
|
// viewport top), leaving permanent duplicates in scrollback. Reserve one
|
|
532
732
|
// row for the trailing newline writeLive appends.
|
|
533
733
|
const budget = (process.stdout.rows || 24) - 1;
|
|
534
|
-
const
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
734
|
+
const entries = [...this.slots.values()];
|
|
735
|
+
// The live page carries every active slot plus the newest settled ones that
|
|
736
|
+
// fit. What it cannot show is not dropped — it moves into the history pages.
|
|
737
|
+
const live = this.fitLivePage(entries.filter(s => s.superseded !== true), w, budget);
|
|
738
|
+
const onLivePage = new Set(live.visible);
|
|
739
|
+
const history = entries.filter(s => !onLivePage.has(s)); // oldest → newest
|
|
740
|
+
// History rows are always folded, and a folded row is clamped to one
|
|
741
|
+
// terminal row, so a history page holds exactly the rows the panels leave.
|
|
742
|
+
const perPage = Math.max(1, budget - this.chromeRows(w, LAYOUT_COMPACT));
|
|
743
|
+
this.pageCount = 1 + Math.ceil(history.length / perPage);
|
|
744
|
+
this.page = Math.max(0, Math.min(this.page, this.pageCount - 1));
|
|
745
|
+
if (this.page === 0)
|
|
746
|
+
return this.fitToBudget(this.renderLayout(w, live.opts, live.visible, entries.length), w, budget);
|
|
747
|
+
// Page 1 is the newest history page, so it ends where the live page begins.
|
|
748
|
+
const end = history.length - (this.page - 1) * perPage;
|
|
749
|
+
const slice = history.slice(Math.max(0, end - perPage), end);
|
|
750
|
+
return this.fitToBudget(this.renderLayout(w, LAYOUT_COMPACT, slice, entries.length), w, budget);
|
|
751
|
+
}
|
|
752
|
+
/**
|
|
753
|
+
* Pick the slots the live page shows: the expanded layout when it fits, then
|
|
754
|
+
* the compact one, then compact with the oldest settled slots handed over to
|
|
755
|
+
* history until the block fits the viewport. Active slots are never handed
|
|
756
|
+
* over — a running review must stay on screen.
|
|
757
|
+
*/
|
|
758
|
+
fitLivePage(pool, w, budget) {
|
|
759
|
+
// Bound the work: a slot costs at least one row, so a settled slot older
|
|
760
|
+
// than the last `budget` of them can never fit on the live page anyway.
|
|
761
|
+
const visible = trimToBudget(pool, budget);
|
|
762
|
+
const fits = (opts) => this.countRenderedLines(this.renderLayout(w, opts, visible, pool.length), w) <= budget;
|
|
763
|
+
if (fits(LAYOUT_NORMAL))
|
|
764
|
+
return { visible, opts: LAYOUT_NORMAL };
|
|
765
|
+
while (!fits(LAYOUT_COMPACT)) {
|
|
766
|
+
const oldestSettled = visible.findIndex(s => s.completedAt !== undefined);
|
|
767
|
+
if (oldestSettled === -1)
|
|
768
|
+
break; // only active slots left — truncateTop takes it from here
|
|
769
|
+
visible.splice(oldestSettled, 1);
|
|
541
770
|
}
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
return this.
|
|
771
|
+
return { visible, opts: LAYOUT_COMPACT };
|
|
772
|
+
}
|
|
773
|
+
/** Rows the panels and footer cost, i.e. everything but the PR rows. */
|
|
774
|
+
chromeRows(w, opts) {
|
|
775
|
+
return this.countRenderedLines(this.renderLayout(w, opts, [], this.slots.size), w);
|
|
547
776
|
}
|
|
548
|
-
|
|
777
|
+
fitToBudget(content, w, budget) {
|
|
778
|
+
return this.countRenderedLines(content, w) <= budget
|
|
779
|
+
? content
|
|
780
|
+
: this.truncateTop(content, w, budget);
|
|
781
|
+
}
|
|
782
|
+
renderLayout(w, opts, visible, total) {
|
|
549
783
|
const t = this.theme;
|
|
550
784
|
// Use w-1 to prevent the exact-terminal-width cursor wrap ambiguity that
|
|
551
785
|
// causes the first char of the next line to appear at the end of the separator.
|
|
@@ -555,20 +789,25 @@ export class PRBoard {
|
|
|
555
789
|
sep,
|
|
556
790
|
...this.renderStatsPanel(opts.connLogLines, opts.showTip),
|
|
557
791
|
sep,
|
|
558
|
-
...this.renderPRWorkspace(opts.foldAll),
|
|
792
|
+
...this.renderPRWorkspace(visible, opts.foldAll, w),
|
|
559
793
|
sep,
|
|
794
|
+
// Clamped: the footer is counted as exactly one row when sizing a page.
|
|
795
|
+
truncateVisible(this.renderFooter(visible.length, total), w - 1),
|
|
560
796
|
].join('\n');
|
|
561
797
|
}
|
|
562
|
-
/**
|
|
563
|
-
|
|
798
|
+
/** Drop the oldest settled slots once the session history outgrows the cap. */
|
|
799
|
+
trimHistory() {
|
|
800
|
+
if (this.slots.size <= HISTORY_MAX)
|
|
801
|
+
return;
|
|
802
|
+
let excess = this.slots.size - HISTORY_MAX;
|
|
564
803
|
for (const [key, slot] of this.slots) {
|
|
804
|
+
if (excess <= 0)
|
|
805
|
+
break;
|
|
565
806
|
if (slot.completedAt === undefined)
|
|
566
|
-
continue;
|
|
567
|
-
this.printStatic(this.renderPRSlotFolded(slot));
|
|
807
|
+
continue; // never drop an active slot
|
|
568
808
|
this.slots.delete(key);
|
|
569
|
-
|
|
809
|
+
excess--;
|
|
570
810
|
}
|
|
571
|
-
return false;
|
|
572
811
|
}
|
|
573
812
|
/** Drop rows from the top until the content (plus an indicator line) fits the budget. */
|
|
574
813
|
truncateTop(content, w, budget) {
|
|
@@ -601,7 +840,11 @@ export class PRBoard {
|
|
|
601
840
|
renderStatsPanel(connLogLines, showTip) {
|
|
602
841
|
const t = this.theme;
|
|
603
842
|
const lines = [];
|
|
604
|
-
lines.push(` ${this.statsRow()}
|
|
843
|
+
lines.push(` ${this.statsRow()}`);
|
|
844
|
+
// Session line: start time + age, and the outcome split when anything has
|
|
845
|
+
// settled. One row, because every row here costs a PR row on the live page.
|
|
846
|
+
const outcomes = this.outcomeRow();
|
|
847
|
+
lines.push(` ${this.sessionRow()}${outcomes ? ` ${t.dim('│')} ${outcomes}` : ''}`);
|
|
605
848
|
const { type: tunnelType, url, alive } = this.tunnel;
|
|
606
849
|
if (tunnelType !== 'none') {
|
|
607
850
|
const tunnelLabel = tunnelType === 'serve' ? 'endpoint:' : 'tunnel: ';
|
|
@@ -630,14 +873,14 @@ export class PRBoard {
|
|
|
630
873
|
const formatted = parts.map(p => p.startsWith('`') ? t.accent(p) : t.dim(p)).join('');
|
|
631
874
|
return ` ${badge}${formatted}`;
|
|
632
875
|
}
|
|
633
|
-
renderPRWorkspace(foldAll) {
|
|
876
|
+
renderPRWorkspace(visible, foldAll, w) {
|
|
634
877
|
const t = this.theme;
|
|
635
878
|
const frame = FRAMES[this.frameIdx];
|
|
636
|
-
if (
|
|
637
|
-
return [t.dim(' waiting for PRs...')];
|
|
879
|
+
if (visible.length === 0) {
|
|
880
|
+
return this.slots.size === 0 ? [t.dim(' waiting for PRs...')] : [];
|
|
638
881
|
}
|
|
639
882
|
let completedCount = 0;
|
|
640
|
-
for (const slot of
|
|
883
|
+
for (const slot of visible) {
|
|
641
884
|
if (slot.completedAt !== undefined)
|
|
642
885
|
completedCount++;
|
|
643
886
|
}
|
|
@@ -645,7 +888,7 @@ export class PRBoard {
|
|
|
645
888
|
const lines = [];
|
|
646
889
|
let prevWasExpanded = false;
|
|
647
890
|
let first = true;
|
|
648
|
-
for (const slot of
|
|
891
|
+
for (const slot of visible) {
|
|
649
892
|
const isCompleted = slot.completedAt !== undefined;
|
|
650
893
|
// Sticky fold: once a completed slot folds because the count crossed
|
|
651
894
|
// FOLD_THRESHOLD, keep it folded. Otherwise a later recheck round drops
|
|
@@ -655,9 +898,14 @@ export class PRBoard {
|
|
|
655
898
|
// should re-expand.
|
|
656
899
|
if (isCompleted && foldByCount)
|
|
657
900
|
slot.stickyFolded = true;
|
|
658
|
-
|
|
901
|
+
// A failed slot is always folded: its pipeline bars are frozen wherever the
|
|
902
|
+
// run died ("CR queued", "Fix queued"), which describes work that will
|
|
903
|
+
// never happen. The folded row carries the error instead.
|
|
904
|
+
const useFolded = isCompleted && (foldAll || slot.stickyFolded === true || slot.error !== undefined);
|
|
659
905
|
if (useFolded) {
|
|
660
|
-
|
|
906
|
+
// Clamped so the row never wraps: history pagination sizes a page by
|
|
907
|
+
// counting one terminal row per folded slot.
|
|
908
|
+
lines.push(truncateVisible(this.renderPRSlotFolded(slot), w - 1));
|
|
661
909
|
prevWasExpanded = false;
|
|
662
910
|
}
|
|
663
911
|
else {
|
|
@@ -670,6 +918,22 @@ export class PRBoard {
|
|
|
670
918
|
}
|
|
671
919
|
return lines;
|
|
672
920
|
}
|
|
921
|
+
/** Footer: where in the retained history this page sits, and how to move. */
|
|
922
|
+
renderFooter(shown, total) {
|
|
923
|
+
const t = this.theme;
|
|
924
|
+
if (total === 0)
|
|
925
|
+
return t.dim(' no PRs yet');
|
|
926
|
+
const position = this.page === 0
|
|
927
|
+
? `${t.success('live')}${this.pageCount > 1 ? t.dim(` · page 1/${this.pageCount}`) : ''}`
|
|
928
|
+
: t.dim(`history · page ${this.page + 1}/${this.pageCount}`);
|
|
929
|
+
// "shown of retained", not of stats.prsReceived: rounds add rows, and the
|
|
930
|
+
// history cap eventually drops the oldest, so the two counts diverge.
|
|
931
|
+
const counts = t.dim(`showing ${shown} of ${total}`);
|
|
932
|
+
const keys = this.pageCount > 1
|
|
933
|
+
? ` ${t.dim('│')} ${t.accent('ctrl+<')} ${t.dim('older')} ${t.accent('ctrl+>')} ${t.dim('newer')}`
|
|
934
|
+
: '';
|
|
935
|
+
return ` ${position} ${t.dim('│')} ${counts}${keys}`;
|
|
936
|
+
}
|
|
673
937
|
// ── Folded PR slot ─────────────────────────────────────────────────────────
|
|
674
938
|
renderPRSlotFolded(slot) {
|
|
675
939
|
const t = this.theme;
|
|
@@ -697,29 +961,57 @@ export class PRBoard {
|
|
|
697
961
|
const rFn = this.crLabelFn(slot.recheckVerdict);
|
|
698
962
|
parts.push(`recheck ${rFn(slot.recheckVerdict)}`);
|
|
699
963
|
}
|
|
964
|
+
// A failed run reports the error in place of the verdict trail: whatever the
|
|
965
|
+
// pipeline had reached is what it never got to finish, and the reason it
|
|
966
|
+
// stopped is the only thing worth the row.
|
|
967
|
+
if (slot.error !== undefined) {
|
|
968
|
+
parts.push(t.error(slot.error));
|
|
969
|
+
}
|
|
700
970
|
const urlPart = slot.url ? ` ${t.dim('→')} ${t.accent(slot.url)}` : '';
|
|
701
971
|
const partsStr = parts.length > 0 ? parts.join(t.dim(' · ')) : t.dim('—');
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
// ── Overflow eviction ──────────────────────────────────────────────────────
|
|
705
|
-
evictOverflow() {
|
|
706
|
-
if (this.slots.size <= WORKSPACE_MAX)
|
|
707
|
-
return;
|
|
708
|
-
let toEvict = this.slots.size - WORKSPACE_MAX;
|
|
709
|
-
for (const [key, slot] of this.slots) {
|
|
710
|
-
if (toEvict <= 0)
|
|
711
|
-
break;
|
|
712
|
-
if (slot.completedAt === undefined)
|
|
713
|
-
continue; // never evict active
|
|
714
|
-
this.printStatic(this.renderPRSlotFolded(slot));
|
|
715
|
-
this.slots.delete(key);
|
|
716
|
-
toEvict--;
|
|
717
|
-
}
|
|
972
|
+
const icon = slot.error !== undefined ? t.error('✗') : t.success('✓');
|
|
973
|
+
return ` ${icon} ${t.dim(`#${slot.prNumber}`)} ${t.dim(slot.repo)} ${t.dim(branch)} ${partsStr} ${t.dim(`(${elapsed})`)}${urlPart}`;
|
|
718
974
|
}
|
|
719
975
|
redraw() {
|
|
720
976
|
const content = this.render();
|
|
721
977
|
if (content)
|
|
722
978
|
this.writeLive(content);
|
|
723
979
|
}
|
|
980
|
+
// ── Private: key input ─────────────────────────────────────────────────────
|
|
981
|
+
attachKeys() {
|
|
982
|
+
const stdin = process.stdin;
|
|
983
|
+
if (this.keyHandler || !stdin.isTTY)
|
|
984
|
+
return;
|
|
985
|
+
this.stdinWasRaw = stdin.isRaw === true;
|
|
986
|
+
stdin.setRawMode(true);
|
|
987
|
+
stdin.resume();
|
|
988
|
+
const handler = (data) => {
|
|
989
|
+
const seq = data.toString('utf8');
|
|
990
|
+
// Raw mode suppresses the terminal's own ctrl-c → SIGINT translation, so
|
|
991
|
+
// the board has to raise it itself or the daemon becomes unkillable.
|
|
992
|
+
if (seq === '\u0003') {
|
|
993
|
+
process.kill(process.pid, 'SIGINT');
|
|
994
|
+
return;
|
|
995
|
+
}
|
|
996
|
+
const action = pageKeyAction(seq);
|
|
997
|
+
if (action === 'older')
|
|
998
|
+
this.pageOlder();
|
|
999
|
+
else if (action === 'newer')
|
|
1000
|
+
this.pageNewer();
|
|
1001
|
+
};
|
|
1002
|
+
stdin.on('data', handler);
|
|
1003
|
+
this.keyHandler = handler;
|
|
1004
|
+
}
|
|
1005
|
+
detachKeys() {
|
|
1006
|
+
if (!this.keyHandler)
|
|
1007
|
+
return;
|
|
1008
|
+
process.stdin.off('data', this.keyHandler);
|
|
1009
|
+
this.keyHandler = null;
|
|
1010
|
+
// Hand the terminal back the way it was found: the idle-issue flow and the
|
|
1011
|
+
// interactive prompts read stdin themselves while the board is stopped.
|
|
1012
|
+
if (process.stdin.isTTY && !this.stdinWasRaw)
|
|
1013
|
+
process.stdin.setRawMode(false);
|
|
1014
|
+
process.stdin.pause();
|
|
1015
|
+
}
|
|
724
1016
|
}
|
|
725
1017
|
//# sourceMappingURL=board.js.map
|