limn-review 0.1.176 → 0.1.178

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 (35) hide show
  1. package/dist/api/plugins/rate-limit.js +8 -1
  2. package/dist/api/routes/open-prs.js +3 -0
  3. package/dist/api/routes/prs.js +44 -2
  4. package/dist/db/daily-brief.js +2 -1
  5. package/dist/db/flaky-checks.js +148 -0
  6. package/dist/db/flow-detail.js +23 -0
  7. package/dist/db/migrations/0163_pr_cross_repository.sql +5 -0
  8. package/dist/db/migrations/meta/_journal.json +7 -0
  9. package/dist/db/pending-first-landing.js +5 -3
  10. package/dist/db/pending-tabs.js +51 -6
  11. package/dist/db/pr-chips.js +32 -0
  12. package/dist/db/pr-criteria.js +119 -0
  13. package/dist/db/pr-stacks.js +151 -0
  14. package/dist/db/queries.js +305 -2
  15. package/dist/db/review-waits.js +136 -0
  16. package/dist/db/schema.sqlite.js +5 -0
  17. package/dist/github/queries.js +4 -0
  18. package/dist/merge/arm-intent.js +12 -0
  19. package/dist/merge/auto-merge-runner.js +13 -0
  20. package/dist/platform/insights/sprint-report.js +4 -0
  21. package/dist/platform/slack/pending-blocks.js +5 -2
  22. package/dist/platform/slack/tones.js +1 -0
  23. package/dist/shared/flaky-checks.js +7 -0
  24. package/dist/shared/index.js +2 -0
  25. package/dist/shared/pending-rules.js +8 -1
  26. package/dist/shared/pr-chips.js +24 -0
  27. package/dist/sync/upsert.js +5 -0
  28. package/package.json +1 -1
  29. package/public/assets/{AiFixTab-D_r2JEy_.js → AiFixTab-c7Zi_vxg.js} +1 -1
  30. package/public/assets/{ClaudeReviewTab-ncqvtble.js → ClaudeReviewTab-_5ovjklE.js} +1 -1
  31. package/public/assets/{PrBotBehaviourTab-Da_67MPm.js → PrBotBehaviourTab-Bvib9sC7.js} +1 -1
  32. package/public/assets/index-CasLyCVA.js +89 -0
  33. package/public/assets/{index-DIq-VB3X.css → index-EhfxyZH_.css} +1 -1
  34. package/public/index.html +2 -2
  35. package/public/assets/index-DVYEmdnp.js +0 -87
@@ -804,10 +804,17 @@ function tierFor(method, path) {
804
804
  // `getAccessToken` on line 35 of github/reviewer-suggest.ts. When in doubt, follow the token.
805
805
  // Genuinely DB-only and correctly left on `read`: `/bot-behaviour`, `/bot-dedup`,
806
806
  // `/mention-candidates`, `/claude-review` (retrieval).
807
- const prGithubGet = /^\/api\/prs\/\d+(\/(merge-options|files|files\/content|files\/diff|suggested-reviewers|checks\/[^/]+\/logs))?$/;
807
+ // `/reviewer-candidate` "Ask someone else": the same suggestion pipeline as
808
+ // `/suggested-reviewers` (CODEOWNERS + team history), on a click.
809
+ const prGithubGet = /^\/api\/prs\/\d+(\/(merge-options|files|files\/content|files\/diff|suggested-reviewers|reviewer-candidate|checks\/[^/]+\/logs))?$/;
808
810
  if (!mutating && prGithubGet.test(path)) {
809
811
  return [TIERS.prDetail, TIERS.read];
810
812
  }
813
+ // GET /api/prs/<id>/review-waits — the PR pane's review waits against the target: two indexed
814
+ // selects and a calendar fold, no GitHub, no model. `read`, DECIDED (spelled exactly) rather than
815
+ // inherited from the blanket.
816
+ if (!mutating && /^\/api\/prs\/\d+\/review-waits$/.test(path))
817
+ return [TIERS.read];
811
818
  // GET /api/threads/<id> hydrates the same way.
812
819
  if (!mutating && /^\/api\/threads\/\d+$/.test(path))
813
820
  return [TIERS.prDetail, TIERS.read];
@@ -1,5 +1,6 @@
1
1
  import { getOpenPrs, resolveWorkspaceScope } from '../../db/queries.js';
2
2
  import { accountIdOf } from '../plugins/auth.js';
3
+ import { attachPrChips } from '../../db/pr-chips.js';
3
4
  // `repoIds` → the `narrow` argument of `resolveWorkspaceScope`, never a scope in its own right.
4
5
  // Empty/absent → null = no narrowing (every repo in the workspace).
5
6
  function parseIntList(raw) {
@@ -29,6 +30,8 @@ export async function openPrsRoutes(app) {
29
30
  repoIds: scope.repoIds,
30
31
  userIds: parseIntList(q.userIds),
31
32
  });
33
+ // PR CHIPS (db/pr-chips.ts): the stack and the criteria — on this list only, never the timeline.
34
+ await attachPrChips(accountId, prs, (p) => p.id);
32
35
  return { prs };
33
36
  });
34
37
  }
@@ -3,9 +3,10 @@ import { REQUEST_CHANGES_DEFAULT_BODY, WITHDRAW_APPROVAL_MESSAGE, mergeStateWith
3
3
  import { accountGithubLogin } from '../../db/review-threads-for-review.js';
4
4
  import { config } from '../../config.js';
5
5
  import { stampPrMergeQueueStateNonFatal } from '../../db/pr-merge-queue-stamp.js';
6
+ import { prFlakyChecks } from '../../db/flaky-checks.js';
6
7
  import { getAccessToken, getAccountUserId } from '../../auth/account.js';
7
8
  import { fetchActionsJobLog } from '../../github/actions-logs.js';
8
- import { disarmAutoMerge, getAutoMergeRequest, listAutoMergeRequests, getMentionCandidates, getPrDetail, getPrBotBehaviour, getPrFilesContext, getPrWriteContext, getReviewerLogins, getResolvableBotThreads, getSuggestedReviewersBasis, getUsersByLogins, clearOwnReviewRequest, markAllViewed, markPrClosedLocally, markPrMergedLocally, markPrReopenedLocally, markPrViewed, stampReviewRequests, markLocalReviewDismissed, upsertLocalPrComment, upsertLocalReview, } from '../../db/queries.js';
9
+ import { disarmAutoMerge, getAutoMergeRequest, listAutoMergeRequests, getMentionCandidates, getPrDetail, getPrBotBehaviour, getPrFilesContext, getPrWriteContext, getReviewerLogins, getResolvableBotThreads, getSuggestedReviewersBasis, getPrReviewWaits, getReviewerCandidate, getUsersByLogins, clearOwnReviewRequest, markAllViewed, markPrClosedLocally, markPrMergedLocally, markPrReopenedLocally, markPrViewed, stampReviewRequests, markLocalReviewDismissed, upsertLocalPrComment, upsertLocalReview, } from '../../db/queries.js';
9
10
  import { resolveArmedQueues, withArmedQueueFields } from '../../db/merge-queue.js';
10
11
  // The watcher's live marks (yields + queue-disabled fallbacks). Read-only, and the reason this
11
12
  // import is not a layering smell: every one of them is a LIVE observation of GitHub made inside
@@ -25,6 +26,7 @@ import { getPrSettleFacts, notePrChangedForPr, schedulePrSettle, } from '../../s
25
26
  import { noteMergeLanded } from '../../sync/unsettled-prs.js';
26
27
  import { resolveThreadsOnGitHub } from '../../bot-triage/resolve.js';
27
28
  import { accountIdOf } from '../plugins/auth.js';
29
+ import { prChipsFor } from '../../db/pr-chips.js';
28
30
  // GitHub anchors a file in the PR "Files changed" diff by the SHA-256 of its
29
31
  // path (matches db/queries.ts + hydrate-detail.ts's diffAnchorId).
30
32
  function diffAnchorId(path) {
@@ -297,6 +299,15 @@ export async function prRoutes(app) {
297
299
  // caches the result in IndexedDB keyed by updatedAt so unchanged PRs don't refetch.
298
300
  // (Suggested reviewers are NOT here — they're a separate live query, see below — so the
299
301
  // cached detail never freezes a stale suggestion.)
302
+ // The FLAKY failing checks at the synced head (db/flaky-checks.ts) — stored rows only, so the
303
+ // Checks row needs no fetch of its own. [] when none (or the head is not red).
304
+ pr.flakyChecks = (await prFlakyChecks(accountId, [pr])).get(pr.id)?.checks ?? [];
305
+ // PR CHIPS (db/pr-chips.ts): the stack (open PRs only) and the criteria, DB only.
306
+ const chips = (await prChipsFor(accountId, [{ id: pr.id, repoId: pr.repoId }])).get(pr.id);
307
+ if (chips != null) {
308
+ pr.branchStack = pr.state === 'open' ? chips.branchStack : null;
309
+ pr.criteria = chips.criteria;
310
+ }
300
311
  return hydratePrDetail(pr, accountId);
301
312
  });
302
313
  // Live PR-detail freshness: the SPA polls this every ~5s while a PR is open AND visible,
@@ -1604,8 +1615,35 @@ export async function prRoutes(app) {
1604
1615
  };
1605
1616
  }
1606
1617
  });
1618
+ // How long each outstanding review request on the PR has waited, in working hours, against the
1619
+ // workspace's review target — the PR pane's reviewers area ("Over the 4h review target", Nudge).
1620
+ // DB only (tier `read`, decided in tierFor). Contract row in docs/API.md.
1621
+ app.get('/api/prs/:id/review-waits', { schema: idParamSchema }, async (req, reply) => {
1622
+ const { id } = req.params;
1623
+ const resp = await getPrReviewWaits(id, accountIdOf(req));
1624
+ if (!resp) {
1625
+ reply.status(404);
1626
+ return { error: 'NotFound', message: `PR ${id} not found` };
1627
+ }
1628
+ const out = resp;
1629
+ return out;
1630
+ });
1631
+ // "Ask someone else": the one suggested reviewer to ask — the least loaded of the PR's suggested
1632
+ // people not already asked. Fetched on the reader's CLICK, never on mount: it reads CODEOWNERS
1633
+ // (network), so `prDetail` + `read` in tierFor. Asking is then the reader's confirm, through
1634
+ // POST …/request-reviewers below. Contract row in docs/API.md.
1635
+ app.get('/api/prs/:id/reviewer-candidate', { schema: idParamSchema }, async (req, reply) => {
1636
+ const { id } = req.params;
1637
+ const resp = await getReviewerCandidate(id, accountIdOf(req));
1638
+ if (!resp) {
1639
+ reply.status(404);
1640
+ return { error: 'NotFound', message: `PR ${id} not found` };
1641
+ }
1642
+ const out = resp;
1643
+ return out;
1644
+ });
1607
1645
  // Request reviewers on a PR (powers each Pending routing card's per-suggestion "Assign"
1608
- // button, one reviewer per request — contract row in docs/API.md). Server
1646
+ // button, a waiting row's "Nudge" / "Ask someone else" and the PR pane's, one reviewer per request — contract row in docs/API.md). Server
1609
1647
  // re-checks write access (WRITE/MAINTAIN/ADMIN — push-style, no author exclusion:
1610
1648
  // an author may request reviewers on their own PR). The given user ids are resolved
1611
1649
  // to GitHub logins (the PR author + bots + unknown ids dropped); GitHub itself gates
