Public: anyone can read this; the crew can change it.
The take pull's description lists the parent's new commits, and is rewritten when the parent moves again #2657
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2656-the-take-pull-s-description-lists-the-pa → main 1 commit fork
3 files
changed +188
−38
src/spawn/take.ts +73 −6
| @@ -9,10 +9,12 @@ | ||
| 9 | 9 | * A conflict changes nothing; it opens (or finds) one issue naming the files. |
| 10 | 10 | */ |
| 11 | 11 | |
| 12 | import { join } from "node:path"; | |
| 13 | import { rename } from "node:fs/promises"; | |
| 12 | 14 | import type { App } from "../app.ts"; |
| 13 | 15 | import { openPull } from "../actions/pulls.ts"; |
| 14 | 16 | import { commitIdentity, forgeHost, noreplyAddress } from "../core/commit_identity.ts"; |
| 15 | import { createIssue, listIssues } from "../core/issues.ts"; | |
| 17 | import { createIssue, listIssues, updateIssueText } from "../core/issues.ts"; | |
| 16 | 18 | import { pullForBranch, trackPullHead } from "../core/pulls.ts"; |
| 17 | 19 | import { getRepo } from "../core/repos.ts"; |
| 18 | 20 | import { getUserById } from "../core/users.ts"; |
| @@ -31,6 +33,68 @@ export const takeTitle = (upstream: string): string => `Take updates from ${upst | ||
| 31 | 33 | /** The one branch the parent's code is put on. */ |
| 32 | 34 | export const TAKE_BRANCH = "forge/take-updates"; |
| 33 | 35 | |
| 36 | const LISTED = 10; | |
| 37 | ||
| 38 | /** | |
| 39 | * The parent's newest commits as a fenced block for the card: short sha, date, | |
| 40 | * subject (the parent's text: first line, 100 characters, no backticks, so it | |
| 41 | * cannot close the fence; the fence renders as escaped plain text). Fixed | |
| 42 | * arguments only (`range` is two oids or fixed refs, never user text). Empty when the log cannot be read: the card goes without. | |
| 43 | */ | |
| 44 | export async function newestCommits(dir: string, behind: number, range = "HEAD..refs/upstream/HEAD"): Promise<string> { | |
| 45 | try { | |
| 46 | const log = await runGit( | |
| 47 | ["log", `--max-count=${LISTED}`, "--format=%h%x09%as%x09%s", range, "--"], | |
| 48 | { cwd: dir }, | |
| 49 | ); | |
| 50 | if (!log.ok) return ""; | |
| 51 | const lines = log.value | |
| 52 | .split("\n") | |
| 53 | .filter((l) => l !== "") | |
| 54 | .map((l) => { | |
| 55 | const [sha = "", date = "", ...rest] = l.split("\t"); | |
| 56 | const subject = Array.from(rest.join(" ").replace(/[\p{Cc}`]/gu, " ").trim()).slice(0, 100).join(""); | |
| 57 | return `${sha} ${date} ${subject}`; | |
| 58 | }); | |
| 59 | if (lines.length === 0) return ""; | |
| 60 | const more = behind - lines.length; | |
| 61 | return `\n\n\`\`\`\n${lines.join("\n")}\n\`\`\`${more > 0 ? `\n\nand ${more} more` : ""}`; | |
| 62 | } catch { | |
| 63 | return ""; | |
| 64 | } | |
| 65 | } | |
| 66 | ||
| 67 | /** The pull body the forge writes: the count and the list, then what the pull does and does not do. */ | |
| 68 | async function takeBody(dir: string, link: ForgeLink, head: string, up: string, commits: number): Promise<string> { | |
| 69 | const list = await newestCommits(dir, commits, `${head}..${up}`); | |
| 70 | return [ | |
| 71 | `${commits} new commit${commits === 1 ? "" : "s"} from ${link.upstream}, put on \`${TAKE_BRANCH}\`.${list}`, | |
| 72 | "", | |
| 73 | "Nothing was changed on the default branch. Merge this pull when its check is green; it is never merged for you.", | |
| 74 | ].join("\n"); | |
| 75 | } | |
| 76 | ||
| 77 | /** The body last written, with its pull: a pull whose body differs was edited by a person. */ | |
| 78 | const wrote = (app: App) => join(app.config.dataDir, "take-body.json"); | |
| 79 | ||
| 80 | async function lastWrote(app: App): Promise<{ number: number; body: string } | undefined> { | |
| 81 | try { | |
| 82 | const v = (await Bun.file(wrote(app)).json()) as Record<string, unknown>; | |
| 83 | return typeof v["number"] === "number" && typeof v["body"] === "string" ? { number: v["number"], body: v["body"] } : undefined; | |
| 84 | } catch { | |
| 85 | return undefined; | |
| 86 | } | |
| 87 | } | |
| 88 | ||
| 89 | async function keepWrote(app: App, number: number, body: string): Promise<void> { | |
| 90 | try { | |
| 91 | await Bun.write(`${wrote(app)}.tmp`, JSON.stringify({ number, body })); | |
| 92 | await rename(`${wrote(app)}.tmp`, wrote(app)); | |
| 93 | } catch { | |
| 94 | // Nothing to keep it in: the body is then left alone next time. | |
| 95 | } | |
| 96 | } | |
| 97 | ||
| 34 | 98 | /** The all-zero old oid: the ref must not exist yet. */ |
| 35 | 99 | const ZERO = "0".repeat(40); |
| 36 | 100 | |
| @@ -102,20 +166,23 @@ export async function takeFromParent(app: App, link: ForgeLink): Promise<Taken | | ||
| 102 | 166 | payload: { number: live.number, headOid: to, branch: TAKE_BRANCH }, |
| 103 | 167 | }); |
| 104 | 168 | number = live.number; |
| 169 | const last = await lastWrote(app); | |
| 170 | if (last !== undefined && last.number === live.number && last.body === live.body) { | |
| 171 | const body = await takeBody(dir, link, head, up, commits); | |
| 172 | if (body !== live.body && updateIssueText(app.db, live.id, { body }).ok) await keepWrote(app, live.number, body); | |
| 173 | } | |
| 105 | 174 | } else { |
| 106 | 175 | if (had === to && open !== undefined) return undefined; // closed unmerged and nothing new since |
| 176 | const body = await takeBody(dir, link, head, up, commits); | |
| 107 | 177 | const opened = await openPull(app, owner, repo, { |
| 108 | 178 | title: takeTitle(link.upstream), |
| 109 | body: [ | |
| 110 | `${commits} new commit${commits === 1 ? "" : "s"} from ${link.upstream}, put on \`${TAKE_BRANCH}\`.`, | |
| 111 | "", | |
| 112 | "Nothing was changed on the default branch. Merge this pull when its check is green; it is never merged for you.", | |
| 113 | ].join("\n"), | |
| 179 | body, | |
| 114 | 180 | baseBranch: branch, |
| 115 | 181 | headBranch: TAKE_BRANCH, |
| 116 | 182 | }); |
| 117 | 183 | if (!opened.ok) return undefined; |
| 118 | 184 | number = opened.value.number; |
| 185 | await keepWrote(app, number, body); | |
| 119 | 186 | } |
| 120 | 187 | app.bus.append({ |
| 121 | 188 | kind: "forge.updates_taken", |
src/spawn/upstream.ts +1 −32
| @@ -20,7 +20,7 @@ import { getUserById, getUserByName } from "../core/users.ts"; | ||
| 20 | 20 | import { runGit } from "../git/env.ts"; |
| 21 | 21 | import { repoDir } from "../git/repos.ts"; |
| 22 | 22 | import { forgeLinkOf, originOf } from "./chain.ts"; |
| 23 | import { takeFromParent, type Taken } from "./take.ts"; | |
| 23 | import { newestCommits, takeFromParent, type Taken } from "./take.ts"; | |
| 24 | 24 | |
| 25 | 25 | export const CHECK_MS = 60 * 60_000; |
| 26 | 26 | export const FETCH_TIMEOUT_MS = 60_000; |
| @@ -178,37 +178,6 @@ async function run(app: App, options: { remote?: string; now?: number }): Promis | ||
| 178 | 178 | } |
| 179 | 179 | } |
| 180 | 180 | |
| 181 | const LISTED = 10; | |
| 182 | ||
| 183 | /** | |
| 184 | * The parent's newest commits as a fenced block for the card: short sha, date, | |
| 185 | * subject (the parent's text: first line, 100 characters, no backticks, so it | |
| 186 | * cannot close the fence; the fence renders as escaped plain text). Fixed | |
| 187 | * arguments only. Empty when the log cannot be read: the card goes without. | |
| 188 | */ | |
| 189 | async function newestCommits(dir: string, behind: number): Promise<string> { | |
| 190 | try { | |
| 191 | const log = await runGit( | |
| 192 | ["log", `--max-count=${LISTED}`, "--format=%h%x09%as%x09%s", "HEAD..refs/upstream/HEAD", "--"], | |
| 193 | { cwd: dir }, | |
| 194 | ); | |
| 195 | if (!log.ok) return ""; | |
| 196 | const lines = log.value | |
| 197 | .split("\n") | |
| 198 | .filter((l) => l !== "") | |
| 199 | .map((l) => { | |
| 200 | const [sha = "", date = "", ...rest] = l.split("\t"); | |
| 201 | const subject = Array.from(rest.join(" ").replace(/[\p{Cc}`]/gu, " ").trim()).slice(0, 100).join(""); | |
| 202 | return `${sha} ${date} ${subject}`; | |
| 203 | }); | |
| 204 | if (lines.length === 0) return ""; | |
| 205 | const more = behind - lines.length; | |
| 206 | return `\n\n\`\`\`\n${lines.join("\n")}\n\`\`\`${more > 0 ? `\n\nand ${more} more` : ""}`; | |
| 207 | } catch { | |
| 208 | return ""; | |
| 209 | } | |
| 210 | } | |
| 211 | ||
| 212 | 181 | /** |
| 213 | 182 | * One inbox card for the owner: an issue on the fork assigned to them, which |
| 214 | 183 | * the inbox shows as "assigned to you", and which links the chain page. The |
test/spawn-take-body.test.ts added +114
| @@ -0,0 +1,114 @@ | ||
| 1 | /** | |
| 2 | * The take pull's description (#2656): it lists the parent's new commits, | |
| 3 | * is rewritten when the parent moves again, and is left alone once a person | |
| 4 | * edited it. Local bare repositories, no network. | |
| 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 { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 12 | import { createApp, type App } from "../src/app.ts"; | |
| 13 | import { setIssueState, updateIssueText } from "../src/core/issues.ts"; | |
| 14 | import { listPulls } from "../src/core/pulls.ts"; | |
| 15 | import type { Repo } from "../src/core/repos.ts"; | |
| 16 | import { createUser } from "../src/core/users.ts"; | |
| 17 | import { runGit } from "../src/git/env.ts"; | |
| 18 | import { repoDir } from "../src/git/repos.ts"; | |
| 19 | import { checkUpstream } from "../src/spawn/upstream.ts"; | |
| 20 | import { renderMarkdown } from "../src/web/markdown.ts"; | |
| 21 | import { quietLogs } from "./helpers/globals.ts"; | |
| 22 | ||
| 23 | quietLogs(); | |
| 24 | ||
| 25 | const git = async (cwd: string, ...args: string[]) => { | |
| 26 | const r = await runGit(["-c", "user.name=t", "-c", "[email protected]", ...args], { cwd }); | |
| 27 | if (!r.ok) throw new Error(r.error.stderr); | |
| 28 | return r.value.trim(); | |
| 29 | }; | |
| 30 | ||
| 31 | describe("the take pull's description", () => { | |
| 32 | let dir = ""; | |
| 33 | let app: App; | |
| 34 | let repo: Repo; | |
| 35 | let parent = ""; | |
| 36 | let work = ""; | |
| 37 | ||
| 38 | const theirs = async (n: string, subject: string) => { | |
| 39 | await Bun.write(join(work, `${n}.txt`), n); | |
| 40 | await git(work, "add", "."); | |
| 41 | await git(work, "commit", "-m", subject); | |
| 42 | await git(work, "push", "-q", parent, "HEAD:refs/heads/main"); | |
| 43 | }; | |
| 44 | const open = () => listPulls(app.db, repo.id, { state: "open" }).filter((p) => p.headBranch === "forge/take-updates"); | |
| 45 | const body = () => open()[0]!.body; | |
| 46 | const take = () => checkUpstream(app, { remote: parent }); | |
| 47 | ||
| 48 | beforeAll(async () => { | |
| 49 | dir = await mkdtemp(join(tmpdir(), "forge-takebody-")); | |
| 50 | app = createApp({ dataDir: join(dir, "data"), url: "https://child.test", port: 0 }); | |
| 51 | const u = await createUser(app.db, { origin: "seed", trusted: true, username: "kid", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true }); | |
| 52 | if (!u.ok) throw new Error(u.error); | |
| 53 | const made = await createRepo(app, u.value, { name: "forge", visibility: "public" }); | |
| 54 | if (!made.ok) throw new Error(String(made.error)); | |
| 55 | repo = made.value; | |
| 56 | const base = await commitToBranch(app, u.value, repo, { message: "base", files: [{ path: "a.txt", content: "base\n" }] }); | |
| 57 | if (!base.ok) throw new Error(JSON.stringify(base.error)); | |
| 58 | parent = join(dir, "parent.git"); | |
| 59 | work = join(dir, "work"); | |
| 60 | await git(dir, "clone", "-q", "--bare", repoDir(app.reposRoot, "kid", "forge"), parent); | |
| 61 | await git(dir, "clone", "-q", parent, work); | |
| 62 | await Bun.write( | |
| 63 | join(app.config.dataDir, "parent.json"), | |
| 64 | JSON.stringify({ | |
| 65 | upstream: "https://parent.test", | |
| 66 | forgeCode: { repo: "kid/forge", upstream: "root/forge", mode: "take-updates", tip: "a".repeat(40), chain: [] }, | |
| 67 | }), | |
| 68 | ); | |
| 69 | }, 30_000); | |
| 70 | afterAll(async () => { | |
| 71 | app.close(); | |
| 72 | await rm(dir, { recursive: true, force: true }); | |
| 73 | }); | |
| 74 | ||
| 75 | test("3 commits are listed, newest first", async () => { | |
| 76 | for (const n of [1, 2, 3]) await theirs(`c${n}`, `subject ${n}`); | |
| 77 | await take(); | |
| 78 | const b = body(); | |
| 79 | expect(b.split("\n")[0]).toContain("3 new commits from root/forge"); | |
| 80 | expect(b).toMatch(/\n[0-9a-f]{7,} \d{4}-\d\d-\d\d subject 3\n/); | |
| 81 | expect(b.indexOf("subject 3")).toBeLessThan(b.indexOf("subject 1")); | |
| 82 | expect(b).toContain("never merged for you"); | |
| 83 | }); | |
| 84 | ||
| 85 | test("2 more are fetched: the same pull's body lists 5", async () => { | |
| 86 | const number = open()[0]!.number; | |
| 87 | for (const n of [4, 5]) await theirs(`c${n}`, `subject ${n}`); | |
| 88 | await take(); | |
| 89 | expect(open()).toHaveLength(1); | |
| 90 | expect(open()[0]!.number).toBe(number); | |
| 91 | const b = body(); | |
| 92 | expect(b.split("\n")[0]).toContain("5 new commits"); | |
| 93 | for (const n of [1, 2, 3, 4, 5]) expect(b).toContain(`subject ${n}\n`); | |
| 94 | }); | |
| 95 | ||
| 96 | test("a body a person edited is kept", async () => { | |
| 97 | updateIssueText(app.db, open()[0]!.id, { body: "My own words." }); | |
| 98 | await theirs("c6", "subject 6"); | |
| 99 | await take(); | |
| 100 | expect(body()).toBe("My own words."); | |
| 101 | expect(open()[0]!.headOid).toBe(await git(parent, "rev-parse", "refs/heads/main")); | |
| 102 | }); | |
| 103 | ||
| 104 | test("hostile subjects stay inert in a fresh pull's body", async () => { | |
| 105 | setIssueState(app.db, open()[0]!.id, "closed", "dropped"); | |
| 106 | await theirs("evil", "<script>alert(1)</script> [x](javascript:alert(1))"); | |
| 107 | await take(); | |
| 108 | expect(body()).toContain("<script>"); // the text is kept; rendering is what makes it inert | |
| 109 | const html = String(renderMarkdown(body())); | |
| 110 | expect(html).not.toContain("<script"); | |
| 111 | expect(html).not.toContain('href="javascript'); | |
| 112 | expect(html).toContain("<script>"); | |
| 113 | }); | |
| 114 | }); |
new above
offline