Public: anyone can read this; the crew can change it.
Take the parent's `following` feature, its three conflicts resolved for the studio (second half of #250) #559
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/258-take-the-parent-s-following-feature-its → main 4 commits fork
5 files
changed +37
−2
docs/STUDIO.md +9
| @@ -167,6 +167,15 @@ reader sees none of this. Both surfaces call the one function that knows | ||
| 167 | 167 | the fact, `terminalFor` (`src/actions/apps.ts`), so the pull's card and |
| 168 | 168 | the studio page never carry two ideas of what the command is. |
| 169 | 169 | |
| 170 | **Following** comes first when the app is a fork of another: one sentence on | |
| 171 | where it stands against what it follows — in step, behind, ahead, or diverged | |
| 172 | — and one thing to do about it. Behind, or diverged where git merges cleanly, | |
| 173 | is **Take updates**, a click. Diverged where both sides changed the same file | |
| 174 | is **Converge with the crew**: a builder merges the parent's branch, keeps | |
| 175 | this app's direction where the two cannot both be kept, and the result comes | |
| 176 | back as a change to look at, or lands on its own when the app runs on its | |
| 177 | own. The words are the studio's; the mechanics are `docs/FOLLOWING.md`. | |
| 178 | ||
| 170 | 179 | **The box** is the front door. A sentence becomes an issue — the first line |
| 171 | 180 | its title, the rest its body — and the routing decision hands it to a |
| 172 | 181 | builder: the builder with the least in flight, then by name. That is one |
scripts/gate.ts +1 −1
| @@ -41,6 +41,7 @@ export const GATE: readonly string[] = [ | ||
| 41 | 41 | "test/autonomy.test.ts", |
| 42 | 42 | "test/studio.test.ts", |
| 43 | 43 | "test/studio-words.test.ts", |
| 44 | "test/following.test.ts", | |
| 44 | 45 | "test/forks.test.ts", |
| 45 | 46 | "test/fork-flow.test.ts", |
| 46 | 47 | "test/cross-repo-pull.test.ts", |
| @@ -140,7 +141,6 @@ export const EXCLUDED: readonly Exclusion[] = [ | ||
| 140 | 141 | }, |
| 141 | 142 | { file: "test/feeds.test.ts", reason: "not yet admitted", issue: 261 }, |
| 142 | 143 | { file: "test/fleet-graph.test.ts", reason: "not yet admitted", issue: 261 }, |
| 143 | { file: "test/following.test.ts", reason: "not yet admitted", issue: 261 }, | |
| 144 | 144 | { file: "test/git-local.test.ts", reason: "not yet admitted", issue: 261 }, |
| 145 | 145 | { file: "test/git-ssh.test.ts", reason: "not yet admitted", issue: 261 }, |
| 146 | 146 | { file: "test/git.test.ts", reason: "not yet admitted", issue: 261 }, |
src/web/pages/following.ts +5
| @@ -324,6 +324,11 @@ export async function followingSubmit(ctx: Ctx): Promise<Response> { | ||
| 324 | 324 | const parentName = `${parent.ownerName}/${parent.name}`; |
| 325 | 325 | |
| 326 | 326 | if (field(form, "do") === "converge") { |
| 327 | if (!canWrite(ctx.app.db, repo, ctx.auth.user)) | |
| 328 | return flashOrJson(ctx, base, { | |
| 329 | tone: "danger", | |
| 330 | message: "Only a writer can ask the crew to converge.", | |
| 331 | }); | |
| 327 | 332 | const outcome = await convergeFork(ctx.app, repo, parent, ctx.auth.user); |
| 328 | 333 | return flashOrJson(ctx, base, convergeWords(outcome, parentName)); |
| 329 | 334 | } |
src/web/studio/app.ts +5
| @@ -30,6 +30,7 @@ import { appTailJs, liveRegionsJs } from "../client.ts"; | ||
| 30 | 30 | import { html, when, type Html } from "../html.ts"; |
| 31 | 31 | import { page } from "../layout.ts"; |
| 32 | 32 | import { liveClientJs, liveMount } from "../live.ts"; |
| 33 | import { followingCard, followingOf } from "../pages/following.ts"; | |
| 33 | 34 | import { |
| 34 | 35 | avatar, |
| 35 | 36 | btn, |
| @@ -421,10 +422,14 @@ export async function studioApp( | ||
| 421 | 422 | )} |
| 422 | 423 | </p>`; |
| 423 | 424 | |
| 425 | // An app that follows another says so before anything else: whether it | |
| 426 | // is in step is the first thing its owner wants to know. | |
| 427 | const following = await followingOf(ctx, repo); | |
| 424 | 428 | const body = html`${repoHeader(ctx, repo, "code", { |
| 425 | 429 | compact: true, |
| 426 | 430 | ownTitle: true, |
| 427 | 431 | })} |
| 432 | ${following === undefined ? "" : followingCard(ctx, repo, following)} | |
| 428 | 433 | <div id="app-head">${head}</div> |
| 429 | 434 | ${await policyLine(ctx, repo, record)} |
| 430 | 435 | <div class="dash"> |
test/following.test.ts +17 −1
| @@ -32,7 +32,7 @@ import { getIssueById, listIssues } from "../src/core/issues.ts"; | ||
| 32 | 32 | import { getRepoById, type Repo } from "../src/core/repos.ts"; |
| 33 | 33 | import { listRuns } from "../src/core/runs.ts"; |
| 34 | 34 | import { createToken } from "../src/core/tokens.ts"; |
| 35 | import { createUser, type User } from "../src/core/users.ts"; | |
| 35 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; | |
| 36 | 36 | import { resolveOid } from "../src/git/merge.ts"; |
| 37 | 37 | import { repoDir } from "../src/git/repos.ts"; |
| 38 | 38 | import { serve, type Servers } from "../src/http/server.ts"; |
| @@ -243,6 +243,22 @@ describe("a fork that follows its parent", () => { | ||
| 243 | 243 | expect(page).toContain("Nobody is on the crew"); |
| 244 | 244 | }); |
| 245 | 245 | |
| 246 | test("the page form refuses a reader who cannot write, and opens nothing", async () => { | |
| 247 | const eve = getUserByName(app.db, "eve")!; | |
| 248 | const before = listIssues(app.db, fork.id, { state: "open" }).length; | |
| 249 | const res = await fetch(`${base}/bob/game/following`, { | |
| 250 | method: "POST", | |
| 251 | headers: { | |
| 252 | authorization: `Bearer ${createToken(app.db, eve.id, "form", "rw").token}`, | |
| 253 | "content-type": "application/x-www-form-urlencoded", | |
| 254 | accept: "application/json", | |
| 255 | }, | |
| 256 | body: "do=converge", | |
| 257 | }); | |
| 258 | expect(res.status).toBe(422); | |
| 259 | expect(listIssues(app.db, fork.id, { state: "open" }).length).toBe(before); | |
| 260 | }); | |
| 261 | ||
| 246 | 262 | test("without a crew, converging is refused honestly", async () => { |
| 247 | 263 | const res = await fetch( |
| 248 | 264 | `${base}/api/v1/repos/bob/game/following/converge`, |
new above
offline