@@ -1654,6 +1692,10 @@ export async function prRoutes(app) {
1654
1692
  await stampReviewRequests(ctx.prId, resolved.map((r) => r.userId), teams.map((slug) => `${ctx.owner}/${slug}`));
1655
1693
  // The stamp moves the routing card; signal it before replying. Never throws.
1656
1694
  await notePrChangedForPr(accountId, ctx.prId);
1695
+ // ONE re-read shortly after (no expectation: the ladder stops at its first read), so the
1696
+ // NEW request event — a "Nudge" re-asks someone already on the hook — reaches the stored
1697
+ // history and restarts that reviewer's wait (db/review-waits.ts). Fire-and-forget.
1698
+ schedulePrSettle(accountId, ctx.prId, asSyncLogger(req.log));
1657
1699
  const result = {
1658
1700
  status: 'ok',
1659
1701
  requestedLogins: logins,
@@ -282,7 +282,8 @@ async function computeBriefCounts(accountId, workspaceId) {
282
282
  // The fourth is housekeeping — an update, not something waiting on you. (`security` IS counted,
283
283
  // below, off `kindTotals` like the other survey lines.) This allow-list and the route's deny-list
284
284
  // are the two hand-maintained spellings of "which kinds count"; daily-brief.test.ts compares them
285
- // per kind, so an omission here is a CI failure, not a silence.
285
+ // per kind, so an omission here is a CI failure, not a silence. `waiting_on_others` never reaches
286
+ // it at all: it is built only for the live board (`withFailingChecks`), and counted only there.
286
287
  for (const c of insights.cards) {
287
288
  if (c.kind === 'ci_failing')
288
289
  ciFailing += 1;
@@ -0,0 +1,148 @@
1
+ // ── FLAKY CHECKS — which failing checks are probably not the PR's fault (CORE, no AI, no GitHub) ──
2
+ //
3
+ // docs/MERGE-CI-TRUNK.md § Flaky checks. A failing check at a PR's CURRENT head is FLAKY when:
4
+ //
5
+ // (a) `passed_and_failed` — the PR's stored CI log (`ci_status_events`) holds a row AT THAT HEAD
6
+ // whose rollup was `success` (every check passed on this commit), and the check fails on it
7
+ // now. ⚠ The log stores the FAILING names only, never per-check passes, so a `success` row is
8
+ // the one stored proof that a check passed. A red row that merely omits a name is NOT one —
9
+ // the check may be re-running (pending) or cancelled.
10
+ // (b) `ci_review_infra` — the latest SUCCEEDED CI review AT THAT HEAD diagnosed the check
11
+ // `flaky_or_infra`. An older head's review never counts: it explained different code.
12
+ //
13
+ // ⚠ ONE PURE FUNCTION decides (`classifyFlakyChecks`); the loader below only gathers its inputs, in
14
+ // three account-scoped reads for the whole board. Nothing fetches; nothing is stored.
15
+ // ⚠ A FLAKY CHECK IS STILL A FAILURE: no count, no CI label, no merge verdict and no card depends on
16
+ // this. It is DISPLAY ONLY — never in a `detail`, a work-plan fact, a hash or a model payload.
17
+ // ⚠ `limn/` statuses are never CI (`isLimnStatusContext`): never a failing check, never flaky.
18
+ import { and, eq, inArray } from 'drizzle-orm';
19
+ import { isLimnStatusContext, } from '../shared/index.js';
20
+ import { db, schema } from './client.js';
21
+ import { isRedCiStatus } from './pending-classify.js';
22
+ import { summariseFailingChecks } from './failing-checks.js';
23
+ const { ciStatusEvents, ciReviews, ciReviewItems } = schema;
24
+ /** A name as the failing-check list spells it (trimmed, sliced) — one spelling for both sides. */
25
+ function normaliseNames(names) {
26
+ return (summariseFailingChecks(names, Number.POSITIVE_INFINITY)?.failingChecks ?? []).filter((n) => !isLimnStatusContext(n));
27
+ }
28
+ /**
29
+ * THE RULE. `failing` = every check failing at `headSha` now (uncapped); `log` = the PR's stored CI
30
+ * log rows (any head — rows for other heads are ignored here); `ciReviewItems` = the items of the
31
+ * latest succeeded CI review AT `headSha`, or null when there is none. null when nothing is flaky.
32
+ */
33
+ export function classifyFlakyChecks(input) {
34
+ const failing = normaliseNames(input.failing);
35
+ if (failing.length === 0)
36
+ return null;
37
+ const passedOnHead = input.log.some((r) => r.headSha === input.headSha && r.status === 'success');
38
+ const infra = new Set(normaliseNames((input.ciReviewItems ?? [])
39
+ .filter((i) => i.status === 'diagnosed' && i.category === 'flaky_or_infra')
40
+ .map((i) => i.checkName)));
41
+ const checks = [];
42
+ for (const name of failing) {
43
+ const reasons = [];
44
+ if (passedOnHead)
45
+ reasons.push('passed_and_failed');
46
+ if (infra.has(name))
47
+ reasons.push('ci_review_infra');
48
+ if (reasons.length > 0)
49
+ checks.push({ name, reasons });
50
+ }
51
+ if (checks.length === 0)
52
+ return null;
53
+ return { checks, allFailing: checks.length === failing.length };
54
+ }
55
+ /**
56
+ * Each RED PR's flaky checks at its CURRENT head, keyed by PR id (absent = none flaky, or not red).
57
+ * The failing set is the NEWEST log row at the head — the row `prFailingChecks` names — and only
58
+ * while that row is red. Three reads: the log rows, the succeeded CI reviews at those heads, their
59
+ * items. Every `IN × IN` predicate over-matches, so rows are kept only for a requested PAIR.
60
+ */
61
+ export async function prFlakyChecks(accountId, prs) {
62
+ const out = new Map();
63
+ const asked = new Map();
64
+ for (const p of prs) {
65
+ if (p.headSha != null && isRedCiStatus(p.ciStatus))
66
+ asked.set(p.id, p.headSha);
67
+ }
68
+ if (asked.size === 0)
69
+ return out;
70
+ const prIds = [...asked.keys()];
71
+ const shas = [...new Set(asked.values())];
72
+ const rows = await db
73
+ .select({
74
+ id: ciStatusEvents.id,
75
+ prId: ciStatusEvents.prId,
76
+ headSha: ciStatusEvents.headSha,
77
+ status: ciStatusEvents.status,
78
+ failingChecks: ciStatusEvents.failingChecks,
79
+ observedAt: ciStatusEvents.observedAt,
80
+ })
81
+ .from(ciStatusEvents)
82
+ .where(and(eq(ciStatusEvents.accountId, accountId), inArray(ciStatusEvents.prId, prIds), inArray(ciStatusEvents.headSha, shas)))
83
+ .execute();
84
+ const logByPr = new Map();
85
+ for (const r of rows) {
86
+ if (asked.get(r.prId) !== r.headSha)
87
+ continue;
88
+ const list = logByPr.get(r.prId) ?? [];
89
+ list.push(r);
90
+ logByPr.set(r.prId, list);
91
+ }
92
+ // The latest SUCCEEDED CI review at each asked head (highest id wins, the persist layer's rule).
93
+ const runs = await db
94
+ .select({ id: ciReviews.id, prId: ciReviews.prId, headSha: ciReviews.headSha })
95
+ .from(ciReviews)
96
+ .where(and(eq(ciReviews.accountId, accountId), inArray(ciReviews.prId, prIds), inArray(ciReviews.headSha, shas), eq(ciReviews.status, 'succeeded')))
97
+ .execute();
98
+ const latestRun = new Map();
99
+ for (const r of runs) {
100
+ if (asked.get(r.prId) !== r.headSha)
101
+ continue;
102
+ if ((latestRun.get(r.prId) ?? 0) < r.id)
103
+ latestRun.set(r.prId, r.id);
104
+ }
105
+ const itemsByRun = new Map();
106
+ if (latestRun.size > 0) {
107
+ const items = await db
108
+ .select({
109
+ ciReviewId: ciReviewItems.ciReviewId,
110
+ checkName: ciReviewItems.checkName,
111
+ status: ciReviewItems.status,
112
+ category: ciReviewItems.category,
113
+ })
114
+ .from(ciReviewItems)
115
+ .where(and(eq(ciReviewItems.accountId, accountId), inArray(ciReviewItems.ciReviewId, [...latestRun.values()])))
116
+ .execute();
117
+ for (const i of items) {
118
+ const list = itemsByRun.get(i.ciReviewId) ?? [];
119
+ list.push(i);
120
+ itemsByRun.set(i.ciReviewId, list);
121
+ }
122
+ }
123
+ for (const [prId, headSha] of asked) {
124
+ const log = logByPr.get(prId) ?? [];
125
+ // The newest row at the head (observedAt, then id) — the failing set the card names.
126
+ let newest;
127
+ for (const r of log) {
128
+ if (newest == null ||
129
+ r.observedAt.getTime() > newest.observedAt.getTime() ||
130
+ (r.observedAt.getTime() === newest.observedAt.getTime() && r.id > newest.id)) {
131
+ newest = r;
132
+ }
133
+ }
134
+ if (newest == null || !isRedCiStatus(newest.status))
135
+ continue;
136
+ const runId = latestRun.get(prId);
137
+ const verdict = classifyFlakyChecks({
138
+ headSha,
139
+ failing: newest.failingChecks ?? [],
140
+ log,
141
+ ciReviewItems: runId != null ? (itemsByRun.get(runId) ?? []) : null,
142
+ });
143
+ if (verdict != null)
144
+ out.set(prId, { headSha, ...verdict });
145
+ }
146
+ return out;
147
+ }
148
+ //# sourceMappingURL=flaky-checks.js.map
@@ -169,6 +169,28 @@ function budgetRow(measure, values, settings) {
169
169
  }
170
170
  return { measure, good, ok, prs: n, p50: r2(p50), p75: r2(p75), p90: r2(p90), verdict, sentence };
171
171
  }
172
+ // ── The review target headline ──────────────────────────────────────────────────────────────
173
+ /**
174
+ * "86% of first reviews arrived within 4 working hours" — the share of measured PRs whose first look
175
+ * came within the first-look budget's "good" value (THE review target; no second setting). Over the
176
+ * same values as the first-look budget row, so the two cannot disagree about the population.
177
+ * ⚠ UNKNOWN IS NEVER ZERO: null under `FLOW_BUDGET_MIN_PRS` first looks — the budget row beside it
178
+ * already says how few there were.
179
+ */
180
+ function reviewTargetOf(worked, settings) {
181
+ const looks = worked.map((w) => w.firstLookWork).filter((v) => v != null);
182
+ if (looks.length < FLOW_BUDGET_MIN_PRS)
183
+ return null;
184
+ const targetHours = settings.budgets.firstLook.good;
185
+ const within = looks.filter((v) => v <= targetHours).length;
186
+ const share = Math.round((within / looks.length) * 100);
187
+ return {
188
+ targetHours,
189
+ within,
190
+ prs: looks.length,
191
+ sentence: `${share}% of first reviews arrived within ${fmtWork(targetHours, 0)}`,
192
+ };
193
+ }
172
194
  const share = (rows, pred) => rows.length === 0 ? 0 : rows.filter(pred).length / rows.length;
173
195
  const SIGNALS = [
174
196
  {
@@ -615,6 +637,7 @@ export function buildFlowDetail(facts, cal, settings, minRepoPrs) {
615
637
  concentration: concentrationOf(worked, minRepoPrs),
616
638
  requests: requestsOf(worked, dayHours),
617
639
  prFigures: prFiguresOf(worked, dayHours),
640
+ reviewTarget: reviewTargetOf(worked, settings),
618
641
  };
619
642
  }
620
643
  // ── Ticket keys ─────────────────────────────────────────────────────────────────────────────
@@ -0,0 +1,5 @@
1
+ -- STACKED PRs (docs/FRONTEND.md § Stacked PRs). Additive; no data to move.
2
+ --
3
+ -- pull_requests.is_cross_repository GraphQL `isCrossRepository`: the head branch lives in a fork.
4
+ -- NULL = not observed yet; written only from a received boolean.
5
+ ALTER TABLE `pull_requests` ADD `is_cross_repository` integer;
@@ -1142,6 +1142,13 @@
1142
1142
  "when": 1792224068016,
1143
1143
  "tag": "0162_dependency_ai_fix",
1144
1144
  "breakpoints": true
1145
+ },
1146
+ {
1147
+ "idx": 163,
1148
+ "version": "6",
1149
+ "when": 1792224069016,
1150
+ "tag": "0163_pr_cross_repository",
1151
+ "breakpoints": true
1145
1152
  }
1146
1153
  ]
1147
1154
  }
@@ -24,7 +24,7 @@
24
24
  // is built until the reader presses Add.
25
25
  //
26
26
  // ⚠ LIVE STATE, like the board: nothing here may enter a payload hash.
27
- import { PENDING_OWN_PRS_SHOWN, PENDING_SUMMARY_DAYS, } from '../shared/index.js';
27
+ import { PENDING_OWN_PRS_SHOWN, PENDING_TABS, PENDING_SUMMARY_DAYS, } from '../shared/index.js';
28
28
  import { and, count, desc, eq, inArray, isNotNull, min, sql } from 'drizzle-orm';
29
29
  import { db, schema } from './client.js';
30
30
  import { ownRunsOnly } from './teammate-reviews.js';
@@ -39,9 +39,11 @@ export function summaryWindowOpen(firstOnboardingAt, now) {
39
39
  return false;
40
40
  return now - firstOnboardingAt.getTime() < PENDING_SUMMARY_DAYS * DAY_MS;
41
41
  }
42
- /** Is every tab empty? The tabs' totals are the board's populations (review load is not counted). */
42
+ /** Is every tab empty? The tabs' totals are the board's populations (review load is not counted).
43
+ * ⚠ "Waiting on others" (`byWait`) is not asked: it is information about the reader's own PRs, not
44
+ * something to do — and the empty board lists those same PRs, with what blocks each. */
43
45
  export function boardIsEmpty(tabs) {
44
- return tabs.every((t) => t.total === 0);
46
+ return tabs.every((t) => t.total === 0 || PENDING_TABS.find((d) => d.key === t.key)?.byWait === true);
45
47
  }
46
48
  /** May the suggestion show at all? Pure — the PR pick is separate. */
47
49
  export function reviewSuggestionAllowed(f) {
@@ -1,10 +1,12 @@
1
1
  // ── THE PENDING BOARD'S TABS (CORE, deterministic, no AI) ───────────────────────────────────
2
2
  //
3
- // Seven tabs (`PENDING_TABS` in packages/shared/src/pending-rules.ts), each a scored list: every PR
3
+ // Eight tabs (`PENDING_TABS` in packages/shared/src/pending-rules.ts), each a scored list: every PR
4
4
  // card is scored by the one Do next scorer (`scoreCards`, db/work-plan.ts), with the READER's
5
5
  // weights (Settings → My Turn), and each tab lists its cards highest score first — except the two
6
6
  // STRICT-GROUP tabs, whose groups come first: My turn by card type in the READER's order
7
7
  // (`groupByReason`) and Dependencies security before bumps (`groupByKind`), each scored within.
8
+ // The one UNSCORED tab, "Waiting on others" (`byWait`), lists the longest wait first instead
9
+ // (`listByWait`): the reader cannot act on it, so there is nothing to score.
8
10
  // There is no severity-first sort and no cross-kind spread rule — a card competes only with the
9
11
  // cards in its own tab (and group), and a PR with two jobs appears once per job, in each job's tab.
10
12
  //
@@ -34,6 +36,7 @@ import { getWorkspaceInsights, mapUser, resolveWorkspaceScope, suggestRoutingRev
34
36
  import { scoreCards } from './work-plan.js';
35
37
  import { getMyTurnSettings, rankRulesOf } from './my-turn-settings.js';
36
38
  import { getPendingFirstLanding } from './pending-first-landing.js';
39
+ import { attachPrChips } from './pr-chips.js';
37
40
  /** Highest score first; ties go to the longer wait, then the lower PR id, then the card id — a
38
41
  * total order, so two polls of unchanged data list the same cards in the same order. */
39
42
  export function compareScored(a, b) {
@@ -95,6 +98,29 @@ export function listTab(def, scored, byId, myTurnOrderIdx) {
95
98
  }
96
99
  return out;
97
100
  }
101
+ /** THE LISTING RULE FOR AN UNSCORED TAB (`byWait` — "Waiting on others"): its cards, longest wait
102
+ * first (then the lower PR id, then the card id — a total order), each list group cut to
103
+ * `boardListCap`. ⚠ No score: these are read-only rows the reader cannot act on next, so the Do
104
+ * next scorer has nothing to rank, and the work plan may never name one (`listedCardIds` lists
105
+ * scored cards only). */
106
+ export function listByWait(def, cards) {
107
+ const waitOf = (c) => (c.kind === 'waiting_on_others' ? c.waitHours : -1);
108
+ const prOf = (c) => ('prId' in c && c.prId != null ? c.prId : Number.MAX_SAFE_INTEGER);
109
+ const inTab = cards
110
+ .filter((c) => pendingTabHolds(def, c))
111
+ .sort((a, b) => waitOf(b) - waitOf(a) || prOf(a) - prOf(b) || a.id.localeCompare(b.id));
112
+ const perGroup = new Map();
113
+ const out = [];
114
+ for (const c of inTab) {
115
+ const group = listGroupOf(c);
116
+ const n = perGroup.get(group) ?? 0;
117
+ if (n >= PENDING_LIMITS.boardListCap)
118
+ continue;
119
+ perGroup.set(group, n + 1);
120
+ out.push(c);
121
+ }
122
+ return out;
123
+ }
98
124
  /** Every card id any tab lists, for these cards, scores and My turn type order — the population
99
125
  * the Pro work plan may name. A plan row whose card the board does not list would put its
100
126
  * sentence on a card the reader cannot find. */
@@ -135,11 +161,19 @@ opts = {}) {
135
161
  // Every scored card of the tab's kinds — in a strict-group tab by group first — highest score
136
162
  // first, then listed per list group to the cap (`listTab`, the one listing rule).
137
163
  const ids = [];
138
- for (const s of listTab(def, scored, byId, orderIdx)) {
139
- ids.push(s.cardId);
140
- listed.push(byId.get(s.cardId));
141
- const { cardId: _cardId, prId: _prId, ...score } = s;
142
- scores[s.cardId] = score;
164
+ if (def.byWait === true) {
165
+ for (const c of listByWait(def, insights.cards)) {
166
+ ids.push(c.id);
167
+ listed.push(c);
168
+ }
169
+ }
170
+ else {
171
+ for (const s of listTab(def, scored, byId, orderIdx)) {
172
+ ids.push(s.cardId);
173
+ listed.push(byId.get(s.cardId));
174
+ const { cardId: _cardId, prId: _prId, ...score } = s;
175
+ scores[s.cardId] = score;
176
+ }
143
177
  }
144
178
  if (def.key === 'review') {
145
179
  routingTop = ids
@@ -216,6 +250,9 @@ export async function buildPendingBoard(accountId, workspace, opts = {}) {
216
250
  const insights = await getWorkspaceInsights(accountId, undefined, scope, {
217
251
  uncapped: true,
218
252
  withFailingChecks: live,
253
+ // "Waiting on others" — the reader's own PRs held by someone else. The route's alone: the
254
+ // Slack snapshot (a TEAM digest) never builds it.
255
+ withWaiting: live,
219
256
  ...(live ? { boardFacts } : {}),
220
257
  });
221
258
  const board = await rankPendingTabs(accountId, scope, insights, opts.now ?? Date.now(), {
@@ -235,6 +272,14 @@ export async function buildPendingBoard(accountId, workspace, opts = {}) {
235
272
  const firstLanding = live
236
273
  ? await getPendingFirstLanding(accountId, scope, board.tabs, opts.now ?? Date.now())
237
274
  : null;
275
+ // PR CHIPS (db/pr-chips.ts) — the stack and the criteria, on the LISTED PR cards, live only (the
276
+ // Slack snapshot shows neither). DB only; the board still may not fetch.
277
+ if (live) {
278
+ await attachPrChips(accountId, board.cards.filter((c) => typeof c.repoId === 'number'), (c) => {
279
+ const id = c.prId;
280
+ return typeof id === 'number' ? id : null;
281
+ });
282
+ }
238
283
  return {
239
284
  ...(firstLanding ?? {}),
240
285
  workspaceId: scope.workspaceId,
@@ -0,0 +1,32 @@
1
+ import { branchStacksFor } from './pr-stacks.js';
2
+ import { prCriteriaFor } from './pr-criteria.js';
3
+ /** Both chips for these PRs. A PR that is not open gets `branchStack: null` (stacks are open PRs). */
4
+ export async function prChipsFor(accountId, prs) {
5
+ const out = new Map();
6
+ if (prs.length === 0)
7
+ return out;
8
+ const repoIds = [...new Set(prs.map((p) => p.repoId))];
9
+ const [stacks, criteria] = await Promise.all([
10
+ branchStacksFor(accountId, repoIds),
11
+ prCriteriaFor(accountId, prs.map((p) => p.id)),
12
+ ]);
13
+ for (const p of prs) {
14
+ out.set(p.id, { branchStack: stacks.get(p.id) ?? null, criteria: criteria.get(p.id) ?? [] });
15
+ }
16
+ return out;
17
+ }
18
+ /** Copy the chips onto rows that carry `id` (or `prId`) in place. */
19
+ export async function attachPrChips(accountId, rows, idOf) {
20
+ const prs = rows.flatMap((r) => {
21
+ const id = idOf(r);
22
+ return id != null ? [{ id, repoId: r.repoId }] : [];
23
+ });
24
+ const chips = await prChipsFor(accountId, prs);
25
+ for (const r of rows) {
26
+ const id = idOf(r);
27
+ const c = id != null ? chips.get(id) : undefined;
28
+ if (c != null)
29
+ Object.assign(r, c);
30
+ }
31
+ }
32
+ //# sourceMappingURL=pr-chips.js.map
@@ -0,0 +1,119 @@
1
+ // THE "Criteria 3/4" CHIP (docs/FRONTEND.md § PR chips; docs/CLAUDE-REVIEW.md § Ticket review).
2
+ // CORE, DB only — never a model, tracker or GitHub call.
3
+ //
4
+ // For each PR, per TICKET: the latest SUCCEEDED ticket review (Story check) of that ticket that had
5
+ // this PR as a MEMBER, and its criteria verdicts. "Met" is `status === 'met'` only — `partly_met`,
6
+ // `not_met`, `unclear` and `not_checked` are not met (shared `PrCriteriaCheck`). A check whose
7
+ // assessment lists no criteria is NO chip (unknown is never zero), and a PR never checked has none.
8
+ //
9
+ // `olderHead`: the run read this PR at a head other than its current synced head.
10
+ //
11
+ // BATCHED: three queries whatever the number of PRs — the members (no JSON), the chosen runs'
12
+ // assessments, the PRs' current heads.
13
+ //
14
+ // ⚠ PURE AT IMPORT: the DB client is reached LAZILY (the fold is unit-tested without one).
15
+ const CHUNK = 400;
16
+ /** Per PR, per ticket ident: the newest run (highest id) that had the PR as a member. Pure. */
17
+ export function latestRunPerTicket(rows) {
18
+ const out = new Map();
19
+ for (const r of rows) {
20
+ const byIdent = out.get(r.prId) ?? new Map();
21
+ const prev = byIdent.get(r.ident);
22
+ if (prev == null || r.runId > prev.runId)
23
+ byIdent.set(r.ident, r);
24
+ out.set(r.prId, byIdent);
25
+ }
26
+ return out;
27
+ }
28
+ /** One chip's figures from one run, or null when the run judged no criteria. Pure. */
29
+ export function criteriaCheckOf(member, run, currentHeadSha) {
30
+ const criteria = run?.assessment?.criteria;
31
+ if (run == null || !Array.isArray(criteria) || criteria.length === 0)
32
+ return null;
33
+ let met = 0;
34
+ let partlyMet = 0;
35
+ let notMet = 0;
36
+ let unsettled = 0;
37
+ for (const c of criteria) {
38
+ if (c.status === 'met')
39
+ met += 1;
40
+ else if (c.status === 'partly_met')
41
+ partlyMet += 1;
42
+ else if (c.status === 'not_met')
43
+ notMet += 1;
44
+ else
45
+ unsettled += 1;
46
+ }
47
+ return {
48
+ ident: member.ident,
49
+ key: run.key,
50
+ title: run.title,
51
+ reviewId: run.id,
52
+ met,
53
+ partlyMet,
54
+ notMet,
55
+ unsettled,
56
+ total: criteria.length,
57
+ checkedAt: (run.completedAt ?? new Date(0)).toISOString(),
58
+ // An unknown current head is not "older" — say nothing rather than guess.
59
+ olderHead: currentHeadSha != null && currentHeadSha !== '' && currentHeadSha !== member.headSha,
60
+ };
61
+ }
62
+ /** Order on the chip row: by key (numerically), pasted stories last. Pure. */
63
+ export function compareCriteriaChecks(a, b) {
64
+ if (a.key == null || b.key == null)
65
+ return a.key == null ? (b.key == null ? a.reviewId - b.reviewId : 1) : -1;
66
+ return a.key.localeCompare(b.key, undefined, { numeric: true });
67
+ }
68
+ const asDate = (d) => (d instanceof Date ? d : d == null ? null : new Date(d));
69
+ /** Every listed PR's criteria chips (this account only). Every asked id gets an entry ([] = none). */
70
+ export async function prCriteriaFor(accountId, prIds) {
71
+ const ids = [...new Set(prIds)];
72
+ const out = new Map(ids.map((id) => [id, []]));
73
+ if (ids.length === 0)
74
+ return out;
75
+ const { and, eq, inArray } = await import('drizzle-orm');
76
+ const { db, schema } = await import('./client.js');
77
+ const { ticketReviews: tr, ticketReviewMembers: trm, pullRequests: pr } = schema;
78
+ const memberRows = [];
79
+ const heads = new Map();
80
+ for (let i = 0; i < ids.length; i += CHUNK) {
81
+ const slice = ids.slice(i, i + CHUNK);
82
+ memberRows.push(...(await db
83
+ .select({ prId: trm.prId, runId: tr.id, ident: tr.ticketIdent, headSha: trm.headSha })
84
+ .from(trm)
85
+ .innerJoin(tr, and(eq(tr.id, trm.ticketReviewId), eq(tr.accountId, trm.accountId)))
86
+ .where(and(eq(trm.accountId, accountId), inArray(trm.prId, slice), eq(tr.status, 'succeeded')))
87
+ .execute()));
88
+ const headRows = (await db
89
+ .select({ id: pr.id, headSha: pr.headSha })
90
+ .from(pr)
91
+ .where(and(eq(pr.accountId, accountId), inArray(pr.id, slice)))
92
+ .execute());
93
+ for (const h of headRows)
94
+ heads.set(h.id, h.headSha);
95
+ }
96
+ const latest = latestRunPerTicket(memberRows);
97
+ const runIds = [...new Set([...latest.values()].flatMap((m) => [...m.values()].map((r) => r.runId)))];
98
+ const runs = new Map();
99
+ for (let i = 0; i < runIds.length; i += CHUNK) {
100
+ const rows = (await db
101
+ .select({ id: tr.id, key: tr.ticketKey, title: tr.ticketTitle, assessment: tr.assessment, completedAt: tr.completedAt })
102
+ .from(tr)
103
+ .where(and(eq(tr.accountId, accountId), inArray(tr.id, runIds.slice(i, i + CHUNK))))
104
+ .execute());
105
+ for (const r of rows)
106
+ runs.set(r.id, { ...r, completedAt: asDate(r.completedAt) });
107
+ }
108
+ for (const [prId, byIdent] of latest) {
109
+ const chips = [];
110
+ for (const m of byIdent.values()) {
111
+ const c = criteriaCheckOf(m, runs.get(m.runId), heads.get(prId));
112
+ if (c != null)
113
+ chips.push(c);
114
+ }
115
+ out.set(prId, chips.sort(compareCriteriaChecks));
116
+ }
117
+ return out;
118
+ }
119
+ //# sourceMappingURL=pr-criteria.js.map