limn-review 0.1.183 → 0.1.185
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/ai/credentials.js +1 -1
- package/dist/api/plugins/rate-limit.js +12 -24
- package/dist/api/routes/insights.js +33 -0
- package/dist/api/routes/me.js +3 -5
- package/dist/db/ai-reviews-seen.js +176 -0
- package/dist/db/bot-dormancy.js +1 -1
- package/dist/db/branch-queries.js +1 -1
- package/dist/db/claude-review-seen.js +36 -0
- package/dist/db/daily-brief.js +1 -8
- package/dist/db/erase-account.js +4 -0
- package/dist/db/migrations/0166_team_claim_skip_lines.sql +20 -0
- package/dist/db/migrations/0167_ai_reviews_seen.sql +4 -0
- package/dist/db/migrations/0168_ai_review_seen_per_run.sql +12 -0
- package/dist/db/migrations/meta/_journal.json +21 -0
- package/dist/db/my-turn-dismissals.js +14 -48
- package/dist/db/pending-first-landing.js +5 -4
- package/dist/db/pending-tabs.js +46 -11
- package/dist/db/queries.js +195 -258
- package/dist/db/schema.sqlite.js +32 -1
- package/dist/db/team-claim-lines-delete.js +3 -0
- package/dist/db/triage.js +2 -3
- package/dist/db/work-plan.js +29 -384
- package/dist/platform/context.js +0 -10
- package/dist/platform/db.js +1 -1
- package/dist/platform/index.js +1 -6
- package/dist/platform/insights/sprint-report.js +3 -0
- package/dist/platform/retention.js +1 -1
- package/dist/platform/slack/pending-blocks.js +5 -9
- package/dist/platform/slack/tones.js +1 -2
- package/dist/platform/work-plan/schema.sqlite.js +3 -0
- package/dist/review/ci-review/auto-post.js +1 -1
- package/dist/review/claude-review/approval-withdraw.js +1 -1
- package/dist/review/claude-review/auto-post.js +1 -1
- package/dist/review/claude-review/auto-resolve.js +36 -15
- package/dist/review/claude-review/auto-settings.js +21 -8
- package/dist/review/claude-review/auto.js +118 -2
- package/dist/review/claude-review/manager.js +46 -3
- package/dist/review/claude-review/persist.js +2 -1
- package/dist/review/claude-review/replies-check.js +9 -4
- package/dist/review/claude-review/routes.js +25 -0
- package/dist/review/claude-review/team-claim-skip.js +209 -0
- package/dist/review/claude-review/team-claim.js +38 -2
- package/dist/review/llm.js +1 -1
- package/dist/review/ticket-review/auto-post.js +3 -1
- package/dist/shared/my-turn-settings.js +9 -21
- package/dist/shared/pending-rules.js +27 -30
- package/dist/shared/types.js +2 -1
- package/dist/status.js +10 -10
- package/package.json +1 -1
- package/public/assets/{AiFixTab-DwjVZ3Od.js → AiFixTab-Bgm-Ja8b.js} +1 -1
- package/public/assets/ClaudeReviewTab-Di50zOZT.js +20 -0
- package/public/assets/{PrBotBehaviourTab-3mieFdQL.js → PrBotBehaviourTab-GP2FO_VM.js} +1 -1
- package/public/assets/index-C5bt1sKw.js +90 -0
- package/public/assets/index-CICgf03S.css +1 -0
- package/public/index.html +5 -2
- package/public/theme-boot.js +17 -0
- package/dist/platform/work-plan/hash.js +0 -99
- package/dist/platform/work-plan/prompt.js +0 -209
- package/dist/platform/work-plan/routes.js +0 -350
- package/public/assets/ClaudeReviewTab-CMCKluXj.js +0 -22
- package/public/assets/index-DG7ITfra.css +0 -1
- package/public/assets/index-DnvzZnsD.js +0 -89
package/dist/ai/credentials.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Fix, the ticket / CI reviews, diagrams, sprint paths, the docs build and chat, resolve with
|
|
3
3
|
// Claude, every chat) AND the one-shot completions (review/llm.ts `cheapComplete`, which the
|
|
4
4
|
// sprint briefs/themes, story labels, docs ranking, sprint Ask Claude and every platform feature —
|
|
5
|
-
// reports, synthesis,
|
|
5
|
+
// reports, synthesis, pointers, bot themes, the Slack
|
|
6
6
|
// report — all go through).
|
|
7
7
|
//
|
|
8
8
|
// 1. THE LOCAL CLAUDE CODE LOGIN (default) — a `claude` session on disk or
|
|
@@ -332,30 +332,8 @@ function tierFor(method, path) {
|
|
|
332
332
|
return [TIERS.ai, TIERS.aiHourly];
|
|
333
333
|
return [TIERS.search, TIERS.read];
|
|
334
334
|
}
|
|
335
|
-
// ---- The work plan (must sit ABOVE the /api/pro/ AI-tier catch-all) ----
|
|
336
|
-
// ONE endpoint, two verbs, two costs — the synthesis shape exactly, and tiered by copying it:
|
|
337
|
-
// POST /api/pro/work-plan the generate path — one Haiku call behind a payload-hash $0 cache,
|
|
338
|
-
// plus the evidence fold below. The catch-all's `generates` branch
|
|
339
|
-
// would land it on `ai` anyway; spelled here so the family's two
|
|
340
|
-
// tiers are DECIDED together (this file's failure mode is a tier
|
|
341
|
-
// nobody re-examined).
|
|
342
|
-
// GET /api/pro/work-plan the free cached read — but it re-runs the whole `getWorkPlan`
|
|
343
|
-
// evidence fold to recompute the payload hash for the `stale` probe,
|
|
344
|
-
// and that fold reaches getWorkspaceInsights (the stalled-review /
|
|
345
|
-
// untouched-thread / reviewer-load / merge / update_branch cards).
|
|
346
|
-
// That is the synthesis / bot-behaviour shape of cost — this
|
|
347
|
-
// process's event loop and this database, not GitHub quota and not
|
|
348
|
-
// Anthropic — so it takes the same 60/min `search` bucket, NOT the
|
|
349
|
-
// catch-all's 600/min GET→read branch. The narration now decorates
|
|
350
|
-
// the Pending board, so this GET fires whenever that board is
|
|
351
|
-
// mounted. It must NEVER grow a generation leg.
|
|
352
|
-
if (path === '/api/pro/work-plan') {
|
|
353
|
-
if (mutating)
|
|
354
|
-
return [TIERS.ai, TIERS.aiHourly];
|
|
355
|
-
return [TIERS.search, TIERS.read];
|
|
356
|
-
}
|
|
357
335
|
// ---- Chronology pointers (must sit ABOVE the /api/pro/ AI-tier catch-all) ----
|
|
358
|
-
// The
|
|
336
|
+
// The synthesis shape and costs exactly: POST is one Haiku call behind a payload-hash $0 cache
|
|
359
337
|
// (the `ai` pair); the free GET re-runs the whole Chronology fold (`getFlowCourts` — the lane
|
|
360
338
|
// resolver plus chunked action scans over every merge in the window) plus the exemplar text
|
|
361
339
|
// reads to recompute the hash for its `stale` probe, so it takes `search`, never the
|
|
@@ -581,6 +559,11 @@ function tierFor(method, path) {
|
|
|
581
559
|
// `hitsGithub` alternation further down is never reached by a `/claude-review` path).
|
|
582
560
|
if (mutating && /^\/api\/prs\/\d+\/claude-review\/team-claim\/clear$/.test(path))
|
|
583
561
|
return [TIERS.githubWrite];
|
|
562
|
+
// SEEN (db/queries.ts `markClaudeReviewSeen`): the AI Review tab showed a finished run — one
|
|
563
|
+
// UPDATE of one column; no GitHub, no model. `read`, EXACT, so it never lands on the AI bucket
|
|
564
|
+
// the `/claude-review` family's generic POST rule below would give it.
|
|
565
|
+
if (mutating && /^\/api\/prs\/\d+\/claude-review\/seen$/.test(path))
|
|
566
|
+
return [TIERS.read];
|
|
584
567
|
// AI Fix PLAN MODE (coding/ai-fix/routes.ts, LOCAL ONLY). Spelled out so the family cannot drift
|
|
585
568
|
// with the /api/pro/ catch-all below, which would also have parked the DB-only discard on `ai`:
|
|
586
569
|
// POST /api/pro/prs/:id/ai-fix/plan starts a planning agent pass → ai + ai_hourly
|
|
@@ -723,7 +706,7 @@ function tierFor(method, path) {
|
|
|
723
706
|
// GET /api/attention — the Pending board. RE-TIERED off the 600/min blanket `read` when it
|
|
724
707
|
// absorbed the ranked head: one request now runs `getWorkspaceInsights` (including the
|
|
725
708
|
// maintained-repo resolution, which the merge-card emitter makes effectively unconditional)
|
|
726
|
-
// AND `
|
|
709
|
+
// AND `rankPendingTabs` (`scoreCards`) on top of it — the approvals fold, the untouched-thread group-by and a
|
|
727
710
|
// narrow PR select. Same cost shape as the daily brief above, same 60/min bucket.
|
|
728
711
|
// ⚠ "This route is DB-only" is exactly the sentence this file exists to distrust: the fold
|
|
729
712
|
// moved into it, so the tier moves with it. The board mounts on navigation and refetches on a
|
|
@@ -746,6 +729,11 @@ function tierFor(method, path) {
|
|
|
746
729
|
// GitHub, it reads.)
|
|
747
730
|
if (mutating && path === '/api/attention/liveness')
|
|
748
731
|
return [TIERS.prDetail, TIERS.read];
|
|
732
|
+
// POST /api/attention/ai-reviews/seen — "AI reviews" was looked at (db/ai-reviews-seen.ts).
|
|
733
|
+
// FOLLOWING THE TOKEN: one UPDATE of one column; no GitHub, no model. `read`, decided rather than
|
|
734
|
+
// inherited; EXACT `===`.
|
|
735
|
+
if (mutating && path === '/api/attention/ai-reviews/seen')
|
|
736
|
+
return [TIERS.read];
|
|
749
737
|
// GET /api/flow-findings — the Chronology tab (deterministic, no model and no GitHub). DECIDED,
|
|
750
738
|
// not inherited: "this
|
|
751
739
|
// route is DB-only" is the sentence this file exists to distrust, and one call here runs the
|
|
@@ -5,11 +5,23 @@ import { getMergedPrsInWindow } from '../../db/merged-prs.js';
|
|
|
5
5
|
import { getStories } from '../../db/stories.js';
|
|
6
6
|
import { getReportingWindow, reportingWindowInfo } from '../../db/reporting-window.js';
|
|
7
7
|
import { buildPendingBoard } from '../../db/pending-tabs.js';
|
|
8
|
+
import { markAiReviewsSeen } from '../../db/ai-reviews-seen.js';
|
|
8
9
|
import { PR_LIVENESS_MAX_IDS, sweepPrLiveness } from '../../sync/pr-liveness-sweep.js';
|
|
9
10
|
import { accountIdOf } from '../plugins/auth.js';
|
|
10
11
|
// The liveness body. `maxItems` is a coarse guard only — the real cap is applied in the handler
|
|
11
12
|
// AFTER de-duplication (a board legitimately renders several cards for one PR), and it 400s
|
|
12
13
|
// rather than truncating.
|
|
14
|
+
const aiReviewsSeenSchema = {
|
|
15
|
+
querystring: {
|
|
16
|
+
type: 'object',
|
|
17
|
+
properties: { workspace: { type: 'string' } },
|
|
18
|
+
},
|
|
19
|
+
body: {
|
|
20
|
+
type: ['object', 'null'],
|
|
21
|
+
additionalProperties: false,
|
|
22
|
+
properties: { through: { type: 'string', maxLength: 40 } },
|
|
23
|
+
},
|
|
24
|
+
};
|
|
13
25
|
const livenessSchema = {
|
|
14
26
|
querystring: {
|
|
15
27
|
type: 'object',
|
|
@@ -228,6 +240,27 @@ export async function insightsRoutes(app) {
|
|
|
228
240
|
}
|
|
229
241
|
return { workspaceId: scope.workspaceId, ...swept };
|
|
230
242
|
});
|
|
243
|
+
// POST /api/attention/ai-reviews/seen — THE READER LOOKED AT "AI REVIEWS" (db/ai-reviews-seen.ts).
|
|
244
|
+
// Moves the workspace's stamp to `through` (the newest review time the reader's list showed; absent
|
|
245
|
+
// = now), never back. Sent by the SPA when the tab is ON SCREEN — never on the board's mount for
|
|
246
|
+
// another tab. DB only (one UPDATE): no GitHub, no model. `?workspace=` resolves like every scoped
|
|
247
|
+
// route (absent / unknown ⇒ the Default) and is echoed.
|
|
248
|
+
app.post('/api/attention/ai-reviews/seen', { schema: aiReviewsSeenSchema }, async (req, reply) => {
|
|
249
|
+
const q = req.query;
|
|
250
|
+
const body = (req.body ?? {});
|
|
251
|
+
let through = null;
|
|
252
|
+
if (body.through != null) {
|
|
253
|
+
const ms = Date.parse(body.through);
|
|
254
|
+
if (!Number.isFinite(ms)) {
|
|
255
|
+
return reply.code(400).send({ error: 'BadThrough', message: '`through` must be an ISO date.' });
|
|
256
|
+
}
|
|
257
|
+
through = new Date(ms);
|
|
258
|
+
}
|
|
259
|
+
const accountId = accountIdOf(req);
|
|
260
|
+
const scope = await resolveWorkspaceScope(accountId, q.workspace);
|
|
261
|
+
const at = await markAiReviewsSeen(accountId, scope.workspaceId, through);
|
|
262
|
+
return { workspaceId: scope.workspaceId, seenAt: (at ?? new Date()).toISOString() };
|
|
263
|
+
});
|
|
231
264
|
// Heavier per-repo analytics for the drill-down chart panel — loaded on demand.
|
|
232
265
|
// Ownership-scoped: a repo not owned by the account 404s.
|
|
233
266
|
app.get('/api/insights/:repoId/analytics', async (req, reply) => {
|
package/dist/api/routes/me.js
CHANGED
|
@@ -131,10 +131,9 @@ const myTurnSettingsSchema = {
|
|
|
131
131
|
additionalProperties: false,
|
|
132
132
|
properties: Object.fromEntries(MY_TURN_TOGGLES.map((k) => [k, { type: 'boolean' }])),
|
|
133
133
|
},
|
|
134
|
-
trunkScope: { type: 'string', maxLength: 32 },
|
|
135
134
|
order: {
|
|
136
135
|
type: 'array',
|
|
137
|
-
maxItems:
|
|
136
|
+
maxItems: MY_TURN_TOGGLES.length,
|
|
138
137
|
items: { type: 'string', maxLength: 32 },
|
|
139
138
|
},
|
|
140
139
|
weights: {
|
|
@@ -442,8 +441,7 @@ export async function meRoutes(app) {
|
|
|
442
441
|
return reply.code(404).send({ error: 'NotFound', message: 'Not found' });
|
|
443
442
|
return body;
|
|
444
443
|
});
|
|
445
|
-
// DISMISS / BRING BACK one My Turn subject — a pull request (`pr`)
|
|
446
|
-
// a repository (`repo`). ⚠ NOT the "Done" routes 0060 deleted: a dismissal lasts only until
|
|
444
|
+
// DISMISS / BRING BACK one My Turn subject — a pull request (`pr`), whatever its card type. ⚠ NOT the "Done" routes 0060 deleted: a dismissal lasts only until
|
|
447
445
|
// something newer happens on the subject, and a subject that leaves your plate discharges it
|
|
448
446
|
// (db/my-turn-dismissals.ts). DB-only, both modes, free.
|
|
449
447
|
//
|
|
@@ -481,7 +479,7 @@ export async function meRoutes(app) {
|
|
|
481
479
|
/** `:kind/:id` → a target, or null for anything else (a non-integer id, an unknown kind). */
|
|
482
480
|
export function parseDismissTarget(params) {
|
|
483
481
|
const p = (params ?? {});
|
|
484
|
-
if (p.kind !== 'pr'
|
|
482
|
+
if (p.kind !== 'pr')
|
|
485
483
|
return null;
|
|
486
484
|
const raw = typeof p.id === 'string' ? p.id : '';
|
|
487
485
|
if (!/^[1-9][0-9]{0,15}$/.test(raw))
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// ── PENDING → "AI REVIEWS": THE NEWEST AI REVIEW ON EACH PR (docs/BACKEND.md § The AI reviews list) ──
|
|
2
|
+
//
|
|
3
|
+
// The tab's one list: a stable REFERENCE, never an inbox. One `ai_review` card per PR of the
|
|
4
|
+
// workspace, open or not — the NEWEST of:
|
|
5
|
+
// • the reader's OWN Limn — a finished full review (`kind IS NULL`, `succeeded`, not a router
|
|
6
|
+
// skip). A Claude + Codex pair is ONE review: listed once, on its first half, with the pair's
|
|
7
|
+
// one verdict and both halves' findings (a merged-away repeat never counts);
|
|
8
|
+
// • a TEAMMATE's Limn — a review it posted to GitHub, imported as `kind = 'teammate'`.
|
|
9
|
+
// Newest first, at most `AI_REVIEWS_LIST_CAP` rows.
|
|
10
|
+
//
|
|
11
|
+
// ⚠ LOOKING NEVER REMOVES A ROW. The workspace's `ai_reviews_seen_at` stamp only says which rows are
|
|
12
|
+
// newer than the reader's last look: the SPA tags them "New" (against the stamp its visit started
|
|
13
|
+
// with) and the tab's badge counts them (`PendingTab.newTotal`). It is moved ONLY by the reader
|
|
14
|
+
// looking (`markAiReviewsSeen`, the SPA's POST while the tab is on screen), never by a read.
|
|
15
|
+
//
|
|
16
|
+
// ⚠ LIVE BOARD ONLY and COUNTED ONLY IN ITS TAB: `buildPendingBoard` appends these after the fold,
|
|
17
|
+
// so the daily brief, Slack, the My Turn counts and the notifications never see one.
|
|
18
|
+
//
|
|
19
|
+
// DB only — no GitHub call.
|
|
20
|
+
import { AI_REVIEWS_LIST_CAP } from '../shared/index.js';
|
|
21
|
+
import { and, eq, inArray, isNotNull, isNull, or, sql } from 'drizzle-orm';
|
|
22
|
+
import { db, schema } from './client.js';
|
|
23
|
+
import { TEAMMATE_KIND } from './teammate-reviews.js';
|
|
24
|
+
const { claudeReviews: cr, claudeReviewFindings: crf, pullRequests, repos, workspaces } = schema;
|
|
25
|
+
/** The stamp, or null when the reader never looked in this workspace. Account-scoped. */
|
|
26
|
+
export async function getAiReviewsSeenAt(accountId, workspaceId) {
|
|
27
|
+
const rows = await db
|
|
28
|
+
.select({ at: workspaces.aiReviewsSeenAt })
|
|
29
|
+
.from(workspaces)
|
|
30
|
+
.where(and(eq(workspaces.id, workspaceId), eq(workspaces.accountId, accountId)))
|
|
31
|
+
.limit(1)
|
|
32
|
+
.execute();
|
|
33
|
+
return rows[0]?.at ?? null;
|
|
34
|
+
}
|
|
35
|
+
/** How many listed rows are newer than the stamp (null = never looked: all of them). Pure. */
|
|
36
|
+
export function countNewAiReviews(cards, seenAt) {
|
|
37
|
+
if (seenAt == null)
|
|
38
|
+
return cards.length;
|
|
39
|
+
const ms = seenAt.getTime();
|
|
40
|
+
return cards.filter((c) => Date.parse(c.at) > ms).length;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* THE ONE WRITER. The reader looked: stamp `through` (the newest review time their list showed),
|
|
44
|
+
* else `now`. Clamped to `now`, and NEVER BACK — an older `through` (a second tab, a stale list)
|
|
45
|
+
* leaves the stamp where it is. Scoped to the account's own workspace; returns the stamp after the
|
|
46
|
+
* call, or null when the workspace is not the account's.
|
|
47
|
+
*/
|
|
48
|
+
export async function markAiReviewsSeen(accountId, workspaceId, through, now = Date.now()) {
|
|
49
|
+
const wanted = Math.floor(Math.min(through?.getTime() ?? now, now) / 1000);
|
|
50
|
+
const rows = await db
|
|
51
|
+
.update(workspaces)
|
|
52
|
+
.set({
|
|
53
|
+
aiReviewsSeenAt: sql `max(coalesce(${workspaces.aiReviewsSeenAt}, 0), ${wanted})`,
|
|
54
|
+
})
|
|
55
|
+
.where(and(eq(workspaces.id, workspaceId), eq(workspaces.accountId, accountId)))
|
|
56
|
+
.returning({ at: workspaces.aiReviewsSeenAt })
|
|
57
|
+
.execute();
|
|
58
|
+
return rows[0]?.at ?? null;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* The `ai_review` cards for one workspace scope: the newest AI review on each PR, newest first, at
|
|
62
|
+
* most `AI_REVIEWS_LIST_CAP` — plus the teammates they name (for `users[]`). DB only.
|
|
63
|
+
*/
|
|
64
|
+
export async function buildAiReviewCards(accountId, repoIds) {
|
|
65
|
+
if (repoIds.length === 0)
|
|
66
|
+
return { cards: [], userIds: [] };
|
|
67
|
+
// Own runs by FINISH time, teammates' reviews by POST time (else import time) — the two clocks
|
|
68
|
+
// `listTeammateReviews` and the run panel already show.
|
|
69
|
+
const clock = sql `case when ${cr.kind} = ${TEAMMATE_KIND} then coalesce(${cr.postedAt}, ${cr.createdAt}) else coalesce(${cr.finishedAt}, ${cr.createdAt}) end`;
|
|
70
|
+
const all = await db
|
|
71
|
+
.select({
|
|
72
|
+
id: cr.id,
|
|
73
|
+
prId: cr.prId,
|
|
74
|
+
kind: cr.kind,
|
|
75
|
+
verdict: cr.verdict,
|
|
76
|
+
trigger: cr.trigger,
|
|
77
|
+
pairId: cr.pairId,
|
|
78
|
+
pairRole: cr.pairRole,
|
|
79
|
+
pairConsolidation: cr.pairConsolidation,
|
|
80
|
+
finishedAt: cr.finishedAt,
|
|
81
|
+
postedAt: cr.postedAt,
|
|
82
|
+
createdAt: cr.createdAt,
|
|
83
|
+
sourceUserId: cr.sourceUserId,
|
|
84
|
+
repoId: pullRequests.repoId,
|
|
85
|
+
prNumber: pullRequests.number,
|
|
86
|
+
prTitle: pullRequests.title,
|
|
87
|
+
owner: repos.owner,
|
|
88
|
+
name: repos.name,
|
|
89
|
+
})
|
|
90
|
+
.from(cr)
|
|
91
|
+
.innerJoin(pullRequests, eq(pullRequests.id, cr.prId))
|
|
92
|
+
.innerJoin(repos, eq(repos.id, pullRequests.repoId))
|
|
93
|
+
.where(and(eq(cr.accountId, accountId), eq(repos.accountId, accountId), inArray(pullRequests.repoId, [...repoIds]), or(and(isNull(cr.kind), eq(cr.status, 'succeeded'),
|
|
94
|
+
// A router SKIP is not a review: nothing was read.
|
|
95
|
+
sql `coalesce(${cr.reviewMode}, '') <> 'skip'`,
|
|
96
|
+
// A pair is ONE review, listed on its first half.
|
|
97
|
+
sql `coalesce(${cr.pairRole}, 'first') = 'first'`), and(eq(cr.kind, TEAMMATE_KIND),
|
|
98
|
+
// Never unnamed: a teammate's review with no poster would read as the reader's own.
|
|
99
|
+
isNotNull(cr.sourceUserId)))))
|
|
100
|
+
.orderBy(sql `${clock} desc`, sql `${cr.id} desc`)
|
|
101
|
+
.execute();
|
|
102
|
+
// ONE ROW PER PR — its newest review — and at most the cap.
|
|
103
|
+
const onPr = new Set();
|
|
104
|
+
const rows = [];
|
|
105
|
+
for (const r of all) {
|
|
106
|
+
if (onPr.has(r.prId))
|
|
107
|
+
continue;
|
|
108
|
+
onPr.add(r.prId);
|
|
109
|
+
rows.push(r);
|
|
110
|
+
if (rows.length >= AI_REVIEWS_LIST_CAP)
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
if (rows.length === 0)
|
|
114
|
+
return { cards: [], userIds: [] };
|
|
115
|
+
// A pair's second half: its findings count, and its finish time when it ended later.
|
|
116
|
+
const pairIds = [...new Set(rows.map((r) => r.pairId).filter((id) => id != null))];
|
|
117
|
+
const secondOf = new Map();
|
|
118
|
+
if (pairIds.length > 0) {
|
|
119
|
+
for (const h of await db
|
|
120
|
+
.select({ id: cr.id, pairId: cr.pairId, finishedAt: cr.finishedAt })
|
|
121
|
+
.from(cr)
|
|
122
|
+
.where(and(eq(cr.accountId, accountId), inArray(cr.pairId, pairIds), eq(cr.pairRole, 'second'), eq(cr.status, 'succeeded')))
|
|
123
|
+
.execute()) {
|
|
124
|
+
if (h.pairId != null)
|
|
125
|
+
secondOf.set(h.pairId, { id: h.id, finishedAt: h.finishedAt });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const runIdsOf = (r) => {
|
|
129
|
+
const second = r.pairId != null ? secondOf.get(r.pairId) : undefined;
|
|
130
|
+
return second != null ? [r.id, second.id] : [r.id];
|
|
131
|
+
};
|
|
132
|
+
// ONE grouped count over every listed run: findings that ask for something.
|
|
133
|
+
const findingCount = new Map();
|
|
134
|
+
for (const f of await db
|
|
135
|
+
.select({ reviewId: crf.reviewId, n: sql `count(*)` })
|
|
136
|
+
.from(crf)
|
|
137
|
+
.where(and(inArray(crf.reviewId, rows.flatMap(runIdsOf)), isNull(crf.mergedInto), sql `${crf.severity} <> 'praise'`))
|
|
138
|
+
.groupBy(crf.reviewId)
|
|
139
|
+
.execute()) {
|
|
140
|
+
findingCount.set(f.reviewId, Number(f.n));
|
|
141
|
+
}
|
|
142
|
+
const userIds = new Set();
|
|
143
|
+
const cards = rows.map((r) => {
|
|
144
|
+
const teammate = r.kind === TEAMMATE_KIND;
|
|
145
|
+
const second = r.pairId != null ? secondOf.get(r.pairId) : undefined;
|
|
146
|
+
const ownAt = Math.max((r.finishedAt ?? r.createdAt).getTime(), second?.finishedAt?.getTime() ?? 0);
|
|
147
|
+
const at = teammate ? (r.postedAt ?? r.createdAt).getTime() : ownAt;
|
|
148
|
+
const verdict = !teammate && r.pairId != null ? (r.pairConsolidation?.verdict ?? r.verdict) : r.verdict;
|
|
149
|
+
if (teammate && r.sourceUserId != null)
|
|
150
|
+
userIds.add(r.sourceUserId);
|
|
151
|
+
return {
|
|
152
|
+
id: `ai_review:${r.id}`,
|
|
153
|
+
kind: 'ai_review',
|
|
154
|
+
severity: 'info',
|
|
155
|
+
reviewId: r.id,
|
|
156
|
+
prId: r.prId,
|
|
157
|
+
repoId: r.repoId,
|
|
158
|
+
repoFullName: `${r.owner}/${r.name}`,
|
|
159
|
+
prNumber: r.prNumber,
|
|
160
|
+
prTitle: r.prTitle,
|
|
161
|
+
githubUrl: `https://github.com/${r.owner}/${r.name}/pull/${r.prNumber}`,
|
|
162
|
+
ranByUserId: teammate ? r.sourceUserId : null,
|
|
163
|
+
auto: !teammate && r.trigger === 'auto',
|
|
164
|
+
verdict,
|
|
165
|
+
findingCount: runIdsOf(r).reduce((n, id) => n + (findingCount.get(id) ?? 0), 0),
|
|
166
|
+
at: new Date(at).toISOString(),
|
|
167
|
+
};
|
|
168
|
+
});
|
|
169
|
+
cards.sort(compareAiReviews);
|
|
170
|
+
return { cards, userIds: [...userIds] };
|
|
171
|
+
}
|
|
172
|
+
/** Newest first; ties to the higher review id — a total order, so two polls list alike. */
|
|
173
|
+
export function compareAiReviews(a, b) {
|
|
174
|
+
return Date.parse(b.at) - Date.parse(a.at) || b.reviewId - a.reviewId;
|
|
175
|
+
}
|
|
176
|
+
//# sourceMappingURL=ai-reviews-seen.js.map
|
package/dist/db/bot-dormancy.js
CHANGED
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
// any observed work at all keeps a reviewer visible.
|
|
33
33
|
//
|
|
34
34
|
// The predicate is CORE so no second surface can grow its own rule: the benchmark reaches this
|
|
35
|
-
// through the OPTIONAL `ProHostQueries.dormantBotUserIds` seam (apiVersion stays 21 — the `
|
|
35
|
+
// through the OPTIONAL `ProHostQueries.dormantBotUserIds` seam (apiVersion stays 21 — the `registerAccountErasure?` /
|
|
36
36
|
// `workspaceHumanSeatCount` precedent; an older host simply hides nothing).
|
|
37
37
|
//
|
|
38
38
|
// ⚠ IT DELIBERATELY IMPORTS NOTHING FROM `queries.ts` — a cycle with the app's largest module
|
|
@@ -14,7 +14,7 @@ export function trunkHeadKey(repoId, sha) {
|
|
|
14
14
|
}
|
|
15
15
|
/**
|
|
16
16
|
* THE ONE READER OF A DEFAULT-BRANCH HEAD'S FAILING CHECKS — `/api/branch-status` and the Pending
|
|
17
|
-
* board's red-trunk
|
|
17
|
+
* board's red-trunk card (ci_failing's trunk arm) reads
|
|
18
18
|
* through it, so the two surfaces cannot name different checks for one red head.
|
|
19
19
|
*
|
|
20
20
|
* The head's own `branch_commits` row holds them (written by the branch snapshot in the same
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// MY TURN → "NEW AI REVIEWS BY YOUR LIMN": THE ONE SEEN WRITER (docs/BACKEND.md § My Turn).
|
|
2
|
+
//
|
|
3
|
+
// The reader had a finished run of their own Limn on screen in the PR's AI Review tab
|
|
4
|
+
// (`POST /api/prs/:id/claude-review/seen`, sent by the tab once per run while `seenAt` is null). The
|
|
5
|
+
// run — and, for a Claude + Codex pair, every half — is stamped only where `seen_at` is still NULL,
|
|
6
|
+
// so a repeat is a no-op and the stamp never moves. A stamped run is no longer a `claude_review` My
|
|
7
|
+
// Turn card (`getUnseenOwnAiReviews`). ⚠ This module opens NO database at import (it takes an
|
|
8
|
+
// executor): the review routes import it.
|
|
9
|
+
import { and, eq, isNull } from 'drizzle-orm';
|
|
10
|
+
import * as schema from './schema.sqlite.js';
|
|
11
|
+
const cr = schema.claudeReviews;
|
|
12
|
+
/**
|
|
13
|
+
* Stamp one finished own run (and its pair) seen. Account- and PR-scoped. Returns how many rows it
|
|
14
|
+
* stamped: 0 for a run that is not the account's, not this PR's, not a full review, not finished, or
|
|
15
|
+
* already seen — the route answers 404 only for the first three (`null`).
|
|
16
|
+
*/
|
|
17
|
+
export async function markClaudeReviewSeen(exec, accountId, prId, reviewId, now = new Date()) {
|
|
18
|
+
const run = (await exec
|
|
19
|
+
.select({ id: cr.id, pairId: cr.pairId, status: cr.status })
|
|
20
|
+
.from(cr)
|
|
21
|
+
.where(and(eq(cr.id, reviewId), eq(cr.accountId, accountId), eq(cr.prId, prId), isNull(cr.kind)))
|
|
22
|
+
.limit(1)
|
|
23
|
+
.execute())[0];
|
|
24
|
+
if (run == null)
|
|
25
|
+
return null;
|
|
26
|
+
if (run.status !== 'succeeded')
|
|
27
|
+
return 0;
|
|
28
|
+
const stamped = await exec
|
|
29
|
+
.update(cr)
|
|
30
|
+
.set({ seenAt: now })
|
|
31
|
+
.where(and(eq(cr.accountId, accountId), eq(cr.prId, prId), isNull(cr.seenAt), run.pairId != null ? eq(cr.pairId, run.pairId) : eq(cr.id, run.id)))
|
|
32
|
+
.returning({ id: cr.id })
|
|
33
|
+
.execute();
|
|
34
|
+
return stamped.length;
|
|
35
|
+
}
|
|
36
|
+
//# sourceMappingURL=claude-review-seen.js.map
|
package/dist/db/daily-brief.js
CHANGED
|
@@ -239,11 +239,7 @@ async function computeBriefCounts(accountId, workspaceId) {
|
|
|
239
239
|
cached(anomalyCache, `${accountId}:${scope.workspaceId}`, ANOMALY_TTL_MS, () => briefBotAnomalies(accountId, scope)),
|
|
240
240
|
trunkRedRepos(accountId, scope),
|
|
241
241
|
]);
|
|
242
|
-
|
|
243
|
-
// card (and in `myTurn` below), so listing it on this line too would count one red branch twice.
|
|
244
|
-
// Filtered after the fold resolves, then capped.
|
|
245
|
-
const promotedTrunks = new Set(insights.myTurnTrunkRepoIds ?? []);
|
|
246
|
-
const trunkRed = allRedTrunks.filter((t) => !promotedTrunks.has(t.repoId)).slice(0, TRUNK_CAP);
|
|
242
|
+
const trunkRed = allRedTrunks.slice(0, TRUNK_CAP);
|
|
247
243
|
let myTurn = 0;
|
|
248
244
|
// The `MyTurnCard.personal` subset of the same cards — the figure the NOTIFICATION surfaces
|
|
249
245
|
// display, where the board keeps displaying `myTurn`. Counted off the cards for exactly the
|
|
@@ -287,9 +283,6 @@ async function computeBriefCounts(accountId, workspaceId) {
|
|
|
287
283
|
for (const c of insights.cards) {
|
|
288
284
|
if (c.kind === 'ci_failing')
|
|
289
285
|
ciFailing += 1;
|
|
290
|
-
// A finished Claude review is listed in its own tab, not My turn, so no My turn line counts it.
|
|
291
|
-
else if (c.kind === 'my_turn' && c.reason === 'claude_review')
|
|
292
|
-
continue;
|
|
293
286
|
else if (c.kind === 'my_turn') {
|
|
294
287
|
myTurn += 1;
|
|
295
288
|
if (c.personal)
|
package/dist/db/erase-account.js
CHANGED
|
@@ -177,6 +177,8 @@ export async function eraseAccountData(accountId) {
|
|
|
177
177
|
await tx.delete(schema.teamClaimLines).where(eq(schema.teamClaimLines.accountId, accountId)).execute();
|
|
178
178
|
// The teammate lines the AI Review tab names (migration 0159): GitHub logins. No FK.
|
|
179
179
|
await tx.delete(schema.teamClaimSeen).where(eq(schema.teamClaimSeen.accountId, accountId)).execute();
|
|
180
|
+
// The skipped lines (migration 0166): heads and providers. No FK.
|
|
181
|
+
await tx.delete(schema.teamClaimSkipLines).where(eq(schema.teamClaimSkipLines.accountId, accountId)).execute();
|
|
180
182
|
// The worktree ledger (migration 0146): which PR / ticket a checkout on disk was for. No FK. The
|
|
181
183
|
// directories themselves become unledgered orphans the worktree sweep collects by age.
|
|
182
184
|
await tx.delete(schema.worktreeLedger).where(eq(schema.worktreeLedger.accountId, accountId)).execute();
|
|
@@ -370,6 +372,8 @@ export function accountScopedTables() {
|
|
|
370
372
|
{ name: 'teamClaimLines', col: schema.teamClaimLines.accountId, table: schema.teamClaimLines },
|
|
371
373
|
// The teammate lines (migration 0159), erased explicitly above.
|
|
372
374
|
{ name: 'teamClaimSeen', col: schema.teamClaimSeen.accountId, table: schema.teamClaimSeen },
|
|
375
|
+
// The skipped lines (migration 0166), erased explicitly above.
|
|
376
|
+
{ name: 'teamClaimSkipLines', col: schema.teamClaimSkipLines.accountId, table: schema.teamClaimSkipLines },
|
|
373
377
|
// The worktree ledger (migration 0146), erased explicitly above.
|
|
374
378
|
{ name: 'worktreeLedger', col: schema.worktreeLedger.accountId, table: schema.worktreeLedger },
|
|
375
379
|
{ name: 'workspaceSonar', col: schema.workspaceSonar.accountId, table: schema.workspaceSonar },
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
-- AN AUTO REVIEW THIS LIMN SKIPPED, SAID ON THE PR (docs/CLAUDE-REVIEW.md § Team claims → The skipped
|
|
2
|
+
-- line). When the reader's Limn does not start an auto review it would otherwise run (the AI budget is
|
|
3
|
+
-- used, the workspace's daily limit is reached, the provider is over its usage limit, AI is not signed
|
|
4
|
+
-- in), it posts ONE green `limn/review-<provider>` line on the head. One row per (account, PR, head,
|
|
5
|
+
-- provider, reason), claimed BEFORE the GitHub write: at most once, never retried. Additive.
|
|
6
|
+
CREATE TABLE `team_claim_skip_lines` (
|
|
7
|
+
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
|
8
|
+
`account_id` integer NOT NULL,
|
|
9
|
+
`pr_id` integer NOT NULL,
|
|
10
|
+
`head_sha` text NOT NULL,
|
|
11
|
+
`provider` text NOT NULL,
|
|
12
|
+
`reason` text NOT NULL,
|
|
13
|
+
`state` text NOT NULL,
|
|
14
|
+
`description` text,
|
|
15
|
+
`last_error` text,
|
|
16
|
+
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
|
|
17
|
+
`updated_at` integer DEFAULT (unixepoch()) NOT NULL
|
|
18
|
+
);
|
|
19
|
+
--> statement-breakpoint
|
|
20
|
+
CREATE UNIQUE INDEX `team_claim_skip_lines_key` ON `team_claim_skip_lines` (`account_id`,`pr_id`,`head_sha`,`provider`,`reason`);
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
-- PENDING → "AI REVIEWS" SINCE THE READER LAST LOOKED (docs/BACKEND.md § The AI reviews list): when the
|
|
2
|
+
-- reader last looked at the tab in this workspace. NULL = never (the list covers the last 7 days).
|
|
3
|
+
-- Additive and nullable; no backfill.
|
|
4
|
+
ALTER TABLE `workspaces` ADD `ai_reviews_seen_at` integer;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
-- MY TURN → "NEW AI REVIEWS BY YOUR LIMN" (docs/BACKEND.md § My Turn): when the reader first had a run
|
|
2
|
+
-- on screen in the PR's AI Review tab after it finished. NULL = not seen — the newest such run on an
|
|
3
|
+
-- open PR is a `claude_review` My Turn card until it is seen or dismissed.
|
|
4
|
+
ALTER TABLE `claude_reviews` ADD `seen_at` integer;
|
|
5
|
+
--> statement-breakpoint
|
|
6
|
+
-- Every run that already finished is treated as seen: the card's meaning changed (it used to be
|
|
7
|
+
-- "un-posted findings"), and an upgrade must not drop every PR's latest review onto My Turn at once.
|
|
8
|
+
UPDATE `claude_reviews` SET `seen_at` = coalesce(`finished_at`, `created_at`) WHERE `status` = 'succeeded';
|
|
9
|
+
--> statement-breakpoint
|
|
10
|
+
-- The red default branch is no longer a My Turn type: its repository-subject dismissals have no item
|
|
11
|
+
-- to hide. (The `repo_id` column stays, dormant.)
|
|
12
|
+
DELETE FROM `my_turn_dismissals` WHERE `pr_id` IS NULL;
|
|
@@ -1163,6 +1163,27 @@
|
|
|
1163
1163
|
"when": 1792224073016,
|
|
1164
1164
|
"tag": "0165_withdraw_approval_on_push",
|
|
1165
1165
|
"breakpoints": true
|
|
1166
|
+
},
|
|
1167
|
+
{
|
|
1168
|
+
"idx": 166,
|
|
1169
|
+
"version": "6",
|
|
1170
|
+
"when": 1792224075016,
|
|
1171
|
+
"tag": "0166_team_claim_skip_lines",
|
|
1172
|
+
"breakpoints": true
|
|
1173
|
+
},
|
|
1174
|
+
{
|
|
1175
|
+
"idx": 167,
|
|
1176
|
+
"version": "6",
|
|
1177
|
+
"when": 1792224077016,
|
|
1178
|
+
"tag": "0167_ai_reviews_seen",
|
|
1179
|
+
"breakpoints": true
|
|
1180
|
+
},
|
|
1181
|
+
{
|
|
1182
|
+
"idx": 168,
|
|
1183
|
+
"version": "6",
|
|
1184
|
+
"when": 1792224079016,
|
|
1185
|
+
"tag": "0168_ai_review_seen_per_run",
|
|
1186
|
+
"breakpoints": true
|
|
1166
1187
|
}
|
|
1167
1188
|
]
|
|
1168
1189
|
}
|
|
@@ -5,8 +5,7 @@
|
|
|
5
5
|
//
|
|
6
6
|
// ── 1. A DISMISSAL SETS ONE ITEM DOWN UNTIL SOMETHING NEW HAPPENS ────────────────────────────
|
|
7
7
|
//
|
|
8
|
-
// The reader can take a subject — a pull request
|
|
9
|
-
// their plate. ⚠ This is NOT the "Done" button migration 0060 deleted, and the difference is the
|
|
8
|
+
// The reader can take a subject — a pull request — off their plate. ⚠ This is NOT the "Done" button migration 0060 deleted, and the difference is the
|
|
10
9
|
// whole design. That one stored "I dealt with this" and never expired, so a PR whose ball came back
|
|
11
10
|
// stayed hidden behind it for weeks. This one stores a TIMESTAMP and lets the fold decide:
|
|
12
11
|
//
|
|
@@ -39,10 +38,10 @@
|
|
|
39
38
|
// ⚠ IT IS A BOARD RULE, NOT A NOTIFICATION RULE. `getWorkspaceInsights` asks for it; the
|
|
40
39
|
// account-wide `GET /api/my-turn` does not, because the browser-notification watcher diffs item
|
|
41
40
|
// ids — a deduplicated list would flip the winner whenever the top job cleared and announce the
|
|
42
|
-
// runner-up as "new".
|
|
41
|
+
// runner-up as "new".
|
|
43
42
|
import { and, eq, inArray } from 'drizzle-orm';
|
|
44
43
|
import { db, schema } from './client.js';
|
|
45
|
-
const { myTurnDismissals, pullRequests
|
|
44
|
+
const { myTurnDismissals, pullRequests } = schema;
|
|
46
45
|
export const subjectKey = (t) => `${t.kind}:${t.id}`;
|
|
47
46
|
/** Every dismissal the account holds. One indexed select plus a join for the PR's repo/state. */
|
|
48
47
|
export async function readMyTurnDismissals(accountId) {
|
|
@@ -68,14 +67,6 @@ export async function readMyTurnDismissals(accountId) {
|
|
|
68
67
|
closed: r.prState !== 'open',
|
|
69
68
|
});
|
|
70
69
|
}
|
|
71
|
-
else if (r.repoId != null) {
|
|
72
|
-
out.push({
|
|
73
|
-
target: { kind: 'repo', id: r.repoId },
|
|
74
|
-
dismissedAtMs: r.dismissedAt.getTime(),
|
|
75
|
-
repoId: r.repoId,
|
|
76
|
-
closed: false,
|
|
77
|
-
});
|
|
78
|
-
}
|
|
79
70
|
}
|
|
80
71
|
return out;
|
|
81
72
|
}
|
|
@@ -97,38 +88,19 @@ export function dismissalInstant(nowMs) {
|
|
|
97
88
|
*/
|
|
98
89
|
export async function dismissMyTurn(accountId, target, nowMs = Date.now()) {
|
|
99
90
|
const at = dismissalInstant(nowMs);
|
|
100
|
-
if (target.kind === 'pr') {
|
|
101
|
-
const owned = await db
|
|
102
|
-
.select({ id: pullRequests.id })
|
|
103
|
-
.from(pullRequests)
|
|
104
|
-
.where(and(eq(pullRequests.id, target.id), eq(pullRequests.accountId, accountId)))
|
|
105
|
-
.limit(1)
|
|
106
|
-
.execute();
|
|
107
|
-
if (owned[0] == null)
|
|
108
|
-
return null;
|
|
109
|
-
await db
|
|
110
|
-
.insert(myTurnDismissals)
|
|
111
|
-
.values({ accountId, prId: target.id, repoId: null, dismissedAt: at })
|
|
112
|
-
.onConflictDoUpdate({
|
|
113
|
-
target: [myTurnDismissals.accountId, myTurnDismissals.prId],
|
|
114
|
-
set: { dismissedAt: at },
|
|
115
|
-
})
|
|
116
|
-
.execute();
|
|
117
|
-
return at;
|
|
118
|
-
}
|
|
119
91
|
const owned = await db
|
|
120
|
-
.select({ id:
|
|
121
|
-
.from(
|
|
122
|
-
.where(and(eq(
|
|
92
|
+
.select({ id: pullRequests.id })
|
|
93
|
+
.from(pullRequests)
|
|
94
|
+
.where(and(eq(pullRequests.id, target.id), eq(pullRequests.accountId, accountId)))
|
|
123
95
|
.limit(1)
|
|
124
96
|
.execute();
|
|
125
97
|
if (owned[0] == null)
|
|
126
98
|
return null;
|
|
127
99
|
await db
|
|
128
100
|
.insert(myTurnDismissals)
|
|
129
|
-
.values({ accountId, prId:
|
|
101
|
+
.values({ accountId, prId: target.id, repoId: null, dismissedAt: at })
|
|
130
102
|
.onConflictDoUpdate({
|
|
131
|
-
target: [myTurnDismissals.accountId, myTurnDismissals.
|
|
103
|
+
target: [myTurnDismissals.accountId, myTurnDismissals.prId],
|
|
132
104
|
set: { dismissedAt: at },
|
|
133
105
|
})
|
|
134
106
|
.execute();
|
|
@@ -136,30 +108,22 @@ export async function dismissMyTurn(accountId, target, nowMs = Date.now()) {
|
|
|
136
108
|
}
|
|
137
109
|
/** Bring a subject back. True when a dismissal existed. Scoped by account on the row itself. */
|
|
138
110
|
export async function restoreMyTurn(accountId, target) {
|
|
139
|
-
const col = target.kind === 'pr' ? myTurnDismissals.prId : myTurnDismissals.repoId;
|
|
140
111
|
const deleted = await db
|
|
141
112
|
.delete(myTurnDismissals)
|
|
142
|
-
.where(and(eq(myTurnDismissals.accountId, accountId), eq(
|
|
113
|
+
.where(and(eq(myTurnDismissals.accountId, accountId), eq(myTurnDismissals.prId, target.id)))
|
|
143
114
|
.returning({ id: myTurnDismissals.id })
|
|
144
115
|
.execute();
|
|
145
116
|
return deleted.length > 0;
|
|
146
117
|
}
|
|
147
118
|
/** Drop the rows the fold found nothing left for — see `MyTurnDismissalFilter.dischargeable`. */
|
|
148
119
|
export async function dischargeMyTurnDismissals(accountId, targets) {
|
|
149
|
-
const prIds = targets.
|
|
150
|
-
const repoIds = targets.filter((t) => t.kind === 'repo').map((t) => t.id);
|
|
120
|
+
const prIds = targets.map((t) => t.id);
|
|
151
121
|
if (prIds.length > 0) {
|
|
152
122
|
await db
|
|
153
123
|
.delete(myTurnDismissals)
|
|
154
124
|
.where(and(eq(myTurnDismissals.accountId, accountId), inArray(myTurnDismissals.prId, prIds)))
|
|
155
125
|
.execute();
|
|
156
126
|
}
|
|
157
|
-
if (repoIds.length > 0) {
|
|
158
|
-
await db
|
|
159
|
-
.delete(myTurnDismissals)
|
|
160
|
-
.where(and(eq(myTurnDismissals.accountId, accountId), inArray(myTurnDismissals.repoId, repoIds)))
|
|
161
|
-
.execute();
|
|
162
|
-
}
|
|
163
127
|
}
|
|
164
128
|
/**
|
|
165
129
|
* The per-fold filter. `keep` is called once per candidate item, in any order; it records which
|
|
@@ -271,6 +235,7 @@ export function onePerPr(mt, order) {
|
|
|
271
235
|
approvedPrs: pr('pr_approved'),
|
|
272
236
|
ownReady: pr('own_ready'),
|
|
273
237
|
yourPrs: pr('your_pr'),
|
|
238
|
+
approvedMerged: pr('ai_approved_merged'),
|
|
274
239
|
watchedRepoPrs: pr('watched_repo_pr'),
|
|
275
240
|
};
|
|
276
241
|
const thread = (reason) => ({
|
|
@@ -309,6 +274,8 @@ export function onePerPr(mt, order) {
|
|
|
309
274
|
offer(S.ownReady.of(i));
|
|
310
275
|
for (const i of mt.yourPrs)
|
|
311
276
|
offer(S.yourPrs.of(i));
|
|
277
|
+
for (const i of mt.approvedMerged)
|
|
278
|
+
offer(S.approvedMerged.of(i));
|
|
312
279
|
for (const i of mt.watchedRepoPrs)
|
|
313
280
|
offer(S.watchedRepoPrs.of(i));
|
|
314
281
|
for (const i of mt.threadsAwaiting)
|
|
@@ -333,8 +300,7 @@ export function onePerPr(mt, order) {
|
|
|
333
300
|
ownThreads: mt.ownThreads.filter((i) => winnerIs(ownThreads.of(i))),
|
|
334
301
|
// Exempt (header, rule 2): every Claude review keeps its own card.
|
|
335
302
|
claudeReviewsToAction: mt.claudeReviewsToAction,
|
|
336
|
-
|
|
337
|
-
redTrunks: mt.redTrunks,
|
|
303
|
+
approvedMerged: mt.approvedMerged.filter((i) => winnerIs(S.approvedMerged.of(i))),
|
|
338
304
|
};
|
|
339
305
|
}
|
|
340
306
|
//# sourceMappingURL=my-turn-dismissals.js.map
|