Public: anyone can read this; the crew can change it.
Asks still read per pull once a pull has a check result #2789
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/2781-asks-still-read-per-pull-once-a-pull-has → main 9 commits fork
9 files
changed +657
−197
src/core/agents.ts +46 −2
| @@ -9,7 +9,7 @@ | ||
| 9 | 9 | |
| 10 | 10 | import type { Database } from "bun:sqlite"; |
| 11 | 11 | import { limitValue } from "../settings/limits.ts"; |
| 12 | import { getUserById, type User } from "./users.ts"; | |
| 12 | import { getUserById, getUsersByIds, type User } from "./users.ts"; | |
| 13 | 13 | |
| 14 | 14 | export interface Agent { |
| 15 | 15 | readonly user: User; |
| @@ -37,7 +37,10 @@ export const BRIEF_MAX = 4_000; | ||
| 37 | 37 | |
| 38 | 38 | function toAgent(db: Database, row: AgentRow): Agent | undefined { |
| 39 | 39 | 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 { | |
| 41 | 44 | return { |
| 42 | 45 | user, |
| 43 | 46 | model: row.model, |
| @@ -335,6 +338,47 @@ export function listCrewRoles( | ||
| 335 | 338 | })); |
| 336 | 339 | } |
| 337 | 340 | |
| 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 | ||
| 338 | 382 | /** |
| 339 | 383 | * Which of these repositories have a builder on their crew, as a set — a |
| 340 | 384 | * 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( | ||
| 173 | 173 | return row ? toDecision(row) : undefined; |
| 174 | 174 | } |
| 175 | 175 | |
| 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 | ||
| 176 | 201 | /** |
| 177 | 202 | * Whether a `retry` decision still stops its pull. The crew's stop is its |
| 178 | 203 | * 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 { | ||
| 131 | 131 | .get(pullId)?.at ?? undefined |
| 132 | 132 | ); |
| 133 | 133 | } |
| 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( | ||
| 337 | 337 | db: Database, |
| 338 | 338 | issueId: string, |
| 339 | 339 | ): Map<string, Review> { |
| 340 | return latestByAuthor(listReviews(db, issueId)); | |
| 341 | } | |
| 342 | ||
| 343 | function latestByAuthor(reviews: readonly Review[]): Map<string, Review> { | |
| 340 | 344 | 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); | |
| 344 | 346 | return latest; |
| 345 | 347 | } |
| 346 | 348 | |
| @@ -364,12 +366,72 @@ export function reviewSummary( | ||
| 364 | 366 | issueId: string, |
| 365 | 367 | headOid: string, |
| 366 | 368 | ): 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 { | |
| 368 | 430 | let approvals = 0; |
| 369 | 431 | let changesRequested = 0; |
| 370 | 432 | let commented = 0; |
| 371 | 433 | let stale = 0; |
| 372 | for (const review of latestReviewByAuthor(db, issueId).values()) { | |
| 434 | for (const review of latestByAuthor(reviews).values()) { | |
| 373 | 435 | if (review.commitOid !== headOid) { |
| 374 | 436 | if (review.state !== "commented") stale++; |
| 375 | 437 | continue; |
| @@ -407,27 +469,43 @@ export const REVIEW_ROUNDS = 2; | ||
| 407 | 469 | * pull: whether each is a reviewer on the pull's crew now, and on how many |
| 408 | 470 | * heads other than a given one an agent asked for changes. |
| 409 | 471 | */ |
| 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 { | |
| 431 | 509 | const authors = new Map( |
| 432 | 510 | rows.map((r) => [r.author_id, { reviewer: r.role === "reviewer" }]), |
| 433 | 511 | ); |
src/core/runs.ts +27
| @@ -898,6 +898,33 @@ export function automaticRunsSince( | ||
| 898 | 898 | return row?.n ?? 0; |
| 899 | 899 | } |
| 900 | 900 | |
| 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 | ||
| 901 | 928 | /** What an agent has done, in totals: the number a person weighs a crew by. */ |
| 902 | 929 | export interface AgentTotals { |
| 903 | 930 | readonly runs: number; |
src/crew/guard.ts +66 −45
| @@ -280,7 +280,7 @@ export function handoverOf( | ||
| 280 | 280 | ) |
| 281 | 281 | .get(pullId, repo.id)!.n; |
| 282 | 282 | if (crewAsked < HANDOVER_ROUNDS) return undefined; |
| 283 | return handoverSince(db, repo, pullId); | |
| 283 | return handoversSince(db, [{ id: pullId, repo }]).get(pullId); | |
| 284 | 284 | } |
| 285 | 285 | |
| 286 | 286 | /** Pulls `handoversOf` counts per query: well under SQLite's bound on variables. */ |
| @@ -310,61 +310,82 @@ export function handoversOf( | ||
| 310 | 310 | GROUP BY r.issue_id`, |
| 311 | 311 | ) |
| 312 | 312 | .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); | |
| 318 | 317 | } |
| 319 | 318 | return out; |
| 320 | 319 | } |
| 321 | 320 | |
| 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( | |
| 324 | 328 | 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( | |
| 329 | 336 | 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`, | |
| 332 | 341 | ) |
| 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]), | |
| 348 | 344 | ); |
| 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 | |
| 359 | 372 | 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' | |
| 363 | 377 | ORDER BY r.created_at, r.id`, |
| 364 | 378 | ) |
| 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; | |
| 368 | 389 | } |
| 369 | 390 | |
| 370 | 391 | /** 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 @@ | ||
| 19 | 19 | |
| 20 | 20 | import type { Database } from "bun:sqlite"; |
| 21 | 21 | 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"; | |
| 24 | 24 | import { getIssueById, LIST_LIMIT_MAX } from "../core/issues.ts"; |
| 25 | 25 | import { listPulls, pullDiff, type Pull, pullDir} from "../core/pulls.ts"; |
| 26 | 26 | import { getRepoById, type Repo } from "../core/repos.ts"; |
| 27 | import { reviewSummary } from "../core/reviews.ts"; | |
| 27 | import { reviewSummariesFor, reviewSummary } from "../core/reviews.ts"; | |
| 28 | 28 | import { checksForHead } from "../core/runs.ts"; |
| 29 | 29 | import { repoDir } from "../git/repos.ts"; |
| 30 | 30 | import { createLog } from "../kernel/log.ts"; |
| 31 | 31 | import { queueAgentRun } from "./dispatch.ts"; |
| 32 | import { type Handover, handoverOf } from "./guard.ts"; | |
| 32 | import { type Handover, handoverOf, handoversOf } from "./guard.ts"; | |
| 33 | 33 | |
| 34 | 34 | const log = createLog("crew"); |
| 35 | 35 | |
| @@ -64,9 +64,11 @@ export function pickReviewer( | ||
| 64 | 64 | |
| 65 | 65 | /** The crew's reviewers on a repository. */ |
| 66 | 66 | 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"); | |
| 70 | 72 | |
| 71 | 73 | /** Whether every check on the pull's head ran and passed. */ |
| 72 | 74 | export function greenHead(db: Database, pull: Pull): boolean { |
| @@ -129,48 +131,80 @@ export function reviewAttempts( | ||
| 129 | 131 | db: Database, |
| 130 | 132 | pull: Pull, |
| 131 | 133 | ): 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')`, | |
| 139 | 160 | ) |
| 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; | |
| 174 | 208 | } |
| 175 | 209 | |
| 176 | 210 | /** What the run table says about review on a head. */ |
| @@ -198,17 +232,6 @@ export function stageOf( | ||
| 198 | 232 | return reviewStage(history.lateAt ?? history.greenAt, history.attempts, now); |
| 199 | 233 | } |
| 200 | 234 | |
| 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 | ||
| 212 | 235 | /** Why an agent's green pull waits for a person, and since when. */ |
| 213 | 236 | export interface ReviewWait { |
| 214 | 237 | readonly why: "no reviewer" | "lapsed" | "rounds"; |
| @@ -218,6 +241,16 @@ export interface ReviewWait { | ||
| 218 | 241 | readonly lateCheckMinutes?: number; |
| 219 | 242 | } |
| 220 | 243 | |
| 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 | ||
| 221 | 254 | /** |
| 222 | 255 | * Whether this agent's green pull now waits for a person's review: its |
| 223 | 256 | * crew has no reviewer, or the review it was sent lapsed (asked twice, or |
| @@ -232,24 +265,7 @@ export function waitsForPerson( | ||
| 232 | 265 | pull: Pull, |
| 233 | 266 | now = new Date(), |
| 234 | 267 | ): 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; | |
| 253 | 269 | } |
| 254 | 270 | |
| 255 | 271 | /** |
| @@ -266,16 +282,86 @@ export function waitsForReviewer( | ||
| 266 | 282 | pull: Pull, |
| 267 | 283 | now = new Date(), |
| 268 | 284 | ): 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; | |
| 279 | 365 | } |
| 280 | 366 | |
| 281 | 367 | /** |
src/web/studio/asks.ts +72 −23
| @@ -15,7 +15,7 @@ import { waitingFor, type WaitingFor } from "../../core/waiting_for.ts"; | ||
| 15 | 15 | import { crewForRepos } from "../../core/agents.ts"; |
| 16 | 16 | import { capacityDefault, isCapacityKey, storedCapacity } from "../../core/capacity.ts"; |
| 17 | 17 | import { appsForRepos } from "../../core/apps.ts"; |
| 18 | import { latestDecision, stopStands } from "../../core/decisions.ts"; | |
| 18 | import { latestDecision, latestDecisionsFor, stopStands } from "../../core/decisions.ts"; | |
| 19 | 19 | import { getHold, holdsFor } from "../../core/holds.ts"; |
| 20 | 20 | import { issueTitles, proposalsFor, type Proposal } from "../../core/proposals.ts"; |
| 21 | 21 | import { chipsOf, type Chips } from "../../core/feed_chips.ts"; |
| @@ -42,14 +42,13 @@ import { | ||
| 42 | 42 | ownerFactRepoIds, |
| 43 | 43 | type Repo, |
| 44 | 44 | } from "../../core/repos.ts"; |
| 45 | import { listReviews } from "../../core/reviews.ts"; | |
| 46 | 45 | import { |
| 47 | 46 | activeRunsFor, |
| 48 | automaticRunsSince, | |
| 47 | automaticRunsSinceFor, | |
| 49 | 48 | latestRunsForIssues, |
| 50 | 49 | type Run, |
| 51 | 50 | } from "../../core/runs.ts"; |
| 52 | import { getUserById, type User } from "../../core/users.ts"; | |
| 51 | import { getUserById, getUsersByIds, type User } from "../../core/users.ts"; | |
| 53 | 52 | import { |
| 54 | 53 | isPausedAtCeiling, |
| 55 | 54 | plannerDeclines, |
| @@ -59,7 +58,7 @@ import { breakerParked, WAITING_NOTES } from "../../crew/breaker.ts"; | ||
| 59 | 58 | import { CHECK_FAILED_LIMIT } from "../../crew/dispatch.ts"; |
| 60 | 59 | import { handoverLines, handoversOf, type Handover } from "../../crew/guard.ts"; |
| 61 | 60 | 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"; | |
| 63 | 62 | import type { Ctx } from "../../http/context.ts"; |
| 64 | 63 | import { repoBase } from "../chrome.ts"; |
| 65 | 64 | import { html, type Html } from "../html.ts"; |
| @@ -238,16 +237,32 @@ export const KIND_TIER: Readonly<Record<DecisionKind, Consequence>> = { | ||
| 238 | 237 | setting: { score: 35, why: "a setting away from its default needs your word" }, |
| 239 | 238 | }; |
| 240 | 239 | |
| 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; | |
| 251 | 266 | } |
| 252 | 267 | |
| 253 | 268 | /** |
| @@ -331,6 +346,32 @@ function askCandidatesFor( | ||
| 331 | 346 | db, |
| 332 | 347 | pulls.map((p) => ({ id: p.id, repo: byId.get(p.repoId)! })), |
| 333 | 348 | ); |
| 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 | ); | |
| 334 | 375 | for (const issue of pulls) { |
| 335 | 376 | const repo = byId.get(issue.repoId)!; |
| 336 | 377 | const pull = joined.get(issue.id); |
| @@ -368,9 +409,8 @@ function askCandidatesFor( | ||
| 368 | 409 | // own machinery — a person's own failing pull never accrues tries, |
| 369 | 410 | // so it never earns a "stopped" card; it just stays waiting. |
| 370 | 411 | 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)); | |
| 374 | 414 | // A repository on its own parks what it cannot finish and moves on; |
| 375 | 415 | // only an assisted one stops here for a person. |
| 376 | 416 | if ( |
| @@ -399,7 +439,7 @@ function askCandidatesFor( | ||
| 399 | 439 | ) { |
| 400 | 440 | // An agent's green pull waits for its reviewer first: it is the |
| 401 | 441 | // 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; | |
| 403 | 443 | // Autonomous repositories look at their own green pulls: the decider, |
| 404 | 444 | // then the crew's reviewer. Everything else waits for a person — |
| 405 | 445 | // whoever opened it, since a green change asks to be looked at |
| @@ -425,11 +465,11 @@ function askCandidatesFor( | ||
| 425 | 465 | // An autonomous repository lands its own green pulls once reviewed; |
| 426 | 466 | // an agent's change with no reviewer, or whose review lapsed, waits |
| 427 | 467 | // for a person — and so asks one (#597). |
| 428 | const wait = waitsForPerson(db, repo.id, pull); | |
| 468 | const wait = waits.get(pull.id)?.person; | |
| 429 | 469 | // A review nobody answered is the sweep's to send, whatever its age |
| 430 | 470 | // (`sendToReview`); every other wait is still a person's. |
| 431 | 471 | const resent = |
| 432 | wait?.why === "lapsed" && reviewAttempts(db, pull)?.attempts.length === 0; | |
| 472 | wait?.why === "lapsed" && waits.get(pull.id)?.history?.attempts.length === 0; | |
| 433 | 473 | if (wait !== undefined && !resent) |
| 434 | 474 | out.push({ |
| 435 | 475 | kind: "review", |
| @@ -554,10 +594,19 @@ function consequencesOf( | ||
| 554 | 594 | ): Map<string, Consequence> { |
| 555 | 595 | // What a pull says it closes is its author's free text: it counts only |
| 556 | 596 | // 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>(); | |
| 557 | 604 | const trusted = (pull: Pull, repo: Repo): number[] => { |
| 558 | 605 | 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 []; | |
| 561 | 610 | } |
| 562 | 611 | return closesFromBody(pull.body); |
| 563 | 612 | }; |
test/asks-counts.test.ts +123 −14
| @@ -15,13 +15,25 @@ import { mkdtemp, rm } from "node:fs/promises"; | ||
| 15 | 15 | import { tmpdir } from "node:os"; |
| 16 | 16 | import { join } from "node:path"; |
| 17 | 17 | 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"; | |
| 19 | 19 | import { createApp as createAppRecord } from "../src/core/apps.ts"; |
| 20 | 20 | import { createRepo, type Repo } from "../src/core/repos.ts"; |
| 21 | 21 | 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"; | |
| 23 | 29 | import { createSession } from "../src/core/sessions.ts"; |
| 24 | 30 | 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"; | |
| 25 | 37 | import { handoverOf, handoversOf } from "../src/crew/guard.ts"; |
| 26 | 38 | import { serve, type Servers } from "../src/http/server.ts"; |
| 27 | 39 | import { id } from "../src/kernel/id.ts"; |
| @@ -68,6 +80,8 @@ describe("home's asks are read grouped, and once", () => { | ||
| 68 | 80 | : { password: "a-good-passphrase", origin: "seed" as const, trusted: true }), |
| 69 | 81 | }); |
| 70 | 82 | 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}.` }); | |
| 71 | 85 | return made.value; |
| 72 | 86 | }; |
| 73 | 87 | |
| @@ -87,7 +101,31 @@ describe("home's asks are read grouped, and once", () => { | ||
| 87 | 101 | |
| 88 | 102 | const check = (on: Repo, issueId: string, state: "ok" | "failed") => { |
| 89 | 103 | 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)); | |
| 91 | 129 | }; |
| 92 | 130 | |
| 93 | 131 | /** 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", () => { | ||
| 100 | 138 | .get(on.id)!.issue_seq; |
| 101 | 139 | app.db |
| 102 | 140 | .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)`, | |
| 105 | 143 | ) |
| 106 | .run(issueId, on.id, number, title, author.id); | |
| 144 | .run(issueId, on.id, number, title, author.id, ago(2)); | |
| 107 | 145 | app.db |
| 108 | 146 | .query( |
| 109 | 147 | `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", () => { | ||
| 125 | 163 | }; |
| 126 | 164 | |
| 127 | 165 | /** |
| 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). | |
| 132 | 170 | */ |
| 133 | 171 | 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; | |
| 135 | 186 | for (const state of states) { |
| 136 | 187 | 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})`); | |
| 138 | 190 | if (state === "working") |
| 139 | 191 | createRun(app.db, { repoId: on.id, kind: "agent", issueId, actorId: builder.id }); |
| 140 | 192 | 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"); | |
| 141 | 207 | } |
| 142 | 208 | }; |
| 143 | 209 | |
| @@ -176,12 +242,26 @@ describe("home's asks are read grouped, and once", () => { | ||
| 176 | 242 | expect(many).toBe(few); |
| 177 | 243 | }); |
| 178 | 244 | |
| 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 () => { | |
| 180 | 252 | const sql = await seen(app.db, async () => void (await home())); |
| 181 | 253 | 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")); | |
| 183 | 259 | expect(active.length).toBe(1); |
| 184 | 260 | 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); | |
| 185 | 265 | }); |
| 186 | 266 | |
| 187 | 267 | test("the grouped reads agree with the singular ones", () => { |
| @@ -203,6 +283,35 @@ describe("home's asks are read grouped, and once", () => { | ||
| 203 | 283 | expect(active.size).toBeGreaterThan(0); |
| 204 | 284 | }); |
| 205 | 285 | |
| 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 | ||
| 206 | 315 | test("a green pull on an app still offers its preview", async () => { |
| 207 | 316 | const green = filePull(assisted, owner, "Green and waiting"); |
| 208 | 317 | check(assisted, green.issueId, "ok"); |
new above
offline