Public: anyone can read this; the crew can change it.
Who told and who shipped: a change shows the agent that wrote it but not the person whose words it answers, and lands under neither together #516
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/415-who-told-and-who-shipped-a-change-shows → main 8 commits fork
9 files
changed +269
−21
forge.json +1 −1
| @@ -6,7 +6,7 @@ | ||
| 6 | 6 | "ready": 90 |
| 7 | 7 | }, |
| 8 | 8 | "check": { |
| 9 | "run": "bun x tsc --noEmit && bun scripts/test-shards.ts --timeout 60000 -- crew crew-wrapup crew-limits crew-parks crew-sandbox steer session-settle landing decide ledger checks dependencies autonomy studio studio-words forks fork-flow cross-repo-pull pulls apps apps-adversarial apps-busy apps-token web git-trust helpers shards migrate vitals guard asks feed feed-page offline share cloud run-secrets forge-cloudflare usage credentials standing board-windows spawn-plan spawn web-apps fleet-ops meter", | |
| 9 | "run": "bun x tsc --noEmit && bun scripts/test-shards.ts --timeout 60000 -- crew crew-wrapup crew-limits crew-parks crew-sandbox steer session-settle landing decide ledger checks dependencies autonomy studio studio-words forks fork-flow cross-repo-pull pulls apps apps-adversarial apps-busy apps-token web git-trust helpers shards migrate vitals guard asks feed feed-page offline share cloud run-secrets forge-cloudflare usage credentials standing board-windows spawn-plan spawn web-apps fleet-ops meter told-by", | |
| 10 | 10 | "timeout": 1800 |
| 11 | 11 | } |
| 12 | 12 | } |
src/actions/pulls.ts +35 −7
| @@ -17,6 +17,8 @@ import { | ||
| 17 | 17 | } from "../core/issues.ts"; |
| 18 | 18 | import { |
| 19 | 19 | closesFromBody, |
| 20 | landingTrailers, | |
| 21 | toldBy, | |
| 20 | 22 | createPull, |
| 21 | 23 | getPull, |
| 22 | 24 | mergePull as landPull, |
| @@ -47,7 +49,7 @@ import { | ||
| 47 | 49 | type Side, |
| 48 | 50 | type ThreadState, |
| 49 | 51 | } from "../core/reviews.ts"; |
| 50 | import type { User } from "../core/users.ts"; | |
| 52 | import { getUserById, getUserByName, type User } from "../core/users.ts"; | |
| 51 | 53 | import type { MergeStrategy } from "../git/merge.ts"; |
| 52 | 54 | import { fetchInto, resolveOid, writeRef } from "../git/merge.ts"; |
| 53 | 55 | import { repoDir } from "../git/repos.ts"; |
| @@ -294,6 +296,33 @@ export async function mergePull( | ||
| 294 | 296 | // A fork may have pushed since the pull opened; merge what is being |
| 295 | 297 | // proposed now, not the snapshot taken when it was. |
| 296 | 298 | await refreshPullHead(app, repo, pull); |
| 299 | const lander = { | |
| 300 | name: writer.value.displayName || writer.value.username, | |
| 301 | email: writer.value.email, | |
| 302 | }; | |
| 303 | // The agent that wrote the change is the commit's author; the person who | |
| 304 | // asked is named in `Requested-by`, the person who landed it in | |
| 305 | // `Co-authored-by`. | |
| 306 | const writtenBy = getUserById(app.db, pull.authorId); | |
| 307 | const agentAuthor = | |
| 308 | writtenBy?.kind === "agent" | |
| 309 | ? { | |
| 310 | name: writtenBy.displayName || writtenBy.username, | |
| 311 | email: writtenBy.email, | |
| 312 | } | |
| 313 | : undefined; | |
| 314 | const asked = toldBy(app.db, pull); | |
| 315 | const requester = | |
| 316 | asked === null ? undefined : getUserByName(app.db, asked); | |
| 317 | const trailers = landingTrailers( | |
| 318 | requester | |
| 319 | ? { | |
| 320 | name: requester.displayName || requester.username, | |
| 321 | email: requester.email, | |
| 322 | } | |
| 323 | : null, | |
| 324 | lander, | |
| 325 | ); | |
| 297 | 326 | const merged = await landPull( |
| 298 | 327 | app.db, |
| 299 | 328 | repoDir(app.reposRoot, repo.ownerName, repo.name), |
| @@ -305,15 +334,14 @@ export async function mergePull( | ||
| 305 | 334 | requireChecks: repo.requireChecks, |
| 306 | 335 | }, |
| 307 | 336 | strategy, |
| 308 | message: | |
| 337 | message: `${ | |
| 309 | 338 | strategy === "squash" |
| 310 | 339 | ? `${pull.title} (#${pull.number})` |
| 311 | : `Merge pull request #${pull.number} from ${pull.headBranch}\n\n${pull.title}`, | |
| 340 | : `Merge pull request #${pull.number} from ${pull.headBranch}\n\n${pull.title}` | |
| 341 | }\n\n${trailers}`, | |
| 312 | 342 | actorId: writer.value.id, |
| 313 | author: { | |
| 314 | name: writer.value.displayName || writer.value.username, | |
| 315 | email: writer.value.email, | |
| 316 | }, | |
| 343 | author: agentAuthor ?? lander, | |
| 344 | committer: lander, | |
| 317 | 345 | }, |
| 318 | 346 | ); |
| 319 | 347 | if (!merged.ok) return merged; |
src/core/pulls.ts +39
| @@ -598,6 +598,8 @@ export async function mergePull( | ||
| 598 | 598 | message: string; |
| 599 | 599 | actorId: string; |
| 600 | 600 | author: { name: string; email: string }; |
| 601 | /** Who commits it, when the author is the agent that wrote the change. */ | |
| 602 | committer?: { name: string; email: string }; | |
| 601 | 603 | /** The review gate. Absent means no review policy applies. */ |
| 602 | 604 | gate?: ReviewGate; |
| 603 | 605 | }, |
| @@ -625,6 +627,7 @@ export async function mergePull( | ||
| 625 | 627 | strategy: input.strategy, |
| 626 | 628 | message: input.message, |
| 627 | 629 | author: input.author, |
| 630 | ...(input.committer === undefined ? {} : { committer: input.committer }), | |
| 628 | 631 | }); |
| 629 | 632 | if (!landed.ok) return landed; |
| 630 | 633 | |
| @@ -679,3 +682,39 @@ export function openPullsClosing( | ||
| 679 | 682 | if (!out.has(n)) out.set(n, pull); |
| 680 | 683 | return out; |
| 681 | 684 | } |
| 685 | ||
| 686 | /** | |
| 687 | * The person whose words a pull answers: the author of the issue it closes | |
| 688 | * (or continues), when that is a person. Derived from the body and the issue | |
| 689 | * on every read — no column to drift. Null when it closes nothing, or only | |
| 690 | * work an agent filed. | |
| 691 | */ | |
| 692 | export function toldBy(db: Database, pull: Pull): string | null { | |
| 693 | for (const n of closesFromBody(pull.body)) { | |
| 694 | const issue = getIssue(db, pull.repoId, n); | |
| 695 | if (issue && !issue.authorIsAgent && issue.originAuthor === null) | |
| 696 | return issue.authorName; | |
| 697 | } | |
| 698 | return null; | |
| 699 | } | |
| 700 | ||
| 701 | /** A trailer value is one line: no control bytes, no angle brackets. */ | |
| 702 | function oneLine(s: string): string { | |
| 703 | return s.replace(/[\u0000-\u001f\u007f<>]/g, "").trim(); | |
| 704 | } | |
| 705 | ||
| 706 | /** | |
| 707 | * The trailers a landed change carries: who asked (`Requested-by`) and who | |
| 708 | * landed it (`Co-authored-by`), the agent being the commit's author. | |
| 709 | */ | |
| 710 | export function landingTrailers( | |
| 711 | requester: { name: string; email: string } | null, | |
| 712 | lander: { name: string; email: string }, | |
| 713 | ): string { | |
| 714 | const line = (key: string, who: { name: string; email: string }) => | |
| 715 | `${key}: ${oneLine(who.name)} <${oneLine(who.email)}>`; | |
| 716 | return [ | |
| 717 | ...(requester ? [line("Requested-by", requester)] : []), | |
| 718 | line("Co-authored-by", lander), | |
| 719 | ].join("\n"); | |
| 720 | } |
src/feed/items.ts +11 −5
| @@ -10,7 +10,7 @@ import type { Database } from "bun:sqlite"; | ||
| 10 | 10 | import { listDecidedIn } from "../core/decisions.ts"; |
| 11 | 11 | import { audienceFilter } from "../core/event_audience.ts"; |
| 12 | 12 | import { listEventsVisibleTo, type ForgeEvent } from "../core/events.ts"; |
| 13 | import { getPull } from "../core/pulls.ts"; | |
| 13 | import { getPull, toldBy } from "../core/pulls.ts"; | |
| 14 | 14 | import { |
| 15 | 15 | canSeeOwnerFacts, |
| 16 | 16 | getReposByIds, |
| @@ -148,8 +148,14 @@ function fromEvent( | ||
| 148 | 148 | }; |
| 149 | 149 | const p = e.payload; |
| 150 | 150 | const number = typeof p["number"] === "number" ? p["number"] : undefined; |
| 151 | const title = | |
| 152 | number === undefined ? "" : (getPull(db, repo.id, number)?.title ?? ""); | |
| 151 | const pull = number === undefined ? undefined : getPull(db, repo.id, number); | |
| 152 | const title = pull?.title ?? ""; | |
| 153 | const told = pull === undefined ? null : toldBy(db, pull); | |
| 154 | const toldParts = [ | |
| 155 | ...(told === null ? [] : [`told by ${told}`]), | |
| 156 | ...(pull?.authorIsAgent ? [`by ${pull.authorName}`] : []), | |
| 157 | ].join(", "); | |
| 158 | const toldWords = toldParts === "" ? "" : ` (${toldParts})`; | |
| 153 | 159 | const change = number === undefined ? base(repo) : `${base(repo)}/changes/${number}`; |
| 154 | 160 | switch (e.kind) { |
| 155 | 161 | case "run.finished": { |
| @@ -174,9 +180,9 @@ function fromEvent( | ||
| 174 | 180 | }; |
| 175 | 181 | } |
| 176 | 182 | case "pull.opened": |
| 177 | return { ...common, kind: "pull", link: change, what: `a change was opened${title ? `: ${clip(title)}` : ""}` }; | |
| 183 | return { ...common, kind: "pull", link: change, what: `a change was opened${title ? `: ${clip(title)}` : ""}${toldWords}` }; | |
| 178 | 184 | case "pull.merged": |
| 179 | return { ...common, kind: "pull", link: change, what: `a change shipped${title ? `: ${clip(title)}` : ""}` }; | |
| 185 | return { ...common, kind: "pull", link: change, what: `a change shipped${title ? `: ${clip(title)}` : ""}${toldWords}` }; | |
| 180 | 186 | case "pull.reviewed": |
| 181 | 187 | return { ...common, kind: "pull", link: change, what: `a change was reviewed${title ? `: ${clip(title)}` : ""}` }; |
| 182 | 188 | case "look.taken": |
src/git/merge.ts +4 −2
| @@ -108,6 +108,8 @@ export async function merge( | ||
| 108 | 108 | strategy: MergeStrategy; |
| 109 | 109 | message: string; |
| 110 | 110 | author: MergeIdentity; |
| 111 | /** Who commits it, when that is not the author. */ | |
| 112 | committer?: MergeIdentity; | |
| 111 | 113 | }, |
| 112 | 114 | ): Promise<Result<MergeResult, MergeError>> { |
| 113 | 115 | if ( |
| @@ -130,8 +132,8 @@ export async function merge( | ||
| 130 | 132 | const env = { |
| 131 | 133 | GIT_AUTHOR_NAME: input.author.name, |
| 132 | 134 | GIT_AUTHOR_EMAIL: input.author.email, |
| 133 | GIT_COMMITTER_NAME: input.author.name, | |
| 134 | GIT_COMMITTER_EMAIL: input.author.email, | |
| 135 | GIT_COMMITTER_NAME: (input.committer ?? input.author).name, | |
| 136 | GIT_COMMITTER_EMAIL: (input.committer ?? input.author).email, | |
| 135 | 137 | }; |
| 136 | 138 | const commit = await runGit( |
| 137 | 139 | ["commit-tree", tree.value.treeOid, ...parents, "-m", input.message], |
src/web/pages/board.ts +3 −2
| @@ -35,7 +35,7 @@ import { forgeHealth, rolloutLine } from "../../kernel/guard.ts"; | ||
| 35 | 35 | import { vitals, type Vitals } from "../../kernel/vitals.ts"; |
| 36 | 36 | import { getIssue, getIssuesByIds, type Issue } from "../../core/issues.ts"; |
| 37 | 37 | import { looksForApps } from "../../core/looks.ts"; |
| 38 | import { listPulls, mergedPullsForRepos, type Pull } from "../../core/pulls.ts"; | |
| 38 | import { listPulls, mergedPullsForRepos, toldBy, type Pull } from "../../core/pulls.ts"; | |
| 39 | 39 | import { |
| 40 | 40 | canSeeOwnerFacts, |
| 41 | 41 | listReposVisibleTo, |
| @@ -515,6 +515,7 @@ function landed(ctx: Ctx, scope: BoardScope): Html { | ||
| 515 | 515 | { name: pull.authorName, isAgent: pull.authorIsAgent }, |
| 516 | 516 | scope.ownerFacts.has(repo.id), |
| 517 | 517 | ); |
| 518 | const told = toldBy(db, pull); | |
| 518 | 519 | return html`<li> |
| 519 | 520 | <a |
| 520 | 521 | class="board-card board-landed" |
| @@ -536,7 +537,7 @@ function landed(ctx: Ctx, scope: BoardScope): Html { | ||
| 536 | 537 | > |
| 537 | 538 | <span class="board-landed-title">${pull.title}</span> |
| 538 | 539 | <span class="board-when">${time(pull.mergedAt ?? pull.createdAt)}</span> |
| 539 | <span class="board-landed-meta">${repo.name} · ${author}</span> | |
| 540 | <span class="board-landed-meta">${repo.name} · ${author}${told === null ? "" : html` · told by ${told}`}</span> | |
| 540 | 541 | </a> |
| 541 | 542 | </li>`; |
| 542 | 543 | }); |
src/web/pages/pull.ts +6 −2
| @@ -24,7 +24,7 @@ import { | ||
| 24 | 24 | type PullReviewStatus, |
| 25 | 25 | type PullStatus, |
| 26 | 26 | } from "../../core/pulls.ts"; |
| 27 | import { getPull } from "../../core/pulls.ts"; | |
| 27 | import { getPull, toldBy } from "../../core/pulls.ts"; | |
| 28 | 28 | import { checkSummary, type CheckSummary } from "../../core/runs.ts"; |
| 29 | 29 | import { |
| 30 | 30 | anchorKey, |
| @@ -822,7 +822,11 @@ export async function pullPage(ctx: Ctx): Promise<Response> { | ||
| 822 | 822 | ? originTag(pull.originAuthor, originGlyph(repo)) |
| 823 | 823 | : userLink(pull.authorName)} |
| 824 | 824 | opened this ${time(pull.createdAt)}</span |
| 825 | > | |
| 825 | >${when( | |
| 826 | toldBy(ctx.app.db, pull), | |
| 827 | (who) => | |
| 828 | html`<span class="told-by">in answer to ${userLink(who)}</span>`, | |
| 829 | )} | |
| 826 | 830 | ${when( |
| 827 | 831 | pull.originUrl, |
| 828 | 832 | (url) => |
src/web/studio/change.ts +6 −2
| @@ -9,7 +9,7 @@ | ||
| 9 | 9 | |
| 10 | 10 | import { appsRuntime } from "../../apps/runtime.ts"; |
| 11 | 11 | import type { AppRecord } from "../../core/apps.ts"; |
| 12 | import { pullStatus, revertOf, type Pull } from "../../core/pulls.ts"; | |
| 12 | import { pullStatus, revertOf, toldBy, type Pull } from "../../core/pulls.ts"; | |
| 13 | 13 | import { canWrite, type Repo } from "../../core/repos.ts"; |
| 14 | 14 | import { checkSummary } from "../../core/runs.ts"; |
| 15 | 15 | import { repoDir } from "../../git/repos.ts"; |
| @@ -153,7 +153,11 @@ export async function studioPull( | ||
| 153 | 153 | ? originTag(pull.originAuthor, originGlyph(repo)) |
| 154 | 154 | : userLink(pull.authorName)} |
| 155 | 155 | opened this ${time(pull.createdAt)}</span |
| 156 | > | |
| 156 | >${when( | |
| 157 | toldBy(ctx.app.db, pull), | |
| 158 | (who) => | |
| 159 | html`<span class="told-by">in answer to ${userLink(who)}</span>`, | |
| 160 | )} | |
| 157 | 161 | ${when( |
| 158 | 162 | pull.originUrl, |
| 159 | 163 | (url) => |
test/told-by.test.ts added +164
| @@ -0,0 +1,164 @@ | ||
| 1 | /** | |
| 2 | * Who told and who shipped: a change names the person whose words it answers | |
| 3 | * and the agent that wrote it, on its page, the board, the feed and the merge | |
| 4 | * commit — derived from the issue it closes, stored nowhere. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { createApp, type App } from "../src/app.ts"; | |
| 11 | import { enableApp } from "../src/actions/apps.ts"; | |
| 12 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; | |
| 13 | import { openIssue } from "../src/actions/issues.ts"; | |
| 14 | import { mergePull, openPull } from "../src/actions/pulls.ts"; | |
| 15 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 16 | import { getPull, landingTrailers, toldBy } from "../src/core/pulls.ts"; | |
| 17 | import type { Repo } from "../src/core/repos.ts"; | |
| 18 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; | |
| 19 | import { readFeed } from "../src/feed/feed.ts"; | |
| 20 | import { repoDir } from "../src/git/repos.ts"; | |
| 21 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 22 | ||
| 23 | describe("who told and who shipped", () => { | |
| 24 | let dataDir: string; | |
| 25 | let app: App; | |
| 26 | let servers: Servers; | |
| 27 | let alice: User; | |
| 28 | let bob: User; | |
| 29 | let repo: Repo; | |
| 30 | let cookie: string; | |
| 31 | let number: number; | |
| 32 | ||
| 33 | const get = (path: string) => | |
| 34 | fetch(`${servers.url}${path}`, { headers: { cookie } }); | |
| 35 | ||
| 36 | beforeAll(async () => { | |
| 37 | dataDir = await mkdtemp(join(tmpdir(), "forge-toldby-")); | |
| 38 | app = createApp({ dataDir, port: 0 }); | |
| 39 | servers = await serve(app); | |
| 40 | const a = await createUser(app.db, { | |
| 41 | username: "alice", | |
| 42 | email: "[email protected]", | |
| 43 | password: "a-good-passphrase", | |
| 44 | adminIfFirst: true, | |
| 45 | }); | |
| 46 | const b = await createUser(app.db, { | |
| 47 | username: "bob", | |
| 48 | email: "[email protected]", | |
| 49 | password: "a-good-passphrase", | |
| 50 | }); | |
| 51 | if (!a.ok || !b.ok) throw new Error("users"); | |
| 52 | alice = a.value; | |
| 53 | bob = b.value; | |
| 54 | cookie = ( | |
| 55 | ( | |
| 56 | await fetch(`${servers.url}/signin`, { | |
| 57 | method: "POST", | |
| 58 | body: new URLSearchParams({ | |
| 59 | username: "alice", | |
| 60 | password: "a-good-passphrase", | |
| 61 | }), | |
| 62 | redirect: "manual", | |
| 63 | }) | |
| 64 | ).headers.get("set-cookie") ?? "" | |
| 65 | ).split(";")[0]!; | |
| 66 | const made = await createRepo(app, alice, { | |
| 67 | name: "notes", | |
| 68 | visibility: "public", | |
| 69 | }); | |
| 70 | if (!made.ok) throw new Error(String(made.error)); | |
| 71 | repo = made.value; | |
| 72 | const seeded = await commitToBranch(app, alice, repo, { | |
| 73 | message: "Start", | |
| 74 | files: [ | |
| 75 | { | |
| 76 | path: "package.json", | |
| 77 | content: `{"name":"notes","scripts":{"start":"bun run server.ts"}}\n`, | |
| 78 | }, | |
| 79 | { | |
| 80 | path: "server.ts", | |
| 81 | content: | |
| 82 | "Bun.serve({ port: Number(process.env.PORT), fetch: () => new Response('ok') });\n", | |
| 83 | }, | |
| 84 | ], | |
| 85 | }); | |
| 86 | if (!seeded.ok) throw new Error(JSON.stringify(seeded.error)); | |
| 87 | const enabled = await enableApp(app, alice, repo, { slug: "notes" }); | |
| 88 | if (!enabled.ok) throw new Error(String(enabled.error)); | |
| 89 | const scout = await createAgent(app, alice, { username: "scout" }); | |
| 90 | if (!scout.ok) throw new Error(String(scout.error)); | |
| 91 | addToCrew(app, alice, repo, "scout"); | |
| 92 | ||
| 93 | const told = openIssue(app, bob, repo, { title: "Make it fast" }); | |
| 94 | if (!told.ok) throw new Error(String(told.error)); | |
| 95 | const branch = await commitToBranch(app, alice, repo, { | |
| 96 | branch: "feature/fast", | |
| 97 | base: "main", | |
| 98 | message: "Faster", | |
| 99 | files: [{ path: "FAST.md", content: "fast\n" }], | |
| 100 | }); | |
| 101 | if (!branch.ok) throw new Error(JSON.stringify(branch.error)); | |
| 102 | const opened = await openPull(app, getUserByName(app.db, "scout")!, repo, { | |
| 103 | title: "Faster notes", | |
| 104 | body: `Done.\n\nCloses #${told.value.number}`, | |
| 105 | baseBranch: "main", | |
| 106 | headBranch: "feature/fast", | |
| 107 | }); | |
| 108 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 109 | number = opened.value.number; | |
| 110 | }, 30_000); | |
| 111 | ||
| 112 | afterAll(async () => { | |
| 113 | servers.stop(); | |
| 114 | app.close(); | |
| 115 | await rm(dataDir, { recursive: true, force: true }); | |
| 116 | }); | |
| 117 | ||
| 118 | test("toldBy is the person who wrote the issue it closes, nobody else", () => { | |
| 119 | const pull = getPull(app.db, repo.id, number)!; | |
| 120 | expect(toldBy(app.db, pull)).toBe("bob"); | |
| 121 | expect(toldBy(app.db, { ...pull, body: "no issue here" })).toBeNull(); | |
| 122 | expect(toldBy(app.db, { ...pull, body: "Closes #9999" })).toBeNull(); | |
| 123 | }); | |
| 124 | ||
| 125 | test("the pull's page names both, escaped", async () => { | |
| 126 | const html = await (await get(`/alice/notes/pulls/${number}`)).text(); | |
| 127 | expect(html).toContain("in answer to"); | |
| 128 | expect(html).toMatch(/in answer to[\s\S]{0,200}bob/); | |
| 129 | expect(html).toContain("scout"); | |
| 130 | }); | |
| 131 | ||
| 132 | test("the feed item says who told", () => { | |
| 133 | const items = readFeed(app.db, alice).items; | |
| 134 | expect( | |
| 135 | items.some((i) => i.reason.includes("Faster notes") && | |
| 136 | i.reason.includes("(told by bob, by scout)")), | |
| 137 | ).toBe(true); | |
| 138 | }); | |
| 139 | ||
| 140 | test("the merge commit is the agent's, with both trailers; the board card says who told", async () => { | |
| 141 | const pull = getPull(app.db, repo.id, number)!; | |
| 142 | const merged = await mergePull(app, alice, repo, pull, "squash"); | |
| 143 | if (!merged.ok) throw new Error(JSON.stringify(merged.error)); | |
| 144 | const log = Bun.spawnSync( | |
| 145 | ["git", "log", "-1", "--format=%an|%cn%n%B", "main"], | |
| 146 | { cwd: repoDir(app.reposRoot, "alice", "notes") }, | |
| 147 | ).stdout.toString(); | |
| 148 | expect(log).toContain("scout|alice"); | |
| 149 | expect(log).toContain("Requested-by: bob <[email protected]>"); | |
| 150 | expect(log).toContain("Co-authored-by: alice <[email protected]>"); | |
| 151 | ||
| 152 | const board = await (await get("/board")).text(); | |
| 153 | expect(board).toContain("told by bob"); | |
| 154 | expect(board).toContain("scout"); | |
| 155 | }); | |
| 156 | ||
| 157 | test("trailers are one line each, whatever a name holds", () => { | |
| 158 | const t = landingTrailers( | |
| 159 | { name: "evil\nCo-authored-by: x", email: "e@x" }, | |
| 160 | { name: "a", email: "a@x" }, | |
| 161 | ); | |
| 162 | expect(t.split("\n")).toHaveLength(2); | |
| 163 | }); | |
| 164 | }); |
new above
offline