Public: anyone can read this; the crew can change it.
Reviews are the bottleneck: reviewer runs fail on review anchors, count as failed for pushing nothing, and hold slots #552
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/487-reviews-are-the-bottleneck-reviewer-runs → main 11 commits fork
18 files
changed +1318
−65
docs/ORIGINS.md +7 −1
| @@ -466,7 +466,7 @@ Review states and threads, on any repo: | ||
| 466 | 466 | |
| 467 | 467 | |
| 468 | 468 | GET /api/v1/repos/:o/:n/pulls/:n/review-states the verdicts |
| 469 | POST /api/v1/repos/:o/:n/pulls/:n/review-states {state, body?} → 201 | |
| 469 | POST /api/v1/repos/:o/:n/pulls/:n/review-states {state, body?, comments?} → 201 | |
| 470 | 470 | GET /api/v1/repos/:o/:n/pulls/:n/threads anchors + resolution |
| 471 | 471 | POST /api/v1/repos/:o/:n/pulls/:n/threads/:anchor/resolve |
| 472 | 472 | DELETE /api/v1/repos/:o/:n/pulls/:n/threads/:anchor/resolve |
| @@ -478,6 +478,12 @@ rather than a mode of the same one, because a comment on a line and a verdict | ||
| 478 | 478 | on a change are different things with different rules, and one path meaning |
| 479 | 479 | two of them behind a query parameter is the shape that gets called wrong. |
| 480 | 480 | Each line comment now carries `anchor`, the thread it belongs to. |
| 481 | ||
| 482 | A review that comes with line comments is still one call: `comments` on | |
| 483 | `…/review-states` is a list of `{path, line, side, body}`, at most 50, posted | |
| 484 | before the verdict and all or none — one anchor outside the diff refuses the | |
| 485 | whole review with `bad_anchor`, and the message says how an anchor is written. | |
| 486 | This is the one way the crew's reviewer brief posts a review. | |
| 481 | 487 | `GET …/pulls/:number` gains `status.reviews: |
| 482 | 488 | { approved, changesRequested, required, mergeable }`. |
| 483 | 489 |
docs/PLANNING.md +19 −5
| @@ -134,11 +134,25 @@ asked, under **Settings → Autonomy**: | ||
| 134 | 134 | about a green pull reaches a person: first, a pull that no longer merges |
| 135 | 135 | because `main` moved under it sends its agent one run (`conflict`, naming |
| 136 | 136 | the files) to merge `main` into the branch, one waiting at a time, up to |
| 137 | the same limit a failing check has; then the decider reads the pull | |
| 138 | against the direction and either lands it or hands it to the crew's | |
| 139 | _reviewer_ (`review` run: the diff, the preview, the pictures the forge | |
| 140 | took), whose approval lands it and whose change request goes back to the | |
| 141 | builder as feedback; a crew with no reviewer lands it. Nothing waits for a | |
| 137 | the same limit a failing check has; then the green head goes to the | |
| 138 | crew's _reviewer_ (`review` run: the diff, the preview, the pictures the | |
| 139 | forge took), whose approval lands it and whose change request goes back | |
| 140 | to the builder as feedback; a crew with no reviewer lands it. With two | |
| 141 | reviewers, a change to sign-in, tokens, secrets, the sandbox or the HTTP | |
| 142 | edge goes to the one whose brief says it reads for security, and every | |
| 143 | other change to the other (`crew/review.ts`). Every green pull an agent | |
| 144 | wrote is sent this way in any repository with a reviewer, autonomous or | |
| 145 | not; a review run is `ok` when it posted a verdict, since a reviewer | |
| 146 | pushes nothing. A review that does not come back — the run ends without | |
| 147 | a verdict, or is still out after 20 minutes — is sent once more; after | |
| 148 | the second, or an hour green with no verdict however the runs went, the | |
| 149 | review has lapsed and the pull lands on green with a note saying so. A | |
| 150 | missing reviewer never holds a pull silently. An agent's verdict stands | |
| 151 | only while the agent is a reviewer on the crew, and the reviewer gets two | |
| 152 | rounds of changes on a pull — a round is a head it asked changes on; | |
| 153 | past them its request is advice. A builder's run that answers a change | |
| 154 | request without pushing parks the pull. A review run never resumes an | |
| 155 | earlier session, and the same verdict sent twice on one head is one. Nothing waits for a | |
| 142 | 156 | person: a pull whose check stays red past its tries, or whose branch |
| 143 | 157 | could not be brought up to date, is _parked_ — closed with the reason, |
| 144 | 158 | its issue put back on the map unassigned with the same note — and the |
forge.json +1 −1
| @@ -6,7 +6,7 @@ | ||
| 6 | 6 | "ready": 90 |
| 7 | 7 | }, |
| 8 | 8 | "check": { |
| 9 | "run": "bun x tsc --noEmit && bun scripts/test-shards.ts --timeout 60000 -- crew crew-wrapup crew-limits crew-parks crew-sandbox steer session-settle landing decide ledger checks dependencies map-closing autonomy studio studio-words forks fork-flow cross-repo-pull pulls apps apps-adversarial apps-busy apps-token surfaces-adversarial orgs web git-trust helpers shards migrate vitals guard asks feed feed-page offline share cloud run-secrets forge-cloudflare usage credentials standing board-windows spawn-plan spawn web-apps fleet-ops meter told-by", | |
| 9 | "run": "bun x tsc --noEmit && bun scripts/test-shards.ts --timeout 60000 -- crew review-route review-lines review-gate crew-wrapup crew-limits crew-parks crew-sandbox steer session-settle landing decide ledger checks dependencies map-closing autonomy studio studio-words forks fork-flow cross-repo-pull pulls apps apps-adversarial apps-busy apps-token surfaces-adversarial orgs web git-trust helpers shards migrate vitals guard asks feed feed-page offline share cloud run-secrets forge-cloudflare usage credentials standing board board-windows spawn-plan spawn web-apps fleet-ops meter told-by", | |
| 10 | 10 | "timeout": 1800 |
| 11 | 11 | } |
| 12 | 12 | } |
src/actions/pulls.ts +94 −14
| @@ -13,6 +13,7 @@ import { | ||
| 13 | 13 | type BodyError, |
| 14 | 14 | type Issue, |
| 15 | 15 | getIssue, |
| 16 | MAX_BODY, | |
| 16 | 17 | setIssueState as setIssueStateCore, |
| 17 | 18 | } from "../core/issues.ts"; |
| 18 | 19 | import { |
| @@ -38,6 +39,7 @@ import { | ||
| 38 | 39 | hashLine, |
| 39 | 40 | isReviewState, |
| 40 | 41 | listReviewComments, |
| 42 | listReviews, | |
| 41 | 43 | resolveThread, |
| 42 | 44 | submitReview as recordReview, |
| 43 | 45 | threadComments, |
| @@ -246,29 +248,70 @@ export async function addReview( | ||
| 246 | 248 | const user = requireParticipant(app, actor, repo); |
| 247 | 249 | if (!user.ok) return user; |
| 248 | 250 | if (pull.repoId !== repo.id) return err("not_found"); |
| 249 | if (!Number.isInteger(input.line) || input.line < 1) return err("bad_anchor"); | |
| 251 | const anchored = await anchorLines(app, repo, pull, [input]); | |
| 252 | if (!anchored.ok) return anchored; | |
| 253 | return postLine(app, pull, user.value, anchored.value[0]!); | |
| 254 | } | |
| 255 | ||
| 256 | /** A line comment, with the head and line content it was anchored to. */ | |
| 257 | interface AnchoredLine extends ReviewInput { | |
| 258 | readonly commitOid: string; | |
| 259 | readonly lineHash: string; | |
| 260 | } | |
| 261 | ||
| 262 | /** | |
| 263 | * Each line comment anchored to the pull's current diff, or the first | |
| 264 | * reason one cannot be: a body that is empty or too long, or an anchor | |
| 265 | * that is not a row of the diff. All or none, so a review never lands | |
| 266 | * with half its comments. | |
| 267 | */ | |
| 268 | async function anchorLines( | |
| 269 | app: App, | |
| 270 | repo: Repo, | |
| 271 | pull: Pull, | |
| 272 | lines: readonly ReviewInput[], | |
| 273 | ): Promise<Result<AnchoredLine[], BodyError | "bad_anchor">> { | |
| 250 | 274 | const { status, diff } = await pullDiff( |
| 251 | 275 | repoDir(app.reposRoot, repo.ownerName, repo.name), |
| 252 | 276 | pull, |
| 253 | 277 | ); |
| 254 | const content = anchorContent(diff, input.path, input.side, input.line); | |
| 255 | if (content === undefined) return err("bad_anchor"); | |
| 278 | const out: AnchoredLine[] = []; | |
| 279 | for (const line of lines) { | |
| 280 | if (!Number.isInteger(line.line) || line.line < 1) | |
| 281 | return err("bad_anchor"); | |
| 282 | const content = anchorContent(diff, line.path, line.side, line.line); | |
| 283 | if (content === undefined) return err("bad_anchor"); | |
| 284 | const body = line.body.trim(); | |
| 285 | if (body === "") return err("empty_body"); | |
| 286 | if (body.length > MAX_BODY) return err("body_too_long"); | |
| 287 | out.push({ ...line, commitOid: status.headOid, lineHash: hashLine(content) }); | |
| 288 | } | |
| 289 | return ok(out); | |
| 290 | } | |
| 291 | ||
| 292 | /** Record one anchored line comment and say so on the outbox. */ | |
| 293 | function postLine( | |
| 294 | app: App, | |
| 295 | pull: Pull, | |
| 296 | user: User, | |
| 297 | line: AnchoredLine, | |
| 298 | ): Result<ReviewComment, BodyError> { | |
| 256 | 299 | const added = addReviewComment(app.db, { |
| 257 | 300 | pullId: pull.id, |
| 258 | authorId: user.value.id, | |
| 259 | body: input.body, | |
| 260 | path: input.path, | |
| 261 | line: input.line, | |
| 262 | side: input.side, | |
| 263 | commitOid: status.headOid, | |
| 264 | lineHash: hashLine(content), | |
| 301 | authorId: user.id, | |
| 302 | body: line.body, | |
| 303 | path: line.path, | |
| 304 | line: line.line, | |
| 305 | side: line.side, | |
| 306 | commitOid: line.commitOid, | |
| 307 | lineHash: line.lineHash, | |
| 265 | 308 | }); |
| 266 | 309 | if (!added.ok) return added; |
| 267 | 310 | app.bus.append({ |
| 268 | 311 | kind: "pull.reviewed", |
| 269 | repoId: repo.id, | |
| 270 | actorId: user.value.id, | |
| 271 | payload: { number: pull.number, path: input.path, line: input.line }, | |
| 312 | repoId: pull.repoId, | |
| 313 | actorId: user.id, | |
| 314 | payload: { number: pull.number, path: line.path, line: line.line }, | |
| 272 | 315 | }); |
| 273 | 316 | return added; |
| 274 | 317 | } |
| @@ -391,11 +434,20 @@ export type SubmitReviewActionError = | ||
| 391 | 434 | | "body_too_long" |
| 392 | 435 | | "invalid_state" |
| 393 | 436 | /** You cannot approve your own change. */ |
| 394 | | "self_review"; | |
| 437 | | "self_review" | |
| 438 | | "empty_body" | |
| 439 | /** A line comment's anchor is not a row of the pull's current diff. */ | |
| 440 | | "bad_anchor" | |
| 441 | | "too_many_lines"; | |
| 442 | ||
| 443 | /** The most line comments one review may carry. */ | |
| 444 | export const MAX_REVIEW_LINES = 50; | |
| 395 | 445 | |
| 396 | 446 | export interface SubmitReviewInput { |
| 397 | 447 | readonly state: ReviewState; |
| 398 | 448 | readonly body?: string; |
| 449 | /** Line comments posted with the verdict, all or none. */ | |
| 450 | readonly comments?: readonly ReviewInput[]; | |
| 399 | 451 | } |
| 400 | 452 | |
| 401 | 453 | /** |
| @@ -438,6 +490,13 @@ export async function submitReview( | ||
| 438 | 490 | ) |
| 439 | 491 | return err("forbidden"); |
| 440 | 492 | |
| 493 | const lines = input.comments ?? []; | |
| 494 | if (lines.length > MAX_REVIEW_LINES) return err("too_many_lines"); | |
| 495 | // Every anchor is checked before anything is written: a review with one | |
| 496 | // wrong line posts nothing, and the reviewer can fix it and send it again. | |
| 497 | const anchored = await anchorLines(app, repo, pull, lines); | |
| 498 | if (!anchored.ok) return anchored; | |
| 499 | ||
| 441 | 500 | const dir = repoDir(app.reposRoot, repo.ownerName, repo.name); |
| 442 | 501 | const status = await pullStatus(dir, pull); |
| 443 | 502 | |
| @@ -461,6 +520,8 @@ export async function submitReview( | ||
| 461 | 520 | actorId: user.id, |
| 462 | 521 | payload: { number: pull.number, state: input.state, via: "origin" }, |
| 463 | 522 | }); |
| 523 | // Line comments stay here, as `addReview`'s always have on a mirror. | |
| 524 | for (const line of anchored.value) postLine(app, pull, user, line); | |
| 464 | 525 | const stored = getReviewByRemoteId(app.db, pull.id, sent.value.remoteId); |
| 465 | 526 | return stored ? ok(stored) : err("origin_refused"); |
| 466 | 527 | } |
| @@ -468,6 +529,25 @@ export async function submitReview( | ||
| 468 | 529 | if (input.state === "approved" && pull.authorId === user.id) |
| 469 | 530 | return err("self_review"); |
| 470 | 531 | |
| 532 | // The same verdict again on the same head — a retried call, a run that | |
| 533 | // sent it twice — is the one already there, not a second review. | |
| 534 | const again = listReviews(app.db, pull.id).findLast( | |
| 535 | (r) => r.authorId === user.id, | |
| 536 | ); | |
| 537 | if ( | |
| 538 | again !== undefined && | |
| 539 | again.commitOid === status.headOid && | |
| 540 | again.state === input.state && | |
| 541 | again.body.trim() === (input.body ?? "").trim() | |
| 542 | ) | |
| 543 | return ok(again); | |
| 544 | ||
| 545 | // The lines first, so whoever the verdict wakes reads them with it. | |
| 546 | for (const line of anchored.value) { | |
| 547 | const posted = postLine(app, pull, user, line); | |
| 548 | if (!posted.ok) return posted; | |
| 549 | } | |
| 550 | ||
| 471 | 551 | const recorded = recordReview(app.db, { |
| 472 | 552 | issueId: pull.id, |
| 473 | 553 | authorId: user.id, |
src/core/review-stats.ts added +75
| @@ -0,0 +1,75 @@ | ||
| 1 | /** | |
| 2 | * How review is keeping up, per repository: the crew's pulls that are green | |
| 3 | * and waiting for a verdict, how long a green head waits for one, and how | |
| 4 | * many verdicts each reviewer on the crew passed. Read from the review and | |
| 5 | * run tables; nothing here is stored. | |
| 6 | */ | |
| 7 | ||
| 8 | import type { Database } from "bun:sqlite"; | |
| 9 | ||
| 10 | export interface ReviewStats { | |
| 11 | /** Open pulls an agent wrote whose head is green and has no verdict on it. */ | |
| 12 | readonly awaiting: number; | |
| 13 | /** The median wait from a head's first passing check to an agent's verdict on it, over the window; null with none. */ | |
| 14 | readonly latencyMs: number | null; | |
| 15 | /** Verdicts each agent passed in the window, most first. */ | |
| 16 | readonly byReviewer: readonly { readonly name: string; readonly count: number }[]; | |
| 17 | } | |
| 18 | ||
| 19 | /** The middle of some numbers, or null when there are none. */ | |
| 20 | function median(values: readonly number[]): number | null { | |
| 21 | if (values.length === 0) return null; | |
| 22 | const sorted = [...values].sort((a, b) => a - b); | |
| 23 | const mid = Math.floor(sorted.length / 2); | |
| 24 | return sorted.length % 2 === 1 | |
| 25 | ? sorted[mid]! | |
| 26 | : (sorted[mid - 1]! + sorted[mid]!) / 2; | |
| 27 | } | |
| 28 | ||
| 29 | /** Review on one repository, its verdicts counted from `since` (an ISO time). */ | |
| 30 | export function reviewStats( | |
| 31 | db: Database, | |
| 32 | repoId: string, | |
| 33 | since: string, | |
| 34 | ): ReviewStats { | |
| 35 | const awaiting = | |
| 36 | db | |
| 37 | .query<{ n: number }, [string]>( | |
| 38 | `SELECT COUNT(*) AS n FROM issue | |
| 39 | JOIN pull ON pull.issue_id = issue.id | |
| 40 | JOIN user AS author ON author.id = issue.author_id AND author.is_agent = 1 | |
| 41 | WHERE issue.repo_id = ?1 AND issue.state = 'open' AND pull.merged_at IS NULL | |
| 42 | AND EXISTS (SELECT 1 FROM run WHERE run.issue_id = issue.id | |
| 43 | AND run.commit_oid = pull.head_oid AND run.kind IN ('check','deploy')) | |
| 44 | AND NOT EXISTS (SELECT 1 FROM run WHERE run.issue_id = issue.id | |
| 45 | AND run.commit_oid = pull.head_oid AND run.kind IN ('check','deploy') | |
| 46 | AND run.state <> 'ok') | |
| 47 | AND NOT EXISTS (SELECT 1 FROM review WHERE review.issue_id = issue.id | |
| 48 | AND review.commit_oid = pull.head_oid AND review.state <> 'commented')`, | |
| 49 | ) | |
| 50 | .get(repoId)?.n ?? 0; | |
| 51 | const waits = db | |
| 52 | .query<{ ms: number | null }, [string, string]>( | |
| 53 | `SELECT (julianday(review.created_at) - julianday( | |
| 54 | (SELECT MIN(run.finished_at) FROM run WHERE run.issue_id = review.issue_id | |
| 55 | AND run.commit_oid = review.commit_oid AND run.kind IN ('check','deploy') | |
| 56 | AND run.state = 'ok'))) * 86400000 AS ms | |
| 57 | FROM review | |
| 58 | JOIN issue ON issue.id = review.issue_id | |
| 59 | JOIN user ON user.id = review.author_id AND user.is_agent = 1 | |
| 60 | WHERE issue.repo_id = ?1 AND review.created_at >= ?2`, | |
| 61 | ) | |
| 62 | .all(repoId, since) | |
| 63 | .map((r) => r.ms) | |
| 64 | .filter((ms): ms is number => ms !== null && ms >= 0); | |
| 65 | const byReviewer = db | |
| 66 | .query<{ name: string; count: number }, [string, string]>( | |
| 67 | `SELECT user.username AS name, COUNT(*) AS count FROM review | |
| 68 | JOIN issue ON issue.id = review.issue_id | |
| 69 | JOIN user ON user.id = review.author_id AND user.is_agent = 1 | |
| 70 | WHERE issue.repo_id = ?1 AND review.created_at >= ?2 | |
| 71 | GROUP BY user.username ORDER BY count DESC, name`, | |
| 72 | ) | |
| 73 | .all(repoId, since); | |
| 74 | return { awaiting, latencyMs: median(waits), byReviewer }; | |
| 75 | } |
src/core/reviews.ts +60
| @@ -358,6 +358,7 @@ export function reviewSummary( | ||
| 358 | 358 | issueId: string, |
| 359 | 359 | headOid: string, |
| 360 | 360 | ): ReviewSummary { |
| 361 | const agents = agentVerdicts(db, issueId); | |
| 361 | 362 | let approvals = 0; |
| 362 | 363 | let changesRequested = 0; |
| 363 | 364 | let commented = 0; |
| @@ -367,6 +368,24 @@ export function reviewSummary( | ||
| 367 | 368 | if (review.state !== "commented") stale++; |
| 368 | 369 | continue; |
| 369 | 370 | } |
| 371 | const agent = agents.authors.get(review.authorId); | |
| 372 | // An agent's verdict is its role's, not its own: it stands only while | |
| 373 | // the agent is a reviewer on this crew, so one taken off the crew stops | |
| 374 | // gating the pulls it judged. | |
| 375 | if (agent !== undefined && !agent.reviewer) { | |
| 376 | commented++; | |
| 377 | continue; | |
| 378 | } | |
| 379 | // The crew's reviewer gets REVIEW_ROUNDS rounds of changes on a pull; | |
| 380 | // past them, what it still asks is advice, and the pull lands as it is. | |
| 381 | if ( | |
| 382 | agent !== undefined && | |
| 383 | review.state === "changes_requested" && | |
| 384 | agents.earlierRounds(headOid) >= REVIEW_ROUNDS | |
| 385 | ) { | |
| 386 | commented++; | |
| 387 | continue; | |
| 388 | } | |
| 370 | 389 | if (review.state === "approved") approvals++; |
| 371 | 390 | else if (review.state === "changes_requested") changesRequested++; |
| 372 | 391 | else commented++; |
| @@ -374,6 +393,47 @@ export function reviewSummary( | ||
| 374 | 393 | return { approvals, changesRequested, commented, stale }; |
| 375 | 394 | } |
| 376 | 395 | |
| 396 | /** Rounds of changes the crew's reviewer may ask for on one pull: a round is a head it asked changes on. */ | |
| 397 | export const REVIEW_ROUNDS = 2; | |
| 398 | ||
| 399 | /** | |
| 400 | * What the gate needs to know about the agents who passed verdicts on a | |
| 401 | * pull: whether each is a reviewer on the pull's crew now, and on how many | |
| 402 | * heads other than a given one an agent asked for changes. | |
| 403 | */ | |
| 404 | function agentVerdicts( | |
| 405 | db: Database, | |
| 406 | issueId: string, | |
| 407 | ): { | |
| 408 | authors: Map<string, { reviewer: boolean }>; | |
| 409 | earlierRounds: (headOid: string) => number; | |
| 410 | } { | |
| 411 | const rows = db | |
| 412 | .query< | |
| 413 | { author_id: string; role: string | null; commit_oid: string; state: string }, | |
| 414 | [string] | |
| 415 | >( | |
| 416 | `SELECT review.author_id, repo_crew.role, review.commit_oid, review.state | |
| 417 | FROM review | |
| 418 | JOIN issue ON issue.id = review.issue_id | |
| 419 | JOIN user ON user.id = review.author_id AND user.is_agent = 1 | |
| 420 | LEFT JOIN repo_crew ON repo_crew.repo_id = issue.repo_id | |
| 421 | AND repo_crew.user_id = review.author_id | |
| 422 | WHERE review.issue_id = ?1`, | |
| 423 | ) | |
| 424 | .all(issueId); | |
| 425 | const authors = new Map( | |
| 426 | rows.map((r) => [r.author_id, { reviewer: r.role === "reviewer" }]), | |
| 427 | ); | |
| 428 | const asked = new Set( | |
| 429 | rows.filter((r) => r.state === "changes_requested").map((r) => r.commit_oid), | |
| 430 | ); | |
| 431 | return { | |
| 432 | authors, | |
| 433 | earlierRounds: (headOid) => [...asked].filter((oid) => oid !== headOid).length, | |
| 434 | }; | |
| 435 | } | |
| 436 | ||
| 377 | 437 | // ---- threads ---- |
| 378 | 438 | |
| 379 | 439 | /** |
src/crew/autonomy.ts +72 −28
| @@ -85,6 +85,7 @@ import { interruptionOf } from "./interrupted.ts"; | ||
| 85 | 85 | import { parkedCount as countParks, PARK_STRIKES } from "./parks.ts"; |
| 86 | 86 | import { createLog } from "../kernel/log.ts"; |
| 87 | 87 | import { crewOffInPreview } from "./preview.ts"; |
| 88 | import { reviewersOf, sendToReview, sweepReviews } from "./review.ts"; | |
| 88 | 89 | |
| 89 | 90 | const log = createLog("autonomy"); |
| 90 | 91 | |
| @@ -597,6 +598,7 @@ export function settleAgentRun( | ||
| 597 | 598 | if (run.reason === "review") return false; |
| 598 | 599 | if (/opened pull #|updated pull #/.test(run.summary)) return false; |
| 599 | 600 | if (interruptionOf(run.summary) !== undefined) return false; |
| 601 | if (answeredWithoutChange(app, repo, run)) return true; | |
| 600 | 602 | const issue = getIssueById(app.db, run.issueId); |
| 601 | 603 | if (!issue || issue.kind !== "issue" || issue.state !== "open") return false; |
| 602 | 604 | if (issue.id === repo.directionIssueId) return false; |
| @@ -613,6 +615,33 @@ export function settleAgentRun( | ||
| 613 | 615 | ); |
| 614 | 616 | } |
| 615 | 617 | |
| 618 | /** | |
| 619 | * A builder's run on its pull that ended without a push while the crew's | |
| 620 | * reviewer still asks for changes on the head: the request was not | |
| 621 | * answered, and nothing will answer it. The pull is parked rather than | |
| 622 | * left waiting on a verdict that will not move. | |
| 623 | */ | |
| 624 | function answeredWithoutChange( | |
| 625 | app: App, | |
| 626 | repo: Repo, | |
| 627 | run: { readonly issueId: string; readonly reason: string; readonly actorId: string | null }, | |
| 628 | ): boolean { | |
| 629 | if (run.reason !== "feedback" || run.actorId === null) return false; | |
| 630 | if (crewRole(app.db, repo.id, run.actorId) !== "builder") return false; | |
| 631 | const issue = getIssueById(app.db, run.issueId); | |
| 632 | if (!issue || issue.kind !== "pull" || issue.state !== "open") return false; | |
| 633 | const pull = getPull(app.db, repo.id, issue.number); | |
| 634 | if (!pull || pull.mergedAt !== null) return false; | |
| 635 | if (reviewSummary(app.db, pull.id, pull.headOid).changesRequested === 0) | |
| 636 | return false; | |
| 637 | return parkPull( | |
| 638 | app, | |
| 639 | repo, | |
| 640 | pull, | |
| 641 | "the reviewer asked for changes and the builder's run ended without pushing any", | |
| 642 | ); | |
| 643 | } | |
| 644 | ||
| 616 | 645 | /** |
| 617 | 646 | * Park a pull the crew cannot finish: close it with the reason, put its |
| 618 | 647 | * issues back on the map unassigned with the same reason, and move on. A |
| @@ -740,6 +769,13 @@ export async function autonomyTick(app: App, now = new Date()): Promise<void> { | ||
| 740 | 769 | }); |
| 741 | 770 | } |
| 742 | 771 | } |
| 772 | // Review is not autonomy's alone: an agent's green pull is read by the | |
| 773 | // crew's reviewer in every repository that has one. | |
| 774 | try { | |
| 775 | await sweepReviews(app); | |
| 776 | } catch (error) { | |
| 777 | log.error("review sweep failed", { error: String(error) }); | |
| 778 | } | |
| 743 | 779 | } |
| 744 | 780 | |
| 745 | 781 | /** |
| @@ -847,34 +883,24 @@ export function bringUpToDate( | ||
| 847 | 883 | return agent.user.username; |
| 848 | 884 | } |
| 849 | 885 | |
| 850 | /** The heads already sent to the reviewer, by pull: one pass per head. */ | |
| 851 | const sentToReview = new Map<string, string>(); | |
| 852 | ||
| 853 | 886 | /** |
| 854 | * Whether the review gate lets this green head land. True with no reviewer | |
| 855 | * on the crew, or with an approval on this head and no change requested. | |
| 856 | * Otherwise the reviewer is queued once for the head and the pull waits. | |
| 887 | * Whether the review gate lets this green head land: `pass` with no | |
| 888 | * reviewer on the crew, or with an approval on this head and no change | |
| 889 | * requested; `hold` while the review is out or changes are asked for; and | |
| 890 | * `lapsed` when the reviewer was asked twice and never answered — the pull | |
| 891 | * lands without it, and says so (`crew/review.ts`). | |
| 857 | 892 | */ |
| 858 | async function reviewed(app: App, repo: Repo, pull: Pull): Promise<boolean> { | |
| 859 | const reviewer = listCrewRoles(app.db, repo.id).find( | |
| 860 | (c) => c.role === "reviewer", | |
| 861 | )?.agent; | |
| 862 | if (reviewer === undefined) return true; | |
| 893 | async function reviewed( | |
| 894 | app: App, | |
| 895 | repo: Repo, | |
| 896 | pull: Pull, | |
| 897 | ): Promise<"pass" | "hold" | "lapsed"> { | |
| 898 | if (reviewersOf(app.db, repo.id).length === 0) return "pass"; | |
| 863 | 899 | const seen = reviewSummary(app.db, pull.id, pull.headOid); |
| 864 | if (seen.changesRequested > 0) return false; | |
| 865 | if (seen.approvals > 0) return true; | |
| 866 | if (sentToReview.get(pull.id) === pull.headOid) return false; | |
| 867 | if (activeRunFor(app.db, "agent", pull.id) !== undefined) return false; | |
| 868 | const issue = getIssueById(app.db, pull.id); | |
| 869 | if (!issue) return false; | |
| 870 | if (queueAgentRun(app, repo, issue, reviewer, "review") !== undefined) { | |
| 871 | sentToReview.set(pull.id, pull.headOid); | |
| 872 | log.info("green; queued for review", { | |
| 873 | repo: `${repo.ownerName}/${repo.name}`, | |
| 874 | pull: pull.number, | |
| 875 | }); | |
| 876 | } | |
| 877 | return false; | |
| 900 | if (seen.changesRequested > 0) return "hold"; | |
| 901 | if (seen.approvals > 0) return "pass"; | |
| 902 | const stage = await sendToReview(app, repo, pull); | |
| 903 | return stage === "lapsed" ? "lapsed" : stage === "none" ? "pass" : "hold"; | |
| 878 | 904 | } |
| 879 | 905 | |
| 880 | 906 | /** |
| @@ -968,8 +994,10 @@ export async function landIfGreen( | ||
| 968 | 994 | // One gate for every pull, whoever wrote it: when the crew has a |
| 969 | 995 | // reviewer, a green head is queued for review and waits for an approval |
| 970 | 996 | // on that very head (a person's counts the same). A push is a new head, |
| 971 | // so it starts over. Without a reviewer the pull lands as it always has. | |
| 972 | if (!(await reviewed(app, repo, pull))) return false; | |
| 997 | // so it starts over. Without a reviewer the pull lands as it always has, | |
| 998 | // and so does one whose review lapsed. | |
| 999 | const review = await reviewed(app, repo, pull); | |
| 1000 | if (review === "hold") return false; | |
| 973 | 1001 | // Green and clear of the wall lands. An autonomous repository does not |
| 974 | 1002 | // wait on a person; the ledger keeps the landing as the rule it is, once |
| 975 | 1003 | // per head, so the week's numbers still add up. |
| @@ -986,7 +1014,10 @@ export async function landIfGreen( | ||
| 986 | 1014 | kind: "land", |
| 987 | 1015 | repoId: repo.id, |
| 988 | 1016 | issueId: pull.id, |
| 989 | state: { head: pull.headOid }, | |
| 1017 | state: | |
| 1018 | review === "lapsed" | |
| 1019 | ? { head: pull.headOid, review: "lapsed" } | |
| 1020 | : { head: pull.headOid }, | |
| 990 | 1021 | answer: "ship", |
| 991 | 1022 | p: 1, |
| 992 | 1023 | by: "rule", |
| @@ -1010,6 +1041,19 @@ export async function landIfGreen( | ||
| 1010 | 1041 | } |
| 1011 | 1042 | conflicted.delete(pull.id); |
| 1012 | 1043 | resolveOpen(app.db, pull.id, "land", "shipped"); |
| 1044 | // Said once it has landed, so the note wakes no builder: the review was | |
| 1045 | // asked for and never came. | |
| 1046 | if (review === "lapsed") { | |
| 1047 | const landed = getIssueById(app.db, pull.id); | |
| 1048 | if (landed) | |
| 1049 | addComment( | |
| 1050 | app, | |
| 1051 | by, | |
| 1052 | repo, | |
| 1053 | landed, | |
| 1054 | "Landed on green without a review: the crew's reviewer was asked twice and posted no verdict.", | |
| 1055 | ); | |
| 1056 | } | |
| 1013 | 1057 | for (const number of closesFromBody(pull.body)) { |
| 1014 | 1058 | const closed = getIssue(app.db, repo.id, number); |
| 1015 | 1059 | if (closed) resolveOpen(app.db, closed.id, "next", "shipped"); |
src/crew/brief.ts +11 −6
| @@ -11,7 +11,11 @@ import type { Agent, CrewRole } from "../core/agents.ts"; | ||
| 11 | 11 | import type { Comment, Issue } from "../core/issues.ts"; |
| 12 | 12 | import type { Pull } from "../core/pulls.ts"; |
| 13 | 13 | import type { Repo } from "../core/repos.ts"; |
| 14 | import type { Review, ReviewComment } from "../core/reviews.ts"; | |
| 14 | import { | |
| 15 | REVIEW_ROUNDS, | |
| 16 | type Review, | |
| 17 | type ReviewComment, | |
| 18 | } from "../core/reviews.ts"; | |
| 15 | 19 | |
| 16 | 20 | export type RunReason = |
| 17 | 21 | | "assigned" |
| @@ -197,7 +201,7 @@ function taskBody(input: TaskInput): string { | ||
| 197 | 201 | if (input.reason === "review") { |
| 198 | 202 | const pull = input.pull; |
| 199 | 203 | out.push( |
| 200 | `You are the reviewer on this crew. Pull #${issue.number} — "${issue.title}" — is green and would land on its own; the crew's decider wants a second pair of eyes first, because it may be large, off the direction, or touch money, sign-in, data, or what people see first.`, | |
| 204 | `You are the reviewer on this crew. Pull #${issue.number} — "${issue.title}" — is green; every pull the crew writes is read by a reviewer before it lands, and this one is yours.`, | |
| 201 | 205 | "", |
| 202 | 206 | `The direction of this repository: ${input.repo.autonomyDirection.trim() || "(none written)"}`, |
| 203 | 207 | "", |
| @@ -206,7 +210,7 @@ function taskBody(input: TaskInput): string { | ||
| 206 | 210 | ...(input.previewUrl === undefined |
| 207 | 211 | ? [] |
| 208 | 212 | : [ |
| 209 | `- The preview is at ${input.previewUrl}. Use it as a person would: curl its pages, try the doors it offers.`, | |
| 213 | `- This pull's preview, at this head, is at ${input.previewUrl}. Use it as a person would: curl its pages, try the doors it offers. Judge this preview only — another pull's preview is not this change.`, | |
| 210 | 214 | ]), |
| 211 | 215 | ...(input.looks !== undefined && input.looks.length > 0 |
| 212 | 216 | ? [ |
| @@ -227,9 +231,10 @@ function taskBody(input: TaskInput): string { | ||
| 227 | 231 | "- The contract: every action asks `permit` and none compares roles by hand; a new door has its row in `src/contract.test.ts`; and no cell in `contract.json` is loosened unless the issue asked for exactly that — a loosened cell is a wall a person crosses, so a pull that loosens one without being asked is a change request, whatever else it does well.", |
| 228 | 232 | ]), |
| 229 | 233 | "", |
| 230 | "Then submit exactly one review through the forge's API and stop:", | |
| 231 | ` curl -sS -X POST -H "authorization: Bearer $FORGE_TOKEN" -H "content-type: application/json" "$FORGE_URL/api/v1/repos/${input.repo.ownerName}/${input.repo.name}/pulls/${issue.number}/reviews" -d '{"state":"approved","body":"..."}'`, | |
| 232 | `Approve when it does what the direction asks, works in the preview, and looks right. Otherwise use "changes_requested" with a body a builder can act on: what does not work, and the one thing to fix first, concrete. You get two rounds of changes on a pull; after that it lands as it is, so if something is left, open an issue for it with the API instead. One review, then stop; your last message is not read by anyone.`, | |
| 234 | "Then submit exactly one review through the forge's API — the verdict and its line comments in one call — and stop:", | |
| 235 | ` curl -sS -X POST -H "authorization: Bearer $FORGE_TOKEN" -H "content-type: application/json" "$FORGE_URL/api/v1/repos/${input.repo.ownerName}/${input.repo.name}/pulls/${issue.number}/review-states" -d '{"state":"approved","body":"...","comments":[{"path":"src/x.ts","line":12,"side":"new","body":"..."}]}'`, | |
| 236 | `"comments" is optional. Each anchors to a line of the diff below: "path" is the file, "side" is "new" for an added or kept line (numbered as in the head) or "old" for a removed one (numbered as in the base), and "line" counts from the hunk header — in \`@@ -a,b +c,d @@\` the hunk's first line is c on the new side. If the API answers \`bad_anchor\`, nothing was posted: read its message, fix the anchors, and send the review once more; if it is refused again, send it without "comments" and put those points in the body.`, | |
| 237 | `Approve when it does what the direction asks, works in the preview, and looks right. Otherwise use "changes_requested" with a body a builder can act on: what does not work, and the one thing to fix first, concrete. You get ${REVIEW_ROUNDS} rounds of changes on a pull; after that it lands as it is, so if something is left, open an issue for it with the API instead. One review, then stop; your last message is not read by anyone.`, | |
| 233 | 238 | "", |
| 234 | 239 | "The change:", |
| 235 | 240 | "```diff", |
src/crew/dispatch.ts +16 −2
| @@ -25,6 +25,7 @@ import { resolveOpen } from "../core/decisions.ts"; | ||
| 25 | 25 | import { decideAndRecord } from "./ledger.ts"; |
| 26 | 26 | import { getRepoById, type Repo } from "../core/repos.ts"; |
| 27 | 27 | import { listReviews } from "../core/reviews.ts"; |
| 28 | import { greenHead, sendToReview } from "./review.ts"; | |
| 28 | 29 | import { |
| 29 | 30 | activeRunFor, |
| 30 | 31 | hasQueuedRun, |
| @@ -336,9 +337,12 @@ function route(app: App, event: ForgeEvent): void { | ||
| 336 | 337 | if (!actor) return; |
| 337 | 338 | // Agents talking is not feedback — except the crew's reviewer passing |
| 338 | 339 | // a verdict on a pull, which is exactly what a person's review is. |
| 340 | // Its line comments ride along with the verdict; the verdict alone | |
| 341 | // wakes the builder, once. | |
| 339 | 342 | const reviewerVerdict = |
| 340 | 343 | actor.kind === "agent" && |
| 341 | 344 | event.kind === "pull.reviewed" && |
| 345 | typeof event.payload["state"] === "string" && | |
| 342 | 346 | crewRole(app.db, repo.id, actor.id) === "reviewer"; |
| 343 | 347 | if (actor.kind === "agent" && !reviewerVerdict) return; |
| 344 | 348 | const issue = getIssue(app.db, repo.id, number); |
| @@ -413,11 +417,21 @@ function route(app: App, event: ForgeEvent): void { | ||
| 413 | 417 | "retry", |
| 414 | 418 | event.payload["state"] === "ok" ? "green" : "red", |
| 415 | 419 | ); |
| 416 | // Green, in a repository that ships on green: land it. | |
| 420 | // Green: an agent's pull goes to the crew's reviewer, and in a | |
| 421 | // repository that ships on green it lands (behind that review). | |
| 417 | 422 | if (event.payload["state"] === "ok") { |
| 418 | if (repo.autonomy !== "autonomous") return; | |
| 419 | 423 | const pull = getPull(app.db, repo.id, issue.number); |
| 420 | 424 | if (!pull) return; |
| 425 | if (repo.autonomy !== "autonomous") { | |
| 426 | if (pull.authorIsAgent && greenHead(app.db, pull)) | |
| 427 | sendToReview(app, repo, pull).catch((error) => | |
| 428 | log.error("sending to review failed", { | |
| 429 | pull: issue.number, | |
| 430 | error: String(error), | |
| 431 | }), | |
| 432 | ); | |
| 433 | return; | |
| 434 | } | |
| 421 | 435 | landIfGreen(app, repo, pull).catch((error) => |
| 422 | 436 | log.error("landing on green failed", { |
| 423 | 437 | pull: issue.number, |
src/crew/review.ts added +203
| @@ -0,0 +1,203 @@ | ||
| 1 | /** | |
| 2 | * Who reviews a pull, and sending it to them. | |
| 3 | * | |
| 4 | * A crew may carry more than one reviewer: one who reads for the | |
| 5 | * experience, one who reads for security. A pull that touches the | |
| 6 | * security-bearing paths goes to the second; every other pull goes to the | |
| 7 | * first. Which reviewer watches security is read from what the agent says | |
| 8 | * it is — the first paragraph of its brief, before the rules every brief | |
| 9 | * shares — so naming a reviewer is all it takes to give it the job. | |
| 10 | * | |
| 11 | * Every green head of a pull an agent wrote is sent, whatever the | |
| 12 | * repository's autonomy: the event sends it the moment its checks pass, | |
| 13 | * and the tick sweeps up any head the event missed. A review that does | |
| 14 | * not come back is sent once more; after that it has lapsed, and the pull | |
| 15 | * is not held for it (`reviewStage`). | |
| 16 | */ | |
| 17 | ||
| 18 | import type { Database } from "bun:sqlite"; | |
| 19 | import type { App } from "../app.ts"; | |
| 20 | import { listCrewRoles, type Agent } from "../core/agents.ts"; | |
| 21 | import { getIssueById } from "../core/issues.ts"; | |
| 22 | import { listPulls, pullDiff, type Pull } from "../core/pulls.ts"; | |
| 23 | import { getRepoById, type Repo } from "../core/repos.ts"; | |
| 24 | import { reviewSummary } from "../core/reviews.ts"; | |
| 25 | import { checksForHead } from "../core/runs.ts"; | |
| 26 | import { repoDir } from "../git/repos.ts"; | |
| 27 | import { createLog } from "../kernel/log.ts"; | |
| 28 | import { queueAgentRun } from "./dispatch.ts"; | |
| 29 | ||
| 30 | const log = createLog("crew"); | |
| 31 | ||
| 32 | /** Paths whose change is the security reviewer's to read: sign-in, tokens, secrets, the sandbox, the HTTP edge. */ | |
| 33 | const SECURITY_PATH = /auth|token|secret|sandbox|http/i; | |
| 34 | ||
| 35 | export const touchesSecurity = (paths: readonly string[]): boolean => | |
| 36 | paths.some((p) => SECURITY_PATH.test(p)); | |
| 37 | ||
| 38 | /** What an agent says it is: its brief's first paragraph. */ | |
| 39 | const focusOf = (brief: string): string => | |
| 40 | brief.trim().split(/\n\s*\n/)[0] ?? ""; | |
| 41 | ||
| 42 | /** Whether this reviewer says it reads for security. */ | |
| 43 | export const watchesSecurity = (agent: Agent): boolean => | |
| 44 | /\bsecurity\b/i.test(focusOf(agent.brief)); | |
| 45 | ||
| 46 | /** | |
| 47 | * The reviewer for a change to these paths: the security reviewer when | |
| 48 | * the change touches what it watches, the experience reviewer otherwise, | |
| 49 | * and whichever reviewer there is when the crew has only one kind. | |
| 50 | */ | |
| 51 | export function pickReviewer( | |
| 52 | reviewers: readonly Agent[], | |
| 53 | paths: readonly string[], | |
| 54 | ): Agent | undefined { | |
| 55 | const security = reviewers.filter(watchesSecurity); | |
| 56 | const experience = reviewers.filter((r) => !watchesSecurity(r)); | |
| 57 | const preferred = touchesSecurity(paths) ? security : experience; | |
| 58 | return preferred[0] ?? reviewers[0]; | |
| 59 | } | |
| 60 | ||
| 61 | /** The crew's reviewers on a repository. */ | |
| 62 | export const reviewersOf = (db: Database, repoId: string): Agent[] => | |
| 63 | listCrewRoles(db, repoId) | |
| 64 | .filter((c) => c.role === "reviewer") | |
| 65 | .map((c) => c.agent); | |
| 66 | ||
| 67 | /** Whether every check on the pull's head ran and passed. */ | |
| 68 | export function greenHead(db: Database, pull: Pull): boolean { | |
| 69 | const checks = checksForHead(db, pull.id, pull.headOid); | |
| 70 | return checks.length > 0 && checks.every((c) => c.state === "ok"); | |
| 71 | } | |
| 72 | ||
| 73 | /** How long a review may take from being queued before it is sent again. */ | |
| 74 | export const REVIEW_WAIT_MS = 20 * 60_000; | |
| 75 | /** How long a green head waits for a verdict at most, however the attempts went. */ | |
| 76 | export const REVIEW_LAPSE_MS = 60 * 60_000; | |
| 77 | /** Review runs one head gets: the first, and one more. */ | |
| 78 | const REVIEW_TRIES = 2; | |
| 79 | ||
| 80 | /** One review run sent for a head: when it was queued, and whether it is still out. */ | |
| 81 | export interface ReviewAttempt { | |
| 82 | readonly createdAt: string; | |
| 83 | readonly active: boolean; | |
| 84 | } | |
| 85 | ||
| 86 | /** | |
| 87 | * Where review stands on a green head that has no verdict: `send` a | |
| 88 | * review run (the first, or the one retry), `wait` for the one that is | |
| 89 | * out, or `lapsed` — two runs came back with nothing, or the head has | |
| 90 | * waited past the lapse — and the pull goes on without one. A missing | |
| 91 | * reviewer must never hold a pull silently. | |
| 92 | */ | |
| 93 | export function reviewStage( | |
| 94 | greenAt: string, | |
| 95 | attempts: readonly ReviewAttempt[], | |
| 96 | now: Date, | |
| 97 | ): "send" | "wait" | "lapsed" { | |
| 98 | const at = now.getTime(); | |
| 99 | if (at - Date.parse(greenAt) >= REVIEW_LAPSE_MS) return "lapsed"; | |
| 100 | const last = attempts.at(-1); | |
| 101 | if (last === undefined) return "send"; | |
| 102 | const fresh = at - Date.parse(last.createdAt) < REVIEW_WAIT_MS; | |
| 103 | if (last.active && fresh) return "wait"; | |
| 104 | return attempts.length < REVIEW_TRIES ? "send" : "lapsed"; | |
| 105 | } | |
| 106 | ||
| 107 | /** | |
| 108 | * When the pull's head first went green, and the review runs sent for it | |
| 109 | * since — read from the run table, so a restart forgets nothing. | |
| 110 | */ | |
| 111 | export function reviewAttempts( | |
| 112 | db: Database, | |
| 113 | pull: Pull, | |
| 114 | ): { greenAt: string; attempts: ReviewAttempt[] } | undefined { | |
| 115 | const green = db | |
| 116 | .query<{ at: string | null }, [string, string]>( | |
| 117 | `SELECT MIN(finished_at) AS at FROM run WHERE issue_id = ?1 AND commit_oid = ?2 | |
| 118 | AND kind IN ('check','deploy') AND state = 'ok'`, | |
| 119 | ) | |
| 120 | .get(pull.id, pull.headOid)?.at; | |
| 121 | if (green === null || green === undefined) return undefined; | |
| 122 | const attempts = db | |
| 123 | .query<{ created_at: string; state: string }, [string, string]>( | |
| 124 | `SELECT created_at, state FROM run WHERE issue_id = ?1 AND kind = 'agent' | |
| 125 | AND reason = 'review' AND created_at >= ?2 ORDER BY created_at, id`, | |
| 126 | ) | |
| 127 | .all(pull.id, green) | |
| 128 | .map((r) => ({ | |
| 129 | createdAt: r.created_at, | |
| 130 | active: r.state === "queued" || r.state === "running", | |
| 131 | })); | |
| 132 | return { greenAt: green, attempts }; | |
| 133 | } | |
| 134 | ||
| 135 | /** | |
| 136 | * Move review along on this green head: send it to the right reviewer | |
| 137 | * when it is due (`reviewStage`), and say where it stands. `none` when | |
| 138 | * there is nothing to wait for — no reviewer on the crew — and `done` | |
| 139 | * when a verdict is on the head. A run the pull's builder still has open | |
| 140 | * does not stop a send — the review queues behind it. | |
| 141 | */ | |
| 142 | export async function sendToReview( | |
| 143 | app: App, | |
| 144 | repo: Repo, | |
| 145 | pull: Pull, | |
| 146 | now = new Date(), | |
| 147 | ): Promise<"none" | "done" | "sent" | "wait" | "lapsed"> { | |
| 148 | const reviewers = reviewersOf(app.db, repo.id); | |
| 149 | if (reviewers.length === 0) return "none"; | |
| 150 | const seen = reviewSummary(app.db, pull.id, pull.headOid); | |
| 151 | if (seen.approvals > 0 || seen.changesRequested > 0) return "done"; | |
| 152 | const history = reviewAttempts(app.db, pull); | |
| 153 | if (history === undefined) return "wait"; | |
| 154 | const stage = reviewStage(history.greenAt, history.attempts, now); | |
| 155 | if (stage !== "send") return stage; | |
| 156 | const issue = getIssueById(app.db, pull.id); | |
| 157 | if (!issue) return "wait"; | |
| 158 | const { diff } = await pullDiff( | |
| 159 | repoDir(app.reposRoot, repo.ownerName, repo.name), | |
| 160 | pull, | |
| 161 | ); | |
| 162 | const reviewer = pickReviewer( | |
| 163 | reviewers, | |
| 164 | diff.files.map((f) => f.path), | |
| 165 | ); | |
| 166 | if (reviewer === undefined) return "wait"; | |
| 167 | if (queueAgentRun(app, repo, issue, reviewer, "review") === undefined) | |
| 168 | return "wait"; | |
| 169 | log.info("green; queued for review", { | |
| 170 | repo: `${repo.ownerName}/${repo.name}`, | |
| 171 | pull: pull.number, | |
| 172 | reviewer: reviewer.user.username, | |
| 173 | attempt: history.attempts.length + 1, | |
| 174 | }); | |
| 175 | return "sent"; | |
| 176 | } | |
| 177 | ||
| 178 | /** | |
| 179 | * Every open, green pull an agent wrote that has no verdict on its head, | |
| 180 | * in every repository with a reviewer on its crew, sent to review. The | |
| 181 | * tick's net under the check event; returns how many went. | |
| 182 | */ | |
| 183 | export async function sweepReviews(app: App): Promise<number> { | |
| 184 | const repoIds = app.db | |
| 185 | .query<{ repo_id: string }, []>( | |
| 186 | "SELECT DISTINCT repo_id FROM repo_crew WHERE role = 'reviewer'", | |
| 187 | ) | |
| 188 | .all() | |
| 189 | .map((r) => r.repo_id); | |
| 190 | let queued = 0; | |
| 191 | for (const id of repoIds) { | |
| 192 | const repo = getRepoById(app.db, id); | |
| 193 | if (!repo) continue; | |
| 194 | for (const pull of listPulls(app.db, repo.id, { | |
| 195 | state: "open", | |
| 196 | limit: 50, | |
| 197 | })) { | |
| 198 | if (!pull.authorIsAgent || !greenHead(app.db, pull)) continue; | |
| 199 | if ((await sendToReview(app, repo, pull)) === "sent") queued++; | |
| 200 | } | |
| 201 | } | |
| 202 | return queued; | |
| 203 | } |
src/crew/runner.ts +54 −2
| @@ -36,7 +36,11 @@ import { | ||
| 36 | 36 | type Repo, |
| 37 | 37 | } from "../core/repos.ts"; |
| 38 | 38 | import { getUserById } from "../core/users.ts"; |
| 39 | import { listReviewComments, listReviews } from "../core/reviews.ts"; | |
| 39 | import { | |
| 40 | listReviewComments, | |
| 41 | listReviews, | |
| 42 | type Review, | |
| 43 | } from "../core/reviews.ts"; | |
| 40 | 44 | import { |
| 41 | 45 | cancelRun, |
| 42 | 46 | checkSummary, |
| @@ -199,6 +203,29 @@ export function wrapUpReserve(budgetUsd: number): number { | ||
| 199 | 203 | ); |
| 200 | 204 | } |
| 201 | 205 | |
| 206 | /** | |
| 207 | * How a review run ends, read from the verdicts on the pull: `ok` when the | |
| 208 | * reviewer passed one since the run began, or stands by one on the head it | |
| 209 | * was given (the same verdict sent again is the one already there); | |
| 210 | * `failed` when it did neither. A reviewer pushes nothing by design, so | |
| 211 | * what it pushed never decides it. | |
| 212 | */ | |
| 213 | export function settleReview( | |
| 214 | reviews: readonly Review[], | |
| 215 | reviewerId: string, | |
| 216 | since: string, | |
| 217 | headOid: string, | |
| 218 | ): { state: "ok" | "failed"; summary: string } { | |
| 219 | const verdict = reviews.findLast( | |
| 220 | (r) => | |
| 221 | r.authorId === reviewerId && | |
| 222 | (r.createdAt >= since || r.commitOid === headOid), | |
| 223 | ); | |
| 224 | return verdict === undefined | |
| 225 | ? { state: "failed", summary: "reviewed: no verdict was posted" } | |
| 226 | : { state: "ok", summary: `reviewed: ${verdict.state.replace("_", " ")}` }; | |
| 227 | } | |
| 228 | ||
| 202 | 229 | /** |
| 203 | 230 | * Claim and execute a queued agent run. Never throws. A run the provider |
| 204 | 231 | * turned away ends cancelled with a `paused:` summary and returns when its |
| @@ -489,6 +516,7 @@ async function work( | ||
| 489 | 516 | signal: AbortSignal, |
| 490 | 517 | meter: Meter, |
| 491 | 518 | ): Promise<Outcome> { |
| 519 | const began = new Date().toISOString(); | |
| 492 | 520 | const repo = getRepoById(app.db, run.repoId); |
| 493 | 521 | const issue = |
| 494 | 522 | run.issueId === null ? undefined : getIssueById(app.db, run.issueId); |
| @@ -657,7 +685,12 @@ async function work( | ||
| 657 | 685 | isApp: record !== undefined, |
| 658 | 686 | previewUrl, |
| 659 | 687 | policy, |
| 660 | resuming: previous?.sessionId !== undefined && previous.sessionId !== "", | |
| 688 | // A review reads one head afresh: a resumed session would carry an | |
| 689 | // older head's diff and preview into the verdict on this one. | |
| 690 | resuming: | |
| 691 | !reviewing && | |
| 692 | previous?.sessionId !== undefined && | |
| 693 | previous.sessionId !== "", | |
| 661 | 694 | role: crewRole(app.db, repo.id, agent.user.id), |
| 662 | 695 | }; |
| 663 | 696 | const failedCheck = |
| @@ -990,6 +1023,25 @@ async function work( | ||
| 990 | 1023 | }; |
| 991 | 1024 | } |
| 992 | 1025 | |
| 1026 | // A reviewer's work is its verdict, posted through the API; it pushes | |
| 1027 | // nothing and has nothing to land. The run is judged by whether the | |
| 1028 | // verdict is there, not by what the session said about itself. | |
| 1029 | if (reviewing && pull !== undefined) { | |
| 1030 | const settled = settleReview( | |
| 1031 | listReviews(app.db, pull.id), | |
| 1032 | agent.user.id, | |
| 1033 | began, | |
| 1034 | pull.headOid, | |
| 1035 | ); | |
| 1036 | write(settled.summary); | |
| 1037 | return { | |
| 1038 | ...settled, | |
| 1039 | sessionId: result.sessionId, | |
| 1040 | costUsd: result.costUsd, | |
| 1041 | turns: result.turns, | |
| 1042 | }; | |
| 1043 | } | |
| 1044 | ||
| 993 | 1045 | // Bookkeeping the agent does not have to get right. |
| 994 | 1046 | const landed = await landWork( |
| 995 | 1047 | app, |
src/http/api.ts +59 −2
| @@ -63,6 +63,8 @@ import { | ||
| 63 | 63 | refreshPullHead, |
| 64 | 64 | resolveReviewThread, |
| 65 | 65 | submitReview, |
| 66 | MAX_REVIEW_LINES, | |
| 67 | type ReviewInput, | |
| 66 | 68 | } from "../actions/pulls.ts"; |
| 67 | 69 | import { search } from "../actions/search.ts"; |
| 68 | 70 | import { |
| @@ -555,6 +557,31 @@ function editFailure(error: { kind: string } & Record<string, unknown>) { | ||
| 555 | 557 | * The files of an edit, out of a JSON body. `content: null` deletes; a |
| 556 | 558 | * `base64` encoding is how a client sends bytes that are not text. |
| 557 | 559 | */ |
| 560 | /** | |
| 561 | * How a line comment is anchored, said the same way by every refusal of | |
| 562 | * one — the sentence a reviewer reads to send it right the second time. | |
| 563 | */ | |
| 564 | const ANCHOR_FORMAT = | |
| 565 | 'An anchor is {"path": a file in the pull\'s diff, "line": its line number, "side": "new" (the head\'s numbering, for added or kept lines) or "old" (the base\'s, for removed lines)}; the line must be inside one of the diff\'s hunks: in `@@ -a,b +c,d @@` the hunk\'s first line is c on the new side and a on the old. GET …/pulls/N returns the diff.'; | |
| 566 | ||
| 567 | /** A review's line comments as the API was sent them; undefined when they are not a list of objects. */ | |
| 568 | function reviewLines(raw: unknown): ReviewInput[] | undefined { | |
| 569 | if (raw === undefined || raw === null) return []; | |
| 570 | if (!Array.isArray(raw) || raw.length > MAX_REVIEW_LINES) return undefined; | |
| 571 | const out: ReviewInput[] = []; | |
| 572 | for (const row of raw) { | |
| 573 | if (typeof row !== "object" || row === null) return undefined; | |
| 574 | const r = row as Record<string, unknown>; | |
| 575 | out.push({ | |
| 576 | path: str(r.path), | |
| 577 | line: Number(r.line), | |
| 578 | side: r.side === "old" ? "old" : "new", | |
| 579 | body: str(r.body), | |
| 580 | }); | |
| 581 | } | |
| 582 | return out; | |
| 583 | } | |
| 584 | ||
| 558 | 585 | function editFilesFrom(raw: unknown): EditFile[] { |
| 559 | 586 | if (!Array.isArray(raw)) return []; |
| 560 | 587 | const files: EditFile[] = []; |
| @@ -2099,7 +2126,18 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2099 | 2126 | side, |
| 2100 | 2127 | }, |
| 2101 | 2128 | ); |
| 2102 | if (!added.ok) return respondAccess(added.error, 422); | |
| 2129 | if (!added.ok) { | |
| 2130 | // A verdict sent here is the call this route most often gets wrong. | |
| 2131 | if (added.error === "bad_anchor" && input.state !== undefined) | |
| 2132 | return apiError( | |
| 2133 | "bad_anchor", | |
| 2134 | 422, | |
| 2135 | `this route takes one line comment; a verdict goes to …/review-states as {"state","body","comments"}. ${ANCHOR_FORMAT}`, | |
| 2136 | ); | |
| 2137 | if (added.error === "bad_anchor") | |
| 2138 | return apiError("bad_anchor", 422, ANCHOR_FORMAT); | |
| 2139 | return respondAccess(added.error, 422); | |
| 2140 | } | |
| 2103 | 2141 | return json(added.value, 201); |
| 2104 | 2142 | }, |
| 2105 | 2143 | ); |
| @@ -2134,12 +2172,19 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2134 | 2172 | 422, |
| 2135 | 2173 | "a review is approved, changes_requested, or commented", |
| 2136 | 2174 | ); |
| 2175 | const comments = reviewLines(input.comments); | |
| 2176 | if (comments === undefined) | |
| 2177 | return apiError( | |
| 2178 | "invalid_comments", | |
| 2179 | 422, | |
| 2180 | `comments is a list of {"path","line","side","body"}. ${ANCHOR_FORMAT}`, | |
| 2181 | ); | |
| 2137 | 2182 | const submitted = await submitReview( |
| 2138 | 2183 | ctx.app, |
| 2139 | 2184 | ctx.auth.user, |
| 2140 | 2185 | found.repo, |
| 2141 | 2186 | found.pull, |
| 2142 | { state, body: str(input.body) }, | |
| 2187 | { state, body: str(input.body), comments }, | |
| 2143 | 2188 | ); |
| 2144 | 2189 | if (!submitted.ok) { |
| 2145 | 2190 | if (submitted.error === "self_review") |
| @@ -2148,6 +2193,18 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2148 | 2193 | 422, |
| 2149 | 2194 | "you cannot approve your own pull request", |
| 2150 | 2195 | ); |
| 2196 | if (submitted.error === "bad_anchor") | |
| 2197 | return apiError( | |
| 2198 | "bad_anchor", | |
| 2199 | 422, | |
| 2200 | `nothing was posted: a comment's anchor is not in the diff. ${ANCHOR_FORMAT}`, | |
| 2201 | ); | |
| 2202 | if (submitted.error === "too_many_lines") | |
| 2203 | return apiError( | |
| 2204 | "too_many_lines", | |
| 2205 | 422, | |
| 2206 | `a review carries at most ${MAX_REVIEW_LINES} line comments`, | |
| 2207 | ); | |
| 2151 | 2208 | return originAware(submitted.error, 422); |
| 2152 | 2209 | } |
| 2153 | 2210 | return json(submitted.value, 201); |
src/web/pages/board.ts +28
| @@ -21,6 +21,7 @@ import { | ||
| 21 | 21 | } from "../../core/decisions.ts"; |
| 22 | 22 | import { appsRuntime } from "../../apps/runtime.ts"; |
| 23 | 23 | import { efficiencyForRepos } from "../../core/efficiency.ts"; |
| 24 | import { reviewStats, type ReviewStats } from "../../core/review-stats.ts"; | |
| 24 | 25 | import { standings, WINDOW_MS } from "../../core/standing.ts"; |
| 25 | 26 | import { |
| 26 | 27 | listCredentials, |
| @@ -96,6 +97,26 @@ const KIND_WORDS: Record<DecisionKind, string> = { | ||
| 96 | 97 | |
| 97 | 98 | const money = (n: number): string => `$${n.toFixed(2)}`; |
| 98 | 99 | |
| 100 | /** A wait, in the unit a person reads it in. */ | |
| 101 | function waitWords(ms: number): string { | |
| 102 | const minutes = Math.round(ms / 60_000); | |
| 103 | if (minutes < 60) return `${minutes}m`; | |
| 104 | return `${Math.round(minutes / 60)}h`; | |
| 105 | } | |
| 106 | ||
| 107 | /** How review is keeping up on one app today, or null when there is nothing to say. */ | |
| 108 | function reviewWords(stats: ReviewStats): string | null { | |
| 109 | const parts: string[] = []; | |
| 110 | if (stats.awaiting > 0) parts.push(`${stats.awaiting} awaiting review`); | |
| 111 | if (stats.latencyMs !== null) | |
| 112 | parts.push(`${waitWords(stats.latencyMs)} from green to a verdict`); | |
| 113 | if (stats.byReviewer.length > 0) | |
| 114 | parts.push( | |
| 115 | stats.byReviewer.map((r) => `${r.name} ${r.count}`).join(", "), | |
| 116 | ); | |
| 117 | return parts.length === 0 ? null : parts.join(" · "); | |
| 118 | } | |
| 119 | ||
| 99 | 120 | /** A whole percentage, for the width rules in `boardCss`. */ |
| 100 | 121 | const pct = (n: number): number => |
| 101 | 122 | Math.max(0, Math.min(100, Math.round(n * 100))); |
| @@ -643,6 +664,7 @@ function today(ctx: Ctx, scope: BoardScope): Html { | ||
| 643 | 664 | limit: 100, |
| 644 | 665 | }).filter((p) => p.mergedAt !== null && p.mergedAt >= since).length; |
| 645 | 666 | const share = s.ceilingUsd > 0 ? s.spentTodayUsd / s.ceilingUsd : 0; |
| 667 | const review = reviewWords(reviewStats(db, repo.id, since)); | |
| 646 | 668 | const note = |
| 647 | 669 | s.paused === "ceiling" |
| 648 | 670 | ? "at its ceiling until 00:00 UTC" |
| @@ -672,6 +694,12 @@ function today(ctx: Ctx, scope: BoardScope): Html { | ||
| 672 | 694 | <span class="board-app-week" |
| 673 | 695 | >${perShippedWords(week.get(repo.id))} this week</span |
| 674 | 696 | > |
| 697 | ${when( | |
| 698 | review !== null, | |
| 699 | () => html`<span class="board-app-week board-app-review" | |
| 700 | >${review}</span | |
| 701 | >`, | |
| 702 | )} | |
| 675 | 703 | <span class="board-spend" |
| 676 | 704 | ><span class="board-fill" data-w="${pct(share)}"></span |
| 677 | 705 | ></span> |
test/board.test.ts +43 −1
| @@ -21,7 +21,8 @@ import { recordDecision } from "../src/core/decisions.ts"; | ||
| 21 | 21 | import { recordLook } from "../src/core/looks.ts"; |
| 22 | 22 | import { getPull } from "../src/core/pulls.ts"; |
| 23 | 23 | import { updateRepoSettings, type Repo } from "../src/core/repos.ts"; |
| 24 | import { claimRun, createRun } from "../src/core/runs.ts"; | |
| 24 | import { claimRun, createRun, finishRun } from "../src/core/runs.ts"; | |
| 25 | import { submitReview } from "../src/core/reviews.ts"; | |
| 25 | 26 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; |
| 26 | 27 | import { serve, type Servers } from "../src/http/server.ts"; |
| 27 | 28 | |
| @@ -277,6 +278,47 @@ describe("the board", () => { | ||
| 277 | 278 | expect(byOwner).not.toContain("on the cart"); |
| 278 | 279 | }); |
| 279 | 280 | |
| 281 | test("review on an app's card: pulls awaiting it, the wait for a verdict, verdicts per reviewer", async () => { | |
| 282 | const scout = getUserByName(app.db, "scout")!; | |
| 283 | const branch = await commitToBranch(app, alice, notes, { | |
| 284 | branch: "feature/review-me", | |
| 285 | base: "main", | |
| 286 | message: "Review me", | |
| 287 | files: [{ path: "REVIEW.md", content: "review me\n" }], | |
| 288 | }); | |
| 289 | if (!branch.ok) throw new Error(JSON.stringify(branch.error)); | |
| 290 | const opened = await openPull(app, scout, notes, { | |
| 291 | title: "Review me", | |
| 292 | body: "", | |
| 293 | baseBranch: "main", | |
| 294 | headBranch: "feature/review-me", | |
| 295 | }); | |
| 296 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 297 | const check = createRun(app.db, { | |
| 298 | repoId: notes.id, | |
| 299 | kind: "check", | |
| 300 | issueId: opened.value.id, | |
| 301 | env: `pull-${opened.value.number}`, | |
| 302 | commitOid: opened.value.headOid, | |
| 303 | reason: "pull", | |
| 304 | }); | |
| 305 | claimRun(app.db, check.id); | |
| 306 | finishRun(app.db, check.id, { state: "ok", summary: "check passed" }); | |
| 307 | expect(await (await get("/board")).text()).toContain("1 awaiting review"); | |
| 308 | ||
| 309 | const juniper = await createAgent(app, alice, { username: "juniper" }); | |
| 310 | if (!juniper.ok) throw new Error(String(juniper.error)); | |
| 311 | submitReview(app.db, { | |
| 312 | issueId: opened.value.id, | |
| 313 | authorId: juniper.value.user.id, | |
| 314 | state: "changes_requested", | |
| 315 | commitOid: opened.value.headOid, | |
| 316 | }); | |
| 317 | const html = await (await get("/board")).text(); | |
| 318 | expect(html).not.toContain("awaiting review"); | |
| 319 | expect(html).toMatch(/\d+m from green to a verdict · juniper 1/); | |
| 320 | }); | |
| 321 | ||
| 280 | 322 | test("the home's shelf opens the board", async () => { |
| 281 | 323 | const home = await ( |
| 282 | 324 | await get("/", { cookie: `${cookie}; forge_mode=studio` }) |
test/crew.test.ts +51 −1
| @@ -30,7 +30,8 @@ import { | ||
| 30 | 30 | } from "../src/crew/dispatch.ts"; |
| 31 | 31 | import { workdirFor } from "../src/crew/runner.ts"; |
| 32 | 32 | import { startChecks, type ChecksHandle } from "../src/checks/service.ts"; |
| 33 | import { getAgentByName } from "../src/core/agents.ts"; | |
| 33 | import { getAgentByName, setCrewRole } from "../src/core/agents.ts"; | |
| 34 | import { listReviews } from "../src/core/reviews.ts"; | |
| 34 | 35 | import { getIssue, listComments } from "../src/core/issues.ts"; |
| 35 | 36 | import { listPulls } from "../src/core/pulls.ts"; |
| 36 | 37 | import { resolveOid } from "../src/git/merge.ts"; |
| @@ -120,6 +121,12 @@ if grep -q DECLINE-ME "${promptFile}"; then | ||
| 120 | 121 | echo '{"type":"result","subtype":"success","is_error":false,"result":"DECLINED: this is out of scope for me.","total_cost_usd":0.005,"num_turns":1,"session_id":"fake-2","terminal_reason":"completed"}' |
| 121 | 122 | exit 0 |
| 122 | 123 | fi |
| 124 | if grep -q "You are the reviewer on this crew" "${promptFile}"; then | |
| 125 | path=$(grep -o 'api/v1/repos/[^"\\]*/review-states' "${promptFile}" | head -1) | |
| 126 | curl -sS -X POST -H "authorization: Bearer $FORGE_TOKEN" -H "content-type: application/json" "\${FORGE_URL%/}/$path" -d '{"state":"changes_requested","body":"Name the fence."}' > "${dataDir}/review.out" | |
| 127 | echo '{"type":"result","subtype":"success","is_error":false,"result":"Reviewed.","total_cost_usd":0.004,"num_turns":1,"session_id":"fake-3","terminal_reason":"completed"}' | |
| 128 | exit 0 | |
| 129 | fi | |
| 123 | 130 | if grep -q "The check on your pull" "${promptFile}"; then echo "daffodils" >> CREW.md; fi |
| 124 | 131 | echo "work by the crew $(date +%s%N)" >> CREW.md |
| 125 | 132 | git add -A && git commit -qm "crew work" && git push -q -u origin HEAD |
| @@ -596,4 +603,47 @@ echo '{"type":"result","subtype":"success","is_error":false,"result":"Added CREW | ||
| 596 | 603 | ); |
| 597 | 604 | expect(passed.summary).toMatch(/^check passed/); |
| 598 | 605 | }, 120_000); |
| 606 | ||
| 607 | test("a reviewer's run that posts its verdict is ok, though it pushes nothing", async () => { | |
| 608 | const made = await createAgent(app, alice, { username: "rev" }); | |
| 609 | if (!made.ok) throw new Error(String(made.error)); | |
| 610 | expect(addToCrew(app, alice, repo, "rev").ok).toBe(true); | |
| 611 | setCrewRole(app.db, repo.id, made.value.user.id, "reviewer"); | |
| 612 | const branch = await commitToBranch(app, alice, repo, { | |
| 613 | branch: "fence", | |
| 614 | base: "main", | |
| 615 | message: "A fence", | |
| 616 | files: [{ path: "FENCE.md", content: "a fence\n" }], | |
| 617 | }); | |
| 618 | if (!branch.ok) throw new Error(JSON.stringify(branch.error)); | |
| 619 | const opened = await openPull(app, alice, repo, { | |
| 620 | title: "A fence", | |
| 621 | baseBranch: "main", | |
| 622 | headBranch: "fence", | |
| 623 | }); | |
| 624 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 625 | const issue = getIssue(app.db, repo.id, opened.value.number)!; | |
| 626 | expect(queueAgentRun(app, repo, issue, made.value, "review")).toBeDefined(); | |
| 627 | ||
| 628 | const run = await settled( | |
| 629 | (r) => r.reason === "review" && r.issueId === issue.id, | |
| 630 | ); | |
| 631 | expect(run.state).toBe("ok"); | |
| 632 | expect(run.summary).toBe("reviewed: changes requested"); | |
| 633 | expect( | |
| 634 | listReviews(app.db, issue.id).map((r) => [r.authorId, r.state]), | |
| 635 | ).toEqual([[made.value.user.id, "changes_requested"]]); | |
| 636 | // Nothing pushed and nothing said on the pull: the verdict is the word. | |
| 637 | expect(listComments(app.db, issue.id)).toHaveLength(0); | |
| 638 | ||
| 639 | // A second pass reads afresh: no earlier session carries an older | |
| 640 | // head into it, and the same verdict again is not a second review. | |
| 641 | expect(queueAgentRun(app, repo, issue, made.value, "review")).toBeDefined(); | |
| 642 | const again = await settled( | |
| 643 | (r) => r.reason === "review" && r.issueId === issue.id && r.id !== run.id, | |
| 644 | ); | |
| 645 | expect(again.state).toBe("ok"); | |
| 646 | expect(readFileSync(argsFile, "utf8")).not.toContain("--resume"); | |
| 647 | expect(listReviews(app.db, issue.id)).toHaveLength(1); | |
| 648 | }, 60_000); | |
| 599 | 649 | }); |
test/review-gate.test.ts +111 −2
| @@ -13,11 +13,16 @@ import { openPull } from "../src/actions/pulls.ts"; | ||
| 13 | 13 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; |
| 14 | 14 | import { setCrewRole } from "../src/core/agents.ts"; |
| 15 | 15 | import { getPull, type Pull } from "../src/core/pulls.ts"; |
| 16 | import { submitReview } from "../src/core/reviews.ts"; | |
| 16 | import { listComments } from "../src/core/issues.ts"; | |
| 17 | import { | |
| 18 | REVIEW_ROUNDS, | |
| 19 | reviewSummary, | |
| 20 | submitReview, | |
| 21 | } from "../src/core/reviews.ts"; | |
| 17 | 22 | import { getRepoById, type Repo } from "../src/core/repos.ts"; |
| 18 | 23 | import { claimRun, createRun, finishRun, listRuns } from "../src/core/runs.ts"; |
| 19 | 24 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; |
| 20 | import { landIfGreen } from "../src/crew/autonomy.ts"; | |
| 25 | import { landIfGreen, settleAgentRun } from "../src/crew/autonomy.ts"; | |
| 21 | 26 | |
| 22 | 27 | describe("the review gate", () => { |
| 23 | 28 | let dataDir: string; |
| @@ -158,6 +163,31 @@ describe("the review gate", () => { | ||
| 158 | 163 | }); |
| 159 | 164 | } |
| 160 | 165 | |
| 166 | test("a reviewer asked twice who never answers does not hold the pull: it lands, and says so", async () => { | |
| 167 | setCrewRole(app.db, repo.id, quill.id, "reviewer"); | |
| 168 | const pull = await greenPull(mason, "unanswered"); | |
| 169 | expect(await landIfGreen(app, fresh(), pull)).toBe(false); | |
| 170 | const ended = () => { | |
| 171 | for (const run of reviewRuns(pull).filter((r) => r.state === "queued")) { | |
| 172 | claimRun(app.db, run.id); | |
| 173 | finishRun(app.db, run.id, { | |
| 174 | state: "failed", | |
| 175 | summary: "reviewed: no verdict was posted", | |
| 176 | }); | |
| 177 | } | |
| 178 | }; | |
| 179 | ended(); | |
| 180 | // Back with nothing: sent once more. | |
| 181 | expect(await landIfGreen(app, fresh(), pull)).toBe(false); | |
| 182 | expect(reviewRuns(pull)).toHaveLength(2); | |
| 183 | ended(); | |
| 184 | expect(await landIfGreen(app, fresh(), pull)).toBe(true); | |
| 185 | expect(reviewRuns(pull)).toHaveLength(2); | |
| 186 | expect( | |
| 187 | listComments(app.db, pull.id).map((c) => c.body).join("\n"), | |
| 188 | ).toContain("without a review"); | |
| 189 | }); | |
| 190 | ||
| 161 | 191 | test("a change requested on the head holds the pull", async () => { |
| 162 | 192 | setCrewRole(app.db, repo.id, quill.id, "reviewer"); |
| 163 | 193 | const pull = await greenPull(alice, "contested"); |
| @@ -170,4 +200,83 @@ describe("the review gate", () => { | ||
| 170 | 200 | expect(await landIfGreen(app, fresh(), pull)).toBe(false); |
| 171 | 201 | expect(getPull(app.db, repo.id, pull.number)?.mergedAt).toBeNull(); |
| 172 | 202 | }); |
| 203 | ||
| 204 | const askChanges = (pull: Pull, by: User) => | |
| 205 | submitReview(app.db, { | |
| 206 | issueId: pull.id, | |
| 207 | authorId: by.id, | |
| 208 | state: "changes_requested", | |
| 209 | commitOid: pull.headOid, | |
| 210 | }); | |
| 211 | ||
| 212 | /** Push one more commit to the pull's branch and pass its check. */ | |
| 213 | async function pushTo(pull: Pull, name: string): Promise<Pull> { | |
| 214 | const pushed = await commitToBranch(app, alice, repo, { | |
| 215 | branch: pull.headBranch, | |
| 216 | base: "main", | |
| 217 | message: name, | |
| 218 | files: [{ path: `${name}.ts`, content: `export const x = "${name}";\n` }], | |
| 219 | }); | |
| 220 | if (!pushed.ok) throw new Error(JSON.stringify(pushed.error)); | |
| 221 | return passCheck(getPull(app.db, repo.id, pull.number)!); | |
| 222 | } | |
| 223 | ||
| 224 | test("a reviewer taken off the crew no longer holds the pulls it judged", async () => { | |
| 225 | setCrewRole(app.db, repo.id, quill.id, "reviewer"); | |
| 226 | const pull = await greenPull(mason, "judged"); | |
| 227 | askChanges(pull, quill); | |
| 228 | expect(reviewSummary(app.db, pull.id, pull.headOid).changesRequested).toBe(1); | |
| 229 | setCrewRole(app.db, repo.id, quill.id, "builder"); | |
| 230 | expect(reviewSummary(app.db, pull.id, pull.headOid).changesRequested).toBe(0); | |
| 231 | // A person's request is the person's, whatever their crew. | |
| 232 | askChanges(pull, alice); | |
| 233 | expect(reviewSummary(app.db, pull.id, pull.headOid).changesRequested).toBe(1); | |
| 234 | }); | |
| 235 | ||
| 236 | test(`the reviewer gets ${REVIEW_ROUNDS} rounds of changes; a third is advice and the pull lands`, async () => { | |
| 237 | setCrewRole(app.db, repo.id, quill.id, "reviewer"); | |
| 238 | let pull = await greenPull(mason, "rounds"); | |
| 239 | askChanges(pull, quill); | |
| 240 | // Asked twice on one head is still one round. | |
| 241 | askChanges(pull, quill); | |
| 242 | pull = await pushTo(pull, "rounds-2"); | |
| 243 | askChanges(pull, quill); | |
| 244 | expect(reviewSummary(app.db, pull.id, pull.headOid).changesRequested).toBe(1); | |
| 245 | pull = await pushTo(pull, "rounds-3"); | |
| 246 | askChanges(pull, quill); | |
| 247 | expect(reviewSummary(app.db, pull.id, pull.headOid).changesRequested).toBe(0); | |
| 248 | approve(pull, alice); | |
| 249 | expect(await landIfGreen(app, fresh(), pull)).toBe(true); | |
| 250 | }); | |
| 251 | ||
| 252 | test("a builder's run that answers a change request without pushing parks the pull", async () => { | |
| 253 | setCrewRole(app.db, repo.id, quill.id, "reviewer"); | |
| 254 | setCrewRole(app.db, repo.id, mason.id, "builder"); | |
| 255 | const pull = await greenPull(mason, "unmoved"); | |
| 256 | askChanges(pull, quill); | |
| 257 | expect( | |
| 258 | settleAgentRun(app, fresh(), { | |
| 259 | issueId: pull.id, | |
| 260 | state: "ok", | |
| 261 | summary: "no changes pushed: I think it is fine", | |
| 262 | reason: "feedback", | |
| 263 | actorId: mason.id, | |
| 264 | }), | |
| 265 | ).toBe(true); | |
| 266 | expect(getPull(app.db, repo.id, pull.number)?.state).toBe("closed"); | |
| 267 | ||
| 268 | // One that pushed is answering, and is left alone. | |
| 269 | const moved = await greenPull(mason, "moved"); | |
| 270 | askChanges(moved, quill); | |
| 271 | expect( | |
| 272 | settleAgentRun(app, fresh(), { | |
| 273 | issueId: moved.id, | |
| 274 | state: "ok", | |
| 275 | summary: `updated pull #${moved.number} (abc1234)`, | |
| 276 | reason: "feedback", | |
| 277 | actorId: mason.id, | |
| 278 | }), | |
| 279 | ).toBe(false); | |
| 280 | expect(getPull(app.db, repo.id, moved.number)?.state).toBe("open"); | |
| 281 | }); | |
| 173 | 282 | }); |
test/review-lines.test.ts added +165
| @@ -0,0 +1,165 @@ | ||
| 1 | /** | |
| 2 | * One way to post a review: the verdict and its line comments in one call | |
| 3 | * to `…/review-states`. Every anchor is checked before anything is written, | |
| 4 | * and a refused anchor says how an anchor is written, so a reviewer can | |
| 5 | * send the review right the second time. A verdict sent to the line-comment | |
| 6 | * route — the call the reviewer brief used to make — is told where it goes. | |
| 7 | */ | |
| 8 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 9 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 10 | import { tmpdir } from "node:os"; | |
| 11 | import { join } from "node:path"; | |
| 12 | import { createApp, type App } from "../src/app.ts"; | |
| 13 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; | |
| 14 | import { openPull } from "../src/actions/pulls.ts"; | |
| 15 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 16 | import { listEvents } from "../src/core/events.ts"; | |
| 17 | import { listReviewComments, listReviews } from "../src/core/reviews.ts"; | |
| 18 | import type { Pull } from "../src/core/pulls.ts"; | |
| 19 | import type { Repo } from "../src/core/repos.ts"; | |
| 20 | import { createToken } from "../src/core/tokens.ts"; | |
| 21 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; | |
| 22 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 23 | ||
| 24 | describe("a review with line comments", () => { | |
| 25 | let dataDir: string; | |
| 26 | let app: App; | |
| 27 | let servers: Servers; | |
| 28 | let alice: User; | |
| 29 | let repo: Repo; | |
| 30 | let pull: Pull; | |
| 31 | let token: string; | |
| 32 | ||
| 33 | const post = (path: string, payload: unknown) => | |
| 34 | fetch(`${servers.url}/api/v1/repos/alice/engine/pulls/${pull.number}/${path}`, { | |
| 35 | method: "POST", | |
| 36 | headers: { | |
| 37 | authorization: `Bearer ${token}`, | |
| 38 | "content-type": "application/json", | |
| 39 | }, | |
| 40 | body: JSON.stringify(payload), | |
| 41 | }); | |
| 42 | ||
| 43 | beforeAll(async () => { | |
| 44 | dataDir = await mkdtemp(join(tmpdir(), "forge-review-lines-")); | |
| 45 | app = createApp({ dataDir, port: 0 }); | |
| 46 | servers = await serve(app); | |
| 47 | const a = await createUser(app.db, { | |
| 48 | username: "alice", | |
| 49 | email: "[email protected]", | |
| 50 | password: "a-good-passphrase", | |
| 51 | adminIfFirst: true, | |
| 52 | }); | |
| 53 | if (!a.ok) throw new Error(a.error); | |
| 54 | alice = a.value; | |
| 55 | const made = await createRepo(app, alice, { | |
| 56 | name: "engine", | |
| 57 | visibility: "private", | |
| 58 | }); | |
| 59 | if (!made.ok) throw new Error(String(made.error)); | |
| 60 | repo = made.value; | |
| 61 | await commitToBranch(app, alice, repo, { | |
| 62 | message: "Start", | |
| 63 | files: [{ path: "a.ts", content: "one\ntwo\n" }], | |
| 64 | }); | |
| 65 | await commitToBranch(app, alice, repo, { | |
| 66 | branch: "feature/three", | |
| 67 | base: "main", | |
| 68 | message: "three", | |
| 69 | files: [{ path: "a.ts", content: "one\ntwo\nthree\n" }], | |
| 70 | }); | |
| 71 | const opened = await openPull(app, alice, repo, { | |
| 72 | title: "three", | |
| 73 | baseBranch: "main", | |
| 74 | headBranch: "feature/three", | |
| 75 | }); | |
| 76 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 77 | pull = opened.value; | |
| 78 | const agent = await createAgent(app, alice, { username: "juniper" }); | |
| 79 | if (!agent.ok) throw new Error(String(agent.error)); | |
| 80 | expect(addToCrew(app, alice, repo, "juniper").ok).toBe(true); | |
| 81 | token = createToken( | |
| 82 | app.db, | |
| 83 | getUserByName(app.db, "juniper")!.id, | |
| 84 | "run", | |
| 85 | "rw", | |
| 86 | ).token; | |
| 87 | }); | |
| 88 | ||
| 89 | afterAll(async () => { | |
| 90 | servers.stop(); | |
| 91 | app.close(); | |
| 92 | await rm(dataDir, { recursive: true, force: true }); | |
| 93 | }); | |
| 94 | ||
| 95 | test("a bad anchor posts nothing and says how an anchor is written", async () => { | |
| 96 | const refused = await post("review-states", { | |
| 97 | state: "changes_requested", | |
| 98 | body: "two things", | |
| 99 | comments: [ | |
| 100 | { path: "a.ts", line: 3, side: "new", body: "fine anchor" }, | |
| 101 | { path: "a.ts", line: 99, side: "new", body: "no such line" }, | |
| 102 | ], | |
| 103 | }); | |
| 104 | expect(refused.status).toBe(422); | |
| 105 | const said = (await refused.json()) as { error: string; message: string }; | |
| 106 | expect(said.error).toBe("bad_anchor"); | |
| 107 | expect(said.message).toContain("nothing was posted"); | |
| 108 | expect(said.message).toContain("@@ -a,b +c,d @@"); | |
| 109 | expect(listReviews(app.db, pull.id)).toHaveLength(0); | |
| 110 | expect(listReviewComments(app.db, pull.id)).toHaveLength(0); | |
| 111 | }); | |
| 112 | ||
| 113 | test("the verdict and its line comments land in one call", async () => { | |
| 114 | const sent = await post("review-states", { | |
| 115 | state: "changes_requested", | |
| 116 | body: "one thing", | |
| 117 | comments: [{ path: "a.ts", line: 3, side: "new", body: "say why" }], | |
| 118 | }); | |
| 119 | expect(sent.status).toBe(201); | |
| 120 | expect(listReviews(app.db, pull.id).map((r) => r.state)).toEqual([ | |
| 121 | "changes_requested", | |
| 122 | ]); | |
| 123 | const lines = listReviewComments(app.db, pull.id); | |
| 124 | expect(lines.map((c) => [c.path, c.line, c.body])).toEqual([ | |
| 125 | ["a.ts", 3, "say why"], | |
| 126 | ]); | |
| 127 | // One event for the line, one for the verdict: only the second carries | |
| 128 | // a state, and only that one wakes the builder. | |
| 129 | const kinds = listEvents(app.db, { repoId: repo.id }) | |
| 130 | .filter((e) => e.kind === "pull.reviewed") | |
| 131 | .map((e) => (typeof e.payload["state"] === "string" ? "verdict" : "line")); | |
| 132 | expect(kinds.sort()).toEqual(["line", "verdict"]); | |
| 133 | }); | |
| 134 | ||
| 135 | test("the same verdict sent twice on one head is one review", async () => { | |
| 136 | const again = await post("review-states", { | |
| 137 | state: "changes_requested", | |
| 138 | body: "one thing", | |
| 139 | comments: [{ path: "a.ts", line: 3, side: "new", body: "say why" }], | |
| 140 | }); | |
| 141 | expect(again.status).toBe(201); | |
| 142 | expect(listReviews(app.db, pull.id)).toHaveLength(1); | |
| 143 | expect(listReviewComments(app.db, pull.id)).toHaveLength(1); | |
| 144 | }); | |
| 145 | ||
| 146 | test("comments that are not a list of objects are refused", async () => { | |
| 147 | const refused = await post("review-states", { | |
| 148 | state: "commented", | |
| 149 | comments: "a.ts:3 looks off", | |
| 150 | }); | |
| 151 | expect(refused.status).toBe(422); | |
| 152 | expect(((await refused.json()) as { error: string }).error).toBe( | |
| 153 | "invalid_comments", | |
| 154 | ); | |
| 155 | }); | |
| 156 | ||
| 157 | test("a verdict sent to the line-comment route is told where it goes", async () => { | |
| 158 | const wrong = await post("reviews", { state: "approved", body: "ok" }); | |
| 159 | expect(wrong.status).toBe(422); | |
| 160 | const said = (await wrong.json()) as { error: string; message: string }; | |
| 161 | expect(said.error).toBe("bad_anchor"); | |
| 162 | expect(said.message).toContain("review-states"); | |
| 163 | expect(said.message).toContain('"side"'); | |
| 164 | }); | |
| 165 | }); |
test/review-route.test.ts added +249
| @@ -0,0 +1,249 @@ | ||
| 1 | /** | |
| 2 | * Who reviews a pull, and how a review run ends. A change to the | |
| 3 | * security-bearing paths goes to the reviewer whose brief says it reads for | |
| 4 | * security; everything else to the one who reads for the experience. Every | |
| 5 | * green head an agent wrote is sent, in any repository with a reviewer — | |
| 6 | * not only one that ships on its own. A review run is judged by its verdict: | |
| 7 | * it pushes nothing by design. | |
| 8 | */ | |
| 9 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 10 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 11 | import { tmpdir } from "node:os"; | |
| 12 | import { join } from "node:path"; | |
| 13 | import { createApp, type App } from "../src/app.ts"; | |
| 14 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; | |
| 15 | import { openPull } from "../src/actions/pulls.ts"; | |
| 16 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 17 | import { setCrewRole, type Agent } from "../src/core/agents.ts"; | |
| 18 | import type { Pull } from "../src/core/pulls.ts"; | |
| 19 | import type { Repo } from "../src/core/repos.ts"; | |
| 20 | import type { Review } from "../src/core/reviews.ts"; | |
| 21 | import { claimRun, createRun, finishRun, listRuns } from "../src/core/runs.ts"; | |
| 22 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; | |
| 23 | import { | |
| 24 | pickReviewer, | |
| 25 | REVIEW_LAPSE_MS, | |
| 26 | REVIEW_WAIT_MS, | |
| 27 | reviewStage, | |
| 28 | sweepReviews, | |
| 29 | touchesSecurity, | |
| 30 | watchesSecurity, | |
| 31 | } from "../src/crew/review.ts"; | |
| 32 | import { settleReview } from "../src/crew/runner.ts"; | |
| 33 | ||
| 34 | const SHARED = "How I work, always:\n- Security is not optional."; | |
| 35 | const agent = (name: string, brief: string): Agent => | |
| 36 | ({ user: { username: name }, brief }) as unknown as Agent; | |
| 37 | const juniper = agent( | |
| 38 | "juniper", | |
| 39 | `I am Juniper. I review the experience.\n\n${SHARED}`, | |
| 40 | ); | |
| 41 | const sentinel = agent( | |
| 42 | "sentinel", | |
| 43 | `I am Sentinel. I read every changed line for security.\n\n${SHARED}`, | |
| 44 | ); | |
| 45 | ||
| 46 | describe("which reviewer", () => { | |
| 47 | test("security is read from what the agent says it is, not the rules every brief shares", () => { | |
| 48 | expect(watchesSecurity(sentinel)).toBe(true); | |
| 49 | expect(watchesSecurity(juniper)).toBe(false); | |
| 50 | }); | |
| 51 | ||
| 52 | test("the security-bearing paths", () => { | |
| 53 | for (const p of [ | |
| 54 | "src/http/api.ts", | |
| 55 | "src/core/tokens.ts", | |
| 56 | "src/crew/sandbox.ts", | |
| 57 | "src/web/auth.ts", | |
| 58 | "docs/SECRETS.md", | |
| 59 | ]) | |
| 60 | expect(touchesSecurity([p])).toBe(true); | |
| 61 | expect(touchesSecurity(["src/web/pages/board.ts", "README.md"])).toBe( | |
| 62 | false, | |
| 63 | ); | |
| 64 | }); | |
| 65 | ||
| 66 | test("a security change to sentinel, any other to juniper, and one reviewer takes all", () => { | |
| 67 | const both = [juniper, sentinel]; | |
| 68 | expect(pickReviewer(both, ["src/core/tokens.ts"])).toBe(sentinel); | |
| 69 | expect(pickReviewer(both, ["src/web/pages/board.ts"])).toBe(juniper); | |
| 70 | expect(pickReviewer([juniper], ["src/http/api.ts"])).toBe(juniper); | |
| 71 | expect(pickReviewer([sentinel], ["README.md"])).toBe(sentinel); | |
| 72 | expect(pickReviewer([], ["README.md"])).toBeUndefined(); | |
| 73 | }); | |
| 74 | }); | |
| 75 | ||
| 76 | describe("a review that does not come back", () => { | |
| 77 | const green = "2026-10-02T10:00:00.000Z"; | |
| 78 | const at = (ms: number) => new Date(Date.parse(green) + ms); | |
| 79 | const sent = (ms: number, active: boolean) => ({ | |
| 80 | createdAt: at(ms).toISOString(), | |
| 81 | active, | |
| 82 | }); | |
| 83 | ||
| 84 | test("sent first, waited for while it is out, sent once more, then lapsed", () => { | |
| 85 | expect(reviewStage(green, [], at(1_000))).toBe("send"); | |
| 86 | expect(reviewStage(green, [sent(0, true)], at(60_000))).toBe("wait"); | |
| 87 | // Out past the wait, or back with nothing: one more. | |
| 88 | expect(reviewStage(green, [sent(0, true)], at(REVIEW_WAIT_MS))).toBe( | |
| 89 | "send", | |
| 90 | ); | |
| 91 | expect(reviewStage(green, [sent(0, false)], at(60_000))).toBe("send"); | |
| 92 | expect( | |
| 93 | reviewStage(green, [sent(0, false), sent(60_000, true)], at(120_000)), | |
| 94 | ).toBe("wait"); | |
| 95 | expect( | |
| 96 | reviewStage(green, [sent(0, false), sent(60_000, false)], at(120_000)), | |
| 97 | ).toBe("lapsed"); | |
| 98 | }); | |
| 99 | ||
| 100 | test("a head never sent — at the ceiling, say — still lapses, so nothing is held for ever", () => { | |
| 101 | expect(reviewStage(green, [], at(REVIEW_LAPSE_MS))).toBe("lapsed"); | |
| 102 | }); | |
| 103 | }); | |
| 104 | ||
| 105 | describe("how a review run ends", () => { | |
| 106 | const verdict = ( | |
| 107 | authorId: string, | |
| 108 | createdAt: string, | |
| 109 | commitOid = "old", | |
| 110 | ): Review => | |
| 111 | ({ authorId, state: "changes_requested", createdAt, commitOid }) as Review; | |
| 112 | const since = "2026-10-02T10:00:00.000Z"; | |
| 113 | ||
| 114 | test("a verdict posted during the run is ok, though nothing was pushed", () => { | |
| 115 | expect( | |
| 116 | settleReview( | |
| 117 | [verdict("j", "2026-10-02T10:05:00.000Z")], | |
| 118 | "j", | |
| 119 | since, | |
| 120 | "head", | |
| 121 | ), | |
| 122 | ).toEqual({ state: "ok", summary: "reviewed: changes requested" }); | |
| 123 | }); | |
| 124 | ||
| 125 | test("standing by its verdict on the head it was given is ok too", () => { | |
| 126 | expect( | |
| 127 | settleReview( | |
| 128 | [verdict("j", "2026-10-02T09:00:00.000Z", "head")], | |
| 129 | "j", | |
| 130 | since, | |
| 131 | "head", | |
| 132 | ).state, | |
| 133 | ).toBe("ok"); | |
| 134 | }); | |
| 135 | ||
| 136 | test("no verdict, an earlier one on another head, or someone else's is a failed run", () => { | |
| 137 | for (const reviews of [ | |
| 138 | [], | |
| 139 | [verdict("j", "2026-10-02T09:00:00.000Z")], | |
| 140 | [verdict("x", "2026-10-02T10:05:00.000Z")], | |
| 141 | ]) | |
| 142 | expect(settleReview(reviews, "j", since, "head").state).toBe("failed"); | |
| 143 | }); | |
| 144 | }); | |
| 145 | ||
| 146 | describe("every green pull an agent wrote is sent to review", () => { | |
| 147 | let dataDir: string; | |
| 148 | let app: App; | |
| 149 | let alice: User; | |
| 150 | let repo: Repo; | |
| 151 | ||
| 152 | beforeAll(async () => { | |
| 153 | dataDir = await mkdtemp(join(tmpdir(), "forge-review-route-")); | |
| 154 | app = createApp({ dataDir, port: 0 }); | |
| 155 | const a = await createUser(app.db, { | |
| 156 | username: "alice", | |
| 157 | email: "[email protected]", | |
| 158 | password: "a-good-passphrase", | |
| 159 | adminIfFirst: true, | |
| 160 | }); | |
| 161 | if (!a.ok) throw new Error(a.error); | |
| 162 | alice = a.value; | |
| 163 | const made = await createRepo(app, alice, { | |
| 164 | name: "engine", | |
| 165 | visibility: "private", | |
| 166 | }); | |
| 167 | if (!made.ok) throw new Error(String(made.error)); | |
| 168 | repo = made.value; | |
| 169 | await commitToBranch(app, alice, repo, { | |
| 170 | message: "Start", | |
| 171 | files: [{ path: "README.md", content: "# engine\n" }], | |
| 172 | }); | |
| 173 | for (const [name, brief] of [ | |
| 174 | ["scout", "I am Scout. I ship small things."], | |
| 175 | ["juniper", juniper.brief], | |
| 176 | ["sentinel", sentinel.brief], | |
| 177 | ] as const) { | |
| 178 | const made = await createAgent(app, alice, { username: name, brief }); | |
| 179 | if (!made.ok) throw new Error(String(made.error)); | |
| 180 | expect(addToCrew(app, alice, repo, name).ok).toBe(true); | |
| 181 | } | |
| 182 | for (const name of ["juniper", "sentinel"]) | |
| 183 | setCrewRole(app.db, repo.id, getUserByName(app.db, name)!.id, "reviewer"); | |
| 184 | // Not autonomous: review is not the shipping loop's alone. | |
| 185 | expect(repo.autonomy).not.toBe("autonomous"); | |
| 186 | }); | |
| 187 | ||
| 188 | afterAll(async () => { | |
| 189 | await app.close?.(); | |
| 190 | await rm(dataDir, { recursive: true, force: true }); | |
| 191 | }); | |
| 192 | ||
| 193 | async function pullBy(author: User, name: string, path: string) { | |
| 194 | await commitToBranch(app, alice, repo, { | |
| 195 | branch: `feature/${name}`, | |
| 196 | base: "main", | |
| 197 | message: name, | |
| 198 | files: [{ path, content: `${name}\n` }], | |
| 199 | }); | |
| 200 | const opened = await openPull(app, alice, repo, { | |
| 201 | title: name, | |
| 202 | baseBranch: "main", | |
| 203 | headBranch: `feature/${name}`, | |
| 204 | }); | |
| 205 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 206 | app.db | |
| 207 | .query("UPDATE issue SET author_id = ?2 WHERE id = ?1") | |
| 208 | .run(opened.value.id, author.id); | |
| 209 | return opened.value; | |
| 210 | } | |
| 211 | ||
| 212 | function pass(pull: Pull): void { | |
| 213 | const check = createRun(app.db, { | |
| 214 | repoId: repo.id, | |
| 215 | kind: "check", | |
| 216 | issueId: pull.id, | |
| 217 | env: `pull-${pull.number}`, | |
| 218 | commitOid: pull.headOid, | |
| 219 | reason: "pull", | |
| 220 | }); | |
| 221 | claimRun(app.db, check.id); | |
| 222 | finishRun(app.db, check.id, { state: "ok", summary: "check passed" }); | |
| 223 | } | |
| 224 | ||
| 225 | const reviewer = (pull: Pull) => | |
| 226 | listRuns(app.db, repo.id, { issueId: pull.id }) | |
| 227 | .filter((r) => r.reason === "review") | |
| 228 | .map((r) => getUserByName(app.db, "juniper")!.id === r.actorId ? "juniper" : "sentinel"); | |
| 229 | ||
| 230 | test("green pulls go to the right reviewer, once; red and people's pulls wait", async () => { | |
| 231 | const scout = getUserByName(app.db, "scout")!; | |
| 232 | const page = await pullBy(scout, "page", "src/web/page.ts"); | |
| 233 | const auth = await pullBy(scout, "auth", "src/http/auth.ts"); | |
| 234 | const red = await pullBy(scout, "red", "src/web/red.ts"); | |
| 235 | const person = await pullBy(alice, "mine", "src/web/mine.ts"); | |
| 236 | pass(page); | |
| 237 | pass(auth); | |
| 238 | pass(person); | |
| 239 | ||
| 240 | expect(await sweepReviews(app)).toBe(2); | |
| 241 | expect(reviewer(page)).toEqual(["juniper"]); | |
| 242 | expect(reviewer(auth)).toEqual(["sentinel"]); | |
| 243 | expect(reviewer(red)).toEqual([]); | |
| 244 | expect(reviewer(person)).toEqual([]); | |
| 245 | ||
| 246 | // The next tick sends nothing twice. | |
| 247 | expect(await sweepReviews(app)).toBe(0); | |
| 248 | }); | |
| 249 | }); |
new above
offline