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

Merged opened this

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

Merged

scout/forge:scout/208-the-front-door-asks-you-about-the-crew-s → main 1 commit fork

  • Merged. scout/208-the-front-door-asks-you-about-the-crew-s was merged into main as 49f712c.

Review

Sign in to review this change.
4 files changed +238 −11
src/core/issues.ts +30 −1
@@ -45,6 +45,8 @@ export interface Issue {
4545 readonly assigneeName: string | null;
4646 /** Whether the assignee is a crew member. */
4747 readonly assigneeIsAgent: boolean;
48 /** Whether the author is a crew member, not a person. */
49 readonly authorIsAgent: boolean;
4850 }
4951
5052 interface IssueRow {
@@ -65,10 +67,11 @@ interface IssueRow {
6567 assignee_id: string | null;
6668 assignee_name: string | null;
6769 assignee_is_agent: number | null;
70 author_is_agent: number;
6871 }
6972
7073 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,
7275 assignee.username AS assignee_name, assignee.is_agent AS assignee_is_agent
7376 FROM issue JOIN user ON user.id = issue.author_id
7477 LEFT JOIN user AS assignee ON assignee.id = issue.assignee_id`;
@@ -92,6 +95,7 @@ function toIssue(row: IssueRow): Issue {
9295 assigneeId: row.assignee_id,
9396 assigneeName: row.assignee_name,
9497 assigneeIsAgent: row.assignee_is_agent === 1,
98 authorIsAgent: row.author_is_agent === 1,
9599 };
96100 }
97101
@@ -159,6 +163,31 @@ export function listOpenAgentPullsVisibleTo(
159163 .map(toIssue);
160164 }
161165
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
162191 /**
163192 * Open conversations assigned to one account, across every repository —
164193 * 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";
1313 import { latestDecision } from "../../core/decisions.ts";
1414 import {
1515 listComments,
16 listOpenAgentPullsVisibleTo,
16 listOpenPullsVisibleTo,
1717 type Issue,
1818 } from "../../core/issues.ts";
1919 import { looksForApps } from "../../core/looks.ts";
@@ -93,16 +93,21 @@ interface AskCandidate {
9393 readonly repo: Repo;
9494 readonly pull?: Pull;
9595 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;
96100 /** For "stopped": how many automatic tries, and whether the decider itself stopped it. */
97101 readonly tries?: number;
98102 readonly stoppedByDecider?: boolean;
99103 }
100104
101105 /**
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
106111 * repositories handed in — never by a loop that queries once per app: the
107112 * ceiling check reads spend and crew for every repository in one query
108113 * each (`spentTodayForRepos`, `crewForRepos`), not `autonomyStatus` per
@@ -121,7 +126,7 @@ function askCandidatesFor(
121126 // sees the dollar figure a "paused" card would otherwise carry.
122127 const repos = allRepos.filter((r) => canSeeOwnerFacts(db, r, user));
123128 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) =>
125130 byId.has(p.repoId),
126131 );
127132 const checks = latestRunsForIssues(
@@ -138,6 +143,10 @@ function askCandidatesFor(
138143 // The crew is on it: that is working, not a question for a person.
139144 if (activeRunFor(db, "agent", issue.id) !== undefined) continue;
140145 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;
141150 const since = lastHumanWord(ctx, issue, who);
142151 const tries = automaticRunsSince(db, issue.id, since);
143152 const stopped = latestDecision(db, issue.id, "retry")?.answer === "stop";
@@ -152,13 +161,16 @@ function askCandidatesFor(
152161 repo,
153162 pull,
154163 who,
164 authorIsAgent: true,
155165 tries,
156166 stoppedByDecider: stopped,
157167 });
158168 } else if (check?.state === "ok" && repo.autonomy !== "autonomous") {
159169 // 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 });
162174 }
163175 }
164176 const crew = crewForRepos(
@@ -226,14 +238,21 @@ export function decisionsFor(ctx: Ctx, allRepos: readonly Repo[]): Decision[] {
226238 }
227239 if (c.kind === "look" && c.pull) {
228240 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.
229244 return {
230245 kind: "look",
231246 id: `look:${c.pull.id}`,
232247 repo: c.repo,
233248 pull: c.pull,
234249 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`,
237256 ...(picture === undefined ? {} : { picture }),
238257 };
239258 }
test/map.test.ts +1
@@ -54,6 +54,7 @@ function paperIssue(n: number, state: "open" | "closed" = "open"): Issue {
5454 assigneeId: null,
5555 assigneeName: null,
5656 assigneeIsAgent: false,
57 authorIsAgent: false,
5758 };
5859 }
5960
test/studio.test.ts +178
@@ -793,6 +793,9 @@ describe("the studio", () => {
793793 ).text();
794794 expect(dev).toContain(pull.headBranch);
795795 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);
796799 });
797800
798801 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", () => {
914917 await get("/alice/notes/changes?word=shipped")
915918 ).text();
916919 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);
917923 });
918924
919925 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
60526058 expect(flashOf(queued)).toContain("Redeploying production.");
60536059 });
60546060 });
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs