Public: anyone can read this; the crew can change it.

Asks still read per pull once a pull has a check result #2789

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/forge:mason/2781-asks-still-read-per-pull-once-a-pull-has → main 9 commits fork

  • Merged. mason/2781-asks-still-read-per-pull-once-a-pull-has was merged into main as 921b229.

Review

Sign in to review this change.
9 files changed +657 −197
src/core/agents.ts +46 −2
@@ -9,7 +9,7 @@
99
1010 import type { Database } from "bun:sqlite";
1111 import { limitValue } from "../settings/limits.ts";
12 import { getUserById, type User } from "./users.ts";
12 import { getUserById, getUsersByIds, type User } from "./users.ts";
1313
1414 export interface Agent {
1515 readonly user: User;
@@ -37,7 +37,10 @@ export const BRIEF_MAX = 4_000;
3737
3838 function toAgent(db: Database, row: AgentRow): Agent | undefined {
3939 const user = getUserById(db, row.user_id);
40 if (!user) return undefined;
40 return user ? agentOf(user, row) : undefined;
41 }
42
43 function agentOf(user: User, row: AgentRow): Agent {
4144 return {
4245 user,
4346 model: row.model,
@@ -335,6 +338,47 @@ export function listCrewRoles(
335338 }));
336339 }
337340
341 /**
342 * The crew members holding `role` on each of these repositories, by repo,
343 * in `listCrew`'s order — the agents and their accounts one query per chunk
344 * each, never one per repository or per member.
345 */
346 export function crewWithRole(
347 db: Database,
348 repoIds: readonly string[],
349 role: CrewRole,
350 ): Map<string, Agent[]> {
351 const rows: (AgentRow & { repo_id: string })[] = [];
352 const unique = [...new Set(repoIds)];
353 const CHUNK = 400;
354 for (let i = 0; i < unique.length; i += CHUNK) {
355 const batch = unique.slice(i, i + CHUNK);
356 rows.push(
357 ...db
358 .query<AgentRow & { repo_id: string }, string[]>(
359 `SELECT repo_crew.repo_id, agent.* FROM repo_crew
360 JOIN agent ON agent.user_id = repo_crew.user_id
361 JOIN user ON user.id = agent.user_id
362 WHERE repo_crew.role = ?1
363 AND repo_crew.repo_id IN (${batch.map((_, n) => `?${n + 2}`).join(",")})
364 ORDER BY user.username COLLATE NOCASE`,
365 )
366 .all(role, ...batch),
367 );
368 }
369 const users = getUsersByIds(db, rows.map((r) => r.user_id));
370 const out = new Map<string, Agent[]>();
371 for (const row of rows) {
372 const user = users.get(row.user_id);
373 if (!user) continue;
374 const agent = agentOf(user, row);
375 const list = out.get(row.repo_id);
376 if (list) list.push(agent);
377 else out.set(row.repo_id, [agent]);
378 }
379 return out;
380 }
381
338382 /**
339383 * Which of these repositories have a builder on their crew, as a set — a
340384 * repo absent from it has none, and nothing can pick up a tell there
src/core/decisions.ts +25
@@ -173,6 +173,31 @@ export function latestDecision(
173173 return row ? toDecision(row) : undefined;
174174 }
175175
176 /** `latestDecision` for many issues at once, by issue: one query per chunk. */
177 export function latestDecisionsFor(
178 db: Database,
179 issueIds: readonly string[],
180 kind: DecisionKind,
181 ): Map<string, Decision> {
182 const out = new Map<string, Decision>();
183 const unique = [...new Set(issueIds)];
184 const CHUNK = 400;
185 for (let i = 0; i < unique.length; i += CHUNK) {
186 const batch = unique.slice(i, i + CHUNK);
187 const rows = db
188 .query<Row, string[]>(
189 `SELECT * FROM (
190 SELECT *, ROW_NUMBER() OVER (PARTITION BY issue_id ORDER BY created_at DESC, id DESC) AS nth
191 FROM decision
192 WHERE kind = ?1 AND issue_id IN (${batch.map((_, n) => `?${n + 2}`).join(",")})
193 ) WHERE nth = 1`,
194 )
195 .all(kind, ...batch);
196 for (const row of rows) if (row.issue_id) out.set(row.issue_id, toDecision(row));
197 }
198 return out;
199 }
200
176201 /**
177202 * Whether a `retry` decision still stops its pull. The crew's stop is its
178203 * answer to a red check (the check is in what it saw), so a check that
src/core/holds.ts +21
@@ -131,3 +131,24 @@ export function lastRelease(db: Database, pullId: string): string | undefined {
131131 .get(pullId)?.at ?? undefined
132132 );
133133 }
134
135 /** `lastRelease` for many pulls at once, by pull: one read per chunk. A pull never let go is absent. */
136 export function lastReleasesFor(
137 db: Database,
138 pullIds: readonly string[],
139 ): Map<string, string> {
140 const out = new Map<string, string>();
141 const CHUNK = 400;
142 for (let i = 0; i < pullIds.length; i += CHUNK) {
143 const batch = pullIds.slice(i, i + CHUNK);
144 for (const row of db
145 .query<{ issue_id: string; at: string }, string[]>(
146 `SELECT issue_id, MAX(released_at) AS at FROM pull_hold
147 WHERE released_at IS NOT NULL AND issue_id IN (${batch.map((_, n) => `?${n + 1}`).join(",")})
148 GROUP BY issue_id`,
149 )
150 .all(...batch))
151 out.set(row.issue_id, row.at);
152 }
153 return out;
154 }
src/core/reviews.ts +104 −26
@@ -337,10 +337,12 @@ export function latestReviewByAuthor(
337337 db: Database,
338338 issueId: string,
339339 ): Map<string, Review> {
340 return latestByAuthor(listReviews(db, issueId));
341 }
342
343 function latestByAuthor(reviews: readonly Review[]): Map<string, Review> {
340344 const latest = new Map<string, Review>();
341 for (const review of listReviews(db, issueId)) {
342 latest.set(review.originAuthor ?? review.authorId, review);
343 }
345 for (const review of reviews) latest.set(review.originAuthor ?? review.authorId, review);
344346 return latest;
345347 }
346348
@@ -364,12 +366,72 @@ export function reviewSummary(
364366 issueId: string,
365367 headOid: string,
366368 ): ReviewSummary {
367 const agents = agentVerdicts(db, issueId);
369 return summarize(listReviews(db, issueId), verdictsOf(agentVerdictRows(db, [issueId])), headOid);
370 }
371
372 /**
373 * `reviewSummary` for many pulls at once, by pull: the reviews and the
374 * agents' verdicts read in one query per chunk each, counted by the same
375 * rule as the singular.
376 */
377 export function reviewSummariesFor(
378 db: Database,
379 pulls: readonly { readonly id: string; readonly headOid: string }[],
380 ): Map<string, ReviewSummary> {
381 const ids = pulls.map((p) => p.id);
382 const reviews = listReviewsFor(db, ids);
383 const rows = new Map<string, AgentVerdictRow[]>();
384 for (const row of agentVerdictRows(db, ids)) {
385 const list = rows.get(row.issue_id);
386 if (list) list.push(row);
387 else rows.set(row.issue_id, [row]);
388 }
389 return new Map(
390 pulls.map((p) => [
391 p.id,
392 summarize(reviews.get(p.id) ?? [], verdictsOf(rows.get(p.id) ?? []), p.headOid),
393 ]),
394 );
395 }
396
397 /** Every verdict on each of these pulls, oldest first, by pull: one query per chunk. */
398 export function listReviewsFor(
399 db: Database,
400 issueIds: readonly string[],
401 ): Map<string, Review[]> {
402 const out = new Map<string, Review[]>();
403 const unique = [...new Set(issueIds)];
404 for (let i = 0; i < unique.length; i += CHUNK) {
405 const batch = unique.slice(i, i + CHUNK);
406 const rows = db
407 .query<ReviewRowRaw & { issue_id: string }, string[]>(
408 `${REVIEW_SELECT.replace("SELECT ", "SELECT review.issue_id, ")}
409 WHERE review.issue_id IN (${batch.map((_, n) => `?${n + 1}`).join(",")})
410 ORDER BY review.id`,
411 )
412 .all(...batch);
413 for (const row of rows) {
414 const list = out.get(row.issue_id);
415 if (list) list.push(toReview(row));
416 else out.set(row.issue_id, [toReview(row)]);
417 }
418 }
419 return out;
420 }
421
422 const CHUNK = 400;
423
424 /** Where a pull's verdicts stand at `headOid`, from its reviews and its agents' verdicts. */
425 function summarize(
426 reviews: readonly Review[],
427 agents: AgentVerdicts,
428 headOid: string,
429 ): ReviewSummary {
368430 let approvals = 0;
369431 let changesRequested = 0;
370432 let commented = 0;
371433 let stale = 0;
372 for (const review of latestReviewByAuthor(db, issueId).values()) {
434 for (const review of latestByAuthor(reviews).values()) {
373435 if (review.commitOid !== headOid) {
374436 if (review.state !== "commented") stale++;
375437 continue;
@@ -407,27 +469,43 @@ export const REVIEW_ROUNDS = 2;
407469 * pull: whether each is a reviewer on the pull's crew now, and on how many
408470 * heads other than a given one an agent asked for changes.
409471 */
410 function agentVerdicts(
411 db: Database,
412 issueId: string,
413 ): {
414 authors: Map<string, { reviewer: boolean }>;
415 earlierRounds: (headOid: string) => number;
416 } {
417 const rows = db
418 .query<
419 { author_id: string; role: string | null; commit_oid: string; state: string },
420 [string]
421 >(
422 `SELECT review.author_id, repo_crew.role, review.commit_oid, review.state
423 FROM review
424 JOIN issue ON issue.id = review.issue_id
425 JOIN user ON user.id = review.author_id AND user.is_agent = 1
426 LEFT JOIN repo_crew ON repo_crew.repo_id = issue.repo_id
427 AND repo_crew.user_id = review.author_id
428 WHERE review.issue_id = ?1`,
429 )
430 .all(issueId);
472 interface AgentVerdicts {
473 readonly authors: Map<string, { reviewer: boolean }>;
474 readonly earlierRounds: (headOid: string) => number;
475 }
476
477 interface AgentVerdictRow {
478 issue_id: string;
479 author_id: string;
480 role: string | null;
481 commit_oid: string;
482 state: string;
483 }
484
485 /** The agents' verdicts on these pulls, with each agent's role on the pull's crew. */
486 function agentVerdictRows(db: Database, issueIds: readonly string[]): AgentVerdictRow[] {
487 const unique = [...new Set(issueIds)];
488 const out: AgentVerdictRow[] = [];
489 for (let i = 0; i < unique.length; i += CHUNK) {
490 const batch = unique.slice(i, i + CHUNK);
491 out.push(
492 ...db
493 .query<AgentVerdictRow, string[]>(
494 `SELECT review.issue_id, review.author_id, repo_crew.role, review.commit_oid, review.state
495 FROM review
496 JOIN issue ON issue.id = review.issue_id
497 JOIN user ON user.id = review.author_id AND user.is_agent = 1
498 LEFT JOIN repo_crew ON repo_crew.repo_id = issue.repo_id
499 AND repo_crew.user_id = review.author_id
500 WHERE review.issue_id IN (${batch.map((_, n) => `?${n + 1}`).join(",")})`,
501 )
502 .all(...batch),
503 );
504 }
505 return out;
506 }
507
508 function verdictsOf(rows: readonly AgentVerdictRow[]): AgentVerdicts {
431509 const authors = new Map(
432510 rows.map((r) => [r.author_id, { reviewer: r.role === "reviewer" }]),
433511 );
src/core/runs.ts +27
@@ -898,6 +898,33 @@ export function automaticRunsSince(
898898 return row?.n ?? 0;
899899 }
900900
901 /**
902 * `automaticRunsSince` for many pulls at once, each from its own moment,
903 * by pull: one query per chunk. A pull with none is absent.
904 */
905 export function automaticRunsSinceFor(
906 db: Database,
907 since: ReadonlyMap<string, string>,
908 ): Map<string, number> {
909 const out = new Map<string, number>();
910 const pairs = [...since];
911 const CHUNK = 400;
912 for (let i = 0; i < pairs.length; i += CHUNK) {
913 const batch = pairs.slice(i, i + CHUNK);
914 const rows = db
915 .query<{ issue_id: string; n: number }, string[]>(
916 `WITH since (issue_id, at) AS (VALUES ${batch.map((_, n) => `(?${2 * n + 1}, ?${2 * n + 2})`).join(",")})
917 SELECT run.issue_id, COUNT(*) AS n FROM run JOIN since ON since.issue_id = run.issue_id
918 WHERE run.kind = 'agent' AND run.created_at > since.at
919 AND run.reason IN ('check_failed','conflict')
920 GROUP BY run.issue_id`,
921 )
922 .all(...batch.flat());
923 for (const row of rows) out.set(row.issue_id, row.n);
924 }
925 return out;
926 }
927
901928 /** What an agent has done, in totals: the number a person weighs a crew by. */
902929 export interface AgentTotals {
903930 readonly runs: number;
src/crew/guard.ts +66 −45
@@ -280,7 +280,7 @@ export function handoverOf(
280280 )
281281 .get(pullId, repo.id)!.n;
282282 if (crewAsked < HANDOVER_ROUNDS) return undefined;
283 return handoverSince(db, repo, pullId);
283 return handoversSince(db, [{ id: pullId, repo }]).get(pullId);
284284 }
285285
286286 /** Pulls `handoversOf` counts per query: well under SQLite's bound on variables. */
@@ -310,61 +310,82 @@ export function handoversOf(
310310 GROUP BY r.issue_id`,
311311 )
312312 .all(...chunk);
313 for (const row of rows) {
314 if (row.n < HANDOVER_ROUNDS) continue;
315 const over = handoverSince(db, repoOf.get(row.issueId)!, row.issueId);
316 if (over !== undefined) out.set(row.issueId, over);
317 }
313 const enough = rows
314 .filter((row) => row.n >= HANDOVER_ROUNDS)
315 .map((row) => ({ id: row.issueId, repo: repoOf.get(row.issueId)! }));
316 for (const [id, over] of handoversSince(db, enough)) out.set(id, over);
318317 }
319318 return out;
320319 }
321320
322 /** The hand-over, once a pull has had enough crew verdicts to have one. */
323 function handoverSince(
321 /**
322 * The hand-overs of pulls that have had enough crew verdicts to have one,
323 * by pull: the approvals, the people's words and the crew's rounds are one
324 * query each for the set, and who may direct the crew is asked once per
325 * repository for everyone who spoke there.
326 */
327 function handoversSince(
324328 db: Database,
325 repo: Repo,
326 pullId: string,
327 ): Handover | undefined {
328 const approved =
329 pulls: readonly { readonly id: string; readonly repo: Repo }[],
330 ): Map<string, Handover> {
331 const out = new Map<string, Handover>();
332 if (pulls.length === 0) return out;
333 const ids = pulls.map((p) => p.id);
334 const marks = ids.map((_, n) => `?${n + 2}`).join(",");
335 const approved = new Map(
329336 db
330 .query<{ at: string | null }, [string]>(
331 "SELECT MAX(created_at) AS at FROM review WHERE issue_id = ?1 AND state = 'approved'",
337 .query<{ issueId: string; at: string }, string[]>(
338 `SELECT issue_id AS issueId, MAX(created_at) AS at FROM review
339 WHERE issue_id IN (${ids.map((_, n) => `?${n + 1}`).join(",")}) AND state = 'approved'
340 GROUP BY issue_id`,
332341 )
333 .get(pullId)?.at ?? "";
334 const spoke = db
335 .query<{ id: string; at: string }, [string, string]>(
336 `SELECT author_id AS id, MAX(created_at) AS at FROM comment
337 WHERE issue_id = ?1 AND origin_author IS NULL AND kind != 'review'
338 AND body NOT LIKE ?2
339 GROUP BY author_id`,
340 )
341 .all(pullId, `${HANDOVER_NOTE}%`);
342 const directors = new Set(
343 directorsAmong(
344 db,
345 repo,
346 spoke.map((s) => s.id),
347 ),
342 .all(...ids)
343 .map((r) => [r.issueId, r.at]),
348344 );
349 const since = spoke
350 .filter((s) => directors.has(s.id))
351 .map((s) => s.at)
352 .reduce((a, b) => (a > b ? a : b), approved);
353 const rounds = db
354 .query<
355 { id: string; author: string; body: string; at: string },
356 [string, string, string]
357 >(
358 `SELECT r.id AS id, u.username AS author, r.body AS body, r.created_at AS at
345 const spoke = db
346 .query<{ issueId: string; id: string; at: string }, string[]>(
347 `SELECT issue_id AS issueId, author_id AS id, MAX(created_at) AS at FROM comment
348 WHERE issue_id IN (${marks}) AND origin_author IS NULL AND kind != 'review'
349 AND body NOT LIKE ?1
350 GROUP BY issue_id, author_id`,
351 )
352 .all(`${HANDOVER_NOTE}%`, ...ids);
353 const byRepo = new Map<string, { repo: Repo; ids: Set<string> }>();
354 const repoOf = new Map(pulls.map((p) => [p.id, p.repo]));
355 for (const s of spoke) {
356 const repo = repoOf.get(s.issueId)!;
357 const entry = byRepo.get(repo.id) ?? { repo, ids: new Set<string>() };
358 entry.ids.add(s.id);
359 byRepo.set(repo.id, entry);
360 }
361 const directors = new Map(
362 [...byRepo].map(([repoId, { repo, ids }]) => [repoId, new Set(directorsAmong(db, repo, ids))]),
363 );
364 const since = new Map(ids.map((id) => [id, approved.get(id) ?? ""]));
365 for (const s of spoke)
366 if (directors.get(repoOf.get(s.issueId)!.id)?.has(s.id) && s.at > since.get(s.issueId)!)
367 since.set(s.issueId, s.at);
368 const rounds = new Map<string, { id: string; author: string; body: string; at: string }[]>();
369 for (const r of db
370 .query<{ issueId: string; id: string; author: string; body: string; at: string }, string[]>(
371 `SELECT r.issue_id AS issueId, r.id AS id, u.username AS author, r.body AS body, r.created_at AS at
359372 FROM review r JOIN user u ON u.id = r.author_id
360 JOIN repo_crew c ON c.user_id = r.author_id AND c.repo_id = ?3
361 WHERE r.issue_id = ?1 AND r.state = 'changes_requested'
362 AND r.created_at > ?2 AND c.role = 'reviewer'
373 JOIN issue i ON i.id = r.issue_id
374 JOIN repo_crew c ON c.user_id = r.author_id AND c.repo_id = i.repo_id
375 WHERE r.issue_id IN (${ids.map((_, n) => `?${n + 1}`).join(",")})
376 AND r.state = 'changes_requested' AND c.role = 'reviewer'
363377 ORDER BY r.created_at, r.id`,
364378 )
365 .all(pullId, since, repo.id);
366 if (rounds.length < HANDOVER_ROUNDS) return undefined;
367 return { reviews: rounds, at: rounds[HANDOVER_ROUNDS - 1]!.at };
379 .all(...ids)) {
380 if (r.at <= since.get(r.issueId)!) continue;
381 const { issueId, ...round } = r;
382 const list = rounds.get(issueId);
383 if (list) list.push(round);
384 else rounds.set(issueId, [round]);
385 }
386 for (const [id, list] of rounds)
387 if (list.length >= HANDOVER_ROUNDS) out.set(id, { reviews: list, at: list[HANDOVER_ROUNDS - 1]!.at });
388 return out;
368389 }
369390
370391 /** One line per review, for the card and the thread: who asked, and the first line of what. */
src/crew/review.ts +173 −87
@@ -19,17 +19,17 @@
1919
2020 import type { Database } from "bun:sqlite";
2121 import type { App } from "../app.ts";
22 import { listCrewRoles, type Agent } from "../core/agents.ts";
23 import { getHold, lastRelease } from "../core/holds.ts";
22 import { crewWithRole, type Agent } from "../core/agents.ts";
23 import { getHold, holdsFor, lastReleasesFor } from "../core/holds.ts";
2424 import { getIssueById, LIST_LIMIT_MAX } from "../core/issues.ts";
2525 import { listPulls, pullDiff, type Pull, pullDir} from "../core/pulls.ts";
2626 import { getRepoById, type Repo } from "../core/repos.ts";
27 import { reviewSummary } from "../core/reviews.ts";
27 import { reviewSummariesFor, reviewSummary } from "../core/reviews.ts";
2828 import { checksForHead } from "../core/runs.ts";
2929 import { repoDir } from "../git/repos.ts";
3030 import { createLog } from "../kernel/log.ts";
3131 import { queueAgentRun } from "./dispatch.ts";
32 import { type Handover, handoverOf } from "./guard.ts";
32 import { type Handover, handoverOf, handoversOf } from "./guard.ts";
3333
3434 const log = createLog("crew");
3535
@@ -64,9 +64,11 @@ export function pickReviewer(
6464
6565 /** The crew's reviewers on a repository. */
6666 export const reviewersOf = (db: Database, repoId: string): Agent[] =>
67 listCrewRoles(db, repoId)
68 .filter((c) => c.role === "reviewer")
69 .map((c) => c.agent);
67 reviewersForRepos(db, [repoId]).get(repoId) ?? [];
68
69 /** `reviewersOf` for many repositories at once, by repo; one with none is absent. */
70 export const reviewersForRepos = (db: Database, repoIds: readonly string[]): Map<string, Agent[]> =>
71 crewWithRole(db, repoIds, "reviewer");
7072
7173 /** Whether every check on the pull's head ran and passed. */
7274 export function greenHead(db: Database, pull: Pull): boolean {
@@ -129,48 +131,80 @@ export function reviewAttempts(
129131 db: Database,
130132 pull: Pull,
131133 ): ReviewHistory | undefined {
132 // Review is owed from the check's green: a preview deploy finishes in
133 // seconds, a check may queue for an hour. A deploy counts only where the
134 // head has no check at all.
135 const hasCheck =
136 db
137 .query<{ n: number }, [string, string]>(
138 `SELECT COUNT(*) AS n FROM run WHERE issue_id = ?1 AND commit_oid = ?2 AND kind = 'check'`,
134 return reviewAttemptsFor(db, [pull]).get(pull.id);
135 }
136
137 /**
138 * `reviewAttempts` for many pulls at once, by pull: each head's checks and
139 * deploys, the releases and the review runs are one query per chunk each.
140 * A pull whose head has not gone green is absent.
141 */
142 export function reviewAttemptsFor(
143 db: Database,
144 pulls: readonly Pull[],
145 ): Map<string, ReviewHistory> {
146 const out = new Map<string, ReviewHistory>();
147 const CHUNK = 400;
148 for (let i = 0; i < pulls.length; i += CHUNK) {
149 const batch = pulls.slice(i, i + CHUNK);
150 const ids = batch.map((p) => p.id);
151 const heads = db
152 .query<
153 { issue_id: string; kind: string; state: string; finished_at: string | null; created_at: string },
154 string[]
155 >(
156 `WITH head (issue_id, oid) AS (VALUES ${batch.map((_, n) => `(?${2 * n + 1}, ?${2 * n + 2})`).join(",")})
157 SELECT run.issue_id, run.kind, run.state, run.finished_at, run.created_at
158 FROM run JOIN head ON head.issue_id = run.issue_id AND head.oid = run.commit_oid
159 WHERE run.kind IN ('check','deploy')`,
139160 )
140 .get(pull.id, pull.headOid)!.n > 0;
141 const greenRun = db
142 .query<{ at: string | null; created: string | null }, [string, string, string]>(
143 `SELECT MIN(finished_at) AS at, created_at AS created FROM run
144 WHERE issue_id = ?1 AND commit_oid = ?2 AND kind = ?3 AND state = 'ok'`,
145 )
146 .get(pull.id, pull.headOid, hasCheck ? "check" : "deploy");
147 const green = greenRun?.at;
148 if (green === null || green === undefined) return undefined;
149 const checkMs = hasCheck
150 ? Date.parse(green) - Date.parse(greenRun?.created ?? green)
151 : 0;
152 const released = lastRelease(db, pull.id);
153 const since = released !== undefined && released > green ? released : green;
154 const attempts = db
155 .query<
156 { created_at: string; state: string; actor_id: string | null },
157 [string, string]
158 >(
159 `SELECT created_at, state, actor_id FROM run WHERE issue_id = ?1 AND kind = 'agent'
160 AND reason = 'review' AND created_at >= ?2 ORDER BY created_at, id`,
161 )
162 .all(pull.id, since)
163 .map((r) => ({
164 createdAt: r.created_at,
165 active: r.state === "queued" || r.state === "running",
166 reviewerId: r.actor_id,
167 }));
168 const first = attempts[0];
169 const lateAt =
170 first !== undefined && Date.parse(first.createdAt) - Date.parse(since) >= REVIEW_LAPSE_MS
171 ? first.createdAt
172 : undefined;
173 return { greenAt: since, attempts, lateAt, checkMinutes: Math.round(checkMs / 60_000) };
161 .all(...batch.flatMap((p) => [p.id, p.headOid]));
162 const released = lastReleasesFor(db, ids);
163 const reviews = new Map<string, { createdAt: string; active: boolean; reviewerId: string | null }[]>();
164 for (const r of db
165 .query<{ issue_id: string; created_at: string; state: string; actor_id: string | null }, string[]>(
166 `SELECT issue_id, created_at, state, actor_id FROM run
167 WHERE kind = 'agent' AND reason = 'review'
168 AND issue_id IN (${ids.map((_, n) => `?${n + 1}`).join(",")})
169 ORDER BY created_at, id`,
170 )
171 .all(...ids)) {
172 const attempt = {
173 createdAt: r.created_at,
174 active: r.state === "queued" || r.state === "running",
175 reviewerId: r.actor_id,
176 };
177 const list = reviews.get(r.issue_id);
178 if (list) list.push(attempt);
179 else reviews.set(r.issue_id, [attempt]);
180 }
181 for (const pull of batch) {
182 const runs = heads.filter((r) => r.issue_id === pull.id);
183 // Review is owed from the check's green: a preview deploy finishes in
184 // seconds, a check may queue for an hour. A deploy counts only where the
185 // head has no check at all.
186 const hasCheck = runs.some((r) => r.kind === "check");
187 const greenRun = runs
188 .filter((r) => r.kind === (hasCheck ? "check" : "deploy") && r.state === "ok" && r.finished_at !== null)
189 .reduce<(typeof runs)[number] | undefined>(
190 (first, r) => (first === undefined || r.finished_at! < first.finished_at! ? r : first),
191 undefined,
192 );
193 if (greenRun === undefined) continue;
194 const green = greenRun.finished_at!;
195 const checkMs = hasCheck ? Date.parse(green) - Date.parse(greenRun.created_at) : 0;
196 const release = released.get(pull.id);
197 const since = release !== undefined && release > green ? release : green;
198 const attempts = (reviews.get(pull.id) ?? []).filter((a) => a.createdAt >= since);
199 const first = attempts[0];
200 const lateAt =
201 first !== undefined && Date.parse(first.createdAt) - Date.parse(since) >= REVIEW_LAPSE_MS
202 ? first.createdAt
203 : undefined;
204 out.set(pull.id, { greenAt: since, attempts, lateAt, checkMinutes: Math.round(checkMs / 60_000) });
205 }
206 }
207 return out;
174208 }
175209
176210 /** What the run table says about review on a head. */
@@ -198,17 +232,6 @@ export function stageOf(
198232 return reviewStage(history.lateAt ?? history.greenAt, history.attempts, now);
199233 }
200234
201 /**
202 * Whether the crew's review loop on this agent pull is spent: the one rule
203 * is `handoverOf` (`guard.ts`), so the card, the thread note and the held
204 * state cannot answer differently.
205 */
206 function handedOver(db: Database, repoId: string, pull: Pull): Handover | undefined {
207 if (!pull.authorIsAgent) return undefined;
208 const repo = getRepoById(db, repoId);
209 return repo ? handoverOf(db, repo, pull.id) : undefined;
210 }
211
212235 /** Why an agent's green pull waits for a person, and since when. */
213236 export interface ReviewWait {
214237 readonly why: "no reviewer" | "lapsed" | "rounds";
@@ -218,6 +241,16 @@ export interface ReviewWait {
218241 readonly lateCheckMinutes?: number;
219242 }
220243
244 /** Where review stands on an agent's green pull: who it waits for, if anyone, and its history. */
245 export interface ReviewWaits {
246 /** The crew reviewer it waits for, by name (`waitsForReviewer`). */
247 readonly reviewer: string | undefined;
248 /** Why it waits for a person, and since when (`waitsForPerson`). */
249 readonly person: ReviewWait | undefined;
250 /** The review runs sent for its head (`reviewAttempts`). */
251 readonly history: ReviewHistory | undefined;
252 }
253
221254 /**
222255 * Whether this agent's green pull now waits for a person's review: its
223256 * crew has no reviewer, or the review it was sent lapsed (asked twice, or
@@ -232,24 +265,7 @@ export function waitsForPerson(
232265 pull: Pull,
233266 now = new Date(),
234267 ): ReviewWait | undefined {
235 if (!pull.authorIsAgent || getHold(db, pull.id) !== undefined) return undefined;
236 const seen = reviewSummary(db, pull.id, pull.headOid);
237 if (seen.approvals > 0 || seen.changesRequested > 0) return undefined;
238 const over = handedOver(db, repoId, pull);
239 if (over !== undefined) return { why: "rounds", since: over.at };
240 const history = reviewAttempts(db, pull);
241 if (history === undefined) return undefined;
242 if (reviewersOf(db, repoId).length === 0)
243 return { why: "no reviewer", since: history.greenAt };
244 return stageOf(history, now) === "lapsed"
245 ? {
246 why: "lapsed",
247 since: history.greenAt,
248 ...(history.lateAt === undefined
249 ? {}
250 : { lateCheckMinutes: history.checkMinutes }),
251 }
252 : undefined;
268 return waitsOne(db, repoId, pull, now)?.person;
253269 }
254270
255271 /**
@@ -266,16 +282,86 @@ export function waitsForReviewer(
266282 pull: Pull,
267283 now = new Date(),
268284 ): string | undefined {
269 if (!pull.authorIsAgent || getHold(db, pull.id) !== undefined) return undefined;
270 const seen = reviewSummary(db, pull.id, pull.headOid);
271 if (seen.approvals > 0 || seen.changesRequested > 0) return undefined;
272 const reviewers = reviewersOf(db, repoId);
273 if (reviewers.length === 0 || handedOver(db, repoId, pull) !== undefined) return undefined;
274 const history = reviewAttempts(db, pull);
275 if (history === undefined || stageOf(history, now) === "lapsed") return undefined;
276 const sent = history.attempts.at(-1)?.reviewerId;
277 const who = reviewers.find((r) => r.user.id === sent) ?? reviewers[0]!;
278 return who.user.username;
285 return waitsOne(db, repoId, pull, now)?.reviewer;
286 }
287
288 function waitsOne(db: Database, repoId: string, pull: Pull, now: Date): ReviewWaits | undefined {
289 if (!pull.authorIsAgent) return undefined;
290 const repo = getRepoById(db, repoId);
291 return repo ? reviewWaitsFor(db, [{ repo, pull }], now).get(pull.id) : undefined;
292 }
293
294 /**
295 * `waitsForPerson`, `waitsForReviewer` and `reviewAttempts` for many pulls
296 * at once, by pull, by the one rule: holds, verdicts, hand-overs, review
297 * runs and reviewers are each read once for the set, and only for the
298 * pulls still in question after the read before. A person's pull, a held
299 * one, or one with a verdict on its head waits for nobody, and is absent.
300 * A caller that has already read these pulls' hand-overs (`handoversOf`)
301 * hands them in, and they are not read again.
302 */
303 export function reviewWaitsFor(
304 db: Database,
305 pulls: readonly { readonly repo: Repo; readonly pull: Pull }[],
306 now = new Date(),
307 handovers?: ReadonlyMap<string, Handover>,
308 ): Map<string, ReviewWaits> {
309 const out = new Map<string, ReviewWaits>();
310 const agents = pulls.filter((p) => p.pull.authorIsAgent);
311 const held = holdsFor(db, agents.map((p) => p.pull.id));
312 const unheld = agents.filter((p) => !held.has(p.pull.id));
313 const seen = reviewSummariesFor(db, unheld.map((p) => p.pull));
314 // A verdict on the head is the gate's answer: nobody waits.
315 const open = unheld.filter((p) => {
316 const s = seen.get(p.pull.id)!;
317 return s.approvals === 0 && s.changesRequested === 0;
318 });
319 // The crew's review loop is spent: the one rule is `handoverOf`
320 // (`guard.ts`), so the card, the thread note and the held state cannot
321 // answer differently.
322 const over = handovers ?? handoversOf(db, open.map((p) => ({ id: p.pull.id, repo: p.repo })));
323 const histories = reviewAttemptsFor(db, open.map((p) => p.pull));
324 const reviewers = reviewersForRepos(db, open.map((p) => p.repo.id));
325 for (const { repo, pull } of open) {
326 const handover = over.get(pull.id);
327 const history = histories.get(pull.id);
328 const crew = reviewers.get(repo.id) ?? [];
329 const stage = history === undefined ? undefined : stageOf(history, now);
330 const person: ReviewWait | undefined =
331 handover !== undefined
332 ? { why: "rounds", since: handover.at }
333 : history === undefined
334 ? undefined
335 : crew.length === 0
336 ? { why: "no reviewer", since: history.greenAt }
337 : stage === "lapsed"
338 ? {
339 why: "lapsed",
340 since: history.greenAt,
341 ...(history.lateAt === undefined
342 ? {}
343 : { lateCheckMinutes: history.checkMinutes }),
344 }
345 : undefined;
346 const sent = history?.attempts.at(-1)?.reviewerId;
347 const reviewer =
348 crew.length === 0 || handover !== undefined || history === undefined || stage === "lapsed"
349 ? undefined
350 : (crew.find((r) => r.user.id === sent) ?? crew[0]!).user.username;
351 out.set(pull.id, { reviewer, person, history });
352 }
353 return out;
354 }
355
356 /**
357 * Whether the crew's review loop on this agent pull is spent: the one rule
358 * is `handoverOf` (`guard.ts`), so the card, the thread note and the held
359 * state cannot answer differently.
360 */
361 function handedOver(db: Database, repoId: string, pull: Pull): Handover | undefined {
362 if (!pull.authorIsAgent) return undefined;
363 const repo = getRepoById(db, repoId);
364 return repo ? handoverOf(db, repo, pull.id) : undefined;
279365 }
280366
281367 /**
src/web/studio/asks.ts +72 −23
@@ -15,7 +15,7 @@ import { waitingFor, type WaitingFor } from "../../core/waiting_for.ts";
1515 import { crewForRepos } from "../../core/agents.ts";
1616 import { capacityDefault, isCapacityKey, storedCapacity } from "../../core/capacity.ts";
1717 import { appsForRepos } from "../../core/apps.ts";
18 import { latestDecision, stopStands } from "../../core/decisions.ts";
18 import { latestDecision, latestDecisionsFor, stopStands } from "../../core/decisions.ts";
1919 import { getHold, holdsFor } from "../../core/holds.ts";
2020 import { issueTitles, proposalsFor, type Proposal } from "../../core/proposals.ts";
2121 import { chipsOf, type Chips } from "../../core/feed_chips.ts";
@@ -42,14 +42,13 @@ import {
4242 ownerFactRepoIds,
4343 type Repo,
4444 } from "../../core/repos.ts";
45 import { listReviews } from "../../core/reviews.ts";
4645 import {
4746 activeRunsFor,
48 automaticRunsSince,
47 automaticRunsSinceFor,
4948 latestRunsForIssues,
5049 type Run,
5150 } from "../../core/runs.ts";
52 import { getUserById, type User } from "../../core/users.ts";
51 import { getUserById, getUsersByIds, type User } from "../../core/users.ts";
5352 import {
5453 isPausedAtCeiling,
5554 plannerDeclines,
@@ -59,7 +58,7 @@ import { breakerParked, WAITING_NOTES } from "../../crew/breaker.ts";
5958 import { CHECK_FAILED_LIMIT } from "../../crew/dispatch.ts";
6059 import { handoverLines, handoversOf, type Handover } from "../../crew/guard.ts";
6160 import { conflictsOf, isStalePull, mergesAgainSince, staleGreen } from "../../crew/stale_asks.ts";
62 import { reviewAttempts, waitsForPerson, waitsForReviewer, type ReviewWait } from "../../crew/review.ts";
61 import { reviewWaitsFor, type ReviewWait } from "../../crew/review.ts";
6362 import type { Ctx } from "../../http/context.ts";
6463 import { repoBase } from "../chrome.ts";
6564 import { html, type Html } from "../html.ts";
@@ -238,16 +237,32 @@ export const KIND_TIER: Readonly<Record<DecisionKind, Consequence>> = {
238237 setting: { score: 35, why: "a setting away from its default needs your word" },
239238 };
240239
241 /** When a person last spoke on a conversation, or its start. */
242 function lastHumanWord(db: Database, issue: Issue, agentName: string): string {
243 let latest = issue.createdAt;
244 for (const c of listComments(db, issue.id))
245 if (c.authorName !== agentName && c.createdAt > latest)
246 latest = c.createdAt;
247 for (const r of listReviews(db, issue.id))
248 if (r.authorName !== agentName && r.createdAt > latest)
249 latest = r.createdAt;
250 return latest;
240 /**
241 * When a person last spoke on each of these conversations, or its start,
242 * by issue: anyone but its author, in a comment or a review — one query
243 * per chunk each, never a read per conversation.
244 */
245 function lastHumanWords(db: Database, issues: readonly Issue[]): Map<string, string> {
246 const out = new Map(issues.map((i) => [i.id, i.createdAt]));
247 const author = new Map(issues.map((i) => [i.id, i.authorName]));
248 const ids = [...out.keys()];
249 const CHUNK = 400;
250 for (let n = 0; n < ids.length; n += CHUNK) {
251 const batch = ids.slice(n, n + CHUNK);
252 const marks = batch.map((_, k) => `?${k + 1}`).join(",");
253 for (const table of ["comment", "review"] as const)
254 for (const row of db
255 .query<{ issue_id: string; name: string; at: string }, string[]>(
256 `SELECT ${table}.issue_id, user.username AS name, MAX(${table}.created_at) AS at
257 FROM ${table} JOIN user ON user.id = ${table}.author_id
258 WHERE ${table}.issue_id IN (${marks})
259 GROUP BY ${table}.issue_id, user.username`,
260 )
261 .all(...batch))
262 if (row.name !== author.get(row.issue_id) && row.at > out.get(row.issue_id)!)
263 out.set(row.issue_id, row.at);
264 }
265 return out;
251266 }
252267
253268 /**
@@ -331,6 +346,32 @@ function askCandidatesFor(
331346 db,
332347 pulls.map((p) => ({ id: p.id, repo: byId.get(p.repoId)! })),
333348 );
349 // What a pull with a check result reads on its way to a card, read once
350 // for every pull that could reach it: the crew's red pulls' tries and
351 // stops, and the green pulls' wait for review.
352 const settled = pulls.filter(
353 (p) =>
354 joined.has(p.id) &&
355 held.get(p.id)?.heldBy !== user.id &&
356 !working.has(p.id) &&
357 !handedOver.has(p.id),
358 );
359 const red = settled.filter((p) => p.authorIsAgent && checks.get(p.id)?.state === "failed");
360 const spoke = lastHumanWords(db, red);
361 const tried = automaticRunsSinceFor(db, spoke);
362 const retries = latestDecisionsFor(db, red.map((p) => p.id), "retry");
363 const waits = reviewWaitsFor(
364 db,
365 settled.flatMap((p) => {
366 const pull = joined.get(p.id)!;
367 const check = checks.get(p.id);
368 return check?.state === "ok" && !isStalePull(db, pull.id, pull.headOid) && !staleGreen(db, pull, check)
369 ? [{ repo: byId.get(p.repoId)!, pull }]
370 : [];
371 }),
372 new Date(),
373 handedOver,
374 );
334375 for (const issue of pulls) {
335376 const repo = byId.get(issue.repoId)!;
336377 const pull = joined.get(issue.id);
@@ -368,9 +409,8 @@ function askCandidatesFor(
368409 // own machinery — a person's own failing pull never accrues tries,
369410 // so it never earns a "stopped" card; it just stays waiting.
370411 if (!issue.authorIsAgent) continue;
371 const since = lastHumanWord(db, issue, who);
372 const tries = automaticRunsSince(db, issue.id, since);
373 const stopped = stopStands(latestDecision(db, issue.id, "retry"));
412 const tries = tried.get(issue.id) ?? 0;
413 const stopped = stopStands(retries.get(issue.id));
374414 // A repository on its own parks what it cannot finish and moves on;
375415 // only an assisted one stops here for a person.
376416 if (
@@ -399,7 +439,7 @@ function askCandidatesFor(
399439 ) {
400440 // An agent's green pull waits for its reviewer first: it is the
401441 // crew's to wait on, and reaches the owner with the verdict (#1602).
402 if (waitsForReviewer(db, repo.id, pull) !== undefined) continue;
442 if (waits.get(pull.id)?.reviewer !== undefined) continue;
403443 // Autonomous repositories look at their own green pulls: the decider,
404444 // then the crew's reviewer. Everything else waits for a person —
405445 // whoever opened it, since a green change asks to be looked at
@@ -425,11 +465,11 @@ function askCandidatesFor(
425465 // An autonomous repository lands its own green pulls once reviewed;
426466 // an agent's change with no reviewer, or whose review lapsed, waits
427467 // for a person — and so asks one (#597).
428 const wait = waitsForPerson(db, repo.id, pull);
468 const wait = waits.get(pull.id)?.person;
429469 // A review nobody answered is the sweep's to send, whatever its age
430470 // (`sendToReview`); every other wait is still a person's.
431471 const resent =
432 wait?.why === "lapsed" && reviewAttempts(db, pull)?.attempts.length === 0;
472 wait?.why === "lapsed" && waits.get(pull.id)?.history?.attempts.length === 0;
433473 if (wait !== undefined && !resent)
434474 out.push({
435475 kind: "review",
@@ -554,10 +594,19 @@ function consequencesOf(
554594 ): Map<string, Consequence> {
555595 // What a pull says it closes is its author's free text: it counts only
556596 // when the author could have written the issue's labels themselves.
597 // A person's pull reads its author once for the set, and whether they
598 // write here once per repository and author.
599 const authors = getUsersByIds(
600 db,
601 asks.flatMap((a) => (a.pull && !a.pull.authorIsAgent ? [a.pull.authorId] : [])),
602 );
603 const writes = new Map<string, boolean>();
557604 const trusted = (pull: Pull, repo: Repo): number[] => {
558605 if (!pull.authorIsAgent) {
559 const author = getUserById(db, pull.authorId);
560 if (author === undefined || !canWrite(db, repo, author)) return [];
606 const author = authors.get(pull.authorId);
607 const key = `${repo.id}:${pull.authorId}`;
608 if (author !== undefined && !writes.has(key)) writes.set(key, canWrite(db, repo, author));
609 if (author === undefined || !writes.get(key)) return [];
561610 }
562611 return closesFromBody(pull.body);
563612 };
test/asks-counts.test.ts +123 −14
@@ -15,13 +15,25 @@ import { mkdtemp, rm } from "node:fs/promises";
1515 import { tmpdir } from "node:os";
1616 import { join } from "node:path";
1717 import { createApp, type App } from "../src/app.ts";
18 import { addCrew, setCrewRole } from "../src/core/agents.ts";
18 import { addCrew, createAgentRow, setCrewRole } from "../src/core/agents.ts";
1919 import { createApp as createAppRecord } from "../src/core/apps.ts";
2020 import { createRepo, type Repo } from "../src/core/repos.ts";
2121 import { submitReview } from "../src/core/reviews.ts";
22 import { activeRunFor, activeRunsFor, createRun } from "../src/core/runs.ts";
22 import {
23 activeRunFor,
24 activeRunsFor,
25 automaticRunsSince,
26 automaticRunsSinceFor,
27 createRun,
28 } from "../src/core/runs.ts";
2329 import { createSession } from "../src/core/sessions.ts";
2430 import { createUser, type User } from "../src/core/users.ts";
31 import { latestDecision, latestDecisionsFor, recordDecision } from "../src/core/decisions.ts";
32 import { addComment, getIssueById } from "../src/core/issues.ts";
33 import { joinMany } from "../src/core/pulls.ts";
34 import { reviewSummariesFor, reviewSummary } from "../src/core/reviews.ts";
35 import { reviewAttempts, reviewWaitsFor, waitsForPerson, waitsForReviewer } from "../src/crew/review.ts";
36 import { CHECK_FAILED_LIMIT } from "../src/crew/dispatch.ts";
2537 import { handoverOf, handoversOf } from "../src/crew/guard.ts";
2638 import { serve, type Servers } from "../src/http/server.ts";
2739 import { id } from "../src/kernel/id.ts";
@@ -68,6 +80,8 @@ describe("home's asks are read grouped, and once", () => {
6880 : { password: "a-good-passphrase", origin: "seed" as const, trusted: true }),
6981 });
7082 if (!made.ok) throw new Error(`seed ${username}: ${made.error}`);
83 // A crew member is an agent row too: without one, nobody reviews.
84 if (agent) createAgentRow(app.db, { userId: made.value.id, brief: `${username}.` });
7185 return made.value;
7286 };
7387
@@ -87,7 +101,31 @@ describe("home's asks are read grouped, and once", () => {
87101
88102 const check = (on: Repo, issueId: string, state: "ok" | "failed") => {
89103 const run = createRun(app.db, { repoId: on.id, kind: "check", issueId, commitOid: ZERO });
90 app.db.query("UPDATE run SET state = ?2 WHERE id = ?1").run(run.id, state);
104 app.db
105 .query("UPDATE run SET state = ?2, finished_at = ?3 WHERE id = ?1")
106 .run(run.id, state, new Date().toISOString());
107 };
108
109 /** A finished automatic follow-up on a red pull: one of the tries the limit counts. */
110 const retried = (on: Repo, issueId: string) => {
111 const run = createRun(app.db, {
112 repoId: on.id,
113 kind: "agent",
114 issueId,
115 actorId: builder.id,
116 reason: "check_failed",
117 });
118 app.db.query("UPDATE run SET state = 'failed' WHERE id = ?1").run(run.id);
119 };
120
121 /** `minutes` ago: what was seeded before now, so a `>` never ties within one millisecond. */
122 const ago = (minutes: number) => new Date(Date.now() - minutes * 60_000).toISOString();
123
124 /** A person's word on a pull, a minute ago: after it opened, before what follows. */
125 const spoke = (issueId: string, body: string) => {
126 const made = addComment(app.db, { issueId, authorId: owner.id, body });
127 if (!made.ok) throw new Error(`seed comment: ${made.error}`);
128 app.db.query("UPDATE comment SET created_at = ?2 WHERE id = ?1").run(made.value.id, ago(1));
91129 };
92130
93131 /** An open pull on `on` by `author`, as rows: its id and number. */
@@ -100,10 +138,10 @@ describe("home's asks are read grouped, and once", () => {
100138 .get(on.id)!.issue_seq;
101139 app.db
102140 .query(
103 `INSERT INTO issue (id, repo_id, number, kind, title, body, author_id, state)
104 VALUES (?1, ?2, ?3, 'pull', ?4, '', ?5, 'open')`,
141 `INSERT INTO issue (id, repo_id, number, kind, title, body, author_id, state, created_at)
142 VALUES (?1, ?2, ?3, 'pull', ?4, '', ?5, 'open', ?6)`,
105143 )
106 .run(issueId, on.id, number, title, author.id);
144 .run(issueId, on.id, number, title, author.id, ago(2));
107145 app.db
108146 .query(
109147 `INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, head_repo_id)
@@ -125,19 +163,47 @@ describe("home's asks are read grouped, and once", () => {
125163 };
126164
127165 /**
128 * One open pull in each state that reaches the reads grouped here. A
129 * pull with a check result reads more, per pull, on its way to a card
130 * (staleness, the reviewer's wait, the retry count): that is #2772's
131 * follow-up, not measured here.
166 * One open pull in each state that reaches a read grouped here: quiet,
167 * worked on, reviewed once, red (with a person's word and the crew's
168 * tries since), green — a person's and the crew's, assisted and
169 * autonomous — and handed over after three rounds (#2781).
132170 */
133171 const seedRound = () => {
134 const states = ["quiet", "working", "reviewed", "autonomous-quiet"] as const;
172 const states = [
173 "quiet",
174 "working",
175 "reviewed",
176 "failed",
177 "stopped",
178 "green-person",
179 "green-crew",
180 "handed",
181 "autonomous-quiet",
182 "autonomous-failed",
183 "autonomous-green-person",
184 "autonomous-green-crew",
185 ] as const;
135186 for (const state of states) {
136187 const on = state.startsWith("autonomous") ? autonomous : assisted;
137 const { issueId } = filePull(on, builder, `Pull (${state})`);
188 const author = state.endsWith("person") ? owner : builder;
189 const { issueId } = filePull(on, author, `Pull (${state})`);
138190 if (state === "working")
139191 createRun(app.db, { repoId: on.id, kind: "agent", issueId, actorId: builder.id });
140192 if (state === "reviewed") changesRequested(issueId, 1);
193 if (state === "handed") {
194 spoke(issueId, "Have a look.");
195 changesRequested(issueId, 3);
196 }
197 if (state.endsWith("failed") || state === "stopped") {
198 spoke(issueId, "Try again.");
199 check(on, issueId, "failed");
200 }
201 if (state === "stopped") for (let n = 0; n < CHECK_FAILED_LIMIT; n++) retried(on, issueId);
202 // A stop a person later lifted: only the newest decision counts.
203 if (state === "failed")
204 for (const answer of ["stop", "go"])
205 recordDecision(app.db, { kind: "retry", repoId: on.id, issueId, state: "by hand", answer, p: 1, by: owner.id, ms: 0 });
206 if (state.includes("green")) check(on, issueId, "ok");
141207 }
142208 };
143209
@@ -176,12 +242,26 @@ describe("home's asks are read grouped, and once", () => {
176242 expect(many).toBe(few);
177243 });
178244
179 test("one request reads the open pulls' active runs and hand-overs once", async () => {
245 test("the seed reaches the cards a pull with a check result asks for", async () => {
246 const page = await home();
247 for (const state of ["stopped", "green-person", "handed"]) expect(page).toContain(`Pull (${state})`);
248 expect(page).not.toContain("Pull (failed)");
249 });
250
251 test("one request reads the open pulls' runs, hand-overs, verdicts, tries and reviewers once", async () => {
180252 const sql = await seen(app.db, async () => void (await home()));
181253 const active = sql.filter((s) => s.includes("state IN ('queued','running')") && s.includes("issue_id IN"));
182 const handovers = sql.filter((s) => s.startsWith("SELECT r.issue_id") && s.includes("changes_requested"));
254 const handovers = sql.filter((s) => s.startsWith("SELECT r.issue_id AS issueId, COUNT(*)"));
255 const rounds = sql.filter((s) => s.startsWith("SELECT r.issue_id AS issueId, r.id AS id"));
256 const verdicts = sql.filter((s) => s.startsWith("SELECT review.issue_id, review.id"));
257 const tries = sql.filter((s) => s.includes("reason IN ('check_failed','conflict')"));
258 const reviewers = sql.filter((s) => s.includes("WHERE repo_crew.role = ?1"));
183259 expect(active.length).toBe(1);
184260 expect(handovers.length).toBe(1);
261 expect(rounds.length).toBe(1);
262 expect(verdicts.length).toBe(1);
263 expect(tries.length).toBe(1);
264 expect(reviewers.length).toBe(1);
185265 });
186266
187267 test("the grouped reads agree with the singular ones", () => {
@@ -203,6 +283,35 @@ describe("home's asks are read grouped, and once", () => {
203283 expect(active.size).toBeGreaterThan(0);
204284 });
205285
286 test("the grouped reads of a pull with a check result agree with the singular ones", () => {
287 const rows = app.db
288 .query<{ id: string; repoId: string }, []>(
289 "SELECT id, repo_id AS repoId FROM issue WHERE kind = 'pull' AND state = 'open'",
290 )
291 .all();
292 const repoOf = (repoId: string) => (repoId === assisted.id ? assisted : autonomous);
293 const pulls = joinMany(app.db, rows.map((r) => getIssueById(app.db, r.id)!)).map((pull) => ({
294 repo: repoOf(pull.repoId),
295 pull,
296 }));
297 const since = new Map(rows.map((r) => [r.id, getIssueById(app.db, r.id)!.createdAt]));
298 const tries = automaticRunsSinceFor(app.db, since);
299 const retries = latestDecisionsFor(app.db, rows.map((r) => r.id), "retry");
300 const seen = reviewSummariesFor(app.db, pulls.map((p) => p.pull));
301 const waits = reviewWaitsFor(app.db, pulls);
302 for (const { repo, pull } of pulls) {
303 expect(tries.get(pull.id) ?? 0).toBe(automaticRunsSince(app.db, pull.id, since.get(pull.id)!));
304 expect(retries.get(pull.id)).toEqual(latestDecision(app.db, pull.id, "retry"));
305 expect(seen.get(pull.id)).toEqual(reviewSummary(app.db, pull.id, pull.headOid));
306 expect(waits.get(pull.id)?.person).toEqual(waitsForPerson(app.db, repo.id, pull));
307 expect(waits.get(pull.id)?.reviewer).toEqual(waitsForReviewer(app.db, repo.id, pull));
308 if (waits.has(pull.id)) expect(waits.get(pull.id)?.history).toEqual(reviewAttempts(app.db, pull));
309 }
310 expect([...tries.values()].some((n) => n >= CHECK_FAILED_LIMIT)).toBe(true);
311 expect([...retries.values()].every((d) => d.answer === "go")).toBe(true);
312 expect([...waits.values()].some((w) => w.reviewer !== undefined)).toBe(true);
313 });
314
206315 test("a green pull on an app still offers its preview", async () => {
207316 const green = filePull(assisted, owner, "Green and waiting");
208317 check(assisted, green.issueId, "ok");

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs