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

Merged opened this

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

Merged

scout/forge:scout/2644-the-tell-me-inbox-card-lists-the-parent → main 1 commit fork

  • Merged. scout/2644-the-tell-me-inbox-card-lists-the-parent was merged into main as e7d6a89.

Review

Sign in to review this change.
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
164164 (state.behind ?? 0) > 0 && state.parentTip !== state.toldTip;
165165 if (tell) state = { ...state, toldTip: state.parentTip };
166166 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!));
168168 // Tell an open chain page its line changed; no actor when the timer checks.
169169 if (repo !== undefined)
170170 app.bus.append({
@@ -178,12 +178,43 @@ async function run(app: App, options: { remote?: string; now?: number }): Promis
178178 }
179179 }
180180
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
181212 /**
182213 * One inbox card for the owner: an issue on the fork assigned to them, which
183214 * the inbox shows as "assigned to you", and which links the chain page. The
184215 * sentinel files it when the forge has one, so it is not the owner's own ask.
185216 */
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 {
187218 try {
188219 const owner = getUserById(app.db, repo.ownerId);
189220 if (owner === undefined) return;
@@ -192,7 +223,7 @@ function tellOwner(app: App, repo: Repo, fork: string, behind: number): void {
192223 repoId: repo.id,
193224 authorId: (sentinel ?? owner).id,
194225 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}`,
196227 });
197228 if (made.ok) setAssignee(app.db, made.value.id, owner.id);
198229 } catch {
test/spawn-upstream-card.test.ts +78 −3
@@ -4,7 +4,7 @@
44 * Two local bare repositories, no network.
55 */
66
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
88 import { mkdir, mkdtemp, rm } from "node:fs/promises";
99 import { tmpdir } from "node:os";
1010 import { join } from "node:path";
@@ -14,6 +14,7 @@ import { listAssignedTo } from "../src/core/issues.ts";
1414 import { createUser } from "../src/core/users.ts";
1515 import { runGit } from "../src/git/env.ts";
1616 import { repoDir } from "../src/git/repos.ts";
17 import { renderMarkdown } from "../src/web/markdown.ts";
1718 import { checkUpstream } from "../src/spawn/upstream.ts";
1819 import { quietLogs } from "./helpers/globals.ts";
1920
@@ -32,10 +33,10 @@ describe("the parent moved: one card", () => {
3233 let work = "";
3334 let ownerId = "";
3435
35 const commit = async (n: number) => {
36 const commit = async (n: number, subject?: string) => {
3637 await Bun.write(join(work, `f${n}.txt`), String(n));
3738 await git(work, "add", ".");
38 await git(work, "commit", "-m", `c${n}`);
39 await git(work, "commit", "-m", subject ?? `c${n}`);
3940 await git(work, "push", "-q", parent, "HEAD:refs/heads/main");
4041 };
4142 const link = async (patch: Record<string, unknown>) => {
@@ -113,3 +114,77 @@ describe("the parent moved: one card", () => {
113114 expect(cards()).toHaveLength(2);
114115 });
115116 });
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("&lt;script&gt;");
188 expect(body()).not.toContain("z".repeat(100));
189 });
190 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs