@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.
Files changed (108) hide show
  1. package/README.md +2 -0
  2. package/crosscheck.config.example.yml +17 -4
  3. package/dist/__tests__/board.test.js +332 -33
  4. package/dist/__tests__/board.test.js.map +1 -1
  5. package/dist/__tests__/lock-ownership.test.d.ts +2 -0
  6. package/dist/__tests__/lock-ownership.test.d.ts.map +1 -0
  7. package/dist/__tests__/lock-ownership.test.js +167 -0
  8. package/dist/__tests__/lock-ownership.test.js.map +1 -0
  9. package/dist/__tests__/optimize.test.js +4 -3
  10. package/dist/__tests__/optimize.test.js.map +1 -1
  11. package/dist/__tests__/repository-cache.test.d.ts +2 -0
  12. package/dist/__tests__/repository-cache.test.d.ts.map +1 -0
  13. package/dist/__tests__/repository-cache.test.js +91 -0
  14. package/dist/__tests__/repository-cache.test.js.map +1 -0
  15. package/dist/__tests__/repository-guidance.test.js +1 -1
  16. package/dist/__tests__/repository-guidance.test.js.map +1 -1
  17. package/dist/__tests__/review-command-strategy.test.d.ts +2 -0
  18. package/dist/__tests__/review-command-strategy.test.d.ts.map +1 -0
  19. package/dist/__tests__/review-command-strategy.test.js +194 -0
  20. package/dist/__tests__/review-command-strategy.test.js.map +1 -0
  21. package/dist/__tests__/review-efficiency-config.test.d.ts +2 -0
  22. package/dist/__tests__/review-efficiency-config.test.d.ts.map +1 -0
  23. package/dist/__tests__/review-efficiency-config.test.js +16 -0
  24. package/dist/__tests__/review-efficiency-config.test.js.map +1 -0
  25. package/dist/__tests__/review-memory.test.d.ts +2 -0
  26. package/dist/__tests__/review-memory.test.d.ts.map +1 -0
  27. package/dist/__tests__/review-memory.test.js +252 -0
  28. package/dist/__tests__/review-memory.test.js.map +1 -0
  29. package/dist/__tests__/review-models.test.js +1 -0
  30. package/dist/__tests__/review-models.test.js.map +1 -1
  31. package/dist/__tests__/review-strategy.test.js +78 -3
  32. package/dist/__tests__/review-strategy.test.js.map +1 -1
  33. package/dist/__tests__/vendor-error-summary.test.d.ts +2 -0
  34. package/dist/__tests__/vendor-error-summary.test.d.ts.map +1 -0
  35. package/dist/__tests__/vendor-error-summary.test.js +74 -0
  36. package/dist/__tests__/vendor-error-summary.test.js.map +1 -0
  37. package/dist/__tests__/watch-concurrency.test.d.ts +2 -0
  38. package/dist/__tests__/watch-concurrency.test.d.ts.map +1 -0
  39. package/dist/__tests__/watch-concurrency.test.js +53 -0
  40. package/dist/__tests__/watch-concurrency.test.js.map +1 -0
  41. package/dist/cli.js +18 -2
  42. package/dist/cli.js.map +1 -1
  43. package/dist/commands/onboard.js +2 -2
  44. package/dist/commands/onboard.js.map +1 -1
  45. package/dist/commands/review.d.ts +3 -1
  46. package/dist/commands/review.d.ts.map +1 -1
  47. package/dist/commands/review.js +146 -7
  48. package/dist/commands/review.js.map +1 -1
  49. package/dist/commands/run.d.ts.map +1 -1
  50. package/dist/commands/run.js +24 -6
  51. package/dist/commands/run.js.map +1 -1
  52. package/dist/commands/watch.d.ts +3 -0
  53. package/dist/commands/watch.d.ts.map +1 -1
  54. package/dist/commands/watch.js +170 -25
  55. package/dist/commands/watch.js.map +1 -1
  56. package/dist/config/schema.d.ts +27 -0
  57. package/dist/config/schema.d.ts.map +1 -1
  58. package/dist/config/schema.js +12 -1
  59. package/dist/config/schema.js.map +1 -1
  60. package/dist/github/review-status.d.ts +33 -0
  61. package/dist/github/review-status.d.ts.map +1 -1
  62. package/dist/github/review-status.js +94 -9
  63. package/dist/github/review-status.js.map +1 -1
  64. package/dist/lib/board.d.ts +32 -1
  65. package/dist/lib/board.d.ts.map +1 -1
  66. package/dist/lib/board.js +169 -50
  67. package/dist/lib/board.js.map +1 -1
  68. package/dist/lib/clone.d.ts +3 -0
  69. package/dist/lib/clone.d.ts.map +1 -1
  70. package/dist/lib/clone.js +21 -1
  71. package/dist/lib/clone.js.map +1 -1
  72. package/dist/lib/pr-lock.d.ts +37 -1
  73. package/dist/lib/pr-lock.d.ts.map +1 -1
  74. package/dist/lib/pr-lock.js +176 -29
  75. package/dist/lib/pr-lock.js.map +1 -1
  76. package/dist/lib/pr-workflow-state.d.ts +2 -0
  77. package/dist/lib/pr-workflow-state.d.ts.map +1 -1
  78. package/dist/lib/pr-workflow-state.js +15 -0
  79. package/dist/lib/pr-workflow-state.js.map +1 -1
  80. package/dist/lib/repository-cache.d.ts +18 -0
  81. package/dist/lib/repository-cache.d.ts.map +1 -0
  82. package/dist/lib/repository-cache.js +125 -0
  83. package/dist/lib/repository-cache.js.map +1 -0
  84. package/dist/lib/review-memory.d.ts +212 -0
  85. package/dist/lib/review-memory.d.ts.map +1 -0
  86. package/dist/lib/review-memory.js +305 -0
  87. package/dist/lib/review-memory.js.map +1 -0
  88. package/dist/lib/review-models.d.ts +2 -2
  89. package/dist/lib/review-models.js +2 -2
  90. package/dist/lib/runner.d.ts +37 -2
  91. package/dist/lib/runner.d.ts.map +1 -1
  92. package/dist/lib/runner.js +81 -28
  93. package/dist/lib/runner.js.map +1 -1
  94. package/dist/lib/tips.js +1 -1
  95. package/dist/lib/tips.js.map +1 -1
  96. package/dist/lib/vendor-error-summary.d.ts +19 -0
  97. package/dist/lib/vendor-error-summary.d.ts.map +1 -0
  98. package/dist/lib/vendor-error-summary.js +95 -0
  99. package/dist/lib/vendor-error-summary.js.map +1 -0
  100. package/dist/reviewers/claude.d.ts.map +1 -1
  101. package/dist/reviewers/claude.js +2 -1
  102. package/dist/reviewers/claude.js.map +1 -1
  103. package/dist/reviewers/codex.d.ts.map +1 -1
  104. package/dist/reviewers/codex.js +2 -1
  105. package/dist/reviewers/codex.js.map +1 -1
  106. package/docs/dynamic-thoroughness.md +7 -4
  107. package/get-started.md +83 -9
  108. 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;AAWtC,wBAAsB,eAAe,CACnC,OAAO,EAAE,OAAO,EAChB,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,MAAM,EACZ,GAAG,EAAE,MAAM,GACV,OAAO,CAAC,OAAO,CAAC,CAYlB;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;AAKD,wBAAgB,wBAAwB,CACtC,OAAO,EAAE,OAAO,EAChB,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,MAAM,EACZ,GAAG,EAAE,MAAM,GACV,MAAM,IAAI,CAUZ;AAED,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,CAMf"}
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 { data } = await octokit.rest.repos.getCombinedStatusForRef({ owner, repo, ref: sha });
12
- const status = data.statuses
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
- const age = Date.now() - new Date(status.updated_at).getTime();
18
- return age < STALE_MS;
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: `Review started at ${new Date().toISOString()}`,
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: `Review active at ${new Date().toISOString()}`,
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":"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"}
1
+ {"version":3,"file":"review-status.js","sourceRoot":"","sources":["../../src/github/review-status.ts"],"names":[],"mappings":"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"}
@@ -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
- private uptime;
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;
@@ -1 +1 @@
1
- {"version":3,"file":"board.d.ts","sourceRoot":"","sources":["../../src/lib/board.ts"],"names":[],"mappings":"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"}
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
- // Bare `<`/`>` (and their unshifted `,`/`.`) always work. Terminals cannot agree
98
- // on how to report ctrl/cmd with a punctuation key, so every encoding they do
99
- // emit is accepted: meta-prefixed, CSI-u (kitty / xterm modifyOtherKeys) and
100
- // modified arrows. macOS Terminal swallows cmd entirely — there the bare keys,
101
- // or ctrl+arrow, are the way through.
102
- const PAGE_OLDER_KEYS = new Set(['<', ',', '\u001b<', '\u001b,', '\u001b[D', '\u001b[5~']);
103
- const PAGE_NEWER_KEYS = new Set(['>', '.', '\u001b>', '\u001b.', '\u001b[C', '\u001b[6~']);
104
- // eslint-disable-next-line no-control-regex -- the ESC byte is the sequence
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
- const ts = fmtTime();
387
- this.printStatic(`${chalk.dim(ts)} PR #${slot.prNumber} ${chalk.red('✗')} ${error}`);
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
- uptime() {
458
- const totalSec = Math.floor((Date.now() - this.stats.sessionStart) / 1000);
459
- const h = Math.floor(totalSec / 3600);
460
- const m = Math.floor((totalSec % 3600) / 60);
461
- const s = totalSec % 60;
462
- if (h > 0)
463
- return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
464
- return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
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
- const timePart = isCompleted
489
- ? t.dim(eSuffix)
490
- : `${t.dim('started ' + fmtStartTime(slot.startedAt))} ${t.dim(eSuffix)}`;
491
- const rightPart = `${timePart} ${phaseLabel}`;
492
- const identityPlain = ` #${slot.prNumber} ${slot.repo} ${branch}`;
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(`#${slot.prNumber}`) : chalk.bold(`#${slot.prNumber}`);
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()} ${t.dim('│')} ${t.dim('↑')} ${this.uptime()}`);
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
- const useFolded = isCompleted && (foldAll || slot.stickyFolded === true);
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('│')} ${t.accent('ctrl+<')} ${t.dim('older')} ${t.accent('ctrl+>')} ${t.dim('newer')}`
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
- return ` ${t.success('✓')} ${t.dim(`#${slot.prNumber}`)} ${t.dim(slot.repo)} ${t.dim(branch)} ${partsStr} ${t.dim(`(${elapsed})`)}${urlPart}`;
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();