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

Follow upstream across forges: new commits on the origin show with one action to take them #8996

Merged opened this

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

Merged

mason/forge:mason/8966-follow-upstream-across-forges-new-commit → main 11 commits fork

  • Merged. mason/8966-follow-upstream-across-forges-new-commit was merged into main as 8af8ad5.

Review

Sign in to review this change.
14 files changed +807 −16
docs/features/origin-ahead.md added +31
@@ -0,0 +1,31 @@
1 # New commits upstream, on a mirror
2
3 A repository brought from another forge (or any origin) shows "N new commits
4 upstream" under its head when the origin's default branch has moved past it,
5 with **Take them** (`POST /:owner/:name/sync/take`).
6
7 - `src/origins/ahead.ts` reads the origin's tip (objects only, no ref moves),
8 counts, and keeps the reading as an origin cursor. The worker re-reads up to
9 four mirrors a minute, each at most every two minutes; a sync clears it.
10 - The readings run under their own single-flight guard (`runAheadReads`,
11 started from the same minute tick as `runSync`), never inside the sweep's:
12 a reading stuck on a slow origin holds only the next reading, never a sync.
13 - Only a person who may write the repository sees **Take them**; everyone who
14 may read it sees the count, and a guest sees nothing on a private one.
15 - At the terminal: the palette's "New commits upstream" lists the mirrors you
16 can read that are behind (one query over the readings, not one per
17 repository); `t` takes them under the page's own check, `requireWriter`
18 (`src/tui/screens/originahead.ts`).
19 - A mirror whose default branch holds commits the origin lacks shows
20 "conflicts with commits here". Take them then moves nothing, and neither
21 does the sweep: it leaves that mirror's git alone and says why where the
22 last fetch is shown. **Sync now** is unchanged: the person asked for the
23 origin's branches, so it replaces the diverged branch, and it is the way out.
24 An origin that cannot be read just then is refused the same way: it is not
25 known whether taking would overwrite.
26 - A repository's readings go with it when it is deleted or linked to another
27 origin (`forgetRepoCursors`); disconnecting an origin drops its cursors by
28 the schema.
29 - `test/origin-ahead.test.ts` runs two forges: commit on the origin, see the
30 count, take them, count 0; a diverged mirror survives Take them and a sweep;
31 a silent origin does not hold the sweep.
src/actions/origins.ts +35 −1
@@ -55,6 +55,7 @@ import { canAdmin, getRepoById, type Repo } from "../core/repos.ts";
5555 import type { User } from "../core/users.ts";
5656 import { repoDir } from "../git/repos.ts";
5757 import { bustOrigin } from "../view/cache.ts";
58 import { readAhead } from "../origins/ahead.ts";
5859 import { discover } from "../origins/discover.ts";
5960 import {
6061 lastGuardTry,
@@ -1022,6 +1023,8 @@ export async function syncOriginNow(
10221023 /**
10231024 * Sync one mirror now. Any writer of the local repo may ask for this — it
10241025 * only pulls, so it borrows nobody's identity and grants nobody anything.
1026 * It is the one path that overwrites a default branch holding commits the
1027 * origin lacks: the person asked for the origin's branches as they are.
10251028 */
10261029 export async function syncRepoNow(
10271030 app: App,
@@ -1032,7 +1035,38 @@ export async function syncRepoNow(
10321035 if (!writer.ok) return writer;
10331036 const link = originForRepo(app.db, repo.id);
10341037 if (!link) return err("not_found");
1035 return ok(await syncOrigin(app, link.origin, { onlyRepoId: repo.id }));
1038 return ok(await syncOrigin(app, link.origin, { onlyRepoId: repo.id, overwrite: true }));
1039 }
1040
1041 /** Taking the origin's new commits: what the sync did, or why it did not. */
1042 export type Taking =
1043 | { readonly kind: "taken"; readonly count: number; readonly report: SyncReport }
1044 | { readonly kind: "conflict"; readonly count: number }
1045 | { readonly kind: "unread" };
1046
1047 /**
1048 * The one action behind "N new commits upstream": a mirror takes what its
1049 * origin has, unless its default branch holds commits the origin lacks — a
1050 * sync would overwrite them, so that is a conflict and nothing moves. When
1051 * the origin cannot be read, nothing moves either: it is not known whether
1052 * the sync would overwrite. The sweep holds such a mirror too; only Sync now
1053 * (`syncRepoNow`) overwrites, because a person asked for exactly that.
1054 */
1055 export async function takeFromOrigin(
1056 app: App,
1057 actor: User | undefined,
1058 repo: Repo,
1059 ): Promise<Result<Taking, "unauthorized" | "forbidden" | "not_found">> {
1060 const writer = requireWriter(app, actor, repo);
1061 if (!writer.ok) return writer;
1062 const link = originForRepo(app.db, repo.id);
1063 if (link === undefined) return err("not_found");
1064 const seen = await readAhead(app, repo);
1065 if (seen === undefined) return ok({ kind: "unread" });
1066 if (seen.diverged) return ok({ kind: "conflict", count: seen.count });
1067 const report = await syncOrigin(app, link.origin, { onlyRepoId: repo.id });
1068 await readAhead(app, repo);
1069 return ok({ kind: "taken", count: seen.count, report });
10361070 }
10371071
10381072 // ---- reconnecting a frozen mirror ----
src/core/origins.ts +17
@@ -545,6 +545,22 @@ export function clearCursor(db: Database, originId: string, key: string): void {
545545 );
546546 }
547547
548 /**
549 * A repository's own cursors on every origin (`repo:<id>:…`): what its sync
550 * and its upstream readings left. Cleared when the repository goes, and on
551 * every origin but `keep` when it is linked afresh, so a reading never
552 * outlives the link it was taken over. (Disconnecting an origin drops its
553 * cursors with it, by the schema.)
554 */
555 export function forgetRepoCursors(db: Database, repoId: string, keep = ""): void {
556 const prefix = `repo:${repoId}:`;
557 db.query("DELETE FROM origin_cursor WHERE substr(key, 1, ?2) = ?1 AND origin_id != ?3").run(
558 prefix,
559 prefix.length,
560 keep,
561 );
562 }
563
548564 // ---- the mirror link on a repo ----
549565
550566 export interface MirrorLink {
@@ -606,6 +622,7 @@ export function linkRepoToOrigin(
606622 htmlUrl: string;
607623 },
608624 ): void {
625 forgetRepoCursors(db, repoId, input.originId);
609626 db.query(
610627 `UPDATE repo SET origin_id = ?1, origin_full_name = ?2,
611628 origin_remote_id = ?3, origin_html_url = ?4,
src/core/repos.ts +2
@@ -23,6 +23,7 @@ import { createLog } from "../kernel/log.ts";
2323 import { err, ok, type Result } from "../kernel/result.ts";
2424 import { appsNaming, isAppMember } from "./app_members.ts";
2525 import { forgetListings } from "./listings.ts";
26 import { forgetRepoCursors } from "./origins.ts";
2627 import { likeEscape, pageLimit } from "./issues.ts";
2728 import {
2829 OWNER_STANDING,
@@ -959,6 +960,7 @@ export async function deleteRepo(
959960 const removed = await deleteRepoDir(dir);
960961 if (!removed.ok && present) return err("delete_failed");
961962 db.query("DELETE FROM repo WHERE id = ?1").run(repo.id);
963 forgetRepoCursors(db, repo.id);
962964 forgetListings(db);
963965 if (!removed.ok) return err("delete_failed");
964966 return ok();
src/http/server.ts +2
@@ -174,6 +174,7 @@ import {
174174 repoFiles,
175175 repoHome,
176176 repoSyncSubmit,
177 repoTakeSubmit,
177178 treePage,
178179 } from "../web/pages/repo.ts";
179180 import { mediaBlob } from "../web/pages/media.ts";
@@ -1296,6 +1297,7 @@ export function buildRouter(): Router<Handler> {
12961297 // A mirror pulls on demand; a pin says "keep this on my machine". Both are
12971298 // ordinary repo-scoped writes, not account-level ones.
12981299 router.add("POST", "/:owner/:name/sync", repoSyncSubmit);
1300 router.add("POST", "/:owner/:name/sync/take", repoTakeSubmit);
12991301 router.add("POST", "/:owner/:name/pin", pinSubmit);
13001302
13011303 // Settings (owner-gated inside the handlers).
src/main.ts +5 −1
@@ -37,6 +37,7 @@ import { drainStreams } from "./http/sse.ts";
3737 import { serve } from "./http/server.ts";
3838 import { checkDue } from "./actions/upstream_follow.ts";
3939 import { runSync } from "./origins/sync.ts";
40 import { runAheadReads } from "./origins/ahead.ts";
4041 import { runGuardSweep } from "./origins/credential.ts";
4142 import { startForgeSsh } from "./tui/ssh.ts";
4243 import { AppsRuntime } from "./apps/runtime.ts";
@@ -573,10 +574,13 @@ function positive(value: string | undefined, fallback: number): number {
573574 // pushed to: an origin is due every ten minutes by default, and the tick only
574575 // asks who is due — so this costs one indexed read a minute when nothing is
575576 // connected. Like runDelivery, runSync never throws into the timer.
576 // The operator guard's sweep of stored origins rides the same tick: one
577 // The mirrors' "N new commits upstream" readings ride the same tick under
578 // their own guard (`origins/ahead.ts`), so a slow origin there never holds
579 // the sweep. The operator guard's sweep of stored origins rides it too: one
577580 // origin a tick, the never-checked first (so on boot), every other daily.
578581 const syncTimer = setInterval(() => {
579582 runSync(app);
583 void runAheadReads(app);
580584 void runGuardSweep(app);
581585 }, 60_000);
582586 runSync(app); // one pass on boot: a forge that was down has fetching to do
src/origins/ahead.ts added +204
@@ -0,0 +1,204 @@
1 /**
2 * How far a mirror's origin has moved on: the commits on the origin's default
3 * branch that the mirror's has not taken yet.
4 *
5 * A reading is one fetch of the origin's tip — objects only, no ref moves, so
6 * nothing the mirror serves changes — and a count. It is kept as a cursor
7 * (a cache: losing it costs one reading) and shown on the repository's head
8 * with the one action that takes the commits. The action is the mirror's own
9 * sync, so a mirror takes exactly what a sweep would; the difference is that
10 * a mirror whose default branch holds commits the origin lacks is not
11 * overwritten, it is reported as a conflict.
12 *
13 * Never throws: it runs from the worker's tick and from a click.
14 */
15
16 import type { App } from "../app.ts";
17 import {
18 clearCursor,
19 getCursor,
20 listAllOrigins,
21 listMirrors,
22 originForRepo,
23 setCursor,
24 } from "../core/origins.ts";
25 import { canRead, getRepoById, type Repo } from "../core/repos.ts";
26 import type { User } from "../core/users.ts";
27 import { isSafeOid } from "../git/guard.ts";
28 import { aheadCount, resolveOid } from "../git/merge.ts";
29 import { repoDir } from "../git/repos.ts";
30 import { providerForOrigin } from "./credential.ts";
31 import { fetchRefFrom } from "./git-mirror.ts";
32
33 /** How stale a reading may be before the tick takes another. */
34 export const AHEAD_EVERY_MS = 2 * 60_000;
35 /** Readings one tick takes, so a slow origin cannot hold the others. */
36 const MAX_READINGS = 4;
37
38 export interface Ahead {
39 /** New commits on the origin's default branch. */
40 readonly count: number;
41 /** The origin's tip when read. */
42 readonly tip: string;
43 /** The mirror holds commits the origin does not: taking would overwrite them. */
44 readonly diverged: boolean;
45 readonly at: string;
46 }
47
48 const aheadKey = (repoId: string): string => `repo:${repoId}:ahead`;
49
50 /** The last reading of a mirror, or undefined when there is none or it is level. */
51 export function aheadOf(app: App, repo: Repo): Ahead | undefined {
52 if (repo.origin === null) return undefined;
53 return parse(getCursor(app.db, repo.origin.originId, aheadKey(repo.id)));
54 }
55
56 function parse(value: string | undefined): Ahead | undefined {
57 const [count, tip, diverged, at] = (value ?? "").split(" ");
58 const n = Number(count);
59 if (!Number.isInteger(n) || n < 1 || !isSafeOid(tip ?? "") || at === undefined) return undefined;
60 return { count: n, tip: tip!, diverged: diverged === "1", at };
61 }
62
63 /** A sync took the commits: the reading is of an origin that has since moved. */
64 export function forgetAhead(app: App, originId: string, repoId: string): void {
65 clearCursor(app.db, originId, aheadKey(repoId));
66 }
67
68 /** Read one mirror against its origin now. Returns the reading, or undefined when it could not be taken. */
69 export async function readAhead(app: App, repo: Repo): Promise<Ahead | undefined> {
70 try {
71 return await read(app, repo);
72 } catch (error) {
73 app.log.warn("ahead reading failed", { repo: repo.id, error: String(error) });
74 return undefined;
75 }
76 }
77
78 async function read(app: App, repo: Repo): Promise<Ahead | undefined> {
79 const link = originForRepo(app.db, repo.id);
80 if (link === undefined || link.origin.needsReauth || link.cloneUrl === "") return undefined;
81 const provider = providerForOrigin(app, link.origin);
82 if (!provider.ok) return undefined;
83 const dir = repoDir(app.reposRoot, repo.ownerName, repo.name);
84 const main = await resolveOid(dir, `refs/heads/${repo.defaultBranch}`);
85 if (main === undefined) return undefined;
86 const authEnv = await provider.value.gitAuthEnv(link.cloneUrl);
87 const fetched = await fetchRefFrom(dir, link.cloneUrl, `refs/heads/${repo.defaultBranch}`, authEnv);
88 if (!fetched.ok) return undefined;
89 const tip = fetched.value;
90 const count = tip === main ? 0 : await aheadCount(dir, main, tip);
91 const key = aheadKey(repo.id);
92 if (count === 0) {
93 clearCursor(app.db, link.origin.id, key);
94 return { count: 0, tip, diverged: false, at: new Date().toISOString() };
95 }
96 const diverged = (await aheadCount(dir, tip, main)) > 0;
97 const reading = { count, tip, diverged, at: new Date().toISOString() };
98 setCursor(app.db, link.origin.id, key, `${count} ${tip} ${diverged ? 1 : 0} ${reading.at}`);
99 return reading;
100 }
101
102 /**
103 * The tick's pass: the mirrors read longest ago, a few at a time. A mirror
104 * with no reading is read first.
105 */
106 export async function readAheadDue(app: App, now = Date.now()): Promise<number> {
107 const seen: { repoId: string; at: number }[] = [];
108 for (const origin of listAllOrigins(app.db)) {
109 if (origin.needsReauth) continue;
110 for (const mirror of listMirrors(app.db, origin.id)) {
111 const last = getCursor(app.db, origin.id, `repo:${mirror.repoId}:ahead-read`);
112 seen.push({ repoId: mirror.repoId, at: last === undefined ? 0 : Date.parse(last) });
113 }
114 }
115 let read = 0;
116 // Each reading is one fetch bounded by FETCH_TIMEOUT_MS (git-mirror.ts), and
117 // at most MAX_READINGS run, one after another. They run under their own
118 // guard (runAheadReads), never the sweep's: a slow origin here delays only
119 // the next reading, never a sync.
120 for (const repoId of pickDue(seen, now)) {
121 const repo = getRepoById(app.db, repoId);
122 const link = repo === undefined ? undefined : originForRepo(app.db, repoId);
123 if (repo === undefined || link === undefined) continue;
124 setCursor(app.db, link.origin.id, `repo:${repoId}:ahead-read`, new Date(now).toISOString());
125 await readAhead(app, repo);
126 read++;
127 }
128 return read;
129 }
130
131 /**
132 * The timer's entry point, beside `runSync` and apart from it: single-flight
133 * per forge under its own guard, so a reading stuck on a slow origin never
134 * holds the sweep, and the sweep never holds a reading. Never throws.
135 */
136 export function runAheadReads(app: App): Promise<void> {
137 const running = reading.get(app);
138 if (running !== undefined) return running;
139 const run = readAheadDue(app)
140 .then(() => undefined)
141 .catch((error: unknown) => {
142 app.log.error("ahead readings failed", { error: String(error) });
143 })
144 .finally(() => {
145 reading.delete(app);
146 });
147 reading.set(app, run);
148 return run;
149 }
150
151 const reading = new WeakMap<App, Promise<void>>();
152
153 /** For tests: whether a pass of readings is running. */
154 export const aheadBusy = (app: App): boolean => reading.has(app);
155
156 /** Which mirrors one tick reads: those not read for AHEAD_EVERY_MS, longest-ago first, at most MAX_READINGS. */
157 export function pickDue(seen: readonly { repoId: string; at: number }[], now: number): string[] {
158 return seen
159 .filter((m) => now - m.at >= AHEAD_EVERY_MS)
160 .sort((a, b) => a.at - b.at)
161 .slice(0, MAX_READINGS)
162 .map((m) => m.repoId);
163 }
164
165 /** What the head says of a reading: the count, and a conflict when taking would overwrite. */
166 export function aheadWords(ahead: Ahead): string {
167 const n = `${ahead.count} new commit${ahead.count === 1 ? "" : "s"} upstream`;
168 return ahead.diverged ? `${n} · conflicts with commits here` : n;
169 }
170
171 /**
172 * Every mirror the viewer may read whose last reading found the origin ahead:
173 * one query for the readings that exist, so the cost is the mirrors behind,
174 * not the repositories visible.
175 */
176 export function mirrorsBehind(app: App, viewer: User | undefined): { repo: Repo; ahead: Ahead }[] {
177 const rows = app.db
178 .query<{ id: string; value: string }, []>(
179 `SELECT repo.id, origin_cursor.value FROM origin_cursor
180 JOIN repo ON repo.origin_id = origin_cursor.origin_id
181 AND origin_cursor.key = 'repo:' || repo.id || ':ahead'
182 ORDER BY repo.id`,
183 )
184 .all();
185 const out: { repo: Repo; ahead: Ahead }[] = [];
186 for (const row of rows) {
187 const repo = getRepoById(app.db, row.id);
188 const ahead = parse(row.value);
189 if (repo !== undefined && ahead !== undefined && canRead(app.db, repo, viewer)) out.push({ repo, ahead });
190 }
191 return out;
192 }
193
194 /** The reading of a mirror to someone who may read it; nothing otherwise. */
195 export function aheadFor(app: App, repo: Repo, viewer: User | undefined): Ahead | undefined {
196 return canRead(app.db, repo, viewer) ? aheadOf(app, repo) : undefined;
197 }
198
199 export const CONFLICT_WORDS = (branch: string, count: number): string =>
200 `${branch} here holds commits the origin lacks, so taking its ${count} would overwrite them. Nothing moved; Sync now replaces ${branch} with what the origin has.`;
201 /** Why the sweep left a mirror's git alone: shown where its last fetch is. */
202 export const HELD_WORDS = (branch: string): string =>
203 `${branch} here holds commits the origin lacks, so the sweep left the git alone. Sync now replaces ${branch} with what the origin has.`;
204 export const UNREAD_WORDS = "The origin could not be read just now, so nothing was taken. Try again shortly.";
src/origins/sync.ts +15 −1
@@ -45,6 +45,7 @@ import { pullDiff, type Pull, getPull } from "../core/pulls.ts";
4545 import { repoDir } from "../git/repos.ts";
4646 import { createLog } from "../kernel/log.ts";
4747 import { bustOrigin } from "../view/cache.ts";
48 import { aheadOf, forgetAhead, HELD_WORDS } from "./ahead.ts";
4849 import { providerForOrigin } from "./credential.ts";
4950 import { fetchMirror } from "./git-mirror.ts";
5051 import {
@@ -114,6 +115,12 @@ export interface SyncOptions {
114115 readonly initial?: boolean;
115116 /** Sync only this repo (the "sync this mirror now" path). */
116117 readonly onlyRepoId?: string;
118 /**
119 * Sync now on one mirror: a person asked for the origin's branches, so a
120 * default branch holding commits the origin lacks is overwritten. Without
121 * it such a mirror is held (see `origins/ahead.ts`).
122 */
123 readonly overwrite?: boolean;
117124 /** Live stage reports, for a mirror job with a UI attached. */
118125 readonly onProgress?: SyncProgress;
119126 }
@@ -354,7 +361,13 @@ async function syncMirrorUnguarded(
354361 const runStarted = new Date().toISOString();
355362
356363 // ---- git ----
357 if (cloneUrl !== "") {
364 // A mirror whose default branch holds commits the origin lacks (the last
365 // reading said so) is held: its page says taking would overwrite them, and
366 // the sweep keeps that true. Only Sync now, a person's word, overwrites.
367 const held = options.overwrite !== true && aheadOf(app, repo)?.diverged === true;
368 if (held) {
369 recordFetch(app.db, repo.id, { at: runStarted, error: HELD_WORDS(repo.defaultBranch) });
370 } else if (cloneUrl !== "") {
358371 report("fetching", "fetching git objects");
359372 const authEnv = await provider.gitAuthEnv(cloneUrl);
360373 const fetched = await fetchMirror(dir, cloneUrl, authEnv);
@@ -366,6 +379,7 @@ async function syncMirrorUnguarded(
366379 });
367380 } else {
368381 recordFetch(app.db, repo.id, { at: runStarted });
382 forgetAhead(app, origin.id, repo.id);
369383 out.refsMoved = fetched.value.changes.length;
370384 if (fetched.value.changes.length > 0) {
371385 // Objects were written without a push: measure again at the next check.
src/tui/screens/entries.ts +2
@@ -33,6 +33,7 @@ import { entry as orgTeams } from "./orgteams.ts";
3333 import { entry as newIssue } from "./newissue.ts";
3434 import { entry as now } from "./nowlist.ts";
3535 import { entry as repos } from "./repolist.ts";
36 import { entry as originAhead } from "./originahead.ts";
3637 import { entry as owner } from "./owner.ts";
3738 import { entry as people } from "./people.ts";
3839 import { entry as pinned } from "./pinned.ts";
@@ -101,6 +102,7 @@ export const ENTRIES: readonly PaletteEntry[] = [
101102 front,
102103 owner,
103104 forges,
105 originAhead,
104106 profile,
105107 now,
106108 repos,
src/tui/screens/originahead.ts added +84
@@ -0,0 +1,84 @@
1 /**
2 * New commits upstream, in the terminal: the mirrors whose origin has moved
3 * on, as the repository head shows them on the web ("N new commits upstream").
4 * `t` takes them through `takeFromOrigin`, the page's own action, offered only
5 * to a person who may write the repository. A reader sees the count; a guest
6 * sees only what is public.
7 */
8
9 import { requireWriter } from "../../actions/access.ts";
10 import { takeFromOrigin } from "../../actions/origins.ts";
11 import type { Repo } from "../../core/repos.ts";
12 import { aheadWords, CONFLICT_WORDS, mirrorsBehind, UNREAD_WORDS } from "../../origins/ahead.ts";
13 import type { Ctx, Screen } from "../app/app.ts";
14 import { binding } from "../app/keys.ts";
15 import { style } from "../term/style.ts";
16 import { truncate } from "../term/width.ts";
17 import type { Session } from "../session.ts";
18 import { fill, len, split } from "../ui/layout.ts";
19 import { emptyState } from "../ui/widgets.ts";
20 import { ListState, drawList, type ListRow } from "./common.ts";
21 import type { PaletteEntry } from "./entries.ts";
22
23 /** The screen, and the take its last `t` started, for whoever must wait on it. */
24 export interface OriginAheadScreen extends Screen {
25 taking(): Promise<void> | undefined;
26 }
27
28 export function originAheadScreen(f: Session, ctx: Ctx): OriginAheadScreen {
29 const state = new ListState();
30 let rows = mirrorsBehind(f.app, f.user);
31 let taking: Promise<void> | undefined;
32 const selected = () => rows[state.index];
33 // Exactly the page's gate: the route behind Take them asks requireWriter.
34 const mayTake = (repo: Repo | undefined): repo is Repo =>
35 repo !== undefined && requireWriter(f.app, f.user, repo).ok;
36
37 return {
38 title: "New commits upstream",
39 keys: [binding(["j", "k"], "move cursor"), binding(["t"], "take them")],
40 render(area, s) {
41 const c = ctx.theme.c;
42 const [head, body] = split(area, "v", [len(1), fill(1)]);
43 if (rows.length === 0) {
44 emptyState(s, body!, c, { title: "Every mirror is level with its origin." });
45 return;
46 }
47 const list: ListRow[] = rows.map((r) => ({
48 title: `${r.repo.ownerName}/${r.repo.name}`,
49 secondary: aheadWords(r.ahead),
50 meta: "",
51 }));
52 const line = mayTake(selected()?.repo) ? "t takes them" : "you can read this, not take it";
53 s.text(head!.x, head!.y, truncate(line, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
54 drawList(s, body!, c, state, list);
55 },
56 key(e) {
57 if (state.key(e, rows.length)) return true;
58 if (e.ctrl || e.alt || e.key !== "t") return false;
59 const repo = selected()?.repo;
60 if (!mayTake(repo)) return false;
61 taking = takeFromOrigin(f.app, f.user, repo).then((done) => {
62 if (!done.ok) ctx.toast("that could not be taken", "danger");
63 else if (done.value.kind === "conflict")
64 ctx.toast(CONFLICT_WORDS(repo.defaultBranch, done.value.count), "danger");
65 else if (done.value.kind === "unread") ctx.toast(UNREAD_WORDS, "danger");
66 else ctx.toast("taken from the origin", "success");
67 rows = mirrorsBehind(f.app, f.user);
68 state.clamp(rows.length);
69 ctx.invalidate();
70 });
71 return true;
72 },
73 taking: () => taking,
74 };
75 }
76
77 /** Mirrors behind their origin, as the repository head has them. */
78 export const entry: PaletteEntry = {
79 id: "origin-ahead",
80 title: "New commits upstream",
81 hint: "mirrors whose origin has moved on: take them",
82 offered: () => true,
83 run: (session, ctx) => ctx.push(originAheadScreen(session, ctx)),
84 };
src/web/pages/repo.ts +36 −13
@@ -11,7 +11,8 @@
1111 import { recordLook } from "../../flow/valve_store.ts";
1212 import { immutableWhenAddressed } from "../../http/addressed.ts";
1313 import { cachedTree, keepTree, resolveCommit } from "./tree_cache.ts";
14 import { behindLine, followingLine } from "./upstream-follow.ts";
14 import { behindLine, followingLine, originAheadLine } from "./upstream-follow.ts";
15 import { CONFLICT_WORDS, UNREAD_WORDS } from "../../origins/ahead.ts";
1516 import { upstreamOffer } from "../../actions/offer_upstream.ts";
1617 import { offerUpstream } from "./offer-upstream.ts";
1718 import { NOT_A_MEMBER } from "../../core/roles.ts";
@@ -38,7 +39,7 @@ import {
3839 } from "./following.ts";
3940 import { nothingNew } from "../../actions/follow.ts";
4041 import { frozenBlock } from "./frozen.ts";
41 import { pinRepo, syncRepoNow, unpinRepo } from "../../actions/origins.ts";
42 import { pinRepo, syncRepoNow, takeFromOrigin, unpinRepo } from "../../actions/origins.ts";
4243 import {
4344 canAdmin,
4445 forkParentFor,
@@ -344,16 +345,7 @@ export async function repoSyncSubmit(ctx: Ctx): Promise<Response> {
344345 });
345346 }
346347 const synced = await syncRepoNow(ctx.app, ctx.auth.user, repo);
347 if (!synced.ok)
348 return flashTo(ctx, repoBase(repo), {
349 tone: "danger",
350 message:
351 synced.error !== "not_found"
352 ? "You need write access to sync this mirror."
353 : repo.detached !== null
354 ? "This mirror is frozen: its origin was disconnected. Reconnect it first."
355 : "This repository does not mirror an origin.",
356 });
348 if (!synced.ok) return flashTo(ctx, repoBase(repo), { tone: "danger", message: mirrorRefusal(repo, synced.error) });
357349 return flashTo(ctx, repoBase(repo), {
358350 ...(synced.value.error === ""
359351 ? { message: "Synced from the origin." }
@@ -361,6 +353,37 @@ export async function repoSyncSubmit(ctx: Ctx): Promise<Response> {
361353 });
362354 }
363355
356 /** Why a mirror's sync or take was refused, in the words both say. */
357 function mirrorRefusal(repo: Repo, error: "unauthorized" | "forbidden" | "not_found"): string {
358 return error !== "not_found"
359 ? "You need write access to sync this mirror."
360 : repo.detached !== null
361 ? "This mirror is frozen: its origin was disconnected. Reconnect it first."
362 : "This repository does not mirror an origin.";
363 }
364
365 // POST /:owner/:name/sync/take — "Take them" under "N new commits upstream":
366 // the mirror's sync, asked first, so a default branch holding commits the
367 // origin lacks is reported as a conflict and nothing moves.
368 export async function repoTakeSubmit(ctx: Ctx): Promise<Response> {
369 const repo = loadRepo(ctx);
370 if (!repo) return notFoundPage(ctx);
371 if (!ctx.auth.user) return signInFirst(ctx);
372 const form = await readForm(ctx, "rw");
373 if (form instanceof Response) return form;
374 const taken = await takeFromOrigin(ctx.app, ctx.auth.user, repo);
375 if (!taken.ok) return flashTo(ctx, repoBase(repo), { tone: "danger", message: mirrorRefusal(repo, taken.error) });
376 const t = taken.value;
377 if (t.kind === "conflict")
378 return flashTo(ctx, repoBase(repo), { tone: "danger", message: CONFLICT_WORDS(repo.defaultBranch, t.count) });
379 if (t.kind === "unread") return flashTo(ctx, repoBase(repo), { tone: "danger", message: UNREAD_WORDS });
380 return flashTo(ctx, repoBase(repo), {
381 ...(t.report.error === ""
382 ? { message: `Took ${t.count} new commit${t.count === 1 ? "" : "s"} from the origin.` }
383 : { tone: "danger" as const, message: t.report.error }),
384 });
385 }
386
364387 /**
365388 * POST /:owner/:name/pin — "keep this on my machine", toggled.
366389 *
@@ -1318,7 +1341,7 @@ async function codeHome(ctx: Ctx, repo: Repo): Promise<Response> {
13181341 const rendering = performance.now();
13191342 const body = html`${scopeHeader(ctx, scope, "code", {
13201343 branchCount: refs.branches.length,
1321 })}${timePhase(ctx.request, "behind", () => behindLine(ctx, repo))}${archivedLine(ctx, repo)}${noApp}
1344 })}${timePhase(ctx.request, "behind", () => behindLine(ctx, repo))}${originAheadLine(ctx, repo)}${archivedLine(ctx, repo)}${noApp}
13221345 ${offerUpstream(repo, parent, offer)}
13231346 ${latest === null
13241347 ? ""
src/web/pages/upstream-follow.ts +26
@@ -18,6 +18,8 @@ import {
1818 upstreamLabel,
1919 upstreamShort,
2020 } from "../../actions/upstream_follow.ts";
21 import { aheadFor, aheadWords } from "../../origins/ahead.ts";
22 import { requireWriter } from "../../actions/access.ts";
2123 import {
2224 addFollowSources,
2325 checkSourcesNow,
@@ -83,6 +85,30 @@ export function behindLine(ctx: Ctx, repo: Repo): Html | null {
8385 </div>`;
8486 }
8587
88 /**
89 * Under the head of a mirror: how many commits its origin has that it has
90 * not taken, with the one action that takes them. Read from the stored
91 * reading, never the network; null when level or unread, or when the viewer
92 * cannot read the repository. Only writers see the action: the route behind
93 * it asks the same.
94 */
95 export function originAheadLine(ctx: Ctx, repo: Repo): Html | null {
96 if (repo.origin === null) return null;
97 const ahead = aheadFor(ctx.app, repo, ctx.auth.user);
98 if (ahead === undefined) return null;
99 const base = repoBase(repo);
100 return html`<div class="small muted" data-origin-ahead>
101 ${icon("sync")}
102 ${aheadWords(ahead)} · read ${timeAgo(ahead.at)}
103 ${when(
104 requireWriter(ctx.app, ctx.auth.user, repo).ok,
105 () => html`<form method="post" action="${base}/sync/take" class="inline">
106 ${btn("Take them", { size: "sm", icon: "sync" })}
107 </form>`,
108 )}
109 </div>`;
110 }
111
86112 /** The last five checks, newest first; nothing when there are none. */
87113 function historyList(ctx: Ctx, repo: Repo): Html | null {
88114 const checks = listChecks(ctx.app.db, repo.id, 5);
test/origin-ahead.test.ts added +344
@@ -0,0 +1,344 @@
1 /**
2 * Following the origin across forges: a repository brought from another forge
3 * shows how many commits the origin has that it has not taken, and one action
4 * takes them. Two real forges: the origin serves git, a fake API speaks for it.
5 */
6
7 import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { createApp, type App } from "../src/app.ts";
12 import { takeFromOrigin } from "../src/actions/origins.ts";
13 import {
14 clearCursor,
15 createOrigin,
16 forgetRepoCursors,
17 getCursor,
18 markNeedsReauth,
19 originForRepo,
20 setCursor,
21 } from "../src/core/origins.ts";
22 import { getRepo } from "../src/core/repos.ts";
23 import { createUser, getUserByName } from "../src/core/users.ts";
24 import { serve, type Servers } from "../src/http/server.ts";
25 import { settled } from "../src/origins/jobs.ts";
26 import {
27 aheadBusy,
28 aheadOf,
29 CONFLICT_WORDS,
30 HELD_WORDS,
31 pickDue,
32 readAhead,
33 readAheadDue,
34 runAheadReads,
35 UNREAD_WORDS,
36 } from "../src/origins/ahead.ts";
37 import { requestSyncNow, runSync, syncBusy, syncOrigin, syncsSettled } from "../src/origins/sync.ts";
38 import type { Ctx } from "../src/tui/app/app.ts";
39 import { originAheadScreen } from "../src/tui/screens/originahead.ts";
40 import type { Session } from "../src/tui/session.ts";
41 import { Screen as Surface } from "../src/tui/term/screen.ts";
42 import { theme } from "../src/tui/ui/theme.ts";
43 import { newFakeState, serveFakeForge, type FakeForge } from "./fake-origin.ts";
44 import { proveAll } from "./helpers/verified.ts";
45 import { seedAdmin } from "./helpers/admin.ts";
46
47 setDefaultTimeout(60_000);
48
49 const LOGIN = "octo";
50 const TOKEN = "octo-ahead-token-7a21c4";
51 const PASSWORD = "alice-secret-5521";
52 const basic = (user: string, secret: string): string =>
53 `Basic ${Buffer.from(`${user}:${secret}`).toString("base64")}`;
54
55 describe("new commits upstream, taken with one action", () => {
56 const dirs: string[] = [];
57 let app: App;
58 let originApp: App;
59 let servers: Servers;
60 let originServers: Servers;
61 let fake: FakeForge;
62 const mine = { authorization: basic("alice", PASSWORD) };
63 const theirs = { "content-type": "application/json", authorization: basic(LOGIN, TOKEN) };
64
65 const commitOnOrigin = async (path: string): Promise<void> => {
66 const made = await fetch(`${originServers.url}/api/v1/repos/octo/lib/contents`, {
67 method: "POST",
68 headers: theirs,
69 body: JSON.stringify({ branch: "main", message: `add ${path}`, files: [{ path, content: `${path}\n` }] }),
70 });
71 expect(made.status).toBeLessThan(300);
72 };
73 const head = async (): Promise<string> =>
74 (await (await fetch(`${servers.url}/alice/lib`, { headers: mine })).text());
75
76 beforeAll(async () => {
77 for (const prefix of ["forge-ahead-", "forge-ahead-origin-"])
78 dirs.push(await mkdtemp(join(tmpdir(), prefix)));
79 originApp = createApp({ dataDir: dirs[1]!, port: 0 });
80 originServers = await serve(originApp);
81 await seedAdmin(originApp, { username: LOGIN, email: "[email protected]", password: TOKEN });
82 proveAll(originApp);
83 const created = await fetch(`${originServers.url}/api/v1/repos`, {
84 method: "POST",
85 headers: theirs,
86 body: JSON.stringify({ name: "lib", visibility: "public" }),
87 });
88 expect(created.status).toBeLessThan(300);
89 await commitOnOrigin("README.md");
90
91 const state = newFakeState("forgejo", LOGIN, TOKEN);
92 state.repos.push({
93 id: 5,
94 fullName: "octo/lib",
95 private: false,
96 defaultBranch: "main",
97 cloneUrl: `${originServers.url}/octo/lib.git`,
98 });
99 fake = serveFakeForge(state);
100 state.repos[0]!.htmlUrl = `${fake.url}/octo/lib`;
101 app = createApp({ dataDir: dirs[0]!, port: 0 });
102 servers = await serve(app);
103 await seedAdmin(app, { username: "alice", email: "[email protected]", password: PASSWORD });
104 proveAll(app);
105 const made = createOrigin(app.db, app.config.dataDir, {
106 userId: getUserByName(app.db, "alice")!.id,
107 kind: "forgejo",
108 apiUrl: fake.apiUrl,
109 webUrl: fake.url,
110 login: LOGIN,
111 token: TOKEN,
112 });
113 if (!made.ok) throw new Error(made.error);
114 const started = await fetch(`${servers.url}/bring`, {
115 method: "POST",
116 redirect: "manual",
117 headers: { ...mine, "content-type": "application/x-www-form-urlencoded" },
118 body: new URLSearchParams({ link: `${fake.url}/octo/lib` }).toString(),
119 });
120 expect(started.status).toBe(303);
121 const job = await settled(started.headers.get("location")!.split("/").pop()!);
122 expect(job?.stage).toBe("done");
123 }, 120_000);
124
125 afterAll(async () => {
126 await syncsSettled();
127 fake.stop();
128 servers.stop();
129 originServers.stop();
130 app.close();
131 originApp.close();
132 for (const d of dirs) await rm(d, { recursive: true, force: true, maxRetries: 5 });
133 }, 120_000);
134
135 const post = (as: Record<string, string>, path = "sync/take") =>
136 fetch(`${servers.url}/alice/lib/${path}`, {
137 method: "POST",
138 redirect: "manual",
139 headers: { ...as, "content-type": "application/x-www-form-urlencoded", origin: servers.url },
140 body: "",
141 });
142 const flashed = async (res: Response): Promise<string> => {
143 const cookie = (res.headers.get("set-cookie") ?? "").split(";")[0]!;
144 return (await (await fetch(`${servers.url}/alice/lib`, { headers: { ...mine, cookie } })).text());
145 };
146 const mainOid = (): string =>
147 Bun.spawnSync(["git", "rev-parse", "main"], { cwd: join(app.reposRoot, "alice", "lib.git") }).stdout.toString().trim();
148
149 test("commit on the origin, see the count, take them, the count is 0", async () => {
150 const repo = getRepo(app.db, "alice", "lib")!;
151 expect(aheadOf(app, repo)).toBeUndefined();
152 expect(await head()).not.toContain("data-origin-ahead");
153
154 await commitOnOrigin("one.txt");
155 await commitOnOrigin("two.txt");
156 expect((await readAhead(app, repo))?.count).toBe(2);
157 expect(aheadOf(app, repo)?.count).toBe(2);
158 const shown = await head();
159 expect(shown).toContain("data-origin-ahead");
160 expect(shown).toContain("2 new commits upstream");
161
162 const taken = await post(mine);
163 expect(taken.status).toBe(303);
164 expect(await flashed(taken)).toContain("Took 2 new commits from the origin.");
165 expect(aheadOf(app, repo)).toBeUndefined();
166 expect((await readAhead(app, repo))?.count).toBe(0);
167 expect(await head()).not.toContain("data-origin-ahead");
168 });
169
170 test("the worker's pass reads a mirror on its own", async () => {
171 const repo = getRepo(app.db, "alice", "lib")!;
172 await commitOnOrigin("three.txt");
173 expect(await readAheadDue(app, Date.now() + 3 * 60_000)).toBeGreaterThan(0);
174 expect(aheadOf(app, repo)?.count).toBe(1);
175 });
176
177 test("a mirror holding commits the origin lacks says conflict and moves nothing", async () => {
178 const repo = getRepo(app.db, "alice", "lib")!;
179 const dir = join(app.reposRoot, "alice", "lib.git");
180 const tree = Bun.spawnSync(["git", "rev-parse", "main^{tree}"], { cwd: dir }).stdout.toString().trim();
181 const main = Bun.spawnSync(["git", "rev-parse", "main"], { cwd: dir }).stdout.toString().trim();
182 const local = Bun.spawnSync(
183 ["git", "-c", "user.name=t", "-c", "user.email=t@t", "commit-tree", tree, "-p", main, "-m", "local only"],
184 { cwd: dir },
185 ).stdout.toString().trim();
186 Bun.spawnSync(["git", "update-ref", "refs/heads/main", local], { cwd: dir });
187 await readAhead(app, repo);
188 expect(aheadOf(app, repo)?.diverged).toBe(true);
189 const taken = await post(mine);
190 expect(taken.status).toBe(303);
191 expect(await flashed(taken)).toContain(CONFLICT_WORDS(repo.defaultBranch, aheadOf(app, repo)!.count));
192 expect(mainOid()).toBe(local);
193 });
194
195 test("the sweep leaves a diverged main untouched, and says why where the last fetch is", async () => {
196 const repo = getRepo(app.db, "alice", "lib")!;
197 const before = mainOid();
198 expect(aheadOf(app, repo)?.diverged).toBe(true);
199 await syncOrigin(app, originForRepo(app.db, repo.id)!.origin);
200 expect(mainOid()).toBe(before);
201 expect(getRepo(app.db, "alice", "lib")!.origin!.fetchError).toBe(HELD_WORDS(repo.defaultBranch));
202 expect(aheadOf(app, repo)?.diverged).toBe(true);
203 });
204
205 test("a reading stuck on a slow origin does not hold the sweep", async () => {
206 const repo = getRepo(app.db, "alice", "lib")!;
207 const origin = originForRepo(app.db, repo.id)!.origin;
208 // An origin that accepts and never answers: git waits on it.
209 const silent = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } });
210 const row = app.db
211 .query<{ clone_url: string }, [string]>("SELECT clone_url FROM origin_repo WHERE origin_id = ?1")
212 .get(origin.id)!;
213 app.db.query("UPDATE origin_repo SET clone_url = ?1 WHERE origin_id = ?2")
214 .run(`http://127.0.0.1:${silent.port}/octo/lib.git`, origin.id);
215 clearCursor(app.db, origin.id, `repo:${repo.id}:ahead-read`);
216 const reads = runAheadReads(app);
217 expect(aheadBusy(app)).toBe(true);
218 app.db.query("UPDATE origin_repo SET clone_url = ?1 WHERE origin_id = ?2").run(row.clone_url, origin.id);
219 requestSyncNow(app, origin.id);
220 runSync(app);
221 expect(syncBusy(app)).toBe(true);
222 const deadline = Date.now() + 30_000;
223 while (syncBusy(app) && Date.now() < deadline) await Bun.sleep(10);
224 expect(syncBusy(app)).toBe(false);
225 expect(getCursor(app.db, origin.id, "next_sync_at") ?? "").not.toBe(new Date(0).toISOString());
226 expect(aheadBusy(app)).toBe(true);
227 silent.stop(true);
228 await reads;
229 expect(aheadBusy(app)).toBe(false);
230 });
231
232 test("a non-writer and a guest see the count but no Take them; a guest sees nothing on a private mirror", async () => {
233 const repo = getRepo(app.db, "alice", "lib")!;
234 const made = await createUser(app.db, {
235 origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "bob-secret-5521",
236 });
237 expect(made.ok).toBe(true);
238 proveAll(app);
239 const bob = { authorization: basic("bob", "bob-secret-5521") };
240 expect(await head()).toContain("Take them");
241 for (const as of [bob, {}]) {
242 const page = await (await fetch(`${servers.url}/alice/lib`, { headers: as })).text();
243 expect(page).toContain("new commit");
244 expect(page).toContain("conflicts with commits here");
245 expect(page).not.toContain("Take them");
246 }
247 expect((await post(bob)).status).not.toBe(200);
248 expect((await post(bob, "sync")).status).not.toBe(200);
249 app.db.query("UPDATE repo SET visibility = 'private' WHERE id = ?1").run(repo.id);
250 const guest = await fetch(`${servers.url}/alice/lib`);
251 expect(await guest.text()).not.toContain("data-origin-ahead");
252 app.db.query("UPDATE repo SET visibility = 'public' WHERE id = ?1").run(repo.id);
253 });
254
255 test("the terminal shows the line and takes them under the page's rules", async () => {
256 const repo = getRepo(app.db, "alice", "lib")!;
257 const alice = getUserByName(app.db, "alice")!;
258 const bob = getUserByName(app.db, "bob")!;
259 const screenFor = (user: typeof alice | undefined) => {
260 const toasts: string[] = [];
261 const ctx = {
262 theme: theme("dark"), invalidate() {}, push() {}, pop() {}, copy() {},
263 toast: (m: string) => toasts.push(m),
264 } as unknown as Ctx;
265 const screen = originAheadScreen({ app, db: app.db, user } as unknown as Session, ctx);
266 const draw = (): string => {
267 const s = new Surface(110, 20);
268 screen.render({ x: 0, y: 0, w: 110, h: 20 }, s);
269 return s.toText();
270 };
271 return { screen, toasts, draw };
272 };
273 expect(screenFor(alice).draw()).toContain("conflicts with commits here");
274 expect(screenFor(bob).draw()).toContain("not take it");
275 expect(screenFor(undefined).draw()).toContain("alice/lib");
276 const before = mainOid();
277 const b = screenFor(bob);
278 expect(b.screen.key!({ key: "t" } as never)).toBe(false);
279 expect(b.screen.taking()).toBeUndefined();
280 const a = screenFor(alice);
281 expect(a.screen.key!({ key: "t" } as never)).toBe(true);
282 await a.screen.taking();
283 expect(a.toasts[0]).toBe(CONFLICT_WORDS(repo.defaultBranch, 1));
284 expect(mainOid()).toBe(before);
285 app.db.query("UPDATE repo SET visibility = 'private' WHERE id = ?1").run(repo.id);
286 expect(screenFor(undefined).draw()).not.toContain("alice/lib");
287 app.db.query("UPDATE repo SET visibility = 'public' WHERE id = ?1").run(repo.id);
288 });
289
290 test("Sync now is the way out: it replaces a diverged main with the origin's", async () => {
291 const repo = getRepo(app.db, "alice", "lib")!;
292 const tip = aheadOf(app, repo)!.tip;
293 const res = await post(mine, "sync");
294 expect(await flashed(res)).toContain("Synced from the origin.");
295 expect(mainOid()).toBe(tip);
296 expect(aheadOf(app, repo)).toBeUndefined();
297 expect(getRepo(app.db, "alice", "lib")!.origin!.fetchError).toBe("");
298 });
299
300 test("an origin that cannot be read is refused, not synced over", async () => {
301 const repo = getRepo(app.db, "alice", "lib")!;
302 const alice = getUserByName(app.db, "alice")!;
303 const origin = originForRepo(app.db, repo.id)!.origin;
304 markNeedsReauth(app.db, origin.id, "test");
305 const before = mainOid();
306 const taken = await takeFromOrigin(app, alice, repo);
307 expect(taken.ok && taken.value.kind).toBe("unread");
308 expect(mainOid()).toBe(before);
309 const res = await post(mine);
310 expect(await flashed(res)).toContain(UNREAD_WORDS);
311 });
312
313 test("a repository's readings go with it, and only its own", () => {
314 const origin = originForRepo(app.db, getRepo(app.db, "alice", "lib")!.id)!.origin;
315 setCursor(app.db, origin.id, "repo:gone:ahead", "1 x 0 now");
316 setCursor(app.db, origin.id, "repo:gone:ahead-read", "now");
317 setCursor(app.db, origin.id, "repo:gonealso:ahead", "1 x 0 now");
318 forgetRepoCursors(app.db, "gone", origin.id);
319 expect(getCursor(app.db, origin.id, "repo:gone:ahead")).toBe("1 x 0 now");
320 forgetRepoCursors(app.db, "gone");
321 expect(getCursor(app.db, origin.id, "repo:gone:ahead")).toBeUndefined();
322 expect(getCursor(app.db, origin.id, "repo:gone:ahead-read")).toBeUndefined();
323 expect(getCursor(app.db, origin.id, "repo:gonealso:ahead")).toBe("1 x 0 now");
324 });
325 });
326
327 describe("which mirrors a tick reads", () => {
328 const MIN = 60_000;
329 const now = 100 * MIN;
330 test("at most four, longest-ago first, none read within two minutes", () => {
331 const seen = [
332 { repoId: "a", at: now - 10 * MIN },
333 { repoId: "b", at: now - 9 * MIN },
334 { repoId: "c", at: now - 8 * MIN },
335 { repoId: "d", at: now - 7 * MIN },
336 { repoId: "e", at: now - 6 * MIN },
337 { repoId: "never", at: 0 },
338 { repoId: "fresh", at: now - 119_000 },
339 ];
340 expect(pickDue(seen, now)).toEqual(["never", "a", "b", "c"]);
341 expect(pickDue([{ repoId: "x", at: now - 2 * MIN }], now)).toEqual(["x"]);
342 expect(pickDue([{ repoId: "x", at: now - 2 * MIN + 1 }], now)).toEqual([]);
343 });
344 });
test/palette-roster.test.ts +4
@@ -131,6 +131,7 @@ test("a site-admin person is offered the whole roster", () => {
131131 ["front", "Front page", "the apps a first visitor sees, ranked; an admin pins or hides"],
132132 ["owner", "Owner's needs", "what needs the owner, snapshots, failure domains"],
133133 ["forges", "Forges you sent", "each forge sent, claimed or not, and its apps"],
134 ["origin-ahead", "New commits upstream", "mirrors whose origin has moved on: take them"],
134135 ["profile", "Your profile", "display name and email"],
135136 ["family", "Family", "the forge that sent this one, and the forges it sent"],
136137 ["chain", "Chain", "the forges this forge's code descends from, root first"],
@@ -190,6 +191,7 @@ test("a site-admin agent is offered the roster without the person-only entries",
190191 ["feed", "Feed", "what happened, ranked; r replies on a post about an issue or pull, l likes"],
191192 ["front", "Front page", "the apps a first visitor sees, ranked; an admin pins or hides"],
192193 ["forges", "Forges you sent", "each forge sent, claimed or not, and its apps"],
194 ["origin-ahead", "New commits upstream", "mirrors whose origin has moved on: take them"],
193195 ["family", "Family", "the forge that sent this one, and the forges it sent"],
194196 ["chain", "Chain", "the forges this forge's code descends from, root first"],
195197 ["quit", "Quit", "disconnect"],
@@ -252,6 +254,7 @@ test("a member person is offered the signed-in roster without the admin entries"
252254 ["origins", "Connected origins", "the hosts your mirrors come from"],
253255 ["feed", "Feed", "what happened, ranked; r replies on a post about an issue or pull, l likes"],
254256 ["front", "Front page", "the apps a first visitor sees, ranked; an admin pins or hides"],
257 ["origin-ahead", "New commits upstream", "mirrors whose origin has moved on: take them"],
255258 ["profile", "Your profile", "display name and email"],
256259 ["family", "Family", "the forge that sent this one, and the forges it sent"],
257260 ["chain", "Chain", "the forges this forge's code descends from, root first"],
@@ -281,6 +284,7 @@ test("signed out, the palette offers sign-in and the public entries", () => {
281284 ["bugs", "Bugs", "open bugs and the bug clock"],
282285 ["audit", "Who can do what", "each app's doors from its contract.json: yes, own or no"],
283286 ["front", "Front page", "the apps a first visitor sees, ranked; an admin pins or hides"],
287 ["origin-ahead", "New commits upstream", "mirrors whose origin has moved on: take them"],
284288 ["family", "Family", "the forge that sent this one, and the forges it sent"],
285289 ["chain", "Chain", "the forges this forge's code descends from, root first"],
286290 ["quit", "Quit", "disconnect"],

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs