Public: anyone can read this; the crew can change it.
A proposal names only what is actually ahead #325
Check · Review · Land · Live done — Landed and live in production.
propose-body-scope → main 1 commit
3 files
changed +80
−1
src/actions/upstream.ts +14 −1
| @@ -13,6 +13,8 @@ import { forkRelation } from "../core/following.ts"; | ||
| 13 | 13 | import { getRepoById, type Repo } from "../core/repos.ts"; |
| 14 | 14 | import { listPulls, mergedPullsForRepos, type Pull } from "../core/pulls.ts"; |
| 15 | 15 | import type { User } from "../core/users.ts"; |
| 16 | import { aheadOids } from "../git/merge.ts"; | |
| 17 | import { repoDir } from "../git/repos.ts"; | |
| 16 | 18 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 17 | 19 | import { requireWriter, type AccessError } from "./access.ts"; |
| 18 | 20 | import { openPull } from "./pulls.ts"; |
| @@ -62,7 +64,11 @@ export async function proposeUpstream( | ||
| 62 | 64 | return err("nothing_to_propose"); |
| 63 | 65 | |
| 64 | 66 | // What landed on the fork since it last proposed: the pulls, newest last, |
| 65 | // so the body reads in the order the work happened. | |
| 67 | // so the body reads in the order the work happened — and only the pulls | |
| 68 | // whose landing commit is actually ahead of the parent. A fork whose work | |
| 69 | // reached upstream some other way (a push, a release) does not propose it | |
| 70 | // twice; without this, a first proposal once listed forty changes for one | |
| 71 | // commit ahead. | |
| 66 | 72 | const lastProposal = listPulls(app.db, parent.id, { |
| 67 | 73 | state: "closed", |
| 68 | 74 | limit: 200, |
| @@ -71,10 +77,17 @@ export async function proposeUpstream( | ||
| 71 | 77 | .map((p) => p.createdAt) |
| 72 | 78 | .sort() |
| 73 | 79 | .at(-1); |
| 80 | const ahead = await aheadOids( | |
| 81 | repoDir(app.reposRoot, fork.ownerName, fork.name), | |
| 82 | relation.parentTip, | |
| 83 | relation.forkTip, | |
| 84 | ); | |
| 74 | 85 | const landed = mergedPullsForRepos(app.db, [fork.id], 40) |
| 75 | 86 | .filter( |
| 76 | 87 | (p) => |
| 77 | 88 | p.mergedAt !== null && |
| 89 | p.mergedOid !== null && | |
| 90 | ahead.has(p.mergedOid) && | |
| 78 | 91 | (lastProposal === undefined || p.mergedAt > lastProposal), |
| 79 | 92 | ) |
| 80 | 93 | .reverse(); |
src/git/merge.ts +13
| @@ -187,6 +187,19 @@ export async function ancestorsOf( | ||
| 187 | 187 | return result.ok ? new Set(result.value.split("\n")) : undefined; |
| 188 | 188 | } |
| 189 | 189 | |
| 190 | /** The commits reachable from head but not base — what a proposal carries. */ | |
| 191 | export async function aheadOids( | |
| 192 | dir: string, | |
| 193 | baseOid: string, | |
| 194 | headOid: string, | |
| 195 | ): Promise<ReadonlySet<string>> { | |
| 196 | if (!isSafeOid(baseOid) || !isSafeOid(headOid)) return new Set(); | |
| 197 | const result = await runGit(["rev-list", `${baseOid}..${headOid}`], { | |
| 198 | cwd: dir, | |
| 199 | }); | |
| 200 | return new Set(result.ok ? result.value.split("\n").filter(Boolean) : []); | |
| 201 | } | |
| 202 | ||
| 190 | 203 | /** Count commits reachable from head but not base (the PR's commit count). */ |
| 191 | 204 | export async function aheadCount( |
| 192 | 205 | dir: string, |
test/following.test.ts +53
| @@ -597,6 +597,59 @@ describe("a fork that follows its parent", () => { | ||
| 597 | 597 | expect(page).toContain("See the upstream proposal"); |
| 598 | 598 | expect(page).not.toContain('value="propose"'); |
| 599 | 599 | }); |
| 600 | ||
| 601 | test("a proposal names only what is actually ahead: work the parent already has is not listed again", async () => { | |
| 602 | // A clean pair of its own: the fixture's pair has diverged on purpose. | |
| 603 | const made = await createRepo(app, alice, { name: "lamp", visibility: "public" }); | |
| 604 | if (!made.ok) throw new Error(String(made.error)); | |
| 605 | const lamp = made.value; | |
| 606 | await commit(alice, lamp, "README.md", "# lamp\n"); | |
| 607 | const forked = await forkRepo(app, bob, lamp); | |
| 608 | if (!forked.ok) throw new Error(String(forked.error)); | |
| 609 | const mine = forked.value; | |
| 610 | const land = async (title: string, file: string): Promise<void> => { | |
| 611 | const branch = await commitToBranch(app, bob, mine, { | |
| 612 | branch: `feature/${file}`, | |
| 613 | base: mine.defaultBranch, | |
| 614 | message: title, | |
| 615 | files: [{ path: `${file}.txt`, content: `${file}\n` }], | |
| 616 | }); | |
| 617 | if (!branch.ok) throw new Error(JSON.stringify(branch.error)); | |
| 618 | const opened = await openPull(app, bob, mine, { | |
| 619 | title, | |
| 620 | baseBranch: mine.defaultBranch, | |
| 621 | headBranch: `feature/${file}`, | |
| 622 | }); | |
| 623 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 624 | const landed = await mergePull(app, bob, mine, opened.value, "squash"); | |
| 625 | if (!landed.ok) throw new Error(JSON.stringify(landed.error)); | |
| 626 | }; | |
| 627 | const propose = async () => { | |
| 628 | const res = await fetch(`${base}/api/v1/repos/bob/lamp/following/propose`, { | |
| 629 | method: "POST", | |
| 630 | headers: as(bobToken), | |
| 631 | }); | |
| 632 | expect(res.status).toBe(200); | |
| 633 | return (await res.json()) as { number: number; changes: number; title: string }; | |
| 634 | }; | |
| 635 | const { getPull } = await import("../src/core/pulls.ts"); | |
| 636 | ||
| 637 | await land("Glow in the dark", "glow"); | |
| 638 | const first = await propose(); | |
| 639 | expect(first.title).toBe("Glow in the dark"); | |
| 640 | // The parent takes it, so Glow is upstream now. | |
| 641 | const standing = getPull(app.db, lamp.id, first.number)!; | |
| 642 | const taken = await mergePull(app, alice, lamp, standing, "merge"); | |
| 643 | if (!taken.ok) throw new Error(JSON.stringify(taken.error)); | |
| 644 | ||
| 645 | await land("Dim the lights", "dim"); | |
| 646 | const second = await propose(); | |
| 647 | expect(second.title).toBe("Dim the lights"); | |
| 648 | expect(second.changes).toBe(1); | |
| 649 | const upstream = getPull(app.db, lamp.id, second.number)!; | |
| 650 | expect(upstream.body).toContain("- Dim the lights (bob/lamp#"); | |
| 651 | expect(upstream.body).not.toContain("Glow in the dark"); | |
| 652 | }); | |
| 600 | 653 | }); |
| 601 | 654 | |
| 602 | 655 | /** |
new above
offline