Public: anyone can read this; the crew can change it.
A fork that is ahead of its parent shows "Offer N commits upstream" on its home, opening the cross-repository pull form already filled in #5971
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5964-a-fork-that-is-ahead-of-its-parent-shows → main 3 commits fork 3 threads · 3 unresolved
7 files
changed +341
−3
README.md +2
| @@ -288,6 +288,8 @@ each, under the forge strip; a list cut to three says "+N more". | ||
| 288 | 288 | The line is the shared `.quiet-details` disclosure, and the "need you" count |
| 289 | 289 | carries the attention dot. |
| 290 | 290 | |
| 291 | A fork ahead of its parent offers its commits upstream: on its home a signed-in reader of the parent sees "Offer N commits upstream" (into the new-pull form with both branches chosen) or "Offered in #M" once a pull is open; a writer who has the Following card gets its "Propose upstream" instead, one route not two. In the terminal the fork's About card (`I`) has an `offer` row and `o` opens the same pull form (or the open pull). Both read `upstreamOffer` (`src/actions/offer_upstream.ts`), the open pull by one SQL lookup. | |
| 292 | ||
| 291 | 293 | ## Live in the terminal |
| 292 | 294 | |
| 293 | 295 | `ssh` into the forge and you get the whole thing — repositories, code, issues, |
src/actions/offer_upstream.ts added +39
| @@ -0,0 +1,39 @@ | ||
| 1 | /** | |
| 2 | * A fork's commits, offered to its parent: plain data for every door. The | |
| 3 | * page draws a button from it and the terminal a key; both are gated the way | |
| 4 | * `openPull` gates a pull — a signed-in viewer who may read the parent — and | |
| 5 | * both find the open pull in the database, never in git. | |
| 6 | */ | |
| 7 | ||
| 8 | import type { App } from "../app.ts"; | |
| 9 | import { openPullFromHead } from "../core/pulls.ts"; | |
| 10 | import { canRead, type Repo } from "../core/repos.ts"; | |
| 11 | import type { User } from "../core/users.ts"; | |
| 12 | ||
| 13 | export interface UpstreamOffer { | |
| 14 | readonly ahead: number; | |
| 15 | /** The open pull already offering this branch, when there is one. */ | |
| 16 | readonly openNumber: number | null; | |
| 17 | readonly head: string; | |
| 18 | readonly base: string; | |
| 19 | } | |
| 20 | ||
| 21 | /** `ahead` is the drift the caller has read; null or zero offers nothing. */ | |
| 22 | export function upstreamOffer( | |
| 23 | app: App, | |
| 24 | viewer: User | undefined, | |
| 25 | fork: Repo, | |
| 26 | parent: Repo | undefined, | |
| 27 | ahead: number | null | undefined, | |
| 28 | ): UpstreamOffer | null { | |
| 29 | if (parent === undefined || viewer === undefined) return null; | |
| 30 | if (ahead === null || ahead === undefined || ahead <= 0) return null; | |
| 31 | if (!canRead(app.db, parent, viewer)) return null; | |
| 32 | const open = openPullFromHead(app.db, parent.id, fork.id, fork.defaultBranch); | |
| 33 | return { | |
| 34 | ahead, | |
| 35 | openNumber: open?.number ?? null, | |
| 36 | head: fork.defaultBranch, | |
| 37 | base: parent.defaultBranch, | |
| 38 | }; | |
| 39 | } |
src/core/pulls.ts +21
| @@ -430,6 +430,27 @@ export function listOpenPullsFromHead( | ||
| 430 | 430 | return joinMany(db, [...getIssuesByIds(db, ids).values()]); |
| 431 | 431 | } |
| 432 | 432 | |
| 433 | /** | |
| 434 | * The one open pull into `baseRepoId` whose head is this branch of this | |
| 435 | * repository, asked of the database rather than found in a page of pulls. | |
| 436 | */ | |
| 437 | export function openPullFromHead( | |
| 438 | db: Database, | |
| 439 | baseRepoId: string, | |
| 440 | headRepoId: string, | |
| 441 | branch: string, | |
| 442 | ): Pull | undefined { | |
| 443 | const row = db | |
| 444 | .query<{ number: number }, [string, string, string]>( | |
| 445 | `SELECT issue.number FROM pull JOIN issue ON issue.id = pull.issue_id | |
| 446 | WHERE issue.repo_id = ?1 AND pull.head_repo_id = ?2 AND pull.head_branch = ?3 | |
| 447 | AND issue.state = 'open' | |
| 448 | ORDER BY issue.number DESC LIMIT 1`, | |
| 449 | ) | |
| 450 | .get(baseRepoId, headRepoId, branch); | |
| 451 | return row === null || row === undefined ? undefined : getPull(db, baseRepoId, row.number); | |
| 452 | } | |
| 453 | ||
| 433 | 454 | /** |
| 434 | 455 | * The newest open pull of each repository, in one query — what a shelf row |
| 435 | 456 | * shows when nothing about the app is moving otherwise: a change waits for |
src/tui/screens/about.ts +54 −1
| @@ -21,6 +21,8 @@ import { parentReleases, releasesSentence } from "../../core/parent_releases.ts" | ||
| 21 | 21 | import { running } from "../../kernel/version.ts"; |
| 22 | 22 | import { checkMemoryAt } from "../../apps/check-memory.ts"; |
| 23 | 23 | import { repoDir } from "../../git/repos.ts"; |
| 24 | import { forkDrift } from "../../actions/forks.ts"; | |
| 25 | import { upstreamOffer, type UpstreamOffer } from "../../actions/offer_upstream.ts"; | |
| 24 | 26 | import { listRefs } from "../../git/read.ts"; |
| 25 | 27 | import { updateRepoSettings } from "../../actions/repos.ts"; |
| 26 | 28 | import { showProvenance } from "../../actions/provenance.ts"; |
| @@ -54,7 +56,9 @@ import { badge, card } from "../ui/widgets.ts"; | ||
| 54 | 56 | import type { Session } from "../session.ts"; |
| 55 | 57 | import { PIN_MARK, ago, drawFacts, plural } from "./common.ts"; |
| 56 | 58 | import { connectionsScreen, egressScreen } from "./egress.ts"; |
| 57 | import { forkOf, hostOf, liveOrigin, localRepo, sourceName } from "./where.ts"; | |
| 59 | import { newPullScreen } from "./newpull.ts"; | |
| 60 | import { pullScreen } from "./pull.ts"; | |
| 61 | import { forkOf, hostOf, liveOrigin, local, localRepo, sourceName } from "./where.ts"; | |
| 58 | 62 | |
| 59 | 63 | /** |
| 60 | 64 | * `I` — everything a repository is, in one card: what it is for, whether it |
| @@ -75,6 +79,7 @@ export function aboutScreen( | ||
| 75 | 79 | const may = (): boolean => |
| 76 | 80 | repo !== undefined && f.user !== undefined && canAdmin(f.db, repo, f.user); |
| 77 | 81 | let cell: [string, string][] = []; |
| 82 | let offered: UpstreamOffer | null = null; | |
| 78 | 83 | // The trail's switch (#3293) is a person's, an admin's, and only where |
| 79 | 84 | // there is a trail to switch: the same rule as the settings page. |
| 80 | 85 | const mayTrail = (): boolean => |
| @@ -92,9 +97,25 @@ export function aboutScreen( | ||
| 92 | 97 | ...aboutFacts(f, src, facts, ref), |
| 93 | 98 | ...trailRows(f, repo), |
| 94 | 99 | ...upstreamRows(f, repo), |
| 100 | ...offerRows(offered), | |
| 95 | 101 | ...cell, |
| 96 | 102 | ]; |
| 97 | 103 | let rows = factRows(); |
| 104 | // A fork ahead of its parent offers its commits upstream: the page's | |
| 105 | // button, read the same way (`upstreamOffer`), pressed here with `o`. | |
| 106 | const forkParent = | |
| 107 | repo === undefined ? undefined : forkParentFor(f.db, repo, f.user); | |
| 108 | if (repo !== undefined && forkParent !== undefined) { | |
| 109 | const at = repo; | |
| 110 | void forkDrift(f.app, at, forkParent) | |
| 111 | .then((drift) => { | |
| 112 | offered = upstreamOffer(f.app, f.user, at, forkParent, drift.ahead); | |
| 113 | if (offered === null) return; | |
| 114 | rows = factRows(); | |
| 115 | ctx.invalidate(); | |
| 116 | }) | |
| 117 | .catch(() => {}); | |
| 118 | } | |
| 98 | 119 | // An admin also sees the check cell's memory, read once from forge.json. |
| 99 | 120 | if (may() && repo !== undefined) { |
| 100 | 121 | const at = repo; |
| @@ -151,6 +172,16 @@ export function aboutScreen( | ||
| 151 | 172 | ...(f.user?.isAdmin === true && repo !== undefined |
| 152 | 173 | ? [binding(["e"], "egress"), binding(["c"], "connections")] |
| 153 | 174 | : []), |
| 175 | ...(offered === null | |
| 176 | ? [] | |
| 177 | : [ | |
| 178 | binding( | |
| 179 | ["o"], | |
| 180 | offered.openNumber === null | |
| 181 | ? `offer ${plural(offered.ahead, "commit")} upstream` | |
| 182 | : `see #${offered.openNumber} upstream`, | |
| 183 | ), | |
| 184 | ]), | |
| 154 | 185 | binding(["esc"], "close"), |
| 155 | 186 | ]; |
| 156 | 187 | }, |
| @@ -237,6 +268,15 @@ export function aboutScreen( | ||
| 237 | 268 | ); |
| 238 | 269 | }, |
| 239 | 270 | key(e) { |
| 271 | if (e.key === "o" && repo !== undefined && offered !== null && forkParent !== undefined) { | |
| 272 | ctx.pop(); | |
| 273 | ctx.push( | |
| 274 | offered.openNumber === null | |
| 275 | ? newPullScreen(f, ctx, repo) | |
| 276 | : pullScreen(f, ctx, local(f, forkParent), offered.openNumber), | |
| 277 | ); | |
| 278 | return true; | |
| 279 | } | |
| 240 | 280 | if (e.key === "y" && cloneUrl !== "") { |
| 241 | 281 | ctx.copy(cloneUrl); |
| 242 | 282 | ctx.toast("copied the clone URL", "success"); |
| @@ -335,6 +375,19 @@ export function trailRows( | ||
| 335 | 375 | ]; |
| 336 | 376 | } |
| 337 | 377 | |
| 378 | /** The offer row: what the fork has for its parent, and where it stands. */ | |
| 379 | export function offerRows(offer: UpstreamOffer | null): [string, string][] { | |
| 380 | if (offer === null) return []; | |
| 381 | return [ | |
| 382 | [ | |
| 383 | "offer", | |
| 384 | offer.openNumber === null | |
| 385 | ? `${plural(offer.ahead, "commit")} ahead · o opens the pull` | |
| 386 | : `${plural(offer.ahead, "commit")} ahead · offered in #${offer.openNumber}`, | |
| 387 | ], | |
| 388 | ]; | |
| 389 | } | |
| 390 | ||
| 338 | 391 | /** |
| 339 | 392 | * The upstream row, as the web About line has it: for an admin only, with |
| 340 | 393 | * the address, branch and last check while following is on, and "off" when |
src/web/pages/offer-upstream.ts added +33
| @@ -0,0 +1,33 @@ | ||
| 1 | /** | |
| 2 | * The offer on a fork's home: its own commits, one tap from the pull form. | |
| 3 | * "Offer 3 commits upstream" opens the new-pull form with the fork's default | |
| 4 | * branch as head and the parent's as base; when an open pull already offers | |
| 5 | * that branch the line links to it instead. The gate and the lookup are | |
| 6 | * `upstreamOffer`'s, shared with the terminal; no git here. | |
| 7 | */ | |
| 8 | ||
| 9 | import type { UpstreamOffer } from "../../actions/offer_upstream.ts"; | |
| 10 | import type { Repo } from "../../core/repos.ts"; | |
| 11 | import { repoBase } from "../chrome.ts"; | |
| 12 | import { html, type Html } from "../html.ts"; | |
| 13 | import { btn } from "../ui.ts"; | |
| 14 | ||
| 15 | export function offerUpstream( | |
| 16 | repo: Repo, | |
| 17 | parent: Repo | undefined, | |
| 18 | offer: UpstreamOffer | null, | |
| 19 | ): Html | null { | |
| 20 | if (offer === null || parent === undefined) return null; | |
| 21 | if (offer.openNumber !== null) | |
| 22 | return html`<p class="mt small"> | |
| 23 | <a href="${repoBase(parent)}/pulls/${offer.openNumber}">Offered in #${offer.openNumber}</a> | |
| 24 | </p>`; | |
| 25 | const query = new URLSearchParams({ head: offer.head, base: offer.base }); | |
| 26 | return html`<div class="mt"> | |
| 27 | ${btn(`Offer ${offer.ahead} commit${offer.ahead === 1 ? "" : "s"} upstream`, { | |
| 28 | size: "sm", | |
| 29 | icon: "merge", | |
| 30 | href: `${repoBase(repo)}/pulls/new?${query}`, | |
outdated
Capped at 200 open pulls on the parent. On a busy parent the fork's pull can fall outside that window and the button would reappear. Query by head repo and branch if listPulls can. |
||
| 31 | })} | |
| 32 | </div>`; | |
| 33 | } |
src/web/pages/repo.ts +13 −2
| @@ -9,9 +9,11 @@ | ||
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | 11 | import { followingLine } from "./upstream-follow.ts"; |
| 12 | import { upstreamOffer } from "../../actions/offer_upstream.ts"; | |
| 13 | import { offerUpstream } from "./offer-upstream.ts"; | |
| 12 | 14 | import { NOT_A_MEMBER } from "../../core/roles.ts"; |
| 13 | 15 | import { storageLimitMessage } from "../../core/storage.ts"; |
| 14 | import { canRead, listForks, type Repo } from "../../core/repos.ts"; | |
| 16 | import { canRead, canWrite, listForks, type Repo } from "../../core/repos.ts"; | |
| 15 | 17 | import { FORKS_PER_DAY, forkRepo, repositoryLimitMessage } from "../../actions/repos.ts"; |
| 16 | 18 | import { forkDrift, takeUpdates, type ForkDrift } from "../../actions/forks.ts"; |
| 17 | 19 | import { lineageOf } from "../../core/following.ts"; |
| @@ -1026,7 +1028,9 @@ async function codeHome(ctx: Ctx, repo: Repo): Promise<Response> { | ||
| 1026 | 1028 | const parent = forkParent(ctx, repo); |
| 1027 | 1029 | const [entries, drift] = await Promise.all([ |
| 1028 | 1030 | timePhaseAsync(ctx.request, "tree", () => scope.source.tree(ref, "")), |
outdated
This puts git (forkDrift) on the render path of every fork home. Naming the phase helps, but stored figures were the ask. |
||
| 1029 | parent === undefined ? undefined : forkDrift(ctx.app, repo, parent), | |
| 1031 | parent === undefined | |
| 1032 | ? undefined | |
| 1033 | : timePhaseAsync(ctx.request, "drift", () => forkDrift(ctx.app, repo, parent)), | |
| 1030 | 1034 | ]); |
| 1031 | 1035 | const tree = entries.ok ? entries.value : []; |
| 1032 | 1036 | |
| @@ -1037,10 +1041,17 @@ async function codeHome(ctx: Ctx, repo: Repo): Promise<Response> { | ||
| 1037 | 1041 | const latest = await timePhaseAsync(ctx.request, "wave", () => |
| 1038 | 1042 | latestWave(repoDir(ctx.app.reposRoot, repo.ownerName, repo.name), repo.defaultBranch), |
| 1039 | 1043 | ); |
| 1044 | // The Following card's "Propose upstream" is a writer's route to the same | |
| 1045 | // pull; a writer who has the card is not offered a second button. | |
| 1046 | const offer = | |
| 1047 | following !== undefined && canWrite(ctx.app.db, repo, ctx.auth.user) | |
| 1048 | ? null | |
| 1049 | : upstreamOffer(ctx.app, ctx.auth.user, repo, parent, drift?.ahead); | |
| 1040 | 1050 | const rendering = performance.now(); |
| 1041 | 1051 | const body = html`${scopeHeader(ctx, scope, "code", { |
| 1042 | 1052 | branchCount: refs.branches.length, |
| 1043 | 1053 | })}${archivedLine(ctx, repo)}${noApp} |
| 1054 | ${offerUpstream(repo, parent, offer)} | |
| 1044 | 1055 | ${latest === null |
| 1045 | 1056 | ? "" |
| 1046 | 1057 | : html`<p class="muted latest-wave">Latest: |
test/offer-upstream.test.ts added +179
| @@ -0,0 +1,179 @@ | ||
| 1 | /** | |
| 2 | * A fork ahead of its parent offers its commits upstream from its home: a | |
| 3 | * button into the pull form with both branches chosen, `Offered in #N` once a | |
| 4 | * pull is open, nothing when level, nothing for a viewer who may not open one. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, 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 { setRole } from "../src/actions/roles.ts"; | |
| 11 | import { openPull } from "../src/actions/pulls.ts"; | |
| 12 | import { commitToBranch, createRepo, forkRepo } from "../src/actions/repos.ts"; | |
| 13 | import { upstreamOffer } from "../src/actions/offer_upstream.ts"; | |
| 14 | import { createApp, type App } from "../src/app.ts"; | |
| 15 | import { getRepo } from "../src/core/repos.ts"; | |
| 16 | import { offerRows } from "../src/tui/screens/about.ts"; | |
| 17 | import { createUser } from "../src/core/users.ts"; | |
| 18 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 19 | import { quietLogs } from "./helpers/globals.ts"; | |
| 20 | ||
| 21 | quietLogs(); | |
| 22 | ||
| 23 | let dataDir: string; | |
| 24 | let app: App; | |
| 25 | let servers: Servers; | |
| 26 | let users: Awaited<ReturnType<typeof mk>>[] = []; | |
| 27 | ||
| 28 | async function mk(username: string, first: boolean) { | |
| 29 | const made = await createUser(app.db, { | |
| 30 | origin: "seed", | |
| 31 | trusted: true, | |
| 32 | username, | |
| 33 | email: `${username}@forge.local`, | |
| 34 | password: "a-good-passphrase", | |
| 35 | adminIfFirst: first, | |
| 36 | }); | |
| 37 | if (!made.ok) throw new Error(made.error); | |
| 38 | return made.value; | |
| 39 | } | |
| 40 | ||
| 41 | const signIn = async (username: string): Promise<string> => { | |
| 42 | const r = await fetch(`${servers.url}/signin`, { | |
| 43 | method: "POST", | |
| 44 | body: new URLSearchParams({ username, password: "a-good-passphrase" }), | |
| 45 | redirect: "manual", | |
| 46 | }); | |
| 47 | return (r.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 48 | }; | |
| 49 | ||
| 50 | const home = async (path: string, cookie = ""): Promise<string> => { | |
| 51 | const r = await fetch(`${servers.url}${path}`, { | |
| 52 | headers: cookie ? { cookie } : {}, | |
| 53 | }); | |
| 54 | expect(r.status).toBe(200); | |
| 55 | return r.text(); | |
| 56 | }; | |
| 57 | ||
| 58 | beforeAll(async () => { | |
| 59 | dataDir = await mkdtemp(join(tmpdir(), "forge-offer-")); | |
| 60 | app = createApp({ dataDir, port: 0, host: "forge.test" }); | |
| 61 | servers = await serve(app); | |
| 62 | users = [await mk("hank", true), await mk("olive", false), await mk("ravi", false)]; | |
| 63 | for (const name of ["olive", "ravi"]) { | |
| 64 | const promoted = setRole(app, users[0]!, name, "member"); | |
| 65 | if (!promoted.ok) throw new Error(promoted.error); | |
| 66 | } | |
| 67 | const r = await createRepo(app, users[1]!, { name: "yard", visibility: "public" }); | |
| 68 | if (!r.ok) throw new Error(String(r.error)); | |
| 69 | await commitToBranch(app, users[1]!, r.value, { | |
| 70 | message: "first", | |
| 71 | files: [{ path: "a.txt", content: "a\n" }], | |
| 72 | }); | |
| 73 | const f = await forkRepo(app, users[2]!, r.value); | |
| 74 | if (!f.ok) throw new Error(String(f.error)); | |
| 75 | }); | |
| 76 | ||
| 77 | afterAll(async () => { | |
| 78 | await servers.stop(); | |
| 79 | await rm(dataDir, { recursive: true, force: true }); | |
| 80 | }); | |
| 81 | ||
| 82 | const ownWork = async (n: number) => { | |
| 83 | const fork = getRepo(app.db, "ravi", "yard")!; | |
| 84 | for (let i = 0; i < n; i++) | |
| 85 | await commitToBranch(app, users[2]!, fork, { | |
| 86 | message: `mine ${i}`, | |
| 87 | files: [{ path: `m${i}-${Date.now()}.txt`, content: "x\n" }], | |
| 88 | }); | |
| 89 | }; | |
| 90 | ||
| 91 | test("level with its parent, the fork offers nothing", async () => { | |
| 92 | const h = await home("/ravi/yard", await signIn("ravi")); | |
| 93 | expect(h).not.toContain("commit upstream"); | |
| 94 | expect(h).not.toContain("commits upstream"); | |
| 95 | expect(h).not.toContain("Offered in"); | |
| 96 | }); | |
| 97 | ||
| 98 | test("ahead with no pull: a button into the pull form with both branches chosen", async () => { | |
| 99 | await ownWork(3); | |
| 100 | const h = await home("/ravi/yard", await signIn("olive")); | |
| 101 | expect(h).toContain("Offer 3 commits upstream"); | |
| 102 | expect(h).toContain('href="/ravi/yard/pulls/new?head=main&base=main"'); | |
| 103 | expect(h).not.toContain("Offered in"); | |
| 104 | const form = await home("/ravi/yard/pulls/new?head=main&base=main", await signIn("olive")); | |
| 105 | expect(form).toContain("olive/yard"); | |
| 106 | }); | |
| 107 | ||
| 108 | test("the drift read is a named Server-Timing phase", async () => { | |
| 109 | const r = await fetch(`${servers.url}/ravi/yard`, { | |
| 110 | headers: { cookie: await signIn("olive") }, | |
| 111 | }); | |
| 112 | expect(r.headers.get("server-timing") ?? "").toContain("drift"); | |
| 113 | }); | |
| 114 | ||
| 115 | test("a viewer who may not open a pull is not offered one", async () => { | |
| 116 | const h = await home("/ravi/yard"); | |
| 117 | expect(h).not.toContain("Offer 3 commits upstream"); | |
| 118 | }); | |
| 119 | ||
| 120 | test("ahead with an open pull: the line links to it", async () => { | |
| 121 | const parent = getRepo(app.db, "olive", "yard")!; | |
| 122 | const fork = getRepo(app.db, "ravi", "yard")!; | |
| 123 | const opened = await openPull(app, users[2]!, parent, { | |
| 124 | title: "Mine", | |
| 125 | body: "", | |
| 126 | baseBranch: "main", | |
| 127 | headBranch: "main", | |
| 128 | headRepo: fork, | |
| 129 | }); | |
| 130 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 131 | const h = await home("/ravi/yard", await signIn("olive")); | |
| 132 | expect(h).toContain(`Offered in #${opened.value.number}`); | |
| 133 | expect(h).toContain(`href="/olive/yard/pulls/${opened.value.number}"`); | |
| 134 | expect(h).not.toContain("Offer 3 commits upstream"); | |
| 135 | }); | |
| 136 | ||
| 137 | test("a pull whose head is another branch does not stand for the default branch", async () => { | |
| 138 | const parent = getRepo(app.db, "olive", "yard")!; | |
| 139 | const fork = getRepo(app.db, "ravi", "yard")!; | |
| 140 | const own = await commitToBranch(app, users[2]!, fork, { | |
| 141 | message: "side", | |
| 142 | branch: "side", | |
| 143 | files: [{ path: "side.txt", content: "s\n" }], | |
| 144 | }); | |
| 145 | if (!own.ok) throw new Error(String(own.error)); | |
| 146 | const side = await openPull(app, users[2]!, parent, { | |
| 147 | title: "Side", | |
| 148 | body: "", | |
| 149 | baseBranch: "main", | |
| 150 | headBranch: "side", | |
| 151 | headRepo: fork, | |
| 152 | }); | |
| 153 | if (!side.ok) throw new Error(String(side.error)); | |
| 154 | const offer = upstreamOffer(app, users[0]!, fork, parent, 3); | |
| 155 | expect(offer?.openNumber).not.toBe(side.value.number); | |
| 156 | expect(offer?.head).toBe("main"); | |
| 157 | }); | |
| 158 | ||
| 159 | test("the shared offer is plain data, gated on a signed-in reader of the parent", () => { | |
| 160 | const parent = getRepo(app.db, "olive", "yard")!; | |
| 161 | const fork = getRepo(app.db, "ravi", "yard")!; | |
| 162 | expect(upstreamOffer(app, undefined, fork, parent, 3)).toBeNull(); | |
| 163 | expect(upstreamOffer(app, users[0]!, fork, undefined, 3)).toBeNull(); | |
| 164 | expect(upstreamOffer(app, users[0]!, fork, parent, 0)).toBeNull(); | |
| 165 | expect(upstreamOffer(app, users[0]!, fork, parent, null)).toBeNull(); | |
| 166 | const offer = upstreamOffer(app, users[0]!, fork, parent, 3)!; | |
| 167 | expect(offer.ahead).toBe(3); | |
| 168 | expect(offer.base).toBe("main"); | |
| 169 | expect(offerRows(null)).toEqual([]); | |
| 170 | expect(offerRows({ ahead: 3, openNumber: null, head: "main", base: "main" })[0]![1]).toContain("o opens the pull"); | |
| 171 | expect(offerRows({ ahead: 1, openNumber: 7, head: "main", base: "main" })[0]![1]).toContain("#7"); | |
| 172 | }); | |
| 173 | ||
| 174 | test("a writer with the Following card has one route, its Propose upstream", async () => { | |
| 175 | const h = await home("/ravi/yard", await signIn("ravi")); | |
| 176 | expect(h).toContain("See the upstream proposal"); | |
| 177 | expect(h).not.toContain("Offered in"); | |
| 178 | expect(h).not.toContain("pulls/new?head=main"); | |
| 179 | }); |
new above
offline
Lists up to 200 pulls then filters in JS. Ask the database for the one open pull with this head repo and branch.