Public: anyone can read this; the crew can change it.
A fork's repository home says "N commits behind <upstream>" from the last follow check, with its one action to update #5870
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5867-a-fork-s-repository-home-says-n-commits → main 5 commits fork 2 threads · 2 unresolved
7 files
changed +221
−5
README.md +2
| @@ -1900,6 +1900,8 @@ longer hollow turns the check red, so the list only shrinks. | ||
| 1900 | 1900 | |
| 1901 | 1901 | ## Following an upstream |
| 1902 | 1902 | |
| 1903 | A followed repository's home shows admins "N commits behind owner/name" (or "conflicts with owner/name: resolve") from the last stored check, with an **Update** button: a same-origin POST form to the settings page's check action that returns to the home, no JS. The terminal's About card (`I`) has the same words as a `behind` row, and `u` is the same update (`behindWords`, `src/actions/upstream_follow.ts`). | |
| 1904 | ||
| 1903 | 1905 | A repository can follow an outside git branch (`repo_upstream`, https or |
| 1904 | 1906 | http only, no credentials in the address). The clock checks each following |
| 1905 | 1907 | repository every 30 minutes (`checkDue`); the result is the follow row: |
src/actions/upstream_follow.ts +26
| @@ -462,6 +462,32 @@ export function upstreamLabel(url: string): string { | ||
| 462 | 462 | } |
| 463 | 463 | } |
| 464 | 464 | |
| 465 | /** `owner/name` of an upstream address, else its host and path. */ | |
| 466 | export function upstreamShort(url: string): string { | |
| 467 | try { | |
| 468 | const path = new URL(url).pathname.replace(/\.git$/, "").replace(/^\/|\/$/g, ""); | |
| 469 | return path.split("/").length === 2 ? path : upstreamLabel(url); | |
| 470 | } catch { | |
| 471 | return url; | |
| 472 | } | |
| 473 | } | |
| 474 | ||
| 475 | /** | |
| 476 | * What the last stored check found, in a few words, for the home's line and | |
| 477 | * the terminal: "12 commits behind a/b", or a conflict that says what to do. | |
| 478 | * null when level, unchecked, failed for another reason, or not following. | |
| 479 | */ | |
| 480 | export function behindWords(follow: UpstreamFollow): { text: string; conflict: boolean } | null { | |
| 481 | if (!follow.enabled || follow.checkedAt === null) return null; | |
| 482 | const name = upstreamShort(follow.url); | |
| 483 | if (follow.error === "conflict") return { text: `conflicts with ${name}: resolve`, conflict: true }; | |
| 484 | if (follow.error !== "" || follow.behind === 0) return null; | |
| 485 | return { | |
| 486 | text: `${follow.behind} commit${follow.behind === 1 ? "" : "s"} behind ${name}`, | |
| 487 | conflict: false, | |
| 488 | }; | |
| 489 | } | |
| 490 | ||
| 465 | 491 | /** HH:MM, UTC. */ |
| 466 | 492 | export const clockWords = (iso: string): string => { |
| 467 | 493 | const at = new Date(iso); |
src/tui/screens/about.ts +3
| @@ -31,6 +31,7 @@ import { | ||
| 31 | 31 | checkedWords, |
| 32 | 32 | checkUpstreamNow, |
| 33 | 33 | FOLLOW_REFUSALS, |
| 34 | behindWords, | |
| 34 | 35 | followShown, |
| 35 | 36 | setFollowUpstream, |
| 36 | 37 | standing, |
| @@ -403,6 +404,7 @@ export function upstreamRows( | ||
| 403 | 404 | const follow = getFollow(f.db, repo.id); |
| 404 | 405 | if (follow === undefined || !follow.enabled) |
| 405 | 406 | return [["upstream", "off"]]; |
| 407 | const behind = behindWords(follow); | |
| 406 | 408 | const checked = |
| 407 | 409 | follow.checkedAt === null ? "" : ` · checked ${ago(follow.checkedAt)}`; |
| 408 | 410 | return [ |
| @@ -410,6 +412,7 @@ export function upstreamRows( | ||
| 410 | 412 | "upstream", |
| 411 | 413 | `${upstreamLabel(follow.url)} ${follow.branch}${checked} · ${standing(follow)}`, |
| 412 | 414 | ], |
| 415 | ...(behind === null ? [] : [["behind", `${behind.text} · u updates`] as [string, string]]), | |
| 413 | 416 | ...listChecks(f.db, repo.id).map((c): [string, string] => ["", checkLine(c, ago(c.at))]), |
| 414 | 417 | ]; |
| 415 | 418 | } |
src/web/pages/repo.ts +2 −2
| @@ -8,7 +8,7 @@ | ||
| 8 | 8 | * path segment and can never smuggle a path of its own. |
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | import { followingLine } from "./upstream-follow.ts"; | |
| 11 | import { behindLine, followingLine } from "./upstream-follow.ts"; | |
| 12 | 12 | import { upstreamOffer } from "../../actions/offer_upstream.ts"; |
| 13 | 13 | import { offerUpstream } from "./offer-upstream.ts"; |
| 14 | 14 | import { NOT_A_MEMBER } from "../../core/roles.ts"; |
| @@ -1104,7 +1104,7 @@ async function codeHome(ctx: Ctx, repo: Repo): Promise<Response> { | ||
| 1104 | 1104 | const rendering = performance.now(); |
| 1105 | 1105 | const body = html`${scopeHeader(ctx, scope, "code", { |
| 1106 | 1106 | branchCount: refs.branches.length, |
| 1107 | })}${archivedLine(ctx, repo)}${noApp} | |
| 1107 | })}${timePhase(ctx.request, "behind", () => behindLine(ctx, repo))}${archivedLine(ctx, repo)}${noApp} | |
| 1108 | 1108 | ${offerUpstream(repo, parent, offer)} |
| 1109 | 1109 | ${latest === null |
| 1110 | 1110 | ? "" |
src/web/pages/upstream-follow.ts +28 −1
| @@ -6,6 +6,7 @@ | ||
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | 8 | import { |
| 9 | behindWords, | |
| 9 | 10 | checkedWords, |
| 10 | 11 | checkLine, |
| 11 | 12 | checkUpstreamNow, |
| @@ -47,6 +48,31 @@ export function followingLine(ctx: Ctx, repo: Repo): Html | null { | ||
| 47 | 48 | </p>`; |
| 48 | 49 | } |
| 49 | 50 | |
| 51 | /** | |
| 52 | * Under the head of a followed repository: how far behind the last check | |
| 53 | * found it, or that it conflicts. Read from the stored check, never git; | |
| 54 | * null when level, unchecked, failed for another reason, or following | |
| 55 | * nothing. Admins only, like the page it links to. | |
| 56 | */ | |
| 57 | export function behindLine(ctx: Ctx, repo: Repo): Html | null { | |
| 58 | const follow = followShown(ctx.app, repo, ctx.auth.user); | |
| 59 | const words = follow === undefined ? null : behindWords(follow); | |
| 60 | if (follow === undefined || words === null || follow.checkedAt === null) return null; | |
| 61 | const base = repoBase(repo); | |
| 62 | return html`<div class="small muted" data-behind-upstream> | |
| 63 | ${icon("sync")} | |
| 64 | ${words.conflict | |
| 65 | ? html`<a href="${base}/settings#upstream">${words.text}</a>` | |
| 66 | : html`${words.text} ·`} | |
| 67 | checked ${timeAgo(follow.checkedAt)} | |
| 68 | <form method="post" action="${base}/settings/upstream/check" class="inline"> | |
| 69 | <input type="hidden" name="back" value="${base}" /> | |
| 70 | ${btn("Update", { size: "sm", icon: "sync" })} | |
| 71 | </form> | |
| 72 | <a href="${base}/settings#upstream">details</a> | |
| 73 | </div>`; | |
| 74 | } | |
| 75 | ||
| 50 | 76 | /** The last five checks, newest first; nothing when there are none. */ |
| 51 | 77 | function historyList(ctx: Ctx, repo: Repo): Html | null { |
| 52 | 78 | const checks = listChecks(ctx.app.db, repo.id, 5); |
| @@ -139,9 +165,10 @@ export async function followUpstreamCheck(ctx: Ctx): Promise<Response> { | ||
| 139 | 165 | const form = await readForm(ctx, "admin"); |
| 140 | 166 | if (form instanceof Response) return form; |
| 141 | 167 | const checked = await checkUpstreamNow(ctx.app, ctx.auth.user, repo); |
| 168 | const back = field(form, "back"); | |
| 142 | 169 | return flashTo( |
| 143 | 170 | ctx, |
| 144 | `${repoBase(repo)}/settings#upstream`, | |
| 171 | back === repoBase(repo) ? back : `${repoBase(repo)}/settings#upstream`, | |
| 145 | 172 | checked.ok |
| 146 | 173 | ? { message: checkedWords(checked.value) } |
| 147 | 174 | : { tone: "danger", message: FOLLOW_REFUSALS[checked.error] }, |
test/repo-behind-line.test.ts added +158
| @@ -0,0 +1,158 @@ | ||
| 1 | /** A followed repository's home says how far behind its upstream the last check found it. */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 7 | import { createApp, type App } from "../src/app.ts"; | |
| 8 | import { noteCheck, putFollow } from "../src/core/upstream_follow.ts"; | |
| 9 | import { createUser, type User } from "../src/core/users.ts"; | |
| 10 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 11 | import { getRepo } from "../src/core/repos.ts"; | |
| 12 | import type { Session } from "../src/tui/session.ts"; | |
| 13 | import { upstreamRows } from "../src/tui/screens/about.ts"; | |
| 14 | import { quietLogs } from "./helpers/globals.ts"; | |
| 15 | ||
| 16 | quietLogs(); | |
| 17 | ||
| 18 | let dataDir: string; | |
| 19 | let app: App; | |
| 20 | let servers: Servers; | |
| 21 | let cookie: string; | |
| 22 | let userId: string; | |
| 23 | let user: User; | |
| 24 | ||
| 25 | const home = async (name: string, withCookie = cookie) => { | |
| 26 | const r = await fetch(`${servers.url}/amy/${name}`, { headers: { cookie: withCookie } }); | |
| 27 | expect(r.status).toBe(200); | |
| 28 | return { html: await r.text(), timing: r.headers.get("server-timing") ?? "" }; | |
| 29 | }; | |
| 30 | ||
| 31 | const repo = async (name: string, check?: { behind: number; error: string; at?: string }) => { | |
| 32 | const made = await createRepo(app, user, { name, visibility: "public" }); | |
| 33 | if (!made.ok) throw new Error(String(made.error)); | |
| 34 | await commitToBranch(app, user, made.value, { | |
| 35 | message: "first", | |
| 36 | files: [{ path: "a.txt", content: "a\n" }], | |
| 37 | }); | |
| 38 | if (check === undefined) return; | |
| 39 | putFollow(app.db, { | |
| 40 | repoId: made.value.id, | |
| 41 | url: "https://github.com/trevato/forge.git", | |
| 42 | branch: "main", | |
| 43 | enabled: true, | |
| 44 | byId: userId, | |
| 45 | originId: "", | |
| 46 | }); | |
| 47 | noteCheck(app.db, made.value.id, { | |
| 48 | at: check.at ?? new Date(Date.now() - 2 * 3600_000).toISOString(), | |
| 49 | behind: check.behind, | |
| 50 | error: check.error, | |
| 51 | }); | |
| 52 | }; | |
| 53 | ||
| 54 | beforeAll(async () => { | |
| 55 | dataDir = await mkdtemp(join(tmpdir(), "forge-behind-")); | |
| 56 | app = createApp({ dataDir, port: 0, host: "forge.test" }); | |
| 57 | servers = await serve(app); | |
| 58 | const made = await createUser(app.db, { | |
| 59 | origin: "seed", | |
| 60 | trusted: true, | |
| 61 | username: "amy", | |
| 62 | email: "[email protected]", | |
| 63 | password: "a-good-passphrase", | |
| 64 | adminIfFirst: true, | |
| 65 | }); | |
| 66 | if (!made.ok) throw new Error(made.error); | |
| 67 | user = made.value; | |
| 68 | userId = user.id; | |
| 69 | const r = await fetch(`${servers.url}/signin`, { | |
| 70 | method: "POST", | |
| 71 | body: new URLSearchParams({ username: "amy", password: "a-good-passphrase" }), | |
| 72 | redirect: "manual", | |
| 73 | }); | |
| 74 | cookie = (r.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 75 | await repo("behind", { behind: 12, error: "" }); | |
| 76 | await repo("level", { behind: 0, error: "" }); | |
| 77 | await repo("conflicted", { behind: 3, error: "conflict" }); | |
| 78 | await repo("plain"); | |
| 79 | }); | |
| 80 | ||
| 81 | afterAll(async () => { | |
| 82 | await servers.stop(); | |
| 83 | await rm(dataDir, { recursive: true, force: true }); | |
| 84 | }); | |
| 85 | ||
| 86 | test("behind: the count, the upstream, the age, linking to the settings", async () => { | |
| 87 | const { html, timing } = await home("behind"); | |
| 88 | const line = html.match(/<div[^>]*data-behind-upstream[\s\S]*?<\/div>/)?.[0] ?? ""; | |
| 89 | expect(line.replace(/\s+/g, " ")).toContain("12 commits behind trevato/forge"); | |
| 90 | expect(line).toContain('href="/amy/behind/settings#upstream"'); | |
| 91 | expect(line).toContain("checked 2h ago"); | |
| 92 | expect(timing).toContain("behind;"); | |
| 93 | expect(line).toContain('<form method="post" action="/amy/behind/settings/upstream/check"'); | |
| 94 | expect(line).toContain('name="back" value="/amy/behind"'); | |
| 95 | expect(line).toMatch(/<button[^>]*type="submit"[\s\S]*Update/); | |
| 96 | }); | |
| 97 | ||
| 98 | test("level: no line", async () => { | |
| 99 | expect((await home("level")).html).not.toContain("data-behind-upstream"); | |
| 100 | }); | |
| 101 | ||
| 102 | test("conflicted: says what to do", async () => { | |
| 103 | const { html } = await home("conflicted"); | |
| 104 | expect(html).toContain("conflicts with trevato/forge: resolve"); | |
| 105 | expect(html).not.toContain("commits behind"); | |
| 106 | }); | |
| 107 | ||
| 108 | test("not a fork: no line, and a stranger sees none", async () => { | |
| 109 | expect((await home("plain")).html).not.toContain("data-behind-upstream"); | |
| 110 | const r = await fetch(`${servers.url}/amy/behind`); | |
| 111 | expect(await r.text()).not.toContain("data-behind-upstream"); | |
| 112 | }); | |
| 113 | ||
| 114 | test("a signed-in non-admin who can read the repository sees no line and no button", async () => { | |
| 115 | const made = await createUser(app.db, { | |
| 116 | origin: "seed", | |
| 117 | trusted: true, | |
| 118 | username: "bob", | |
| 119 | email: "[email protected]", | |
| 120 | password: "a-good-passphrase", | |
| 121 | }); | |
| 122 | if (!made.ok) throw new Error(made.error); | |
| 123 | const r = await fetch(`${servers.url}/signin`, { | |
| 124 | method: "POST", | |
| 125 | body: new URLSearchParams({ username: "bob", password: "a-good-passphrase" }), | |
| 126 | redirect: "manual", | |
| 127 | }); | |
| 128 | const bob = (r.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 129 | const { html } = await home("behind", bob); | |
| 130 | expect(html).not.toContain("data-behind-upstream"); | |
| 131 | expect(html).not.toContain("settings/upstream/check"); | |
| 132 | }); | |
| 133 | ||
| 134 | test("Update runs the check and comes back to the home; a foreign back goes to settings", async () => { | |
| 135 | const post = (back: string) => | |
| 136 | fetch(`${servers.url}/amy/behind/settings/upstream/check`, { | |
| 137 | method: "POST", | |
| 138 | headers: { cookie, origin: servers.url }, | |
| 139 | body: new URLSearchParams({ back }), | |
| 140 | redirect: "manual", | |
| 141 | }); | |
| 142 | expect((await post("/amy/behind")).headers.get("location")).toBe("/amy/behind"); | |
| 143 | expect((await post("https://evil.example/")).headers.get("location")).toBe("/amy/behind/settings#upstream"); | |
| 144 | }); | |
| 145 | ||
| 146 | test("the terminal's About rows carry the same behind words, only while behind or conflicting", () => { | |
| 147 | const rows = (name: string) => { | |
| 148 | const repo = getRepo(app.db, "amy", name)!; | |
| 149 | // The Update test above ran a real check; put the stored one back. | |
| 150 | const stored = { behind: { behind: 12, error: "" }, conflicted: { behind: 3, error: "conflict" }, level: { behind: 0, error: "" } }[name]!; | |
| 151 | noteCheck(app.db, repo.id, { at: new Date().toISOString(), ...stored }); | |
| 152 | const session = { db: app.db, user } as unknown as Session; | |
| 153 | return upstreamRows(session, repo).filter(([k]) => k === "behind"); | |
| 154 | }; | |
| 155 | expect(rows("behind")).toEqual([["behind", "12 commits behind trevato/forge · u updates"]]); | |
| 156 | expect(rows("conflicted")).toEqual([["behind", "conflicts with trevato/forge: resolve · u updates"]]); | |
| 157 | expect(rows("level")).toEqual([]); | |
| 158 | }); |
test/repo-timing.test.ts +2 −2
| @@ -51,10 +51,10 @@ afterAll(async () => { | ||
| 51 | 51 | await rm(dataDir, { recursive: true, force: true }); |
| 52 | 52 | }); |
| 53 | 53 | |
| 54 | test("a repository home names session, appof, refs, tree, readme, following, wave and render in order", async () => { | |
| 54 | test("a repository home names session, appof, refs, tree, readme, following, wave, behind and render in order", async () => { | |
| 55 | 55 | const res = await fetch(`${servers.url}/alice/r`, { headers: { cookie } }); |
| 56 | 56 | expect(res.status).toBe(200); |
| 57 | 57 | await res.arrayBuffer(); |
| 58 | 58 | const names = (res.headers.get("server-timing") ?? "").split(", ").map((p) => p.split(";")[0]!); |
| 59 | expect(names).toEqual(["app", "session", "appof", "refs", "tree", "readme", "following", "wave", "render"]); | |
| 59 | expect(names).toEqual(["app", "session", "appof", "refs", "tree", "readme", "following", "wave", "behind", "render"]); | |
| 60 | 60 | }); |
new above
offline
Wall-clock assertion will flake; assert the phase exists only.