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

Merged opened this

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

Merged

scout/forge:scout/5867-a-fork-s-repository-home-says-n-commits → main 5 commits fork 2 threads · 2 unresolved

  • Merged. scout/5867-a-fork-s-repository-home-says-n-commits was merged into main as f0bb677.

Review

Sign in to review this change.
7 files changed +221 −5
README.md +2
@@ -1900,6 +1900,8 @@ longer hollow turns the check red, so the list only shrinks.
19001900
19011901 ## Following an upstream
19021902
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
19031905 A repository can follow an outside git branch (`repo_upstream`, https or
19041906 http only, no credentials in the address). The clock checks each following
19051907 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 {
462462 }
463463 }
464464
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
465491 /** HH:MM, UTC. */
466492 export const clockWords = (iso: string): string => {
467493 const at = new Date(iso);
src/tui/screens/about.ts +3
@@ -31,6 +31,7 @@ import {
3131 checkedWords,
3232 checkUpstreamNow,
3333 FOLLOW_REFUSALS,
34 behindWords,
3435 followShown,
3536 setFollowUpstream,
3637 standing,
@@ -403,6 +404,7 @@ export function upstreamRows(
403404 const follow = getFollow(f.db, repo.id);
404405 if (follow === undefined || !follow.enabled)
405406 return [["upstream", "off"]];
407 const behind = behindWords(follow);
406408 const checked =
407409 follow.checkedAt === null ? "" : ` · checked ${ago(follow.checkedAt)}`;
408410 return [
@@ -410,6 +412,7 @@ export function upstreamRows(
410412 "upstream",
411413 `${upstreamLabel(follow.url)} ${follow.branch}${checked} · ${standing(follow)}`,
412414 ],
415 ...(behind === null ? [] : [["behind", `${behind.text} · u updates`] as [string, string]]),
413416 ...listChecks(f.db, repo.id).map((c): [string, string] => ["", checkLine(c, ago(c.at))]),
414417 ];
415418 }
src/web/pages/repo.ts +2 −2
@@ -8,7 +8,7 @@
88 * path segment and can never smuggle a path of its own.
99 */
1010
11 import { followingLine } from "./upstream-follow.ts";
11 import { behindLine, followingLine } from "./upstream-follow.ts";
1212 import { upstreamOffer } from "../../actions/offer_upstream.ts";
1313 import { offerUpstream } from "./offer-upstream.ts";
1414 import { NOT_A_MEMBER } from "../../core/roles.ts";
@@ -1104,7 +1104,7 @@ async function codeHome(ctx: Ctx, repo: Repo): Promise<Response> {
11041104 const rendering = performance.now();
11051105 const body = html`${scopeHeader(ctx, scope, "code", {
11061106 branchCount: refs.branches.length,
1107 })}${archivedLine(ctx, repo)}${noApp}
1107 })}${timePhase(ctx.request, "behind", () => behindLine(ctx, repo))}${archivedLine(ctx, repo)}${noApp}
11081108 ${offerUpstream(repo, parent, offer)}
11091109 ${latest === null
11101110 ? ""
src/web/pages/upstream-follow.ts +28 −1
@@ -6,6 +6,7 @@
66 */
77
88 import {
9 behindWords,
910 checkedWords,
1011 checkLine,
1112 checkUpstreamNow,
@@ -47,6 +48,31 @@ export function followingLine(ctx: Ctx, repo: Repo): Html | null {
4748 </p>`;
4849 }
4950
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
5076 /** The last five checks, newest first; nothing when there are none. */
5177 function historyList(ctx: Ctx, repo: Repo): Html | null {
5278 const checks = listChecks(ctx.app.db, repo.id, 5);
@@ -139,9 +165,10 @@ export async function followUpstreamCheck(ctx: Ctx): Promise<Response> {
139165 const form = await readForm(ctx, "admin");
140166 if (form instanceof Response) return form;
141167 const checked = await checkUpstreamNow(ctx.app, ctx.auth.user, repo);
168 const back = field(form, "back");
142169 return flashTo(
143170 ctx,
144 `${repoBase(repo)}/settings#upstream`,
171 back === repoBase(repo) ? back : `${repoBase(repo)}/settings#upstream`,
145172 checked.ok
146173 ? { message: checkedWords(checked.value) }
147174 : { 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

Wall-clock assertion will flake; assert the phase exists only.

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 () => {
5151 await rm(dataDir, { recursive: true, force: true });
5252 });
5353
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 () => {
5555 const res = await fetch(`${servers.url}/alice/r`, { headers: { cookie } });
5656 expect(res.status).toBe(200);
5757 await res.arrayBuffer();
5858 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"]);
6060 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs