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

Cross-team work: an issue can name two teams, and both review its pull #2839

Merged opened this

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

Merged

scout/forge:scout/2681-cross-team-work-an-issue-can-name-two-te → main 6 commits fork 11 threads · 11 unresolved

  • Merged. scout/2681-cross-team-work-an-issue-can-name-two-te was merged into main as 50b7533.

Review

Sign in to review this change.
19 files changed +916 −3
README.md +2

Diff not shown — a line 2,529 characters wide (limit 2,000). +2

View file
src/actions/issue_teams.ts added +45
@@ -0,0 +1,45 @@
1 /**
2 * Naming the crew teams on an issue (#2681), for a writer of its repository.
3 * A mirror's issues are the origin's, so it is refused as every local write is.
4 */
5
6 import type { App } from "../app.ts";
7 import { setIssueTeams, TEAMS_MAX, type IssueTeamsError } from "../core/issue_teams.ts";
8 import type { Issue } from "../core/issues.ts";
9 import type { Repo } from "../core/repos.ts";
10 import type { User } from "../core/users.ts";
11 import { err, type Result } from "../kernel/result.ts";
12 import { isMirror } from "../origins/write-through.ts";
13 import { requireWriter, type AccessError } from "./access.ts";
14
15 /** One sentence per refusal, said the same by the page, API, MCP tool and terminal. */
16 export const ISSUE_TEAMS_SAY: Record<string, string> = {
17 too_many: `An issue names at most ${TEAMS_MAX} teams.`,
18 no_such_team: "No such team.",
19 mirrored:
20 "This repository mirrors an origin — do it there, and the next sync brings it back.",
21 unauthorized: "Sign in to name teams.",
22 forbidden: "You need write access to name teams.",
23 not_found: "No such issue.",
24 };
25
26 export function nameTeams(
27 app: App,
28 actor: User | undefined,
29 repo: Repo,
30 issue: Issue,
31 slugs: readonly string[],
32 ): Result<string[], AccessError | "mirrored" | IssueTeamsError> {
33 const who = requireWriter(app, actor, repo);
34 if (!who.ok) return who;
35 if (isMirror(app, repo)) return err("mirrored");
36 const set = setIssueTeams(app.db, issue.id, slugs);
outdated

High: no app.bus.append after a successful set. Open pages don't update live and the audit has no record. Append an issue.teams event like the labeled one in actions/issues.ts.

37 if (!set.ok) return set;
38 app.bus.append({
39 kind: "issue.teams",
40 repoId: repo.id,
41 actorId: who.value.id,
42 payload: { number: issue.number, teams: set.value },
43 });
44 return set;
45 }
src/core/issue_teams.ts added +98
@@ -0,0 +1,98 @@
1 /**
2 * Cross-team work (#2681): the crew teams an issue names (at most two) and,
3 * for a pull, the teams of the issues it closes and where each one's review
4 * stands on the head. Pure data: no authz, no events.
5 */
6
7 import type { Database } from "bun:sqlite";
8 import { err, ok, type Result } from "../kernel/result.ts";
9 import { getIssue } from "./issues.ts";
10 import { closesFromBody } from "./pulls.ts";
11 import { listReviews, type Review, type ReviewState } from "./reviews.ts";
12 import { getTeam } from "./teams.ts";
13
14 export const TEAMS_MAX = 2;
15
16 export type IssueTeamsError = "too_many" | "no_such_team";
17
18 /** The slugs an issue names, in the order they were named. */
19 export function teamsOfIssue(db: Database, issueId: string): string[] {
20 return db
21 .query<{ team_slug: string }, [string]>(
22 "SELECT team_slug FROM issue_team WHERE issue_id = ?1 ORDER BY rowid",
23 )
24 .all(issueId)
25 .map((r) => r.team_slug);
26 }
27
28 /** Replace the teams an issue names: at most two, each a team that exists. */
29 export function setIssueTeams(
30 db: Database,
31 issueId: string,
32 slugs: readonly string[],
33 ): Result<string[], IssueTeamsError> {
34 const unique = [...new Set(slugs)];
35 if (unique.length > TEAMS_MAX) return err("too_many");
36 for (const s of unique) if (!getTeam(db, s)) return err("no_such_team");
37 db.transaction(() => {
38 db.query("DELETE FROM issue_team WHERE issue_id = ?1").run(issueId);
39 for (const s of unique)
40 db.query("INSERT INTO issue_team (issue_id, team_slug) VALUES (?1, ?2)").run(issueId, s);
41 })();
42 return ok(unique);
43 }
44
45 /** The teams a pull must be read by: those named by the issues its body closes, at most two. */
46 export function teamsOfPull(
47 db: Database,
48 pull: { repoId: string; body: string },
49 ): string[] {
50 const out: string[] = [];
51 for (const n of closesFromBody(pull.body)) {
52 const issue = getIssue(db, pull.repoId, n);
53 if (!issue) continue;
54 for (const s of teamsOfIssue(db, issue.id)) if (!out.includes(s)) out.push(s);
55 }
56 return out.slice(0, TEAMS_MAX);
57 }
58
59 export interface TeamVerdict {
60 readonly team: string;
61 readonly name: string;
62 /** The latest verdict by a member of the team; undefined while none has spoken. */
63 readonly state: ReviewState | undefined;
64 readonly by: string | undefined;
65 /** Whether that verdict was passed on an older head. */
66 readonly stale: boolean;
67 }
68
69 /** Where each named team's review stands: the newest verdict from any of its members. */
70 export function teamVerdicts(
71 db: Database,
72 pull: { id: string; repoId: string; body: string; headOid: string },
73 ): TeamVerdict[] {
74 // Each member's newest verdict that is not a plain comment: an approval
75 // followed by a comment still stands.
outdated

Low: this filters out commented after latestReviewByAuthor has kept each author's newest review. If a member approves and then leaves a plain comment, their approval disappears, the team reads as awaited again, and sendToTeams queues another review run. Take each member's newest non-comment verdict instead, and add a test for approve-then-comment.

76 const newest = new Map<string, Review>();
77 for (const r of listReviews(db, pull.id))
78 if (r.originAuthor === null && r.state !== "commented") newest.set(r.authorId, r);
79 const latest = [...newest.values()];
80 return teamsOfPull(db, pull).map((slug) => {
81 const team = getTeam(db, slug)!;
82 const mine = latest
83 .filter((r) => team.members.includes(r.authorId))
84 .sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1));
85 const fresh = mine.find((r) => r.commitOid === pull.headOid) ?? mine[0];
86 return {
87 team: slug,
88 name: team.name,
89 state: fresh?.state,
90 by: fresh?.authorName,
91 stale: fresh !== undefined && fresh.commitOid !== pull.headOid,
92 };
93 });
94 }
95
96 /** Whether every named team has a current verdict on the head (vacuously true with no teams). */
97 export const teamsAnswered = (verdicts: readonly TeamVerdict[]): boolean =>
98 verdicts.every((v) => v.state !== undefined && !v.stale);
src/core/migrations/20261019160000-issue-teams.ts added +9
@@ -0,0 +1,9 @@
1 // Cross-team work (#2681): an issue may name up to two crew teams; the pull
2 // that closes it is reviewed by each. Additive: a new table, nothing altered.
3 export default `
4 CREATE TABLE issue_team (
5 issue_id TEXT NOT NULL REFERENCES issue(id) ON DELETE CASCADE,
6 team_slug TEXT NOT NULL REFERENCES crew_team(slug) ON DELETE CASCADE,
7 PRIMARY KEY (issue_id, team_slug)
8 );
9 `;
src/crew/dispatch.ts +3 −1
@@ -290,6 +290,8 @@ export function queueAgentRun(
290290 again?: { notBefore: string; attempt: number; summary: string },
291291 variantGroup?: string,
292292 retry = false,
293 /** The run is this agent's own on the issue: another agent's run does not stand in its way (team reviews). */
294 own = false,
293295 ): Run | undefined {
294296 // A suspended or removed owner's repositories run nothing, whoever asks.
295297 if (!accountActive(app.db, repo.ownerId)) return undefined;
@@ -343,7 +345,7 @@ export function queueAgentRun(
343345 const run = transact(app.db, () => {
344346 // A follow-up is the builder's: a reviewer's own run on the pull, still
345347 // running as it posts its verdict, is not the run that hears it.
346 const mine = reason === "feedback" ? agent.user.id : undefined;
348 const mine = reason === "feedback" || own ? agent.user.id : undefined;
347349 const active = activeRunFor(app.db, "agent", issue.id, mine);
348350 // A follow-up with nothing new would only read the agent's own reply
349351 // and decline. With a run in flight the answer waits for it to end: the
src/crew/review.ts +63
@@ -24,6 +24,7 @@ import { getHold, holdsFor, lastReleasesFor } from "../core/holds.ts";
2424 import { getIssueById, LIST_LIMIT_MAX } from "../core/issues.ts";
2525 import { listPulls, pullDiff, type Pull, pullDir} from "../core/pulls.ts";
2626 import { getRepoById, type Repo } from "../core/repos.ts";
27 import { teamsOfPull, teamVerdicts } from "../core/issue_teams.ts";
2728 import { reviewSummariesFor, reviewSummary } from "../core/reviews.ts";
2829 import { checksForHead } from "../core/runs.ts";
2930 import { getTeam } from "../core/teams.ts";
@@ -398,6 +399,10 @@ export async function sendToReview(
398399 if (getHold(app.db, pull.id) !== undefined) return "held";
399400 const reviewers = reviewersOf(app.db, repo.id);
400401 if (reviewers.length === 0) return "none";
402 if (teamsOfPull(app.db, pull).length > 0) {
403 const sent = await sendToTeams(app, repo, reviewers, pull, now);
404 if (sent !== undefined) return sent;
405 }
401406 const seen = reviewSummary(app.db, pull.id, pull.headOid);
402407 if (seen.approvals > 0 || seen.changesRequested > 0) return "done";
403408 if (handedOver(app.db, repo.id, pull) !== undefined) return "held";
@@ -440,6 +445,64 @@ export async function sendToReview(
440445 return "sent";
441446 }
442447
448 /**
449 * A pull whose issue names teams (#2681) is read once per team: each team
450 * without a current verdict gets a review run for one of its members who
451 * is a reviewer on the crew. A reviewer on both teams is sent one run, and
452 * their verdict answers both. When no named team has a reviewer on this
453 * crew (teams start empty) there is nobody to ask by team, and this answers
454 * undefined: the ordinary single review goes ahead. A team that cannot be
455 * asked while another can waits, and lapses to a person only with the
456 * hour (`REVIEW_LAPSE_MS`), never at the first sweep.
457 */
458 async function sendToTeams(
459 app: App,
460 repo: Repo,
461 reviewers: readonly Agent[],
462 pull: Pull,
463 now: Date,
464 ): Promise<"done" | "sent" | "wait" | "lapsed" | "held" | undefined> {
465 const named = teamVerdicts(app.db, pull);
466 const asks = (team: string): Agent[] => {
467 const members = getTeam(app.db, team)?.members ?? [];
468 return reviewers.filter((r) => members.includes(r.user.id));
469 };
470 if (!named.some((v) => asks(v.team).length > 0)) return undefined;
471 const pending = named.filter((v) => v.state === undefined || v.stale);
472 if (pending.length === 0) return "done";
473 if (handedOver(app.db, repo.id, pull) !== undefined) return "held";
474 const history = reviewAttempts(app.db, pull);
475 const issue = getIssueById(app.db, pull.id);
476 if (history === undefined || !issue) return "wait";
477 if (now.getTime() - Date.parse(history.greenAt) >= REVIEW_LAPSE_MS) return "lapsed";
478 let sent = false;
479 // A reviewer on two pending teams is asked once: their verdict answers both.
480 const asked = new Set<string>();
481 for (const v of pending) {
482 const mine = asks(v.team);
483 if (mine.some((r) => asked.has(r.user.id))) continue;
484 const tries = (r: Agent) => history.attempts.filter((a) => a.reviewerId === r.user.id);
485 const open = mine.some((r) => {
486 const last = tries(r).at(-1);
487 return last?.active === true && now.getTime() - Date.parse(last.createdAt) < REVIEW_WAIT_MS;
488 });
489 if (open) continue;
490 const reviewer = mine.find((r) => tries(r).length < REVIEW_TRIES);
491 if (reviewer === undefined) continue;
492 if (queueAgentRun(app, repo, issue, reviewer, "review", undefined, undefined, undefined, false, true) !== undefined) {
493 sent = true;
494 asked.add(reviewer.user.id);
495 log.info("green; queued for team review", {
496 repo: `${repo.ownerName}/${repo.name}`,
497 pull: pull.number,
498 team: v.team,
499 reviewer: reviewer.user.username,
500 });
501 }
502 }
503 return sent ? "sent" : "wait";
504 }
505
443506 /** Review runs one sweep pass sends at most: the tick stays bounded by sends, never by which pulls it looks at. */
444507 export const SWEEP_SENDS = 10;
445508
src/design/activity.ts +1
@@ -95,6 +95,7 @@ const WORDS: Record<string, string> = {
9595 "issue.commented": "commented on an issue",
9696 "issue.edited": "edited an issue",
9797 "issue.labeled": "labelled an issue",
98 "issue.teams": "named the teams on an issue",
9899 "issue.sealed": "sealed a security report",
99100 "issue.unsealed": "unsealed a security report",
100101 "pull.opened": "opened a pull request",
src/http/api.ts +26
@@ -27,6 +27,8 @@ import { askCheck } from "../checks/again.ts";
2727 import { mayView, mayViewId, sealedScope } from "../core/sealed.ts";
2828 import { changeCapacity, listCapacity } from "../actions/capacity.ts";
2929 import { changeLimit, listLimits } from "../actions/limits.ts";
30 import { ISSUE_TEAMS_SAY, nameTeams } from "../actions/issue_teams.ts";
31 import { teamsOfIssue } from "../core/issue_teams.ts";
3032 import { changeTeam, readTeams, TEAM_SAYS } from "../actions/teams.ts";
3133 import { changeQuotas, readQuotas } from "../actions/quotas.ts";
3234 import { changeSignup, readSignup } from "../actions/signup.ts";
@@ -2832,6 +2834,30 @@ export function apiRoutes(router: Router<Handler>): void {
28322834 labelsHandler,
28332835 );
28342836
2837 // Name up to two crew teams on an issue: the pull that closes it is
2838 // reviewed by each (`core/issue_teams.ts`).
2839 router.add("GET", "/api/v1/repos/:owner/:name/issues/:number/teams", (ctx) => {
2840 const found = loadIssue(ctx);
outdated

API-only. Needs a page form, an MCP tool and a src/tty.ts action that call the same nameTeams, plus a test for this route's 400 and 422 answers and the scope check.

2841 if ("error" in found) return found.error;
2842 return json({ teams: teamsOfIssue(ctx.app.db, found.issue.id) });
2843 });
2844 router.add("PUT", "/api/v1/repos/:owner/:name/issues/:number/teams", async (ctx) => {
2845 const denied = requireScope(ctx, "rw");
2846 if (denied) return denied;
2847 const found = loadIssue(ctx);
2848 if ("error" in found) return found.error;
2849 const input = await body(ctx.request);
2850 const slugs = Array.isArray(input.teams) ? input.teams : undefined;
outdated

mirrored falls through to a bare 422 {error:"mirrored"} with no sentence. Check what the other local writes answer for a mirror and match it.

2851 if (slugs === undefined || !slugs.every((t): t is string => typeof t === "string"))
2852 return json({ error: "teams must be a list of team slugs" }, 400);
2853 const set = nameTeams(ctx.app, ctx.auth.user, found.repo, found.issue, slugs);
2854 if (!set.ok)
2855 return set.error === "too_many" || set.error === "no_such_team"
2856 ? apiError(set.error, 422, ISSUE_TEAMS_SAY[set.error]!)
2857 : originAware(set.error);
2858 return json({ teams: set.value });
2859 });
2860
28352861 // ── issues — the label-as-queue surface automation drives ────────────
28362862
28372863 router.add("GET", "/api/v1/repos/:owner/:name/issues", (ctx) => {
src/http/mcp.ts +34
@@ -24,6 +24,8 @@ import { mayView, mayViewId, sealedScope } from "../core/sealed.ts";
2424 import { listAccess } from "../actions/access_log.ts";
2525 import { changeCapacity, listCapacity } from "../actions/capacity.ts";
2626 import { changeLimit, listLimits } from "../actions/limits.ts";
27 import { ISSUE_TEAMS_SAY, nameTeams } from "../actions/issue_teams.ts";
28 import { teamsOfIssue } from "../core/issue_teams.ts";
2729 import { changeTeam, planTeam, readTeams, TEAM_SAYS } from "../actions/teams.ts";
2830 import { addAccount, allowWrite, auditAccount, removeAccount, renameAccount, reverify, rotateAccount } from "../actions/cloud.ts";
2931 import { usesByVendor, type VendorUse } from "../cloud/declared.ts";
@@ -890,6 +892,38 @@ const TOOLS: readonly Tool[] = [
890892 });
891893 },
892894 },
895 {
896 name: "issue_teams",
897 description:
898 "Name the crew teams (design, data-science, security, platform) on an issue: at most two. The pull whose body closes it (`Closes #N`) is sent to review once per named team, and the pull page shows each team's verdict. `teams` replaces the set (an empty list clears it); without `teams`, the issue's teams are read. A writer's call. The same as the Teams card on the issue page.",
899 inputSchema: {
900 type: "object",
901 properties: {
902 repo: REPO_ARG,
903 number: { type: "integer", minimum: 1 },
904 teams: { type: "array", maxItems: 2, items: { type: "string", maxLength: 64 } },
905 },
906 required: ["repo", "number"],
907 },
908 writes: true,
909 run(ctx, user, args) {
910 const repo = repoArg(ctx, user, args);
911 if (isAnswer(repo)) return repo;
912 const issue = issueArg(ctx, repo, args, "number");
913 if ("ok" in issue) return issue;
914 const teams = args["teams"];
915 if (teams === undefined) return good({ number: issue.number, teams: teamsOfIssue(ctx.app.db, issue.id) });
916 if (!Array.isArray(teams) || !teams.every((t) => typeof t === "string"))
917 return bad("invalid", "teams is a list of team slugs");
918 const set = nameTeams(ctx.app, user, repo, issue, teams);
919 if (!set.ok) return bad(set.error, ISSUE_TEAMS_SAY[set.error] ?? "that could not be changed");
920 return good({
921 number: issue.number,
922 teams: set.value,
923 says: set.value.length === 0 ? `#${issue.number} names no team.` : `#${issue.number} names ${set.value.join(" and ")}.`,
924 });
925 },
926 },
893927 {
894928 name: "now",
895929 description:
src/http/server.ts +2
@@ -278,6 +278,7 @@ import { dependencySubmit } from "../web/pages/thread.ts";
278278 import {
279279 assigneeSubmit,
280280 labelsSubmit,
281 teamsSubmit,
281282 stateSubmit,
282283 wordsSubmit,
283284 } from "../web/pages/thread.ts";
@@ -1059,6 +1060,7 @@ export function buildRouter(): Router<Handler> {
10591060 router.add("POST", "/:owner/:name/issues/:number/labels", (ctx) =>
10601061 labelsSubmit(ctx, "issue"),
10611062 );
1063 router.add("POST", "/:owner/:name/issues/:number/teams", (ctx) => teamsSubmit(ctx));
10621064 router.add("POST", "/:owner/:name/issues/:number/assignee", (ctx) =>
10631065 assigneeSubmit(ctx, "issue"),
10641066 );
src/tui/screens/issue.ts +35
@@ -19,6 +19,9 @@ import {
1919 setIssueLabelsThrough,
2020 setIssueStateThrough,
2121 } from "../../actions/issues.ts";
22 import { ISSUE_TEAMS_SAY, nameTeams } from "../../actions/issue_teams.ts";
23 import { teamsOfIssue, TEAMS_MAX } from "../../core/issue_teams.ts";
24 import { listTeams } from "../../core/teams.ts";
2225 import { listRepoRuns } from "../../actions/runs.ts";
2326 import { alreadyDone } from "../../core/done_on_main.ts";
2427 import { getIssue, type Issue, type Label } from "../../core/issues.ts";
@@ -221,6 +224,7 @@ export function issueScreen(
221224 disabled: detail?.issue.state !== "closed",
222225 }),
223226 binding(["l"], "labels"),
227 binding(["t"], "teams"),
224228 ]
225229 : liveBindings(src)),
226230 binding(["r"], "refresh"),
@@ -288,6 +292,18 @@ export function issueScreen(
288292 { text: label.name, color: label.color },
289293 right,
290294 );
295 // The teams that review the closing pull, shown to every reader.
296 const named = issueRow === undefined ? [] : teamsOfIssue(f.db, issueRow.id);
297 if (named.length > 0) {
298 const names = new Map(listTeams(f.db).map((t) => [t.slug, t.name]));
299 s.text(
300 x + 1,
301 head!.y + 1,
302 truncate(`teams: ${named.map((t) => names.get(t) ?? t).join(", ")}`, Math.max(0, right - x - 1)),
303 style(c.mutedFg, c.bg),
304 right,
305 );
306 }
291307 if (crewH > 0) {
292308 const inner = card(s, crewRow!, c, {
293309 title: `Crew · ${plural(crew.runs.length, "run")}`,
@@ -387,6 +403,25 @@ export function issueScreen(
387403 );
388404 return true;
389405 }
406 if (e.key === "t" && mayWrite()) {
407 const at = row();
408 if (at?.issue === undefined) return true;
409 const { repo: target, issue } = at;
410 ctx.push(
411 pickerScreen(ctx, screen, {
412 title: `Teams (at most ${TEAMS_MAX})`,
413 multi: true,
414 items: listTeams(f.db).map((t) => ({ id: t.slug, label: t.name })),
415 chosen: teamsOfIssue(f.db, issue.id),
416 onPick: (slugs) => {
417 const set = nameTeams(f.app, f.user, target, issue, slugs);
418 if (!set.ok) ctx.toast(ISSUE_TEAMS_SAY[set.error] ?? errorText(set.error), "danger");
419 else ctx.toast(set.value.length === 0 ? "teams cleared" : `teams: ${set.value.join(", ")}`, "success");
420 },
421 }),
422 );
423 return true;
424 }
390425 if (e.key === "r") {
391426 runAsync(ctx, reload);
392427 return true;
src/web/live.ts +1 −1
@@ -295,7 +295,7 @@ export const liveClientJs = /* js */ `
295295
296296 var KINDS = [
297297 'push', 'repo.created', 'repo.forked',
298 'issue.opened', 'issue.closed', 'issue.reopened', 'issue.commented', 'issue.labeled', 'issue.edited',
298 'issue.opened', 'issue.closed', 'issue.reopened', 'issue.commented', 'issue.labeled', 'issue.teams', 'issue.edited',
299299 'pull.opened', 'pull.closed', 'pull.reopened', 'pull.merged', 'pull.commented', 'pull.reviewed',
300300 'pull.held', 'pull.released', 'pull.edited', 'pull.run_granted',
301301 'branch.created', 'branch.deleted',
src/web/pages/pull.ts +27
@@ -71,6 +71,7 @@ import {
7171 type ThreadView,
7272 } from "../diff-view.ts";
7373 import { field, readForm } from "../form.ts";
74 import { teamVerdicts, type TeamVerdict } from "../../core/issue_teams.ts";
7475 import { html, raw, when, type Html } from "../html.ts";
7576 import { flashTo, fragment, page, redirect } from "../layout.ts";
7677 import { liveRegionsJs } from "../client.ts";
@@ -643,6 +644,31 @@ function threadStateViews(
643644 return views;
644645 }
645646
647 const VERDICT_WORDS: Record<string, string> = {
648 approved: "approved",
649 changes_requested: "asked for changes",
650 commented: "commented",
651 };
652
653 /** One line per team the closed issue names: whose verdict it is, or that it is awaited (#2681). */
654 export function teamsCard(verdicts: readonly TeamVerdict[]): Html {
655 if (verdicts.length === 0) return html``;
656 return html`<section class="card team-verdicts" aria-label="Team reviews">

Low (design): build this with card(body, { head }) like issueTeamsCard does. .team-verdicts has no style, so the <ul> renders with browser defaults. Tracked in #2888.

657 <ul>
658 ${verdicts.map(
659 (v) => html`<li>
660 <strong>${v.name}</strong>:
661 ${v.state === undefined
662 ? html`<span class="muted">awaiting a reviewer</span>`
outdated

This was inserted under reviewCard's doc comment, which now documents VERDICT_WORDS. Move the block above that comment.

663 : html`${VERDICT_WORDS[v.state] ?? v.state} by ${v.by ?? ""}${v.stale
664 ? html` <span class="muted">(on an older head)</span>`
665 : ""}`}
666 </li>`,
667 )}
668 </ul>
669 </section>`;
670 }
671
646672 /**
647673 * The review card: pass a verdict.
648674 *
@@ -891,6 +917,7 @@ export async function pullFilesPage(ctx: Ctx): Promise<Response> {
891917 </p>
892918 ${when(wall, (w) => wallCard(w))}
893919 ${holdCard(ctx, repo, pull, { act: false })}
920 ${teamsCard(teamVerdicts(ctx.app.db, { ...pull, headOid: status.headOid }))}

Only on /files. pullPage (/pulls/:n) should show the team verdicts too; that's where a person reads a pull's review state.

High (parity): this card has no counterpart in the terminal. src/tui/screens/pull.ts should show the same teamVerdicts lines, with a test, so a reader there can see which team is still owed.

894921 ${mergeBox(ctx, repo, pull, status, mayMerge, by, check, isApp, tier, stale)}
895922 <div class="split">
896923 <div>
src/web/pages/thread.ts +57
@@ -9,6 +9,9 @@
99 */
1010
1111 import { mayView } from "../../core/sealed.ts";
12 import { ISSUE_TEAMS_SAY, nameTeams } from "../../actions/issue_teams.ts";
13 import { teamsOfIssue, TEAMS_MAX } from "../../core/issue_teams.ts";
14 import { listTeams } from "../../core/teams.ts";
1215 import { listCrew } from "../../actions/crew.ts";
1316 import {
1417 confirmIssue,
@@ -143,6 +146,11 @@ const EVENT_LOOK: Record<string, { icon: IconName; tone: Tone; verb: string }> =
143146 tone: "default",
144147 verb: "changed the labels",
145148 },
149 "issue.teams": {
150 icon: "tag",
151 tone: "default",
152 verb: "changed the teams",
153 },
146154 "pull.reviewed": {
147155 icon: "comment",
148156 tone: "default",
@@ -888,6 +896,55 @@ export async function labelsSubmit(
888896 });
889897 }
890898
899 // ---- teams ----
900
901 /** The teams rail of an issue (#2681): up to two, for writers a form. */
902 export function issueTeamsCard(ctx: Ctx, repo: Repo, issue: Issue, action: string): Html {
903 const named = teamsOfIssue(ctx.app.db, issue.id);
904 const all = listTeams(ctx.app.db);
905 const head = html`${icon("tag")}
906 <h2>Teams</h2>`;
907 const chips =
908 named.length === 0
909 ? html`<p class="muted small">None named.</p>`
910 : html`<p>${named.map((slug) => all.find((t) => t.slug === slug)?.name ?? slug).join(", ")}</p>`;
911 if (!canWrite(ctx.app.db, repo, ctx.auth.user) || repo.origin !== null)
912 return card(chips, { head });
913 return card(
914 html`${chips}
915 <form method="post" action="${action}" class="mt">
916 <fieldset class="stack">
917 <legend class="sr-only">Teams that review the pull closing this issue (at most ${TEAMS_MAX})</legend>
918 ${all.map(
919 (t) => html`<label class="check">
920 <input type="checkbox" name="teams" value="${t.slug}" ${named.includes(t.slug) ? raw("checked") : null} />
921 ${t.name}
922 </label>`,
923 )}
924 </fieldset>
925 <div class="form-actions">${btn("Name teams", { size: "sm", variant: "primary" })}</div>
926 </form>`,
927 { head },
928 );
929 }
930
931 /** POST …/issues/:number/teams — replace the teams an issue names. Writers only. */
932 export async function teamsSubmit(ctx: Ctx): Promise<Response> {
933 const found = threadContext(ctx, "issue");
934 if (found instanceof Response) return found;
935 if (!ctx.auth.user) return signInFirst(ctx);
936 const form = await readForm(ctx, "rw");
937 if (form instanceof Response) return form;
938 const dest = `${repoBase(found.repo)}/issues/${found.issue.number}`;
939 const chosen = form.getAll("teams").filter((v): v is string => typeof v === "string");
940 const set = nameTeams(ctx.app, ctx.auth.user, found.repo, found.issue, chosen);
941 if (!set.ok)
942 return flashTo(ctx, dest, { tone: "danger", message: ISSUE_TEAMS_SAY[set.error] ?? "That could not be changed." });
943 return flashTo(ctx, dest, {
944 message: set.value.length === 0 ? "Teams cleared." : `Teams set to ${set.value.join(", ")}.`,
945 });
946 }
947
891948 export { labelsFor, listComments, when };
892949
893950 // ---- dependencies ----
src/web/studio/change.ts +3 −1
@@ -64,7 +64,8 @@ import {
6464 } from "../ui.ts";
6565 import { previewCard } from "../pages/apps.ts";
6666 import { lookCard } from "../pages/looks.ts";
67 import { approveHereCard, pullChecksLineFor } from "../pages/pull.ts";
67 import { approveHereCard, pullChecksLineFor, teamsCard } from "../pages/pull.ts";
68 import { teamVerdicts } from "../../core/issue_teams.ts";
6869 import { liveRegionsJs } from "../client.ts";
6970 import { pullState } from "../pages/pulls.ts";
7071 import { workSpent } from "../pages/runs.ts";
@@ -589,6 +590,7 @@ async function changeConversation(
589590 <div id="pull-check">${checkLine}</div>
590591 ${when(runLine, (l) => l)} ${holdCard(ctx, repo, pull, { act: true })}
591592 ${approveHereCard(ctx, repo, pull, headOid, `${thread}/approve-here`)}
593 ${teamsCard(teamVerdicts(db, { ...pull, headOid }))}
592594 ${when(wall, (w) => wallCard(w))} ${decisionSection}
593595 ${takeBackSection}
594596 ${when(record, (r) => lookCard(ctx, repo, r, pull, pull.headOid))}
src/web/studio/tell.ts +2
@@ -58,6 +58,7 @@ import {
5858 conversation,
5959 dependenciesCard,
6060 labelsCard,
61 issueTeamsCard,
6162 originGlyph,
6263 threadContext,
6364 } from "../pages/thread.ts";
@@ -383,6 +384,7 @@ async function studioTell(
383384 <summary>Details</summary>
384385 ${assigneeCard(ctx, repo, issue, `${thread}/assignee`)}
385386 ${labelsCard(ctx, repo, issue, applied, allLabels, `${thread}/labels`)}
387 ${issueTeamsCard(ctx, repo, issue, `${thread}/teams`)}
386388 ${sealCard(ctx, repo, issue, `${thread}/seal`)}
387389 </details>`;
388390
test/issue-teams-doors.test.ts added +336
@@ -0,0 +1,336 @@
1 /**
2 * Cross-team work (#2681), every door: the API route's answers, the issue
3 * page's Teams form, the pull page's verdicts, the MCP tool and the
4 * terminal's `t` key all name teams through `nameTeams`; and a team nobody
5 * on the crew can ask never stops the pull being reviewed.
6 */
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 { addToCrew, createAgent } from "../src/actions/crew.ts";
13 import { openIssue } from "../src/actions/issues.ts";
14 import { openPull } from "../src/actions/pulls.ts";
15 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
16 import { createApp, type App } from "../src/app.ts";
17 import { setCrewRole } from "../src/core/agents.ts";
18 import { listEvents } from "../src/core/events.ts";
19 import { teamsOfIssue, teamVerdicts } from "../src/core/issue_teams.ts";
20 import type { Issue } from "../src/core/issues.ts";
21 import type { Pull } from "../src/core/pulls.ts";
22 import { getRepoById, type Repo } from "../src/core/repos.ts";
23 import { submitReview } from "../src/core/reviews.ts";
24 import { claimRun, createRun, finishRun, listRuns } from "../src/core/runs.ts";
25 import { createSession } from "../src/core/sessions.ts";
26 import { createToken } from "../src/core/tokens.ts";
27 import { saveTeam } from "../src/core/teams.ts";
28 import { createUser, getUserByName, type User } from "../src/core/users.ts";
29 import { sendToReview } from "../src/crew/review.ts";
30 import { serve, type Servers } from "../src/http/server.ts";
31 import { createForgeApp } from "../src/tui/forge.ts";
32 import { issueScreen } from "../src/tui/screens/issue.ts";
33 import { local } from "../src/tui/screens/where.ts";
34 import { headlessTerminal } from "../src/tui/term/terminal.ts";
35 import { quietLogs } from "./helpers/globals.ts";
36 import { settleApp } from "./helpers/tui.ts";
37
38 quietLogs();
39
40 describe("naming teams, at every door", () => {
41 let dataDir = "";
42 let app: App;
43 let servers: Servers;
44 let alice: User;
45 let bob: User;
46 let repo: Repo;
47 let n = 0;
48 const id = (name: string) => getUserByName(app.db, name)!.id;
49 const fresh = () => getRepoById(app.db, repo.id)!;
50 const base = () => `${servers.url}/alice/garden`;
51 const cookie = (u: User) => `forge_session=${createSession(app.db, u.id).token}`;
52
53 const newIssue = (): Issue => {
54 const o = openIssue(app, alice, repo, { title: "A chart" });
55 if (!o.ok) throw new Error(o.error);
56 return o.value;
57 };
58 const api = (u: User | undefined, method: string, path: string, body?: unknown, scope: "ro" | "rw" = "rw") =>
59 fetch(`${servers.url}/api/v1/repos/alice/garden${path}`, {
60 method,
61 headers: {
62 "content-type": "application/json",
63 ...(u ? { authorization: `Bearer ${createToken(app.db, u.id, "t", scope).token}` } : {}),
64 },
65 ...(body === undefined ? {} : { body: typeof body === "string" ? body : JSON.stringify(body) }),
66 });
67 const mcp = async (u: User, name: string, args: unknown, scope: "ro" | "rw" = "rw") => {
68 const res = await fetch(`${servers.url}/mcp`, {
69 method: "POST",
70 headers: {
71 "content-type": "application/json",
72 accept: "application/json, text/event-stream",
73 authorization: `Bearer ${createToken(app.db, u.id, "t", scope).token}`,
74 },
75 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
76 });
77 const body = (await res.json()) as any;
78 const text = body.result?.content?.[0]?.text as string;
79 try {
80 return JSON.parse(text);
81 } catch {
82 return { error: text };
83 }
84 };
85 const pullClosing = async (issue: Issue): Promise<Pull> => {
86 const branch = `d${++n}`;
87 const c = await commitToBranch(app, alice, repo, {
88 branch,
89 base: "main",
90 message: branch,
91 files: [{ path: `${branch}.md`, content: "x\n" }],
92 });
93 if (!c.ok) throw new Error(JSON.stringify(c.error));
94 const o = await openPull(app, alice, repo, {
95 title: branch,
96 body: `Closes #${issue.number}`,
97 baseBranch: "main",
98 headBranch: branch,
99 });
100 if (!o.ok) throw new Error(String(o.error));
101 const run = createRun(app.db, {
102 repoId: repo.id,
103 kind: "check",
104 issueId: o.value.id,
105 env: `pull-${o.value.number}`,
106 commitOid: o.value.headOid,
107 reason: "pull",
108 });
109 claimRun(app.db, run.id);
110 finishRun(app.db, run.id, { state: "ok", summary: "ok" });
111 return { ...o.value, authorIsAgent: true };
112 };
113 const reviewRuns = (pull: Pull) =>
114 listRuns(app.db, repo.id, { issueId: pull.id }).filter((r) => r.reason === "review");
115
116 beforeAll(async () => {
117 dataDir = await mkdtemp(join(tmpdir(), "forge-issue-teams-doors-"));
118 app = createApp({ dataDir, port: 0 });
119 servers = await serve(app);
120 for (const username of ["alice", "bob"]) {
121 const made = await createUser(app.db, {
122 origin: "seed",
123 trusted: true,
124 username,
125 email: `${username}@forge.local`,
126 password: "a-good-passphrase",
127 adminIfFirst: username === "alice",
128 });
129 if (!made.ok) throw new Error(made.error);
130 if (username === "alice") alice = made.value;
131 else bob = made.value;
132 }
133 const r = await createRepo(app, alice, { name: "garden", visibility: "private" });
134 if (!r.ok) throw new Error(String(r.error));
135 repo = r.value;
136 const seeded = await commitToBranch(app, alice, repo, {
137 message: "seed",
138 files: [{ path: "README.md", content: "# garden\n" }],
139 });
140 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
141 const agent = await createAgent(app, alice, { username: "quill" });
142 if (!agent.ok) throw new Error(String(agent.error));
143 addToCrew(app, alice, repo, "quill");
144 setCrewRole(app.db, repo.id, id("quill"), "reviewer");
145 }, 30_000);
146
147 afterAll(async () => {
148 await servers.stop();
149 app.close();
150 await rm(dataDir, { recursive: true, force: true });
151 });
152
153 test("the API names, reads back, and refuses with its reasons", async () => {
154 const issue = newIssue();
155 const path = `/issues/${issue.number}/teams`;
156 const put = await api(alice, "PUT", path, { teams: ["design", "data-science"] });
157 expect(put.status).toBe(200);
158 expect(await put.json()).toEqual({ teams: ["design", "data-science"] });
159 expect(await (await api(alice, "GET", path)).json()).toEqual({ teams: ["design", "data-science"] });
160 for (const bad of [{}, { teams: "design" }, { teams: [1] }, { teams: [null] }])
161 expect((await api(alice, "PUT", path, bad)).status).toBe(400);
162 expect((await api(alice, "PUT", path, "{nope")).status).toBe(400);
163 const many = await api(alice, "PUT", path, { teams: ["design", "security", "platform"] });
164 expect(many.status).toBe(422);
165 expect(JSON.stringify(await many.json())).toContain("too_many");
166 const none = await api(alice, "PUT", path, { teams: ["nope"] });
167 expect(none.status).toBe(422);
168 expect(JSON.stringify(await none.json())).toContain("no_such_team");
169 // A refusal changes nothing.
170 expect(teamsOfIssue(app.db, issue.id)).toEqual(["design", "data-science"]);
171 });
172
173 test("a read-only token cannot name teams; a stranger gets 404 on a private repo", async () => {
174 const issue = newIssue();
175 const path = `/issues/${issue.number}/teams`;
176 expect((await api(alice, "PUT", path, { teams: ["design"] }, "ro")).status).toBe(403);
177 expect(teamsOfIssue(app.db, issue.id)).toEqual([]);
178 expect((await api(bob, "GET", path)).status).toBe(404);
179 expect((await api(bob, "PUT", path, { teams: ["design"] })).status).toBe(404);
180 expect((await api(undefined, "GET", path)).status).toBe(404);
181 expect(teamsOfIssue(app.db, issue.id)).toEqual([]);
182 });
183
184 test("the issue page names teams through a form, same-origin only", async () => {
185 const issue = newIssue();
186 const page = await (await fetch(`${base()}/issues/${issue.number}`, { headers: { cookie: cookie(alice) } })).text();
187 expect(page).toContain(`action="/alice/garden/issues/${issue.number}/teams"`);
188 expect(page).toContain('name="teams" value="design"');
189 const post = (origin: string, teams: string[]) => {
190 const form = new URLSearchParams();
191 for (const t of teams) form.append("teams", t);
192 return fetch(`${base()}/issues/${issue.number}/teams`, {
193 method: "POST",
194 redirect: "manual",
195 headers: { cookie: cookie(alice), "content-type": "application/x-www-form-urlencoded", origin },
196 body: form,
197 });
198 };
199 expect((await post("http://evil.example", ["design"])).status).toBe(403);
200 expect(teamsOfIssue(app.db, issue.id)).toEqual([]);
201 expect((await post(servers.url, ["design", "platform"])).status).toBe(303);
202 expect(teamsOfIssue(app.db, issue.id)).toEqual(["design", "platform"]);
203 const three = await post(servers.url, ["design", "platform", "security"]);
204 expect(three.status).toBe(303);
205 expect(teamsOfIssue(app.db, issue.id)).toEqual(["design", "platform"]);
206 const again = await (await fetch(`${base()}/issues/${issue.number}`, { headers: { cookie: cookie(alice) } })).text();
207 expect(again).toMatch(/name="teams" value="design"\s+checked/);
208 // A reader who cannot write sees no form.
209 const bobsView = await fetch(`${base()}/issues/${issue.number}`, { headers: { cookie: cookie(bob) } });
210 expect(bobsView.status).toBe(404);
211 });
212
213 test("the pull's own page and its files page both show each team's verdict, escaped", async () => {
214 saveTeam(app.db, "design", { charter: "", dial: 0, members: [id("quill")] });
215 const issue = newIssue();
216 await api(alice, "PUT", `/issues/${issue.number}/teams`, { teams: ["design", "security"] });
217 const pull = await pullClosing(issue);
218 submitReview(app.db, { issueId: pull.id, authorId: id("quill"), state: "approved", commitOid: pull.headOid });
219 for (const tail of ["", "/files"]) {
220 const text = await (await fetch(`${base()}/pulls/${pull.number}${tail}`, { headers: { cookie: cookie(alice) } })).text();
221 expect(text).toContain("Team reviews");
222 expect(text).toMatch(/Design<\/strong>:\s*approved by quill/);
223 expect(text).toMatch(/Security<\/strong>:\s*<span class="muted">awaiting a reviewer/);
224 }
225 });
226
227 test("the MCP tool reads, names and refuses the same way", async () => {
228 const issue = newIssue();
229 const told = await mcp(alice, "issue_teams", { repo: "alice/garden", number: issue.number, teams: ["platform"] });
230 expect(told.teams).toEqual(["platform"]);
231 expect((await mcp(alice, "issue_teams", { repo: "alice/garden", number: issue.number })).teams).toEqual(["platform"]);
232 const many = await mcp(alice, "issue_teams", { repo: "alice/garden", number: issue.number, teams: ["design", "security", "platform"] });
233 expect(JSON.stringify(many)).toContain("at most 2");
234 expect(JSON.stringify(await mcp(alice, "issue_teams", { repo: "alice/garden", number: issue.number, teams: "design" }))).toContain("list of team slugs");
235 expect(teamsOfIssue(app.db, issue.id)).toEqual(["platform"]);
236 expect(JSON.stringify(await mcp(bob, "issue_teams", { repo: "alice/garden", number: issue.number, teams: ["design"] }))).toContain("no such");
237 });
238
239 test("the terminal's t key names teams through the same action", async () => {
240 const issue = newIssue();
241 const terminal = headlessTerminal(140, 40);
242 const { app: tui, session } = createForgeApp(
243 app,
244 { sshUsername: "t", remoteAddress: "127.0.0.1", user: alice },
245 { terminal, exitHooks: false },
246 );
247 tui.start();
248 const key = async (k: string) => {
249 tui.dispatch({ type: "key", key: k, ctrl: false, alt: false, shift: false });
250 await settleApp(tui);
251 };
252 try {
253 await settleApp(tui);
254 tui.push(issueScreen(session, tui.ctx, local(session, fresh()), issue.number));
255 await settleApp(tui);
256 await key("t");
257 expect(tui.screen.toText()).toContain("Teams (at most 2)");
258 // Cursor on Design: toggle it and apply.
259 await key(" ");
260 await key("enter");
261 expect(teamsOfIssue(app.db, issue.id)).toEqual(["design"]);
262 } finally {
263 tui.stop();
264 }
265 });
266
267 test("an issue naming a team no crew reviewer is on still gets one review run", async () => {
268 saveTeam(app.db, "platform", { charter: "", dial: 0, members: [] });
269 const issue = newIssue();
270 await api(alice, "PUT", `/issues/${issue.number}/teams`, { teams: ["platform"] });
271 const pull = await pullClosing(issue);
272 expect(await sendToReview(app, fresh(), pull)).toBe("sent");
273 expect(reviewRuns(pull)).toHaveLength(1);
274 });
275
276 test("a team that cannot be asked waits for the hour, not the first sweep, while the other is asked", async () => {
277 saveTeam(app.db, "design", { charter: "", dial: 0, members: [id("quill")] });
278 const issue = newIssue();
279 await api(alice, "PUT", `/issues/${issue.number}/teams`, { teams: ["design", "security"] });
280 const pull = await pullClosing(issue);
281 expect(await sendToReview(app, fresh(), pull)).toBe("sent");
282 expect(reviewRuns(pull)).toHaveLength(1);
283 expect(await sendToReview(app, fresh(), pull)).toBe("wait");
284 // Past the hour, with the security team still unasked, it lapses to a person.
285 expect(await sendToReview(app, fresh(), pull, new Date(Date.now() + 3 * 3_600_000))).toBe("lapsed");
286 });
287
288 test("naming teams is heard: an event with who and which, and the page and terminal show it", async () => {
289 const issue = newIssue();
290 const put = await api(alice, "PUT", `/issues/${issue.number}/teams`, { teams: ["design", "platform"] });
291 expect(put.status).toBe(200);
292 const heard = listEvents(app.db, { repoId: repo.id, limit: 50 }).filter(
293 (e) => e.kind === "issue.teams" && Number(e.payload.number) === issue.number,
294 );
295 expect(heard).toHaveLength(1);
296 expect(heard[0]!.payload.teams).toEqual(["design", "platform"]);
297 expect(heard[0]!.actorId).toBe(alice.id);
298 // A refused naming says nothing.
299 await api(alice, "PUT", `/issues/${issue.number}/teams`, { teams: ["nope"] });
300 expect(listEvents(app.db, { repoId: repo.id, limit: 50 }).filter((e) => e.kind === "issue.teams" && Number(e.payload.number) === issue.number)).toHaveLength(1);
301 // The page's timeline carries it, and its live client listens for it.
302 const page = await (await fetch(`${base()}/issues/${issue.number}`, { headers: { cookie: cookie(alice) } })).text();
303 expect(page).toContain("changed the teams");
304 expect(page).toContain("'issue.teams'");
305 // A reader of the terminal who cannot write still sees the teams.
306 const terminal = headlessTerminal(140, 40);
307 const { app: tui, session } = createForgeApp(
308 app,
309 { sshUsername: "t", remoteAddress: "127.0.0.1", user: bob },
310 { terminal, exitHooks: false },
311 );
312 tui.start();
313 try {
314 await settleApp(tui);
315 tui.push(issueScreen(session, tui.ctx, local(session, fresh()), issue.number));
316 await settleApp(tui);
317 expect(tui.screen.toText()).toContain("teams: Design, Platform");
318 } finally {
319 tui.stop();
320 }
321 });
322
323 test("one reviewer on both teams gets one run, and their approval answers both", async () => {
324 saveTeam(app.db, "design", { charter: "", dial: 0, members: [id("quill")] });
325 saveTeam(app.db, "data-science", { charter: "", dial: 0, members: [id("quill")] });
326 const issue = newIssue();
327 await api(alice, "PUT", `/issues/${issue.number}/teams`, { teams: ["design", "data-science"] });
328 const pull = await pullClosing(issue);
329 expect(await sendToReview(app, fresh(), pull)).toBe("sent");
330 expect(reviewRuns(pull)).toHaveLength(1);
331 submitReview(app.db, { issueId: pull.id, authorId: id("quill"), state: "approved", commitOid: pull.headOid });
332 expect(teamVerdicts(app.db, pull).map((v) => v.state)).toEqual(["approved", "approved"]);
333 expect(await sendToReview(app, fresh(), pull)).toBe("done");
334 expect(reviewRuns(pull)).toHaveLength(1);
335 });
336 });
test/issue-teams.test.ts added +171
@@ -0,0 +1,171 @@
1 /**
2 * Cross-team work (#2681): an issue names up to two teams; the pull that
3 * closes it queues one review per team, the pull page shows both verdicts,
4 * and the naming is a writer's, validated at the boundary.
5 */
6
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { addToCrew, createAgent } from "../src/actions/crew.ts";
12 import { nameTeams } from "../src/actions/issue_teams.ts";
13 import { openIssue } from "../src/actions/issues.ts";
14 import { openPull } from "../src/actions/pulls.ts";
15 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
16 import { createApp, type App } from "../src/app.ts";
17 import { setCrewRole } from "../src/core/agents.ts";
18 import { teamsOfIssue, teamVerdicts } from "../src/core/issue_teams.ts";
19 import type { Issue } from "../src/core/issues.ts";
20 import type { Pull } from "../src/core/pulls.ts";
21 import { getRepoById, type Repo } from "../src/core/repos.ts";
22 import { submitReview } from "../src/core/reviews.ts";
23 import { claimRun, createRun, finishRun, listRuns } from "../src/core/runs.ts";
24 import { saveTeam } from "../src/core/teams.ts";
25 import { createUser, getUserByName, type User } from "../src/core/users.ts";
26 import { sendToReview } from "../src/crew/review.ts";
27 import { quietLogs } from "./helpers/globals.ts";
28
29 quietLogs();
30
31 describe("cross-team work", () => {
32 let dataDir = "";
33 let app: App;
34 let alice: User;
35 let repo: Repo;
36 let n = 0;
37 const fresh = () => getRepoById(app.db, repo.id)!;
38 const id = (name: string) => getUserByName(app.db, name)!.id;
39
40 const issueNaming = (teams: string[]): Issue => {
41 const o = openIssue(app, alice, repo, { title: "A chart" });
42 if (!o.ok) throw new Error(o.error);
43 const set = nameTeams(app, alice, repo, o.value, teams);
44 if (!set.ok) throw new Error(set.error);
45 return o.value;
46 };
47 const pullClosing = async (issue: Issue): Promise<Pull> => {
48 const branch = `b${++n}`;
49 const c = await commitToBranch(app, alice, repo, {
50 branch,
51 base: "main",
52 message: branch,
53 files: [{ path: `${branch}.md`, content: "x\n" }],
54 });
55 if (!c.ok) throw new Error(JSON.stringify(c.error));
56 const o = await openPull(app, alice, repo, {
57 title: branch,
58 body: `Closes #${issue.number}`,
59 baseBranch: "main",
60 headBranch: branch,
61 });
62 if (!o.ok) throw new Error(String(o.error));
63 const run = createRun(app.db, {
64 repoId: repo.id,
65 kind: "check",
66 issueId: o.value.id,
67 env: `pull-${o.value.number}`,
68 commitOid: o.value.headOid,
69 reason: "pull",
70 });
71 claimRun(app.db, run.id);
72 finishRun(app.db, run.id, { state: "ok", summary: "ok" });
73 return { ...o.value, authorIsAgent: true };
74 };
75 const reviewRuns = (pull: Pull) =>
76 listRuns(app.db, repo.id, { issueId: pull.id }).filter((r) => r.reason === "review");
77
78 beforeAll(async () => {
79 dataDir = await mkdtemp(join(tmpdir(), "forge-issue-teams-"));
80 app = createApp({ dataDir, port: 0 });
81 const made = await createUser(app.db, {
82 origin: "seed",
83 trusted: true,
84 username: "alice",
85 email: "[email protected]",
86 password: "a-good-passphrase",
87 adminIfFirst: true,
88 });
89 if (!made.ok) throw new Error(made.error);
90 alice = made.value;
91 const r = await createRepo(app, alice, { name: "garden", visibility: "private" });
92 if (!r.ok) throw new Error(String(r.error));
93 repo = r.value;
94 const seeded = await commitToBranch(app, alice, repo, {
95 message: "seed",
96 files: [{ path: "README.md", content: "# garden\n" }],
97 });
98 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
99 for (const name of ["quill", "pike"]) {
100 const agent = await createAgent(app, alice, { username: name });
101 if (!agent.ok) throw new Error(String(agent.error));
102 addToCrew(app, alice, repo, name);
103 setCrewRole(app.db, repo.id, id(name), "reviewer");
104 }
105 saveTeam(app.db, "design", { charter: "", dial: 0, members: [id("quill")] });
106 saveTeam(app.db, "data-science", { charter: "", dial: 0, members: [id("pike")] });
107 }, 30_000);
108
109 afterAll(async () => {
110 app.close();
111 await rm(dataDir, { recursive: true, force: true });
112 });
113
114 test("an issue names at most two real teams", () => {
115 const o = openIssue(app, alice, repo, { title: "Chart" });
116 if (!o.ok) throw new Error(o.error);
117 const set = (t: string[]) => nameTeams(app, alice, repo, o.value, t);
118 expect(set(["design", "security", "platform"])).toEqual({ ok: false, error: "too_many" });
119 expect(set(["design", "nope"])).toEqual({ ok: false, error: "no_such_team" });
120 expect(teamsOfIssue(app.db, o.value.id)).toEqual([]);
121 expect(set(["design", "data-science", "design"]).ok).toBe(true);
122 expect(teamsOfIssue(app.db, o.value.id)).toEqual(["design", "data-science"]);
123 });
124
125 test("a stranger cannot name teams", () => {
126 const o = openIssue(app, alice, repo, { title: "Chart" });
127 if (!o.ok) throw new Error(o.error);
128 expect(nameTeams(app, undefined, repo, o.value, ["design"]).ok).toBe(false);
129 });
130
131 test("an issue naming two teams queues one review per team on its pull", async () => {
132 const pull = await pullClosing(issueNaming(["design", "data-science"]));
133 expect(await sendToReview(app, fresh(), pull)).toBe("sent");
134 const runs = reviewRuns(pull);
135 expect(runs.map((r) => r.actorId).sort()).toEqual([id("quill"), id("pike")].sort());
136 // Nothing more to send while both are out.
137 expect(await sendToReview(app, fresh(), pull)).toBe("wait");
138 expect(reviewRuns(pull)).toHaveLength(2);
139 });
140
141 test("one team's approval does not stand in for the other's", async () => {
142 const pull = await pullClosing(issueNaming(["design", "data-science"]));
143 await sendToReview(app, fresh(), pull);
144 submitReview(app.db, { issueId: pull.id, authorId: id("quill"), state: "approved", commitOid: pull.headOid });
145 const verdicts = teamVerdicts(app.db, pull);
146 expect(verdicts.map((v) => [v.team, v.state])).toEqual([
147 ["design", "approved"],
148 ["data-science", undefined],
149 ]);
150 // Data science is still owed: its run is out, so the pull waits, not done.
151 expect(await sendToReview(app, fresh(), pull)).toBe("wait");
152 submitReview(app.db, { issueId: pull.id, authorId: id("pike"), state: "changes_requested", commitOid: pull.headOid });
153 expect(teamVerdicts(app.db, pull).map((v) => v.state)).toEqual(["approved", "changes_requested"]);
154 expect(await sendToReview(app, fresh(), pull)).toBe("done");
155 });
156
157 test("an approval followed by a comment still answers for the team", async () => {
158 const pull = await pullClosing(issueNaming(["design"]));
159 submitReview(app.db, { issueId: pull.id, authorId: id("quill"), state: "approved", commitOid: pull.headOid });
160 submitReview(app.db, { issueId: pull.id, authorId: id("quill"), state: "commented", body: "nit", commitOid: pull.headOid });
161 expect(teamVerdicts(app.db, pull).map((v) => v.state)).toEqual(["approved"]);
162 expect(await sendToReview(app, fresh(), pull)).toBe("done");
163 expect(reviewRuns(pull)).toHaveLength(0);
164 });
165
166 test("an issue naming no team is reviewed as before", async () => {
167 const pull = await pullClosing(issueNaming([]));
168 expect(await sendToReview(app, fresh(), pull)).toBe("sent");
169 expect(reviewRuns(pull)).toHaveLength(1);
170 });
171 });
test/mcp.test.ts +1
@@ -180,6 +180,7 @@ describe("the forge over MCP", () => {
180180 "proposal_tokens",
181181 "asks_undecide",
182182 "seal",
183 "issue_teams",
183184 "now",
184185 "stop",
185186 "health",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs