Public: anyone can read this; the crew can change it.

A proposal names only what is actually ahead #325

Merged opened this

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

Merged

propose-body-scope → main 1 commit

  • Merged. propose-body-scope was merged into main as ffdde87.

Review

Sign in to review this change.
3 files changed +80 −1
src/actions/upstream.ts +14 −1
@@ -13,6 +13,8 @@ import { forkRelation } from "../core/following.ts";
1313 import { getRepoById, type Repo } from "../core/repos.ts";
1414 import { listPulls, mergedPullsForRepos, type Pull } from "../core/pulls.ts";
1515 import type { User } from "../core/users.ts";
16 import { aheadOids } from "../git/merge.ts";
17 import { repoDir } from "../git/repos.ts";
1618 import { err, ok, type Result } from "../kernel/result.ts";
1719 import { requireWriter, type AccessError } from "./access.ts";
1820 import { openPull } from "./pulls.ts";
@@ -62,7 +64,11 @@ export async function proposeUpstream(
6264 return err("nothing_to_propose");
6365
6466 // 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.
6672 const lastProposal = listPulls(app.db, parent.id, {
6773 state: "closed",
6874 limit: 200,
@@ -71,10 +77,17 @@ export async function proposeUpstream(
7177 .map((p) => p.createdAt)
7278 .sort()
7379 .at(-1);
80 const ahead = await aheadOids(
81 repoDir(app.reposRoot, fork.ownerName, fork.name),
82 relation.parentTip,
83 relation.forkTip,
84 );
7485 const landed = mergedPullsForRepos(app.db, [fork.id], 40)
7586 .filter(
7687 (p) =>
7788 p.mergedAt !== null &&
89 p.mergedOid !== null &&
90 ahead.has(p.mergedOid) &&
7891 (lastProposal === undefined || p.mergedAt > lastProposal),
7992 )
8093 .reverse();
src/git/merge.ts +13
@@ -187,6 +187,19 @@ export async function ancestorsOf(
187187 return result.ok ? new Set(result.value.split("\n")) : undefined;
188188 }
189189
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
190203 /** Count commits reachable from head but not base (the PR's commit count). */
191204 export async function aheadCount(
192205 dir: string,
test/following.test.ts +53
@@ -597,6 +597,59 @@ describe("a fork that follows its parent", () => {
597597 expect(page).toContain("See the upstream proposal");
598598 expect(page).not.toContain('value="propose"');
599599 });
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 });
600653 });
601654
602655 /**

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs