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.
- package/dist/api/plugins/rate-limit.js +8 -1
- package/dist/api/routes/open-prs.js +3 -0
- package/dist/api/routes/prs.js +44 -2
- package/dist/db/daily-brief.js +2 -1
- package/dist/db/flaky-checks.js +148 -0
- package/dist/db/flow-detail.js +23 -0
- package/dist/db/migrations/0163_pr_cross_repository.sql +5 -0
- package/dist/db/migrations/meta/_journal.json +7 -0
- package/dist/db/pending-first-landing.js +5 -3
- package/dist/db/pending-tabs.js +51 -6
- package/dist/db/pr-chips.js +32 -0
- package/dist/db/pr-criteria.js +119 -0
- package/dist/db/pr-stacks.js +151 -0
- package/dist/db/queries.js +305 -2
- package/dist/db/review-waits.js +136 -0
- package/dist/db/schema.sqlite.js +5 -0
- package/dist/github/queries.js +4 -0
- package/dist/merge/arm-intent.js +12 -0
- package/dist/merge/auto-merge-runner.js +13 -0
- package/dist/platform/insights/sprint-report.js +4 -0
- package/dist/platform/slack/pending-blocks.js +5 -2
- package/dist/platform/slack/tones.js +1 -0
- package/dist/shared/flaky-checks.js +7 -0
- package/dist/shared/index.js +2 -0
- package/dist/shared/pending-rules.js +8 -1
- package/dist/shared/pr-chips.js +24 -0
- package/dist/sync/upsert.js +5 -0
- package/package.json +1 -1
- package/public/assets/{AiFixTab-D_r2JEy_.js → AiFixTab-c7Zi_vxg.js} +1 -1
- package/public/assets/{ClaudeReviewTab-ncqvtble.js → ClaudeReviewTab-_5ovjklE.js} +1 -1
- package/public/assets/{PrBotBehaviourTab-Da_67MPm.js → PrBotBehaviourTab-Bvib9sC7.js} +1 -1
- package/public/assets/index-CasLyCVA.js +89 -0
- package/public/assets/{index-DIq-VB3X.css → index-EhfxyZH_.css} +1 -1
- package/public/index.html +2 -2
- 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
|
-
|
|
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
|
}
|
package/dist/api/routes/prs.js
CHANGED
|
@@ -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,
|
package/dist/db/daily-brief.js
CHANGED
|
@@ -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
|
package/dist/db/flow-detail.js
CHANGED
|
@@ -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) {
|
package/dist/db/pending-tabs.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
// ── THE PENDING BOARD'S TABS (CORE, deterministic, no AI) ───────────────────────────────────
|
|
2
2
|
//
|
|
3
|
-
//
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|