@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
|
@@ -1,6 +1,39 @@
|
|
|
1
1
|
import type { Octokit } from 'octokit';
|
|
2
|
+
/** Parse the instance id out of a claim description. Null for a foreign or legacy status. */
|
|
3
|
+
export declare function parseClaimOwner(description: string | null | undefined): string | null;
|
|
4
|
+
/**
|
|
5
|
+
* Is another instance actively reviewing this commit?
|
|
6
|
+
*
|
|
7
|
+
* Our own claim does not count: a retry inside the same process must not be
|
|
8
|
+
* blocked by the pending status that process itself just wrote.
|
|
9
|
+
*/
|
|
2
10
|
export declare function checkRemoteLock(octokit: Octokit, owner: string, repo: string, sha: string): Promise<boolean>;
|
|
3
11
|
export declare function acquireRemoteLock(octokit: Octokit, owner: string, repo: string, sha: string): Promise<void>;
|
|
12
|
+
/**
|
|
13
|
+
* Claim the commit and confirm the claim held. Returns false when another instance
|
|
14
|
+
* won, in which case this caller must not review.
|
|
15
|
+
*
|
|
16
|
+
* `checkRemoteLock` then `acquireRemoteLock` is a check-then-act race: two instances
|
|
17
|
+
* that receive the same webhook within a round-trip of each other both see no pending
|
|
18
|
+
* status, both write one, and both review the same commit — the duplicate same-SHA
|
|
19
|
+
* reviews this arbitration exists to stop. GitHub has no conditional status write, so
|
|
20
|
+
* instead of pretending the write was exclusive, both writers read back after a settle
|
|
21
|
+
* window and only the instance whose id survived as the newest status proceeds. Last
|
|
22
|
+
* write wins, deterministically, rather than nobody losing.
|
|
23
|
+
*
|
|
24
|
+
* This is arbitration, not a mutex: a peer writing after the read-back still gets in.
|
|
25
|
+
* The window is milliseconds against a review measured in minutes, and the local lock
|
|
26
|
+
* covers same-machine collisions outright.
|
|
27
|
+
*/
|
|
28
|
+
export declare function claimRemoteLock(octokit: Octokit, owner: string, repo: string, sha: string, settleMs?: number): Promise<boolean>;
|
|
4
29
|
export declare function startRemoteLockHeartbeat(octokit: Octokit, owner: string, repo: string, sha: string): () => void;
|
|
30
|
+
/**
|
|
31
|
+
* Publish the review outcome and drop the claim — but only if the claim is still ours.
|
|
32
|
+
*
|
|
33
|
+
* Writing unconditionally let any instance clear any other instance's lock. Worse than
|
|
34
|
+
* the lost mutual exclusion, `crosscheck/review` is a status a repo can require for
|
|
35
|
+
* merge: a late `success` from an instance that reviewed an older commit, or one whose
|
|
36
|
+
* claim was superseded, marks a commit green that nothing has actually reviewed.
|
|
37
|
+
*/
|
|
5
38
|
export declare function releaseRemoteLock(octokit: Octokit, owner: string, repo: string, sha: string, outcome: 'success' | 'failure'): Promise<void>;
|
|
6
39
|
//# sourceMappingURL=review-status.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"review-status.d.ts","sourceRoot":"","sources":["../../src/github/review-status.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,SAAS,CAAA;
|
|
1
|
+
{"version":3,"file":"review-status.d.ts","sourceRoot":"","sources":["../../src/github/review-status.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,SAAS,CAAA;AAyBtC,6FAA6F;AAC7F,wBAAgB,eAAe,CAAC,WAAW,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,MAAM,GAAG,IAAI,CAIrF;AA0BD;;;;;GAKG;AACH,wBAAsB,eAAe,CACnC,OAAO,EAAE,OAAO,EAChB,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,MAAM,EACZ,GAAG,EAAE,MAAM,GACV,OAAO,CAAC,OAAO,CAAC,CASlB;AAED,wBAAsB,iBAAiB,CACrC,OAAO,EAAE,OAAO,EAChB,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,MAAM,EACZ,GAAG,EAAE,MAAM,GACV,OAAO,CAAC,IAAI,CAAC,CAOf;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAsB,eAAe,CACnC,OAAO,EAAE,OAAO,EAChB,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,MAAM,EACZ,GAAG,EAAE,MAAM,EACX,QAAQ,GAAE,MAAwB,GACjC,OAAO,CAAC,OAAO,CAAC,CAelB;AAKD,wBAAgB,wBAAwB,CACtC,OAAO,EAAE,OAAO,EAChB,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,MAAM,EACZ,GAAG,EAAE,MAAM,GACV,MAAM,IAAI,CAUZ;AAED;;;;;;;GAOG;AACH,wBAAsB,iBAAiB,CACrC,OAAO,EAAE,OAAO,EAChB,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,MAAM,EACZ,GAAG,EAAE,MAAM,EACX,OAAO,EAAE,SAAS,GAAG,SAAS,GAC7B,OAAO,CAAC,IAAI,CAAC,CAYf"}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { INSTANCE_ID } from '../lib/pr-lock.js';
|
|
1
2
|
const CONTEXT = 'crosscheck/review';
|
|
2
3
|
// Lock is stale when the process hasn't heartbeated within this window.
|
|
3
4
|
// Shorter = faster self-heal after a crash; must exceed HEARTBEAT_INTERVAL_MS
|
|
@@ -6,16 +7,51 @@ const STALE_MS = 5 * 60 * 1000;
|
|
|
6
7
|
// Heartbeat keeps the pending status fresh so long-running reviews don't look
|
|
7
8
|
// abandoned. Must be well under STALE_MS so a live review never goes stale.
|
|
8
9
|
const HEARTBEAT_INTERVAL_MS = 2 * 60 * 1000;
|
|
10
|
+
// How long to let concurrent claims settle before reading back who won.
|
|
11
|
+
// createCommitStatus is an upsert, not a compare-and-swap — GitHub offers no
|
|
12
|
+
// conditional create for a commit status — so two instances can both write. The
|
|
13
|
+
// settle-then-read-back below is what turns that into a decision instead of two
|
|
14
|
+
// instances both proceeding. It must comfortably exceed a write round-trip.
|
|
15
|
+
const CLAIM_SETTLE_MS = 2_500;
|
|
16
|
+
// The claim marker. Kept short: GitHub truncates a status description at 140 chars,
|
|
17
|
+
// and the instance id has to survive that intact to be readable on the way back.
|
|
18
|
+
function claimDescription(id) {
|
|
19
|
+
return `crosscheck ${id}`;
|
|
20
|
+
}
|
|
21
|
+
/** Parse the instance id out of a claim description. Null for a foreign or legacy status. */
|
|
22
|
+
export function parseClaimOwner(description) {
|
|
23
|
+
if (!description)
|
|
24
|
+
return null;
|
|
25
|
+
const m = description.match(/^crosscheck ([^\s]+)/);
|
|
26
|
+
return m ? (m[1] ?? null) : null;
|
|
27
|
+
}
|
|
28
|
+
async function readClaim(octokit, owner, repo, sha) {
|
|
29
|
+
const { data } = await octokit.rest.repos.getCombinedStatusForRef({ owner, repo, ref: sha });
|
|
30
|
+
const status = data.statuses
|
|
31
|
+
.filter(s => s.context === CONTEXT)
|
|
32
|
+
.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime())[0];
|
|
33
|
+
if (!status)
|
|
34
|
+
return null;
|
|
35
|
+
return {
|
|
36
|
+
state: status.state,
|
|
37
|
+
owner: parseClaimOwner(status.description),
|
|
38
|
+
updatedAt: new Date(status.updated_at).getTime(),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Is another instance actively reviewing this commit?
|
|
43
|
+
*
|
|
44
|
+
* Our own claim does not count: a retry inside the same process must not be
|
|
45
|
+
* blocked by the pending status that process itself just wrote.
|
|
46
|
+
*/
|
|
9
47
|
export async function checkRemoteLock(octokit, owner, repo, sha) {
|
|
10
48
|
try {
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
.filter(s => s.context === CONTEXT)
|
|
14
|
-
.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime())[0];
|
|
15
|
-
if (!status || status.state !== 'pending')
|
|
49
|
+
const claim = await readClaim(octokit, owner, repo, sha);
|
|
50
|
+
if (!claim || claim.state !== 'pending')
|
|
16
51
|
return false;
|
|
17
|
-
|
|
18
|
-
|
|
52
|
+
if (claim.owner === INSTANCE_ID)
|
|
53
|
+
return false;
|
|
54
|
+
return Date.now() - claim.updatedAt < STALE_MS;
|
|
19
55
|
}
|
|
20
56
|
catch {
|
|
21
57
|
return false;
|
|
@@ -26,9 +62,43 @@ export async function acquireRemoteLock(octokit, owner, repo, sha) {
|
|
|
26
62
|
owner, repo, sha,
|
|
27
63
|
state: 'pending',
|
|
28
64
|
context: CONTEXT,
|
|
29
|
-
description:
|
|
65
|
+
description: `${claimDescription(INSTANCE_ID)} started ${new Date().toISOString()}`,
|
|
30
66
|
});
|
|
31
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* Claim the commit and confirm the claim held. Returns false when another instance
|
|
70
|
+
* won, in which case this caller must not review.
|
|
71
|
+
*
|
|
72
|
+
* `checkRemoteLock` then `acquireRemoteLock` is a check-then-act race: two instances
|
|
73
|
+
* that receive the same webhook within a round-trip of each other both see no pending
|
|
74
|
+
* status, both write one, and both review the same commit — the duplicate same-SHA
|
|
75
|
+
* reviews this arbitration exists to stop. GitHub has no conditional status write, so
|
|
76
|
+
* instead of pretending the write was exclusive, both writers read back after a settle
|
|
77
|
+
* window and only the instance whose id survived as the newest status proceeds. Last
|
|
78
|
+
* write wins, deterministically, rather than nobody losing.
|
|
79
|
+
*
|
|
80
|
+
* This is arbitration, not a mutex: a peer writing after the read-back still gets in.
|
|
81
|
+
* The window is milliseconds against a review measured in minutes, and the local lock
|
|
82
|
+
* covers same-machine collisions outright.
|
|
83
|
+
*/
|
|
84
|
+
export async function claimRemoteLock(octokit, owner, repo, sha, settleMs = CLAIM_SETTLE_MS) {
|
|
85
|
+
await acquireRemoteLock(octokit, owner, repo, sha);
|
|
86
|
+
await new Promise(resolve => setTimeout(resolve, settleMs));
|
|
87
|
+
try {
|
|
88
|
+
const claim = await readClaim(octokit, owner, repo, sha);
|
|
89
|
+
// No claim readable, or one with no owner (a legacy status, or a description
|
|
90
|
+
// GitHub truncated): nothing proves we lost, and refusing to review on an
|
|
91
|
+
// unreadable status would strand the PR. Proceed — the local lock still holds.
|
|
92
|
+
if (!claim || claim.owner === null)
|
|
93
|
+
return true;
|
|
94
|
+
return claim.owner === INSTANCE_ID;
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
// The read-back is the optional half. Failing it leaves us exactly where the
|
|
98
|
+
// old unconditional acquire always was, which is still safe enough to review.
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
32
102
|
// Starts a repeating interval that refreshes the pending status timestamp so
|
|
33
103
|
// checkRemoteLock never treats an active review as stale. Returns a stop
|
|
34
104
|
// function that must be called in the finally block after the review completes.
|
|
@@ -38,15 +108,30 @@ export function startRemoteLockHeartbeat(octokit, owner, repo, sha) {
|
|
|
38
108
|
owner, repo, sha,
|
|
39
109
|
state: 'pending',
|
|
40
110
|
context: CONTEXT,
|
|
41
|
-
description:
|
|
111
|
+
description: `${claimDescription(INSTANCE_ID)} active ${new Date().toISOString()}`,
|
|
42
112
|
}).catch(() => { });
|
|
43
113
|
}, HEARTBEAT_INTERVAL_MS);
|
|
44
114
|
return () => clearInterval(id);
|
|
45
115
|
}
|
|
116
|
+
/**
|
|
117
|
+
* Publish the review outcome and drop the claim — but only if the claim is still ours.
|
|
118
|
+
*
|
|
119
|
+
* Writing unconditionally let any instance clear any other instance's lock. Worse than
|
|
120
|
+
* the lost mutual exclusion, `crosscheck/review` is a status a repo can require for
|
|
121
|
+
* merge: a late `success` from an instance that reviewed an older commit, or one whose
|
|
122
|
+
* claim was superseded, marks a commit green that nothing has actually reviewed.
|
|
123
|
+
*/
|
|
46
124
|
export async function releaseRemoteLock(octokit, owner, repo, sha, outcome) {
|
|
47
125
|
try {
|
|
126
|
+
const claim = await readClaim(octokit, owner, repo, sha);
|
|
127
|
+
// A claim owned by someone else is theirs to resolve. An unowned claim (legacy
|
|
128
|
+
// or truncated) is indistinguishable from our own predecessor's, so resolve it
|
|
129
|
+
// rather than leaving a pending status behind forever.
|
|
130
|
+
if (claim && claim.owner !== null && claim.owner !== INSTANCE_ID)
|
|
131
|
+
return;
|
|
48
132
|
await octokit.rest.repos.createCommitStatus({
|
|
49
133
|
owner, repo, sha, state: outcome, context: CONTEXT,
|
|
134
|
+
description: `${claimDescription(INSTANCE_ID)} ${outcome}`,
|
|
50
135
|
});
|
|
51
136
|
}
|
|
52
137
|
catch { /* best-effort */ }
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"review-status.js","sourceRoot":"","sources":["../../src/github/review-status.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"review-status.js","sourceRoot":"","sources":["../../src/github/review-status.ts"],"names":[],"mappings":"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"}
|
package/dist/lib/board.d.ts
CHANGED
|
@@ -24,8 +24,26 @@ export interface PRCompletionData {
|
|
|
24
24
|
* run, such as one the review strategy short-circuited. */
|
|
25
25
|
label?: string;
|
|
26
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* The terminal state of one settled slot, for the session outcome distribution.
|
|
29
|
+
* `skipped` is a PR that settled without any verdict at all — the review
|
|
30
|
+
* strategy short-circuited it (lockfile-only, doc-only) — as distinct from
|
|
31
|
+
* `no verdict`, where a reviewer ran and returned nothing parseable.
|
|
32
|
+
*/
|
|
33
|
+
export type Outcome = 'APPROVE' | 'NEEDS WORK' | 'BLOCK' | 'no verdict' | 'skipped' | 'error';
|
|
27
34
|
export declare function fmtTime(d?: Date): string;
|
|
28
35
|
export declare const FMT_TIME_WIDTH = 11;
|
|
36
|
+
export declare function fmtEnteredAt(epochMs: number): string;
|
|
37
|
+
export declare function fmtUptime(ms: number): string;
|
|
38
|
+
/**
|
|
39
|
+
* Whole percentages summing to exactly 100, by the largest-remainder method.
|
|
40
|
+
* Plain rounding drifts — three equal shares render as 33/33/33 — and a
|
|
41
|
+
* distribution that visibly fails to add up reads as a bug in the numbers.
|
|
42
|
+
*
|
|
43
|
+
* A non-zero count too small to earn a point comes back as 0; the caller
|
|
44
|
+
* renders those as "<1%" rather than claiming the outcome never happened.
|
|
45
|
+
*/
|
|
46
|
+
export declare function distribute(counts: readonly number[]): number[];
|
|
29
47
|
export declare function fmtTokens(n?: number): string;
|
|
30
48
|
export declare function fmtTokensRaw(n?: number): string;
|
|
31
49
|
export declare function isNoticeLine(msg: string): boolean;
|
|
@@ -60,6 +78,13 @@ export declare class PRBoard {
|
|
|
60
78
|
addPR(key: string, prNumber: number, repo: string, branch: string, round?: number): void;
|
|
61
79
|
updatePR(key: string, updates: PRUpdate): void;
|
|
62
80
|
completePR(key: string, data: PRCompletionData): void;
|
|
81
|
+
/**
|
|
82
|
+
* Settle a slot that ended in an error. The slot stays in the workspace as a
|
|
83
|
+
* settled row, the same as a completed one: a failed run is a session record,
|
|
84
|
+
* and deleting it here was what pushed reviewer timeouts out of the table and
|
|
85
|
+
* into raw scrollback, where a long watch session showed 43 errors above an
|
|
86
|
+
* empty workspace reading "no PRs yet".
|
|
87
|
+
*/
|
|
63
88
|
failPR(key: string, error: string): void;
|
|
64
89
|
/** Print 1–2 static lines to scrollback (above the live block). */
|
|
65
90
|
log(line1: string, line2?: string): void;
|
|
@@ -72,7 +97,13 @@ export declare class PRBoard {
|
|
|
72
97
|
private verdictBadge;
|
|
73
98
|
private crFillFn;
|
|
74
99
|
private crLabelFn;
|
|
75
|
-
|
|
100
|
+
/** "since 08:25 PM · up 3h32m" — when watch started, and how long it has run. */
|
|
101
|
+
private sessionRow;
|
|
102
|
+
/**
|
|
103
|
+
* Outcome shares across every PR that settled this session, e.g.
|
|
104
|
+
* "APPROVE 45% · BLOCK 30% · error 25%". Empty when nothing has settled yet.
|
|
105
|
+
*/
|
|
106
|
+
private outcomeRow;
|
|
76
107
|
private statsRow;
|
|
77
108
|
private renderPRSlot;
|
|
78
109
|
private phaseLine1Label;
|
package/dist/lib/board.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"board.d.ts","sourceRoot":"","sources":["../../src/lib/board.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,MAAM,EAAgB,MAAM,qBAAqB,CAAA;AAC/D,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,eAAe,CAAA;
|
|
1
|
+
{"version":3,"file":"board.d.ts","sourceRoot":"","sources":["../../src/lib/board.ts"],"names":[],"mappings":"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"}
|
package/dist/lib/board.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
import { selectTip } from './tips.js';
|
|
3
|
+
import { oneLine } from './vendor-error-summary.js';
|
|
4
|
+
const OUTCOME_ORDER = ['APPROVE', 'NEEDS WORK', 'BLOCK', 'no verdict', 'skipped', 'error'];
|
|
3
5
|
// ── Constants ─────────────────────────────────────────────────────────────────
|
|
4
6
|
const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
5
7
|
const BAR_FILLED = '█';
|
|
@@ -23,10 +25,55 @@ function fmtDuration(ms) {
|
|
|
23
25
|
return `${m}m${String(s).padStart(2, '0')}s`;
|
|
24
26
|
return `${s}s`;
|
|
25
27
|
}
|
|
28
|
+
// When a PR entered the board, in the operator's locale: "09/22, 03:50 PM" in
|
|
29
|
+
// en-US. Every part is 2-digit so the column keeps one width within a session.
|
|
30
|
+
export function fmtEnteredAt(epochMs) {
|
|
31
|
+
return new Date(epochMs).toLocaleString(undefined, {
|
|
32
|
+
month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit',
|
|
33
|
+
});
|
|
34
|
+
}
|
|
26
35
|
// Short HH:MM timestamp (no seconds) for the "started" label
|
|
27
36
|
function fmtStartTime(epochMs) {
|
|
28
37
|
return new Date(epochMs).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: true });
|
|
29
38
|
}
|
|
39
|
+
// Session age in hours and minutes: "3h32m", or "12m" under the hour. Deliberately
|
|
40
|
+
// not fmtDuration: that renders MM:SS shaped output under an hour, which reads as
|
|
41
|
+
// hours:minutes on a long-running watch and understates the session by 60x.
|
|
42
|
+
export function fmtUptime(ms) {
|
|
43
|
+
const totalMin = Math.max(0, Math.floor(ms / 60_000));
|
|
44
|
+
const h = Math.floor(totalMin / 60);
|
|
45
|
+
const m = totalMin % 60;
|
|
46
|
+
return h > 0 ? `${h}h${String(m).padStart(2, '0')}m` : `${m}m`;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Whole percentages summing to exactly 100, by the largest-remainder method.
|
|
50
|
+
* Plain rounding drifts — three equal shares render as 33/33/33 — and a
|
|
51
|
+
* distribution that visibly fails to add up reads as a bug in the numbers.
|
|
52
|
+
*
|
|
53
|
+
* A non-zero count too small to earn a point comes back as 0; the caller
|
|
54
|
+
* renders those as "<1%" rather than claiming the outcome never happened.
|
|
55
|
+
*/
|
|
56
|
+
export function distribute(counts) {
|
|
57
|
+
const total = counts.reduce((a, b) => a + b, 0);
|
|
58
|
+
if (total === 0)
|
|
59
|
+
return counts.map(() => 0);
|
|
60
|
+
const exact = counts.map(c => (c * 100) / total);
|
|
61
|
+
const out = exact.map(Math.floor);
|
|
62
|
+
// Exact shares sum to 100, so the floors leave fewer whole points than there
|
|
63
|
+
// are entries: one pass in remainder order always places every last one.
|
|
64
|
+
let remaining = 100 - out.reduce((a, b) => a + b, 0);
|
|
65
|
+
const byRemainder = exact
|
|
66
|
+
.map((e, i) => ({ i, rem: e - Math.floor(e) }))
|
|
67
|
+
.filter(({ i }) => counts[i] > 0)
|
|
68
|
+
.sort((a, b) => b.rem - a.rem);
|
|
69
|
+
for (const { i } of byRemainder) {
|
|
70
|
+
if (remaining <= 0)
|
|
71
|
+
break;
|
|
72
|
+
out[i]++;
|
|
73
|
+
remaining--;
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
30
77
|
// Format token count as a compact suffix: "(900)", "(1.2K)", "(1.5M)". Returns '' when undefined.
|
|
31
78
|
export function fmtTokens(n) {
|
|
32
79
|
if (n == null)
|
|
@@ -94,35 +141,20 @@ const NOTICE_PREFIX = /^(?:\x1B\[[0-9;]*m)*\s*[⚠✗]/;
|
|
|
94
141
|
export function isNoticeLine(msg) {
|
|
95
142
|
return NOTICE_PREFIX.test(msg) || msg.includes('\n');
|
|
96
143
|
}
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
|
|
103
|
-
const PAGE_NEWER_KEYS = new Set(['
|
|
104
|
-
|
|
105
|
-
const CSI_U = /^\u001b\[(\d+);\d+u$/; // ESC [ <codepoint> ; <modifiers> u
|
|
106
|
-
// eslint-disable-next-line no-control-regex -- the ESC byte is the sequence
|
|
107
|
-
const CSI_ARROW = /^\u001b\[1;\d+([DC])$/; // ESC [ 1 ; <modifiers> D|C
|
|
144
|
+
// ← / → are the only page keys, on Windows, Linux and macOS alike: every
|
|
145
|
+
// terminal forwards a bare arrow, where modifier and punctuation combinations
|
|
146
|
+
// arrive inconsistently or not at all (macOS never forwards cmd). Each arrow has
|
|
147
|
+
// two encodings: normal cursor mode (`ESC [ D`) and application cursor mode
|
|
148
|
+
// (`ESC O D`), which Terminal.app emits while an alternate-screen app owns the tty.
|
|
149
|
+
// ← moves toward newer PRs (the live page), → toward older history.
|
|
150
|
+
const PAGE_NEWER_KEYS = new Set(['\u001b[D', '\u001bOD']);
|
|
151
|
+
const PAGE_OLDER_KEYS = new Set(['\u001b[C', '\u001bOC']);
|
|
108
152
|
/** Map a raw stdin key sequence to a page direction, or null when it is not a page key. */
|
|
109
153
|
export function pageKeyAction(seq) {
|
|
110
154
|
if (PAGE_OLDER_KEYS.has(seq))
|
|
111
155
|
return 'older';
|
|
112
156
|
if (PAGE_NEWER_KEYS.has(seq))
|
|
113
157
|
return 'newer';
|
|
114
|
-
const csiU = CSI_U.exec(seq);
|
|
115
|
-
if (csiU) {
|
|
116
|
-
const codepoint = Number(csiU[1]);
|
|
117
|
-
if (codepoint === 44 || codepoint === 60)
|
|
118
|
-
return 'older'; // , <
|
|
119
|
-
if (codepoint === 46 || codepoint === 62)
|
|
120
|
-
return 'newer'; // . >
|
|
121
|
-
return null;
|
|
122
|
-
}
|
|
123
|
-
const arrow = CSI_ARROW.exec(seq);
|
|
124
|
-
if (arrow)
|
|
125
|
-
return arrow[1] === 'D' ? 'older' : 'newer';
|
|
126
158
|
return null;
|
|
127
159
|
}
|
|
128
160
|
// Keep every active slot plus the newest `budget` settled ones. Anything older
|
|
@@ -135,6 +167,24 @@ function trimToBudget(pool, budget) {
|
|
|
135
167
|
const keep = new Set(settled.slice(settled.length - budget));
|
|
136
168
|
return pool.filter(s => s.completedAt === undefined || keep.has(s));
|
|
137
169
|
}
|
|
170
|
+
/**
|
|
171
|
+
* The one outcome a settled slot counts toward. A recheck verdict supersedes
|
|
172
|
+
* the review verdict — it is the later word on the same PR — and `null` from
|
|
173
|
+
* either step means a reviewer ran but returned nothing parseable, which is a
|
|
174
|
+
* different event from never having run at all.
|
|
175
|
+
*/
|
|
176
|
+
function outcomeOf(slot) {
|
|
177
|
+
if (slot.error !== undefined)
|
|
178
|
+
return 'error';
|
|
179
|
+
const final = typeof slot.recheckVerdict === 'string'
|
|
180
|
+
? slot.recheckVerdict
|
|
181
|
+
: typeof slot.verdict === 'string' ? slot.verdict : null;
|
|
182
|
+
if (final === 'APPROVE' || final === 'NEEDS WORK' || final === 'BLOCK')
|
|
183
|
+
return final;
|
|
184
|
+
if (slot.recheckVerdict === null || slot.verdict === null)
|
|
185
|
+
return 'no verdict';
|
|
186
|
+
return 'skipped';
|
|
187
|
+
}
|
|
138
188
|
function makeBar(filled, total, fillFn, emptyFn) {
|
|
139
189
|
const f = Math.max(0, Math.min(total, Math.round(filled)));
|
|
140
190
|
return fillFn(BAR_FILLED.repeat(f)) + emptyFn(BAR_EMPTY.repeat(total - f));
|
|
@@ -220,6 +270,7 @@ function buildTheme(cfg) {
|
|
|
220
270
|
warning: chalk.yellow,
|
|
221
271
|
error: chalk.red,
|
|
222
272
|
dim: chalk.dim,
|
|
273
|
+
muted: chalk.gray.dim,
|
|
223
274
|
accent: chalk.cyan,
|
|
224
275
|
barPRFill: resolveColor(cfg.bar_fill),
|
|
225
276
|
barEmpty: empty,
|
|
@@ -256,6 +307,7 @@ export class PRBoard {
|
|
|
256
307
|
errorsOccurred: 0,
|
|
257
308
|
crTotalMs: 0,
|
|
258
309
|
sessionStart: Date.now(),
|
|
310
|
+
outcomes: { 'APPROVE': 0, 'NEEDS WORK': 0, 'BLOCK': 0, 'no verdict': 0, 'skipped': 0, 'error': 0 },
|
|
259
311
|
};
|
|
260
312
|
tunnel = {
|
|
261
313
|
type: 'none', url: null, alive: false,
|
|
@@ -372,19 +424,37 @@ export class PRBoard {
|
|
|
372
424
|
}
|
|
373
425
|
if (fixCount !== undefined && fixCount > 0)
|
|
374
426
|
this.stats.fixesApplied++;
|
|
427
|
+
this.stats.outcomes[outcomeOf(slot)]++;
|
|
375
428
|
// Non-TTY has no live block to re-render — emit the folded line to scrollback and drop the slot.
|
|
376
429
|
if (!this.isTTY) {
|
|
377
430
|
process.stdout.write(this.renderPRSlotFolded(slot) + '\n');
|
|
378
431
|
this.slots.delete(key);
|
|
379
432
|
}
|
|
380
433
|
}
|
|
434
|
+
/**
|
|
435
|
+
* Settle a slot that ended in an error. The slot stays in the workspace as a
|
|
436
|
+
* settled row, the same as a completed one: a failed run is a session record,
|
|
437
|
+
* and deleting it here was what pushed reviewer timeouts out of the table and
|
|
438
|
+
* into raw scrollback, where a long watch session showed 43 errors above an
|
|
439
|
+
* empty workspace reading "no PRs yet".
|
|
440
|
+
*/
|
|
381
441
|
failPR(key, error) {
|
|
382
442
|
const slot = this.slots.get(key);
|
|
383
|
-
this.slots.delete(key);
|
|
384
443
|
this.stats.errorsOccurred++;
|
|
385
|
-
if (slot)
|
|
386
|
-
|
|
387
|
-
|
|
444
|
+
if (!slot || slot.completedAt !== undefined)
|
|
445
|
+
return;
|
|
446
|
+
slot.completedAt = Date.now();
|
|
447
|
+
// A failure message is whatever the thrower had — a subprocess dump can
|
|
448
|
+
// arrive with embedded newlines and hundreds of columns. The folded row is
|
|
449
|
+
// clamped to one terminal row and history pagination counts on that, so the
|
|
450
|
+
// text is flattened here rather than at render time. The untouched error is
|
|
451
|
+
// in the file log.
|
|
452
|
+
slot.error = oneLine(error, 160);
|
|
453
|
+
slot.label = 'failed';
|
|
454
|
+
this.stats.outcomes.error++;
|
|
455
|
+
if (!this.isTTY) {
|
|
456
|
+
process.stdout.write(this.renderPRSlotFolded(slot) + '\n');
|
|
457
|
+
this.slots.delete(key);
|
|
388
458
|
}
|
|
389
459
|
}
|
|
390
460
|
/** Print 1–2 static lines to scrollback (above the live block). */
|
|
@@ -454,14 +524,43 @@ export class PRBoard {
|
|
|
454
524
|
return t.barCRNeedsWork;
|
|
455
525
|
}
|
|
456
526
|
// ── Private: render ────────────────────────────────────────────────────────
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
const
|
|
460
|
-
const
|
|
461
|
-
const
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
527
|
+
/** "since 08:25 PM · up 3h32m" — when watch started, and how long it has run. */
|
|
528
|
+
sessionRow() {
|
|
529
|
+
const t = this.theme;
|
|
530
|
+
const started = fmtStartTime(this.stats.sessionStart);
|
|
531
|
+
const age = fmtUptime(Date.now() - this.stats.sessionStart);
|
|
532
|
+
return `${t.dim('since')} ${started} ${t.dim('·')} ${t.dim('up')} ${age}`;
|
|
533
|
+
}
|
|
534
|
+
/**
|
|
535
|
+
* Outcome shares across every PR that settled this session, e.g.
|
|
536
|
+
* "APPROVE 45% · BLOCK 30% · error 25%". Empty when nothing has settled yet.
|
|
537
|
+
*/
|
|
538
|
+
outcomeRow() {
|
|
539
|
+
const t = this.theme;
|
|
540
|
+
const counts = OUTCOME_ORDER.map(o => this.stats.outcomes[o]);
|
|
541
|
+
const total = counts.reduce((a, b) => a + b, 0);
|
|
542
|
+
if (total === 0)
|
|
543
|
+
return '';
|
|
544
|
+
const pcts = distribute(counts);
|
|
545
|
+
const mixed = counts.filter(c => c > 0).length > 1;
|
|
546
|
+
const parts = OUTCOME_ORDER.map((outcome, i) => {
|
|
547
|
+
if (counts[i] === 0)
|
|
548
|
+
return null;
|
|
549
|
+
// A share too small to earn a whole point still happened; "<1%" says so
|
|
550
|
+
// where "0%" would read as never. Its complement is then not a whole 100
|
|
551
|
+
// either — "error 100%" beside "APPROVE <1%" contradicts itself — so the
|
|
552
|
+
// rounded-up bucket reads ">99%" whenever some other outcome is non-zero.
|
|
553
|
+
const pct = pcts[i] === 0 ? '<1%'
|
|
554
|
+
: pcts[i] === 100 && mixed ? '>99%'
|
|
555
|
+
: `${pcts[i]}%`;
|
|
556
|
+
const paint = outcome === 'APPROVE' ? t.barCRApprove
|
|
557
|
+
: outcome === 'BLOCK' ? t.barCRBlock
|
|
558
|
+
: outcome === 'NEEDS WORK' ? t.barCRNeedsWork
|
|
559
|
+
: outcome === 'error' ? t.error
|
|
560
|
+
: t.dim;
|
|
561
|
+
return `${paint(outcome)} ${t.dim(pct)}`;
|
|
562
|
+
}).filter((p) => p !== null);
|
|
563
|
+
return parts.join(t.dim(' · '));
|
|
465
564
|
}
|
|
466
565
|
statsRow() {
|
|
467
566
|
const { prsReceived, crsCompleted, fixesApplied, errorsOccurred, crTotalMs } = this.stats;
|
|
@@ -473,7 +572,7 @@ export class PRBoard {
|
|
|
473
572
|
: '';
|
|
474
573
|
return `PRs: ${prsReceived} · CRs: ${crsCompleted}${errorPart} · fixes: ${fixesApplied}${avgCr}`;
|
|
475
574
|
}
|
|
476
|
-
renderPRSlot(slot, frame) {
|
|
575
|
+
renderPRSlot(slot, frame, numWidth = 0) {
|
|
477
576
|
const t = this.theme;
|
|
478
577
|
const w = process.stdout.columns || 80;
|
|
479
578
|
const isCompleted = slot.completedAt !== undefined;
|
|
@@ -485,15 +584,15 @@ export class PRBoard {
|
|
|
485
584
|
const branch = truncate(slot.branch, 22);
|
|
486
585
|
const icon = isCompleted ? t.success('✓') : t.spinner(frame);
|
|
487
586
|
const phaseLabel = this.phaseLine1Label(slot, frame);
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
const
|
|
492
|
-
const identityPlain = `
|
|
587
|
+
// The entered-at column after the PR number already says when it started.
|
|
588
|
+
const rightPart = `${t.dim(eSuffix)} ${phaseLabel}`;
|
|
589
|
+
const numText = `#${slot.prNumber}`.padEnd(numWidth);
|
|
590
|
+
const entered = fmtEnteredAt(slot.startedAt);
|
|
591
|
+
const identityPlain = ` ${numText} ${entered} ${slot.repo} ${branch}`;
|
|
493
592
|
const l1Pad = Math.max(2, w - stripAnsi(identityPlain).length - stripAnsi(rightPart).length - 2);
|
|
494
|
-
const prNum = isCompleted ? t.dim(
|
|
593
|
+
const prNum = isCompleted ? t.dim(numText) : chalk.bold(numText);
|
|
495
594
|
const repoStr = isCompleted ? t.dim(slot.repo) : chalk.white(slot.repo);
|
|
496
|
-
const l1 = ` ${icon} ${prNum} ${repoStr} ${t.dim(branch)}` +
|
|
595
|
+
const l1 = ` ${icon} ${prNum} ${t.dim(entered)} ${repoStr} ${t.dim(branch)}` +
|
|
497
596
|
' '.repeat(l1Pad) + rightPart;
|
|
498
597
|
// ── Line 2: PR | CR | Fix | Recheck pipeline ────────────────────────────────
|
|
499
598
|
const pipe = t.dim(' | ');
|
|
@@ -740,7 +839,11 @@ export class PRBoard {
|
|
|
740
839
|
renderStatsPanel(connLogLines, showTip) {
|
|
741
840
|
const t = this.theme;
|
|
742
841
|
const lines = [];
|
|
743
|
-
lines.push(` ${this.statsRow()}
|
|
842
|
+
lines.push(` ${this.statsRow()}`);
|
|
843
|
+
// Session line: start time + age, and the outcome split when anything has
|
|
844
|
+
// settled. One row, because every row here costs a PR row on the live page.
|
|
845
|
+
const outcomes = this.outcomeRow();
|
|
846
|
+
lines.push(` ${this.sessionRow()}${outcomes ? ` ${t.dim('│')} ${outcomes}` : ''}`);
|
|
744
847
|
const { type: tunnelType, url, alive } = this.tunnel;
|
|
745
848
|
if (tunnelType !== 'none') {
|
|
746
849
|
const tunnelLabel = tunnelType === 'serve' ? 'endpoint:' : 'tunnel: ';
|
|
@@ -781,6 +884,8 @@ export class PRBoard {
|
|
|
781
884
|
completedCount++;
|
|
782
885
|
}
|
|
783
886
|
const foldByCount = completedCount > FOLD_THRESHOLD;
|
|
887
|
+
// Pad the PR number to the page's widest so the entered-at column lines up.
|
|
888
|
+
const numWidth = Math.max(...visible.map(s => `#${s.prNumber}`.length));
|
|
784
889
|
const lines = [];
|
|
785
890
|
let prevWasExpanded = false;
|
|
786
891
|
let first = true;
|
|
@@ -794,17 +899,20 @@ export class PRBoard {
|
|
|
794
899
|
// should re-expand.
|
|
795
900
|
if (isCompleted && foldByCount)
|
|
796
901
|
slot.stickyFolded = true;
|
|
797
|
-
|
|
902
|
+
// A failed slot is always folded: its pipeline bars are frozen wherever the
|
|
903
|
+
// run died ("CR queued", "Fix queued"), which describes work that will
|
|
904
|
+
// never happen. The folded row carries the error instead.
|
|
905
|
+
const useFolded = isCompleted && (foldAll || slot.stickyFolded === true || slot.error !== undefined);
|
|
798
906
|
if (useFolded) {
|
|
799
907
|
// Clamped so the row never wraps: history pagination sizes a page by
|
|
800
908
|
// counting one terminal row per folded slot.
|
|
801
|
-
lines.push(truncateVisible(this.renderPRSlotFolded(slot), w - 1));
|
|
909
|
+
lines.push(truncateVisible(this.renderPRSlotFolded(slot, numWidth), w - 1));
|
|
802
910
|
prevWasExpanded = false;
|
|
803
911
|
}
|
|
804
912
|
else {
|
|
805
913
|
if (!first && prevWasExpanded)
|
|
806
914
|
lines.push('');
|
|
807
|
-
lines.push(this.renderPRSlot(slot, frame));
|
|
915
|
+
lines.push(this.renderPRSlot(slot, frame, numWidth));
|
|
808
916
|
prevWasExpanded = true;
|
|
809
917
|
}
|
|
810
918
|
first = false;
|
|
@@ -822,13 +930,16 @@ export class PRBoard {
|
|
|
822
930
|
// "shown of retained", not of stats.prsReceived: rounds add rows, and the
|
|
823
931
|
// history cap eventually drops the oldest, so the two counts diverge.
|
|
824
932
|
const counts = t.dim(`showing ${shown} of ${total}`);
|
|
933
|
+
// A key with nowhere to go fades to the muted colour: ← on the live page,
|
|
934
|
+
// → on the oldest history page.
|
|
935
|
+
const key = (arrow, label, live) => live ? `${t.accent(arrow)} ${t.dim(label)}` : t.muted(`${arrow} ${label}`);
|
|
825
936
|
const keys = this.pageCount > 1
|
|
826
|
-
? ` ${t.dim('│')} ${
|
|
937
|
+
? ` ${t.dim('│')} ${key('←', 'newer', this.page > 0)} ${key('→', 'older', this.page < this.pageCount - 1)}`
|
|
827
938
|
: '';
|
|
828
939
|
return ` ${position} ${t.dim('│')} ${counts}${keys}`;
|
|
829
940
|
}
|
|
830
941
|
// ── Folded PR slot ─────────────────────────────────────────────────────────
|
|
831
|
-
renderPRSlotFolded(slot) {
|
|
942
|
+
renderPRSlotFolded(slot, numWidth = 0) {
|
|
832
943
|
const t = this.theme;
|
|
833
944
|
const elapsedMs = (slot.completedAt ?? Date.now()) - slot.startedAt;
|
|
834
945
|
const elapsed = fmtDuration(elapsedMs);
|
|
@@ -854,9 +965,17 @@ export class PRBoard {
|
|
|
854
965
|
const rFn = this.crLabelFn(slot.recheckVerdict);
|
|
855
966
|
parts.push(`recheck ${rFn(slot.recheckVerdict)}`);
|
|
856
967
|
}
|
|
968
|
+
// A failed run reports the error in place of the verdict trail: whatever the
|
|
969
|
+
// pipeline had reached is what it never got to finish, and the reason it
|
|
970
|
+
// stopped is the only thing worth the row.
|
|
971
|
+
if (slot.error !== undefined) {
|
|
972
|
+
parts.push(t.error(slot.error));
|
|
973
|
+
}
|
|
857
974
|
const urlPart = slot.url ? ` ${t.dim('→')} ${t.accent(slot.url)}` : '';
|
|
858
975
|
const partsStr = parts.length > 0 ? parts.join(t.dim(' · ')) : t.dim('—');
|
|
859
|
-
|
|
976
|
+
const icon = slot.error !== undefined ? t.error('✗') : t.success('✓');
|
|
977
|
+
const numText = `#${slot.prNumber}`.padEnd(numWidth);
|
|
978
|
+
return ` ${icon} ${t.dim(numText)} ${t.dim(fmtEnteredAt(slot.startedAt))} ${t.dim(slot.repo)} ${t.dim(branch)} ${partsStr} ${t.dim(`(${elapsed})`)}${urlPart}`;
|
|
860
979
|
}
|
|
861
980
|
redraw() {
|
|
862
981
|
const content = this.render();
|