Public: anyone can read this; the crew can change it.
The front door asks you about the crew's changes and never about your own #224
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/208-the-front-door-asks-you-about-the-crew-s → main 1 commit fork
4 files
changed +238
−11
src/core/issues.ts +30 −1
| @@ -45,6 +45,8 @@ export interface Issue { | ||
| 45 | 45 | readonly assigneeName: string | null; |
| 46 | 46 | /** Whether the assignee is a crew member. */ |
| 47 | 47 | readonly assigneeIsAgent: boolean; |
| 48 | /** Whether the author is a crew member, not a person. */ | |
| 49 | readonly authorIsAgent: boolean; | |
| 48 | 50 | } |
| 49 | 51 | |
| 50 | 52 | interface IssueRow { |
| @@ -65,10 +67,11 @@ interface IssueRow { | ||
| 65 | 67 | assignee_id: string | null; |
| 66 | 68 | assignee_name: string | null; |
| 67 | 69 | assignee_is_agent: number | null; |
| 70 | author_is_agent: number; | |
| 68 | 71 | } |
| 69 | 72 | |
| 70 | 73 | const SELECT = ` |
| 71 | SELECT issue.*, user.username AS author_name, | |
| 74 | SELECT issue.*, user.username AS author_name, user.is_agent AS author_is_agent, | |
| 72 | 75 | assignee.username AS assignee_name, assignee.is_agent AS assignee_is_agent |
| 73 | 76 | FROM issue JOIN user ON user.id = issue.author_id |
| 74 | 77 | LEFT JOIN user AS assignee ON assignee.id = issue.assignee_id`; |
| @@ -92,6 +95,7 @@ function toIssue(row: IssueRow): Issue { | ||
| 92 | 95 | assigneeId: row.assignee_id, |
| 93 | 96 | assigneeName: row.assignee_name, |
| 94 | 97 | assigneeIsAgent: row.assignee_is_agent === 1, |
| 98 | authorIsAgent: row.author_is_agent === 1, | |
| 95 | 99 | }; |
| 96 | 100 | } |
| 97 | 101 | |
| @@ -159,6 +163,31 @@ export function listOpenAgentPullsVisibleTo( | ||
| 159 | 163 | .map(toIssue); |
| 160 | 164 | } |
| 161 | 165 | |
| 166 | /** | |
| 167 | * Open pulls in repositories the viewer can see, newest activity first — | |
| 168 | * whoever opened them. The same query as `listOpenAgentPullsVisibleTo` | |
| 169 | * without the crew-only filter, for a surface that judges a change by what | |
| 170 | * it does rather than by who typed it (the studio's asks panel, the board, | |
| 171 | * a change's own Decide card). Autonomy code keeps reading the agent-only | |
| 172 | * list above: what a person is *shown* here must never widen what the crew | |
| 173 | * is left to decide on its own. | |
| 174 | */ | |
| 175 | export function listOpenPullsVisibleTo( | |
| 176 | db: Database, | |
| 177 | viewer: { id: string }, | |
| 178 | limit = 25, | |
| 179 | ): Issue[] { | |
| 180 | return db | |
| 181 | .query<IssueRow, [string, number]>( | |
| 182 | `${SELECT} JOIN repo ON repo.id = issue.repo_id | |
| 183 | WHERE issue.kind = 'pull' AND issue.state = 'open' | |
| 184 | AND ${repoVisible(true)} | |
| 185 | ORDER BY issue.updated_at DESC LIMIT ?2`, | |
| 186 | ) | |
| 187 | .all(viewer.id, Math.min(Math.max(limit, 1), 200)) | |
| 188 | .map(toIssue); | |
| 189 | } | |
| 190 | ||
| 162 | 191 | /** |
| 163 | 192 | * Open conversations assigned to one account, across every repository — |
| 164 | 193 | * the "on you" list. An assignee can always see what is assigned to them: |
src/web/studio/asks.ts +29 −10
| @@ -13,7 +13,7 @@ import { appsForRepos, getAppByRepo } from "../../core/apps.ts"; | ||
| 13 | 13 | import { latestDecision } from "../../core/decisions.ts"; |
| 14 | 14 | import { |
| 15 | 15 | listComments, |
| 16 | listOpenAgentPullsVisibleTo, | |
| 16 | listOpenPullsVisibleTo, | |
| 17 | 17 | type Issue, |
| 18 | 18 | } from "../../core/issues.ts"; |
| 19 | 19 | import { looksForApps } from "../../core/looks.ts"; |
| @@ -93,16 +93,21 @@ interface AskCandidate { | ||
| 93 | 93 | readonly repo: Repo; |
| 94 | 94 | readonly pull?: Pull; |
| 95 | 95 | readonly who: string; |
| 96 | /** Whether the author is a crew member — a "look" card words itself | |
| 97 | * differently for a person's own change than for the crew's (below). | |
| 98 | * Absent for "paused", which names no author at all. */ | |
| 99 | readonly authorIsAgent?: boolean; | |
| 96 | 100 | /** For "stopped": how many automatic tries, and whether the decider itself stopped it. */ |
| 97 | 101 | readonly tries?: number; |
| 98 | 102 | readonly stoppedByDecider?: boolean; |
| 99 | 103 | } |
| 100 | 104 | |
| 101 | 105 | /** |
| 102 | * The crew's open pulls the viewer may land, sorted into what asks them: | |
| 103 | * stopped (red past the crew's own limit since a person last spoke), or | |
| 104 | * green and waiting for a look. Plus a repository paused at its ceiling. | |
| 105 | * Every read here is bounded by the pulls a person can see or the | |
| 106 | * Open pulls the viewer may land, sorted into what asks them: stopped (red | |
| 107 | * past the crew's own limit since a person last spoke — the crew's own | |
| 108 | * pulls only, since only the crew retries automatically), or green and | |
| 109 | * waiting for a look, whoever opened it. Plus a repository paused at its | |
| 110 | * ceiling. Every read here is bounded by the pulls a person can see or the | |
| 106 | 111 | * repositories handed in — never by a loop that queries once per app: the |
| 107 | 112 | * ceiling check reads spend and crew for every repository in one query |
| 108 | 113 | * each (`spentTodayForRepos`, `crewForRepos`), not `autonomyStatus` per |
| @@ -121,7 +126,7 @@ function askCandidatesFor( | ||
| 121 | 126 | // sees the dollar figure a "paused" card would otherwise carry. |
| 122 | 127 | const repos = allRepos.filter((r) => canSeeOwnerFacts(db, r, user)); |
| 123 | 128 | const byId = new Map(repos.map((r) => [r.id, r])); |
| 124 | const pulls = listOpenAgentPullsVisibleTo(db, user, 50).filter((p) => | |
| 129 | const pulls = listOpenPullsVisibleTo(db, user, 50).filter((p) => | |
| 125 | 130 | byId.has(p.repoId), |
| 126 | 131 | ); |
| 127 | 132 | const checks = latestRunsForIssues( |
| @@ -138,6 +143,10 @@ function askCandidatesFor( | ||
| 138 | 143 | // The crew is on it: that is working, not a question for a person. |
| 139 | 144 | if (activeRunFor(db, "agent", issue.id) !== undefined) continue; |
| 140 | 145 | if (check?.state === "failed") { |
| 146 | // Automatic retries, and the limit that stops them, are the crew's | |
| 147 | // own machinery — a person's own failing pull never accrues tries, | |
| 148 | // so it never earns a "stopped" card; it just stays waiting. | |
| 149 | if (!issue.authorIsAgent) continue; | |
| 141 | 150 | const since = lastHumanWord(ctx, issue, who); |
| 142 | 151 | const tries = automaticRunsSince(db, issue.id, since); |
| 143 | 152 | const stopped = latestDecision(db, issue.id, "retry")?.answer === "stop"; |
| @@ -152,13 +161,16 @@ function askCandidatesFor( | ||
| 152 | 161 | repo, |
| 153 | 162 | pull, |
| 154 | 163 | who, |
| 164 | authorIsAgent: true, | |
| 155 | 165 | tries, |
| 156 | 166 | stoppedByDecider: stopped, |
| 157 | 167 | }); |
| 158 | 168 | } else if (check?.state === "ok" && repo.autonomy !== "autonomous") { |
| 159 | 169 | // Autonomous repositories look at their own green pulls: the decider, |
| 160 | // then the crew's reviewer. Everything else waits for a person. | |
| 161 | out.push({ kind: "look", repo, pull, who }); | |
| 170 | // then the crew's reviewer. Everything else waits for a person — | |
| 171 | // whoever opened it, since a green change asks to be looked at | |
| 172 | // whether the crew wrote it or a person did. | |
| 173 | out.push({ kind: "look", repo, pull, who, authorIsAgent: issue.authorIsAgent }); | |
| 162 | 174 | } |
| 163 | 175 | } |
| 164 | 176 | const crew = crewForRepos( |
| @@ -226,14 +238,21 @@ export function decisionsFor(ctx: Ctx, allRepos: readonly Repo[]): Decision[] { | ||
| 226 | 238 | } |
| 227 | 239 | if (c.kind === "look" && c.pull) { |
| 228 | 240 | const picture = pictureOf(c.repo, c.pull); |
| 241 | // The crew's own words say who finished it; a person's own change | |
| 242 | // gets the wording that is true of it — nobody "finished" it for | |
| 243 | // them, it is simply green and waiting. | |
| 229 | 244 | return { |
| 230 | 245 | kind: "look", |
| 231 | 246 | id: `look:${c.pull.id}`, |
| 232 | 247 | repo: c.repo, |
| 233 | 248 | pull: c.pull, |
| 234 | 249 | who: c.who, |
| 235 | says: `${c.who} finished "${c.pull.title}" and the check is green. Look at the preview, then ship it or say what is off.`, | |
| 236 | short: `${c.who} finished it; the check is green`, | |
| 250 | says: c.authorIsAgent | |
| 251 | ? `${c.who} finished "${c.pull.title}" and the check is green. Look at the preview, then ship it or say what is off.` | |
| 252 | : `"${c.pull.title}" is green — ready for a look. Ship it, or say what is off.`, | |
| 253 | short: c.authorIsAgent | |
| 254 | ? `${c.who} finished it; the check is green` | |
| 255 | : `green — ready for a look`, | |
| 237 | 256 | ...(picture === undefined ? {} : { picture }), |
| 238 | 257 | }; |
| 239 | 258 | } |
test/map.test.ts +1
| @@ -54,6 +54,7 @@ function paperIssue(n: number, state: "open" | "closed" = "open"): Issue { | ||
| 54 | 54 | assigneeId: null, |
| 55 | 55 | assigneeName: null, |
| 56 | 56 | assigneeIsAgent: false, |
| 57 | authorIsAgent: false, | |
| 57 | 58 | }; |
| 58 | 59 | } |
| 59 | 60 |
test/studio.test.ts +178
| @@ -793,6 +793,9 @@ describe("the studio", () => { | ||
| 793 | 793 | ).text(); |
| 794 | 794 | expect(dev).toContain(pull.headBranch); |
| 795 | 795 | expect(dev).not.toContain("data-word="); |
| 796 | // A green pull left open now asks the front door too (#208) — close it | |
| 797 | // so it does not linger into a later test's own count of asks. | |
| 798 | expect(setIssueState(app, alice, repo, pull, "closed").ok).toBe(true); | |
| 796 | 799 | }); |
| 797 | 800 | |
| 798 | 801 | test("every run is the run list in the studio's own words; the word filter narrows it, and ?mode=dev renders today's list", async () => { |
| @@ -914,6 +917,9 @@ describe("the studio", () => { | ||
| 914 | 917 | await get("/alice/notes/changes?word=shipped") |
| 915 | 918 | ).text(); |
| 916 | 919 | expect(changeOther).not.toContain("Door test: a change"); |
| 920 | // A green pull left open now asks the front door too (#208) — close it | |
| 921 | // so it does not linger into a later test's own count of asks. | |
| 922 | expect(setIssueState(app, alice, repo, doorPull, "closed").ok).toBe(true); | |
| 917 | 923 | }); |
| 918 | 924 | |
| 919 | 925 | test("/tells and /changes send a dev-mode viewer, or ?mode=dev, on to /issues and /pulls — the one canonical dev address", async () => { |
| @@ -6052,3 +6058,175 @@ describe("the studio's Stop and Redeploy come back to the page they were pressed | ||
| 6052 | 6058 | expect(flashOf(queued)).toContain("Redeploying production."); |
| 6053 | 6059 | }); |
| 6054 | 6060 | }); |
| 6061 | ||
| 6062 | describe("the front door asks about a person's own change too (#208)", () => { | |
| 6063 | let dataDir: string; | |
| 6064 | let app: App; | |
| 6065 | let servers: Servers; | |
| 6066 | let base: string; | |
| 6067 | let alice: User; | |
| 6068 | let repo: Repo; | |
| 6069 | let scoutUser: User; | |
| 6070 | let aliceCookie: string; | |
| 6071 | let bobCookie: string; | |
| 6072 | ||
| 6073 | const signIn = async (username: string): Promise<string> => | |
| 6074 | ( | |
| 6075 | ( | |
| 6076 | await fetch(`${base}/signin`, { | |
| 6077 | method: "POST", | |
| 6078 | body: new URLSearchParams({ | |
| 6079 | username, | |
| 6080 | password: "a-good-passphrase", | |
| 6081 | }), | |
| 6082 | redirect: "manual", | |
| 6083 | }) | |
| 6084 | ).headers.get("set-cookie") ?? "" | |
| 6085 | ).split(";")[0]!; | |
| 6086 | ||
| 6087 | const home = (cookie: string) => | |
| 6088 | fetch(`${base}/`, { | |
| 6089 | headers: { cookie: `${cookie}; forge_mode=studio` }, | |
| 6090 | redirect: "manual", | |
| 6091 | }).then((r) => r.text()); | |
| 6092 | ||
| 6093 | const appPage = (cookie: string) => | |
| 6094 | fetch(`${base}/alice/front`, { | |
| 6095 | headers: { cookie: `${cookie}; forge_mode=studio` }, | |
| 6096 | redirect: "manual", | |
| 6097 | }).then((r) => r.text()); | |
| 6098 | ||
| 6099 | const greenPull = async ( | |
| 6100 | who: User, | |
| 6101 | name: string, | |
| 6102 | ): Promise<Pull> => { | |
| 6103 | const branch = await commitToBranch(app, alice, repo, { | |
| 6104 | branch: `door/${name}`, | |
| 6105 | base: "main", | |
| 6106 | message: `Door ${name}`, | |
| 6107 | files: [{ path: `${name}.md`, content: `${name}\n` }], | |
| 6108 | }); | |
| 6109 | if (!branch.ok) throw new Error(JSON.stringify(branch.error)); | |
| 6110 | const opened = await openPull(app, who, repo, { | |
| 6111 | title: `Door ${name}`, | |
| 6112 | body: "", | |
| 6113 | baseBranch: "main", | |
| 6114 | headBranch: `door/${name}`, | |
| 6115 | }); | |
| 6116 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 6117 | const pull = opened.value; | |
| 6118 | const run = createRun(app.db, { | |
| 6119 | repoId: repo.id, | |
| 6120 | kind: "check", | |
| 6121 | issueId: pull.id, | |
| 6122 | env: `pull-${pull.number}`, | |
| 6123 | commitOid: pull.headOid, | |
| 6124 | reason: "pull", | |
| 6125 | }); | |
| 6126 | claimRun(app.db, run.id); | |
| 6127 | finishRun(app.db, run.id, { state: "ok", summary: "check passed" }); | |
| 6128 | return pull; | |
| 6129 | }; | |
| 6130 | ||
| 6131 | beforeAll(async () => { | |
| 6132 | dataDir = await mkdtemp(join(tmpdir(), "forge-studio-own-change-")); | |
| 6133 | app = createApp({ dataDir, port: 0 }); | |
| 6134 | servers = await serve(app); | |
| 6135 | base = servers.url; | |
| 6136 | const a = await createUser(app.db, { | |
| 6137 | username: "alice", | |
| 6138 | email: "[email protected]", | |
| 6139 | password: "a-good-passphrase", | |
| 6140 | adminIfFirst: true, | |
| 6141 | }); | |
| 6142 | if (!a.ok) throw new Error(a.error); | |
| 6143 | alice = a.value; | |
| 6144 | const b = await createUser(app.db, { | |
| 6145 | username: "bob", | |
| 6146 | email: "[email protected]", | |
| 6147 | password: "a-good-passphrase", | |
| 6148 | }); | |
| 6149 | if (!b.ok) throw new Error(b.error); | |
| 6150 | aliceCookie = await signIn("alice"); | |
| 6151 | bobCookie = await signIn("bob"); | |
| 6152 | ||
| 6153 | const made = await createRepo(app, alice, { | |
| 6154 | name: "front", | |
| 6155 | visibility: "private", | |
| 6156 | description: "", | |
| 6157 | }); | |
| 6158 | if (!made.ok) throw new Error(String(made.error)); | |
| 6159 | repo = made.value; | |
| 6160 | const seeded = await commitToBranch(app, alice, repo, { | |
| 6161 | message: "Start", | |
| 6162 | files: [{ path: "README.md", content: "f\n" }], | |
| 6163 | }); | |
| 6164 | if (!seeded.ok) throw new Error(JSON.stringify(seeded.error)); | |
| 6165 | const enabled = await enableApp(app, alice, repo, { slug: "front" }); | |
| 6166 | if (!enabled.ok) throw new Error(String(enabled.error)); | |
| 6167 | const scout = await createAgent(app, alice, { username: "scout-door" }); | |
| 6168 | if (!scout.ok) throw new Error(String(scout.error)); | |
| 6169 | expect(addToCrew(app, alice, repo, "scout-door").ok).toBe(true); | |
| 6170 | scoutUser = getUserByName(app.db, "scout-door")!; | |
| 6171 | }); | |
| 6172 | ||
| 6173 | afterAll(async () => { | |
| 6174 | servers.stop(); | |
| 6175 | app.close(); | |
| 6176 | await rm(dataDir, { recursive: true, force: true }); | |
| 6177 | }); | |
| 6178 | ||
| 6179 | test("a person's own green change is a look card on the front door, worded as her own rather than as the crew's", async () => { | |
| 6180 | const mine = await greenPull(alice, "mine"); | |
| 6181 | const page = await home(aliceCookie); | |
| 6182 | expect(page).toContain(`value="look:${mine.id}"`); | |
| 6183 | expect(page).toContain(`form="ask-look:${mine.id}"`); | |
| 6184 | // Ship is offered, the same button a crew pull gets. | |
| 6185 | expect(page).toMatch( | |
| 6186 | new RegExp(`form="ask-look:${mine.id}"[^>]*>\\s*Ship`), | |
| 6187 | ); | |
| 6188 | // Worded as her own — nobody "finished" it for her. | |
| 6189 | expect(page).toContain("is green — ready for a look"); | |
| 6190 | expect(page).not.toContain("alice finished"); | |
| 6191 | expect(page).toContain("1 asks you"); | |
| 6192 | expect(setIssueState(app, alice, repo, mine, "closed").ok).toBe(true); | |
| 6193 | }); | |
| 6194 | ||
| 6195 | test("the same card renders on the app's own studio page", async () => { | |
| 6196 | const mine = await greenPull(alice, "app-page"); | |
| 6197 | const page = await appPage(aliceCookie); | |
| 6198 | expect(page).toContain(`value="look:${mine.id}"`); | |
| 6199 | expect(page).toContain("is green — ready for a look"); | |
| 6200 | expect(setIssueState(app, alice, repo, mine, "closed").ok).toBe(true); | |
| 6201 | }); | |
| 6202 | ||
| 6203 | test("the crew's own green change keeps its own wording, unchanged", async () => { | |
| 6204 | const crewPull = await greenPull(scoutUser, "crew"); | |
| 6205 | const page = await home(aliceCookie); | |
| 6206 | expect(page).toContain(`value="look:${crewPull.id}"`); | |
| 6207 | expect(page).toContain("scout-door finished it; the check is green"); | |
| 6208 | expect(setIssueState(app, alice, repo, crewPull, "closed").ok).toBe(true); | |
| 6209 | }); | |
| 6210 | ||
| 6211 | test("a reader who cannot write the app is asked nothing and sees no money", async () => { | |
| 6212 | const mine = await greenPull(alice, "reader"); | |
| 6213 | const page = await home(bobCookie); | |
| 6214 | expect(page).not.toContain(`value="look:${mine.id}"`); | |
| 6215 | expect(page).not.toContain("front"); | |
| 6216 | expect(page).toMatch( | |
| 6217 | /<span id="topbar-asks" data-live="[^"]*"\s*>\s*<\/span/, | |
| 6218 | ); | |
| 6219 | expect(setIssueState(app, alice, repo, mine, "closed").ok).toBe(true); | |
| 6220 | }); | |
| 6221 | ||
| 6222 | test("the shelf pill and the panel agree, counting a person's own change once", async () => { | |
| 6223 | const a = await greenPull(alice, "pill-a"); | |
| 6224 | const b = await greenPull(scoutUser, "pill-b"); | |
| 6225 | const page = await home(aliceCookie); | |
| 6226 | const picks = page.match(/class="studio-ask-pick"/g)?.length ?? 0; | |
| 6227 | expect(picks).toBe(2); | |
| 6228 | expect(page).toContain("2 asks you"); | |
| 6229 | expect(setIssueState(app, alice, repo, a, "closed").ok).toBe(true); | |
| 6230 | expect(setIssueState(app, alice, repo, b, "closed").ok).toBe(true); | |
| 6231 | }); | |
| 6232 | }); |
new above
offline