Public: anyone can read this; the crew can change it.
The tell-me inbox card lists the parent's new commits, so the owner can decide without leaving the inbox #2646
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2644-the-tell-me-inbox-card-lists-the-parent → main 1 commit fork
2 files
changed +112
−6
src/spawn/upstream.ts +34 −3
| @@ -164,7 +164,7 @@ async function run(app: App, options: { remote?: string; now?: number }): Promis | ||
| 164 | 164 | (state.behind ?? 0) > 0 && state.parentTip !== state.toldTip; |
| 165 | 165 | if (tell) state = { ...state, toldTip: state.parentTip }; |
| 166 | 166 | await keep(app, state); |
| 167 | if (tell) tellOwner(app, repo, link.repo, state.behind!); | |
| 167 | if (tell) tellOwner(app, repo, link.repo, state.behind!, await newestCommits(dir, state.behind!)); | |
| 168 | 168 | // Tell an open chain page its line changed; no actor when the timer checks. |
| 169 | 169 | if (repo !== undefined) |
| 170 | 170 | app.bus.append({ |
| @@ -178,12 +178,43 @@ 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 | ||
| 181 | 212 | /** |
| 182 | 213 | * One inbox card for the owner: an issue on the fork assigned to them, which |
| 183 | 214 | * the inbox shows as "assigned to you", and which links the chain page. The |
| 184 | 215 | * sentinel files it when the forge has one, so it is not the owner's own ask. |
| 185 | 216 | */ |
| 186 | function tellOwner(app: App, repo: Repo, fork: string, behind: number): void { | |
| 217 | function tellOwner(app: App, repo: Repo, fork: string, behind: number, commits = ""): void { | |
| 187 | 218 | try { |
| 188 | 219 | const owner = getUserById(app.db, repo.ownerId); |
| 189 | 220 | if (owner === undefined) return; |
| @@ -192,7 +223,7 @@ function tellOwner(app: App, repo: Repo, fork: string, behind: number): void { | ||
| 192 | 223 | repoId: repo.id, |
| 193 | 224 | authorId: (sentinel ?? owner).id, |
| 194 | 225 | title: `Your parent forge has ${behind} new commit${behind === 1 ? "" : "s"}`, |
| 195 | body: `Your parent forge's code moved. See where you stand and take it from [the chain page](/${fork}/following).`, | |
| 226 | body: `Your parent forge's code moved. See where you stand and take it from [the chain page](/${fork}/following).${commits}`, | |
| 196 | 227 | }); |
| 197 | 228 | if (made.ok) setAssignee(app.db, made.value.id, owner.id); |
| 198 | 229 | } catch { |
test/spawn-upstream-card.test.ts +78 −3
| @@ -4,7 +4,7 @@ | ||
| 4 | 4 | * Two local bare repositories, no network. |
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 7 | import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test"; | |
| 8 | 8 | import { mkdir, mkdtemp, rm } from "node:fs/promises"; |
| 9 | 9 | import { tmpdir } from "node:os"; |
| 10 | 10 | import { join } from "node:path"; |
| @@ -14,6 +14,7 @@ import { listAssignedTo } from "../src/core/issues.ts"; | ||
| 14 | 14 | import { createUser } from "../src/core/users.ts"; |
| 15 | 15 | import { runGit } from "../src/git/env.ts"; |
| 16 | 16 | import { repoDir } from "../src/git/repos.ts"; |
| 17 | import { renderMarkdown } from "../src/web/markdown.ts"; | |
| 17 | 18 | import { checkUpstream } from "../src/spawn/upstream.ts"; |
| 18 | 19 | import { quietLogs } from "./helpers/globals.ts"; |
| 19 | 20 | |
| @@ -32,10 +33,10 @@ describe("the parent moved: one card", () => { | ||
| 32 | 33 | let work = ""; |
| 33 | 34 | let ownerId = ""; |
| 34 | 35 | |
| 35 | const commit = async (n: number) => { | |
| 36 | const commit = async (n: number, subject?: string) => { | |
| 36 | 37 | await Bun.write(join(work, `f${n}.txt`), String(n)); |
| 37 | 38 | await git(work, "add", "."); |
| 38 | await git(work, "commit", "-m", `c${n}`); | |
| 39 | await git(work, "commit", "-m", subject ?? `c${n}`); | |
| 39 | 40 | await git(work, "push", "-q", parent, "HEAD:refs/heads/main"); |
| 40 | 41 | }; |
| 41 | 42 | const link = async (patch: Record<string, unknown>) => { |
| @@ -113,3 +114,77 @@ describe("the parent moved: one card", () => { | ||
| 113 | 114 | expect(cards()).toHaveLength(2); |
| 114 | 115 | }); |
| 115 | 116 | }); |
| 117 | ||
| 118 | describe("the card lists the parent's commits", () => { | |
| 119 | let dir = ""; | |
| 120 | let app: App; | |
| 121 | let parent = ""; | |
| 122 | let work = ""; | |
| 123 | let ownerId = ""; | |
| 124 | const commit = async (n: number, subject: string) => { | |
| 125 | await Bun.write(join(work, `g${n}.txt`), String(n)); | |
| 126 | await git(work, "add", "."); | |
| 127 | await git(work, "commit", "-q", "-m", subject); | |
| 128 | await git(work, "push", "-q", parent, "HEAD:refs/heads/main"); | |
| 129 | }; | |
| 130 | const setup = async (name: string) => { | |
| 131 | dir = await mkdtemp(join(tmpdir(), "forge-uplist-")); | |
| 132 | app = createApp({ dataDir: join(dir, "data"), url: "https://child.test", port: 0 }); | |
| 133 | parent = join(dir, "parent.git"); | |
| 134 | work = join(dir, "work"); | |
| 135 | await git(dir, "init", "-q", "--bare", "-b", "main", parent); | |
| 136 | await git(dir, "init", "-q", "-b", "main", work); | |
| 137 | await commit(0, "base"); | |
| 138 | const owner = await createUser(app.db, { origin: "seed", trusted: true, username: name, email: `${name}@x.test`, isAdmin: true }); | |
| 139 | if (!owner.ok) throw new Error("no owner"); | |
| 140 | ownerId = owner.value.id; | |
| 141 | const made = await createRepo(app, owner.value, { name: "forge", visibility: "public" }); | |
| 142 | if (!made.ok) throw new Error("no repo"); | |
| 143 | await rm(repoDir(app.reposRoot, name, "forge"), { recursive: true, force: true }); | |
| 144 | await mkdir(join(app.reposRoot, name), { recursive: true }); | |
| 145 | await git(dir, "clone", "-q", "--bare", parent, repoDir(app.reposRoot, name, "forge")); | |
| 146 | await Bun.write( | |
| 147 | join(app.config.dataDir, "parent.json"), | |
| 148 | JSON.stringify({ | |
| 149 | upstream: "https://parent.test", | |
| 150 | forgeCode: { repo: `${name}/forge`, upstream: "root/forge", mode: "tell-me", tip: "a".repeat(40), chain: [] }, | |
| 151 | }), | |
| 152 | ); | |
| 153 | }; | |
| 154 | const body = () => listAssignedTo(app.db, ownerId)[0]!.body; | |
| 155 | afterEach(() => rm(dir, { recursive: true, force: true })); | |
| 156 | ||
| 157 | test("3 commits, newest first", async () => { | |
| 158 | await setup("three"); | |
| 159 | for (const n of [1, 2, 3]) await commit(n, `subject ${n}`); | |
| 160 | await checkUpstream(app, { remote: parent }); | |
| 161 | const b = body(); | |
| 162 | expect(b.indexOf("subject 3")).toBeGreaterThan(0); | |
| 163 | expect(b.indexOf("subject 3")).toBeLessThan(b.indexOf("subject 2")); | |
| 164 | expect(b.indexOf("subject 2")).toBeLessThan(b.indexOf("subject 1")); | |
| 165 | expect(b).toMatch(/\n[0-9a-f]{7,} \d{4}-\d\d-\d\d subject 3\n/); | |
| 166 | expect(b).not.toContain("more"); | |
| 167 | }); | |
| 168 | ||
| 169 | test("12 commits show 10 and 'and 2 more'", async () => { | |
| 170 | await setup("twelve"); | |
| 171 | for (let n = 1; n <= 12; n++) await commit(n, `subject ${n}`); | |
| 172 | await checkUpstream(app, { remote: parent }); | |
| 173 | const b = body(); | |
| 174 | expect(b.match(/ subject \d+\n/g)).toHaveLength(10); | |
| 175 | expect(b).toContain("subject 12"); | |
| 176 | expect(b).not.toContain("subject 2\n"); | |
| 177 | expect(b).toContain("and 2 more"); | |
| 178 | }); | |
| 179 | ||
| 180 | test("a hostile subject is inert and long ones are cut", async () => { | |
| 181 | await setup("evil"); | |
| 182 | await commit(1, "<script>alert(1)</script> [x](javascript:alert(1)) ```" + "z".repeat(200)); | |
| 183 | await checkUpstream(app, { remote: parent }); | |
| 184 | const html = String(renderMarkdown(body())); | |
| 185 | expect(html).not.toContain("<script"); | |
| 186 | expect(html).not.toContain("href=\"javascript"); | |
| 187 | expect(html).toContain("<script>"); | |
| 188 | expect(body()).not.toContain("z".repeat(100)); | |
| 189 | }); | |
| 190 | }); |
new above
offline