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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2681-cross-team-work-an-issue-can-name-two-te → main 6 commits fork 11 threads · 11 unresolved
README.md +2
Diff not shown — a line 2,529 characters wide (limit 2,000). +2
View filesrc/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); | |
| 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 |
||
| 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( | ||
| 290 | 290 | again?: { notBefore: string; attempt: number; summary: string }, |
| 291 | 291 | variantGroup?: string, |
| 292 | 292 | 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, | |
| 293 | 295 | ): Run | undefined { |
| 294 | 296 | // A suspended or removed owner's repositories run nothing, whoever asks. |
| 295 | 297 | if (!accountActive(app.db, repo.ownerId)) return undefined; |
| @@ -343,7 +345,7 @@ export function queueAgentRun( | ||
| 343 | 345 | const run = transact(app.db, () => { |
| 344 | 346 | // A follow-up is the builder's: a reviewer's own run on the pull, still |
| 345 | 347 | // 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; | |
| 347 | 349 | const active = activeRunFor(app.db, "agent", issue.id, mine); |
| 348 | 350 | // A follow-up with nothing new would only read the agent's own reply |
| 349 | 351 | // 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"; | ||
| 24 | 24 | import { getIssueById, LIST_LIMIT_MAX } from "../core/issues.ts"; |
| 25 | 25 | import { listPulls, pullDiff, type Pull, pullDir} from "../core/pulls.ts"; |
| 26 | 26 | import { getRepoById, type Repo } from "../core/repos.ts"; |
| 27 | import { teamsOfPull, teamVerdicts } from "../core/issue_teams.ts"; | |
| 27 | 28 | import { reviewSummariesFor, reviewSummary } from "../core/reviews.ts"; |
| 28 | 29 | import { checksForHead } from "../core/runs.ts"; |
| 29 | 30 | import { getTeam } from "../core/teams.ts"; |
| @@ -398,6 +399,10 @@ export async function sendToReview( | ||
| 398 | 399 | if (getHold(app.db, pull.id) !== undefined) return "held"; |
| 399 | 400 | const reviewers = reviewersOf(app.db, repo.id); |
| 400 | 401 | 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 | } | |
| 401 | 406 | const seen = reviewSummary(app.db, pull.id, pull.headOid); |
| 402 | 407 | if (seen.approvals > 0 || seen.changesRequested > 0) return "done"; |
| 403 | 408 | if (handedOver(app.db, repo.id, pull) !== undefined) return "held"; |
| @@ -440,6 +445,64 @@ export async function sendToReview( | ||
| 440 | 445 | return "sent"; |
| 441 | 446 | } |
| 442 | 447 | |
| 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 | ||
| 443 | 506 | /** Review runs one sweep pass sends at most: the tick stays bounded by sends, never by which pulls it looks at. */ |
| 444 | 507 | export const SWEEP_SENDS = 10; |
| 445 | 508 |
src/design/activity.ts +1
| @@ -95,6 +95,7 @@ const WORDS: Record<string, string> = { | ||
| 95 | 95 | "issue.commented": "commented on an issue", |
| 96 | 96 | "issue.edited": "edited an issue", |
| 97 | 97 | "issue.labeled": "labelled an issue", |
| 98 | "issue.teams": "named the teams on an issue", | |
| 98 | 99 | "issue.sealed": "sealed a security report", |
| 99 | 100 | "issue.unsealed": "unsealed a security report", |
| 100 | 101 | "pull.opened": "opened a pull request", |
src/http/api.ts +26
| @@ -27,6 +27,8 @@ import { askCheck } from "../checks/again.ts"; | ||
| 27 | 27 | import { mayView, mayViewId, sealedScope } from "../core/sealed.ts"; |
| 28 | 28 | import { changeCapacity, listCapacity } from "../actions/capacity.ts"; |
| 29 | 29 | 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"; | |
| 30 | 32 | import { changeTeam, readTeams, TEAM_SAYS } from "../actions/teams.ts"; |
| 31 | 33 | import { changeQuotas, readQuotas } from "../actions/quotas.ts"; |
| 32 | 34 | import { changeSignup, readSignup } from "../actions/signup.ts"; |
| @@ -2832,6 +2834,30 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2832 | 2834 | labelsHandler, |
| 2833 | 2835 | ); |
| 2834 | 2836 | |
| 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 |
||
| 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
|
||
| 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 | ||
| 2835 | 2861 | // ── issues — the label-as-queue surface automation drives ──────────── |
| 2836 | 2862 | |
| 2837 | 2863 | 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"; | ||
| 24 | 24 | import { listAccess } from "../actions/access_log.ts"; |
| 25 | 25 | import { changeCapacity, listCapacity } from "../actions/capacity.ts"; |
| 26 | 26 | 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"; | |
| 27 | 29 | import { changeTeam, planTeam, readTeams, TEAM_SAYS } from "../actions/teams.ts"; |
| 28 | 30 | import { addAccount, allowWrite, auditAccount, removeAccount, renameAccount, reverify, rotateAccount } from "../actions/cloud.ts"; |
| 29 | 31 | import { usesByVendor, type VendorUse } from "../cloud/declared.ts"; |
| @@ -890,6 +892,38 @@ const TOOLS: readonly Tool[] = [ | ||
| 890 | 892 | }); |
| 891 | 893 | }, |
| 892 | 894 | }, |
| 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 | }, | |
| 893 | 927 | { |
| 894 | 928 | name: "now", |
| 895 | 929 | description: |
src/http/server.ts +2
| @@ -278,6 +278,7 @@ import { dependencySubmit } from "../web/pages/thread.ts"; | ||
| 278 | 278 | import { |
| 279 | 279 | assigneeSubmit, |
| 280 | 280 | labelsSubmit, |
| 281 | teamsSubmit, | |
| 281 | 282 | stateSubmit, |
| 282 | 283 | wordsSubmit, |
| 283 | 284 | } from "../web/pages/thread.ts"; |
| @@ -1059,6 +1060,7 @@ export function buildRouter(): Router<Handler> { | ||
| 1059 | 1060 | router.add("POST", "/:owner/:name/issues/:number/labels", (ctx) => |
| 1060 | 1061 | labelsSubmit(ctx, "issue"), |
| 1061 | 1062 | ); |
| 1063 | router.add("POST", "/:owner/:name/issues/:number/teams", (ctx) => teamsSubmit(ctx)); | |
| 1062 | 1064 | router.add("POST", "/:owner/:name/issues/:number/assignee", (ctx) => |
| 1063 | 1065 | assigneeSubmit(ctx, "issue"), |
| 1064 | 1066 | ); |
src/tui/screens/issue.ts +35
| @@ -19,6 +19,9 @@ import { | ||
| 19 | 19 | setIssueLabelsThrough, |
| 20 | 20 | setIssueStateThrough, |
| 21 | 21 | } 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"; | |
| 22 | 25 | import { listRepoRuns } from "../../actions/runs.ts"; |
| 23 | 26 | import { alreadyDone } from "../../core/done_on_main.ts"; |
| 24 | 27 | import { getIssue, type Issue, type Label } from "../../core/issues.ts"; |
| @@ -221,6 +224,7 @@ export function issueScreen( | ||
| 221 | 224 | disabled: detail?.issue.state !== "closed", |
| 222 | 225 | }), |
| 223 | 226 | binding(["l"], "labels"), |
| 227 | binding(["t"], "teams"), | |
| 224 | 228 | ] |
| 225 | 229 | : liveBindings(src)), |
| 226 | 230 | binding(["r"], "refresh"), |
| @@ -288,6 +292,18 @@ export function issueScreen( | ||
| 288 | 292 | { text: label.name, color: label.color }, |
| 289 | 293 | right, |
| 290 | 294 | ); |
| 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 | } | |
| 291 | 307 | if (crewH > 0) { |
| 292 | 308 | const inner = card(s, crewRow!, c, { |
| 293 | 309 | title: `Crew · ${plural(crew.runs.length, "run")}`, |
| @@ -387,6 +403,25 @@ export function issueScreen( | ||
| 387 | 403 | ); |
| 388 | 404 | return true; |
| 389 | 405 | } |
| 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 | } | |
| 390 | 425 | if (e.key === "r") { |
| 391 | 426 | runAsync(ctx, reload); |
| 392 | 427 | return true; |
src/web/live.ts +1 −1
| @@ -295,7 +295,7 @@ export const liveClientJs = /* js */ ` | ||
| 295 | 295 | |
| 296 | 296 | var KINDS = [ |
| 297 | 297 | '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', | |
| 299 | 299 | 'pull.opened', 'pull.closed', 'pull.reopened', 'pull.merged', 'pull.commented', 'pull.reviewed', |
| 300 | 300 | 'pull.held', 'pull.released', 'pull.edited', 'pull.run_granted', |
| 301 | 301 | 'branch.created', 'branch.deleted', |
src/web/pages/pull.ts +27
| @@ -71,6 +71,7 @@ import { | ||
| 71 | 71 | type ThreadView, |
| 72 | 72 | } from "../diff-view.ts"; |
| 73 | 73 | import { field, readForm } from "../form.ts"; |
| 74 | import { teamVerdicts, type TeamVerdict } from "../../core/issue_teams.ts"; | |
| 74 | 75 | import { html, raw, when, type Html } from "../html.ts"; |
| 75 | 76 | import { flashTo, fragment, page, redirect } from "../layout.ts"; |
| 76 | 77 | import { liveRegionsJs } from "../client.ts"; |
| @@ -643,6 +644,31 @@ function threadStateViews( | ||
| 643 | 644 | return views; |
| 644 | 645 | } |
| 645 | 646 | |
| 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 |
||
| 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 |
||
| 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 | ||
| 646 | 672 | /** |
| 647 | 673 | * The review card: pass a verdict. |
| 648 | 674 | * |
| @@ -891,6 +917,7 @@ export async function pullFilesPage(ctx: Ctx): Promise<Response> { | ||
| 891 | 917 | </p> |
| 892 | 918 | ${when(wall, (w) => wallCard(w))} |
| 893 | 919 | ${holdCard(ctx, repo, pull, { act: false })} |
| 920 | ${teamsCard(teamVerdicts(ctx.app.db, { ...pull, headOid: status.headOid }))} | |
Only on High (parity): this card has no counterpart in the terminal. |
||
| 894 | 921 | ${mergeBox(ctx, repo, pull, status, mayMerge, by, check, isApp, tier, stale)} |
| 895 | 922 | <div class="split"> |
| 896 | 923 | <div> |
src/web/pages/thread.ts +57
| @@ -9,6 +9,9 @@ | ||
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | 11 | 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"; | |
| 12 | 15 | import { listCrew } from "../../actions/crew.ts"; |
| 13 | 16 | import { |
| 14 | 17 | confirmIssue, |
| @@ -143,6 +146,11 @@ const EVENT_LOOK: Record<string, { icon: IconName; tone: Tone; verb: string }> = | ||
| 143 | 146 | tone: "default", |
| 144 | 147 | verb: "changed the labels", |
| 145 | 148 | }, |
| 149 | "issue.teams": { | |
| 150 | icon: "tag", | |
| 151 | tone: "default", | |
| 152 | verb: "changed the teams", | |
| 153 | }, | |
| 146 | 154 | "pull.reviewed": { |
| 147 | 155 | icon: "comment", |
| 148 | 156 | tone: "default", |
| @@ -888,6 +896,55 @@ export async function labelsSubmit( | ||
| 888 | 896 | }); |
| 889 | 897 | } |
| 890 | 898 | |
| 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 | ||
| 891 | 948 | export { labelsFor, listComments, when }; |
| 892 | 949 | |
| 893 | 950 | // ---- dependencies ---- |
src/web/studio/change.ts +3 −1
| @@ -64,7 +64,8 @@ import { | ||
| 64 | 64 | } from "../ui.ts"; |
| 65 | 65 | import { previewCard } from "../pages/apps.ts"; |
| 66 | 66 | 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"; | |
| 68 | 69 | import { liveRegionsJs } from "../client.ts"; |
| 69 | 70 | import { pullState } from "../pages/pulls.ts"; |
| 70 | 71 | import { workSpent } from "../pages/runs.ts"; |
| @@ -589,6 +590,7 @@ async function changeConversation( | ||
| 589 | 590 | <div id="pull-check">${checkLine}</div> |
| 590 | 591 | ${when(runLine, (l) => l)} ${holdCard(ctx, repo, pull, { act: true })} |
| 591 | 592 | ${approveHereCard(ctx, repo, pull, headOid, `${thread}/approve-here`)} |
| 593 | ${teamsCard(teamVerdicts(db, { ...pull, headOid }))} | |
| 592 | 594 | ${when(wall, (w) => wallCard(w))} ${decisionSection} |
| 593 | 595 | ${takeBackSection} |
| 594 | 596 | ${when(record, (r) => lookCard(ctx, repo, r, pull, pull.headOid))} |
src/web/studio/tell.ts +2
| @@ -58,6 +58,7 @@ import { | ||
| 58 | 58 | conversation, |
| 59 | 59 | dependenciesCard, |
| 60 | 60 | labelsCard, |
| 61 | issueTeamsCard, | |
| 61 | 62 | originGlyph, |
| 62 | 63 | threadContext, |
| 63 | 64 | } from "../pages/thread.ts"; |
| @@ -383,6 +384,7 @@ async function studioTell( | ||
| 383 | 384 | <summary>Details</summary> |
| 384 | 385 | ${assigneeCard(ctx, repo, issue, `${thread}/assignee`)} |
| 385 | 386 | ${labelsCard(ctx, repo, issue, applied, allLabels, `${thread}/labels`)} |
| 387 | ${issueTeamsCard(ctx, repo, issue, `${thread}/teams`)} | |
| 386 | 388 | ${sealCard(ctx, repo, issue, `${thread}/seal`)} |
| 387 | 389 | </details>`; |
| 388 | 390 |
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", () => { | ||
| 180 | 180 | "proposal_tokens", |
| 181 | 181 | "asks_undecide", |
| 182 | 182 | "seal", |
| 183 | "issue_teams", | |
| 183 | 184 | "now", |
| 184 | 185 | "stop", |
| 185 | 186 | "health", |
High: no
app.bus.appendafter a successful set. Open pages don't update live and the audit has no record. Append anissue.teamsevent like thelabeledone inactions/issues.ts.