Skip to content

Commit

Merge branch 'worktree-agent-a5c94df323642630e'

syntaqxcommitted Parentsdc13b7683e9b55Browse files
37 files+1497−140/37 viewed
+38−0
167167 A repository you create is watched the way you choose in
168168 [your settings](#settings): **All activity** unless you change it.
169169
170+## Pages that update live
171+
172+A repository's pages keep up with it while you are on them, without a
173+reload and whatever you watch: new commits, pull requests, checks, runs
174+and comments appear as they happen.
175+
176+| Page | Updates when |
177+| --- | --- |
178+| **Overview** and **Files** | A branch or tag is pushed, created or deleted, a check or run reports on a commit, a release is published, or a deployment moves. |
179+| **Commits** | Commits are pushed to the branch. They wait under a **3 new commits · Show** bar at the top instead of moving the list while you read; checks on the commits shown update in place. |
180+| **Tags** | A tag is pushed or deleted, or a release is published. |
181+| **Pull requests** and **Issues** | One is opened, updated, reviewed, commented on, checked, merged or closed. |
182+| A **pull request** | Commits are pushed to it, its checks report, someone reviews or comments, or it is merged or closed. New items join its timeline where you are reading; if you are at the end of the page, it follows them down. |
183+| An **issue** | Someone comments on it or changes it, or a pull request linked to it moves. |
184+| **Actions** and a **run** | A run starts, one of its jobs starts or finishes, or the run finishes. A running job's log streams on its own. |
185+| **Security** | An alert is opened, fixed, dismissed or reopened. |
186+
187+A page refreshes a second after the last change it shows, so a burst of
188+them is one refresh. It never refreshes while you are typing in it or have
189+a dialog open: the change waits until you leave the field or close the
190+dialog. A tab you are not looking at refreshes at most once a minute, and
191+as soon as you come back to it.
192+
193+How it works: each signed-in tab keeps one live connection to g1t (the
194+same one that brings notifications and chat counts). Opening a
195+repository's page asks to follow that repository, and g1t first checks you
196+can read it. From then on, every push, pull request, issue, comment,
197+review, check, run, release and security alert there sends that tab a
198+short note naming only what kind of thing changed and where: a branch, a
199+number, a commit. Never a title, a message or code. The page then reads
200+itself again, with your access as it is now. Following lasts fifteen
201+minutes and is renewed while the page stays open, so someone who loses
202+access stops hearing of the repository within that time.
203+
204+Signed out, on a public repository, or while the live connection is down,
205+pages fall back to checking for themselves: when you come back to the tab
206+(at most every 30 seconds), and once a minute while it is in view.
207+
170208 ## Security
171209
172210 Security alerts are told the way they can be lived with. A scan that finds
+25−0
1+import { ArrowUp } from "lucide-react";
2+
3+import { Button } from "./ui/button";
4+
5+/**
6+ * "3 new commits · Show": what arrived while the reader was on the page,
7+ * held back so the list does not move under them (lib/use-repo-live.ts,
8+ * `hold`). Nothing when nothing is held. `count` null: arrived, uncounted.
9+ */
10+export function NewItemsBar({ held, count, noun, onShow }: { held: boolean; count: number | null; noun: [string, string]; onShow: () => void }) {
11+ if (!held) return null;
12+ const label = count == null || count < 1 ? `New ${noun[1]}` : `${count} new ${count === 1 ? noun[0] : noun[1]}`;
13+ return (
14+ <div role="status" className="mb-3 flex justify-center">
15+ <Button variant="soft" size="xs" onClick={onShow} className="rounded-full px-3">
16+ <ArrowUp size={12} aria-hidden />
17+ {label}
18+ <span aria-hidden className="text-faint">
19+ ·
20+ </span>
21+ Show
22+ </Button>
23+ </div>
24+ );
25+}
+24−1
1616 * for `IDLE_MS`) or comes back, the feed sends how everyone in the
1717 * workspace shows (`presence`) and your own (`me`), and `setPresence`
1818 * changes yours (lib/presence.ts, components/presence.tsx).
19+ *
20+ * So do repository pages: a tab on one watches its repository, and what
21+ * changes there arrives as `repo` events, handed to the page's
22+ * `useRepoLive` (lib/use-repo-live.ts) through `onRepoChanges`.
1923 */
2024 import { useEffect, useRef, useState, useSyncExternalStore } from "react";
2125
22−import type { FeedCounts, FeedEvent, FeedNotification, NotifyPreferences, OwnPresence, PresenceChange, PresenceEntry } from "@g1t/contracts";
26+import type { FeedCounts, FeedEvent, FeedNotification, NotifyPreferences, OwnPresence, PresenceChange, PresenceEntry, RepoChange } from "@g1t/contracts";
2327
2428 import { openLive } from "./live-socket";
2529 import { dndOn, isIdle, mergePeople } from "./presence";
431435 if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type: "inbox", unread }));
432436 }
433437
438+// ── Live repository pages ────────────────────────────────────────────────
439+
440+const repoListeners = new Set<(changes: RepoChange[]) => void>();
441+
442+/** Hears what changes in the repositories this tab watches (lib/use-repo-live.ts). Returns how to stop. */
443+export function onRepoChanges(listener: (changes: RepoChange[]) => void): () => void {
444+ repoListeners.add(listener);
445+ return () => repoListeners.delete(listener);
446+}
447+
434448 function onEvent(event: FeedEvent): void {
435449 switch (event.type) {
450+ case "repo":
451+ for (const listener of repoListeners) {
452+ try {
453+ listener(Array.isArray(event.changes) ? event.changes : []);
454+ } catch (error) {
455+ console.error("notify: a live page failed on a change", error);
456+ }
457+ }
458+ break;
436459 case "hello":
437460 set({ preferences: event.preferences, vapidKey: event.vapid_public_key, recent: addRecent(state.recent, event.notifications ?? []) });
438461 break;
+170−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import {
5+ FALLBACK_FOCUS_MS,
6+ FALLBACK_POLL_MS,
7+ HIDDEN_MIN_MS,
8+ LIVE_DEBOUNCE_MS,
9+ LIVE_HOLD_CHECK_MS,
10+ LIVE_MAX_WAIT_MS,
11+ type RepoChange,
12+ atBottom,
13+ branchOf,
14+ fallbackDue,
15+ gate,
16+ isTyping,
17+ newCommits,
18+ repoPages,
19+ tagOf,
20+} from "./repo-live.ts";
21+
22+const AT = "2026-10-11T10:00:00Z";
23+const NOW = Date.parse(AT);
24+
25+function change(kind: string, more: Partial<RepoChange> = {}): RepoChange {
26+ return { repo_id: "rep_1", repo: "acme/rocket", kind, at: AT, ...more };
27+}
28+
29+const push = (branch: string, commits?: number) => change("git.push", { ref: `refs/heads/${branch}`, sha: "abc", commits: commits ?? null });
30+
31+test("refs name their branch or tag", () => {
32+ assert.equal(branchOf("refs/heads/feature/x"), "feature/x");
33+ assert.equal(branchOf("refs/tags/v1"), null);
34+ assert.equal(tagOf("refs/tags/v1.2"), "v1.2");
35+ assert.equal(tagOf(null), null);
36+});
37+
38+test("the files and overview pages follow refs, checks, releases and deployments", () => {
39+ const files = repoPages.files();
40+ for (const c of [push("main"), push("other"), change("git.deleted", { ref: "refs/heads/old" }), change("status.created", { sha: "abc" }), change("release.published"), change("deployment_status.created")]) {
41+ assert.equal(files(c), true, c.kind);
42+ }
43+ assert.equal(files(change("issue.opened", { number: 3 })), false);
44+ assert.equal(files(change("comment.created", { number: 3 })), false);
45+});
46+
47+test("the commits page hears pushes to its own branch only", () => {
48+ const commits = repoPages.commits("main");
49+ assert.equal(commits(push("main")), true);
50+ assert.equal(commits(push("feature")), false);
51+ assert.equal(commits(change("git.push", { ref: "refs/tags/v1" })), false);
52+ assert.equal(commits(change("workflow.completed", { sha: "abc", run: "run_1" })), true, "checks on its commits");
53+ assert.equal(commits(change("pull.opened", { number: 1 })), false);
54+});
55+
56+test("branches, tags, and the lists of pull requests and issues", () => {
57+ assert.equal(repoPages.refs()(push("x")), true);
58+ assert.equal(repoPages.refs()(change("pull.opened", { number: 1 })), true);
59+ assert.equal(repoPages.refs()(change("issue.opened", { number: 1 })), false);
60+ assert.equal(repoPages.tags()(change("git.push", { ref: "refs/tags/v2" })), true);
61+ assert.equal(repoPages.tags()(push("main")), false);
62+ assert.equal(repoPages.tags()(change("release.published")), true);
63+ assert.equal(repoPages.pulls()(change("pull.updated", { number: 4 })), true);
64+ assert.equal(repoPages.pulls()(change("review.completed", { number: 4 })), true);
65+ assert.equal(repoPages.pulls()(change("issue.opened", { number: 5 })), false);
66+ assert.equal(repoPages.issues()(change("issue.closed", { number: 5 })), true);
67+ assert.equal(repoPages.issues()(change("comment.created", { number: 5 })), true);
68+ assert.equal(repoPages.issues()(change("pull.merged", { number: 6 })), true, "a merge may close issues");
69+ assert.equal(repoPages.issues()(push("main")), false);
70+});
71+
72+test("a pull request hears its own changes, its head branch and its head's checks", () => {
73+ const pull = repoPages.pull(7, { branch: "feature", sha: "f00d" });
74+ assert.equal(pull(change("pull.updated", { number: 7 })), true);
75+ assert.equal(pull(change("comment.created", { number: 7 })), true);
76+ assert.equal(pull(change("review.completed", { number: 7 })), true);
77+ assert.equal(pull(change("checks.completed", { number: 7, sha: "f00d" })), true);
78+ assert.equal(pull(change("pull.merged", { number: 7 })), true);
79+ assert.equal(pull(push("feature")), true, "new commits");
80+ assert.equal(pull(change("status.created", { sha: "f00d" })), true);
81+ assert.equal(pull(change("run.updated", { run: "run_1", number: 7 })), true, "a run for it");
82+ assert.equal(pull(change("comment.created", { number: 8 })), false);
83+ assert.equal(pull(push("main")), false);
84+ assert.equal(pull(change("status.created", { sha: "beef" })), false);
85+});
86+
87+test("an issue hears its own changes and its linked pull requests", () => {
88+ const issue = repoPages.issue(3, [9]);
89+ assert.equal(issue(change("comment.created", { number: 3 })), true);
90+ assert.equal(issue(change("issue.closed", { number: 3 })), true);
91+ assert.equal(issue(change("pull.merged", { number: 9 })), true);
92+ assert.equal(issue(change("comment.created", { number: 4 })), false);
93+ assert.equal(issue(push("main")), false);
94+});
95+
96+test("runs, one run, and security", () => {
97+ assert.equal(repoPages.runs()(change("run.updated", { run: "run_1" })), true);
98+ assert.equal(repoPages.runs()(change("status.created", { sha: "abc" })), true, "a new run reports pending on its commit");
99+ assert.equal(repoPages.runs()(change("issue.opened", { number: 1 })), false);
100+ const run = repoPages.run("run_1", "abc");
101+ assert.equal(run(change("run.updated", { run: "run_1" })), true);
102+ assert.equal(run(change("workflow.completed", { run: "run_1" })), true);
103+ assert.equal(run(change("run.updated", { run: "run_2" })), false);
104+ assert.equal(run(change("status.created", { sha: "abc" })), true);
105+ assert.equal(repoPages.security()(change("secret_scanning_alert.created")), true);
106+ assert.equal(repoPages.security()(change("code_scanning_alert.fixed", { number: 2 })), true);
107+ assert.equal(repoPages.security()(change("issue.opened", { number: 1 })), false);
108+});
109+
110+test("new commits are counted, and uncounted when a push was not", () => {
111+ assert.equal(newCommits([push("main", 2), push("main", 3), push("other", 9)], "main"), 5);
112+ assert.equal(newCommits([push("main", 2), push("main")], "main"), null);
113+ assert.equal(newCommits([change("status.created")], "main"), 0);
114+});
115+
116+test("a change waits a second for more, five at most, then refreshes", () => {
117+ const idle = { now: NOW, visible: true, busy: false, navigating: false };
118+ assert.deepEqual(gate({ pendingSince: null, lastChangeAt: null, lastRunAt: null }, idle), { action: "idle" });
119+ assert.deepEqual(gate({ pendingSince: NOW, lastChangeAt: NOW, lastRunAt: null }, idle), { action: "wait", ms: LIVE_DEBOUNCE_MS });
120+ assert.deepEqual(gate({ pendingSince: NOW, lastChangeAt: NOW, lastRunAt: null }, { ...idle, now: NOW + LIVE_DEBOUNCE_MS }), { action: "run" });
121+ // A steady stream: refreshed after five seconds anyway.
122+ const stream = { pendingSince: NOW, lastChangeAt: NOW + LIVE_MAX_WAIT_MS - 100, lastRunAt: null };
123+ assert.deepEqual(gate(stream, { ...idle, now: NOW + LIVE_MAX_WAIT_MS - 50 }), { action: "wait", ms: 50 });
124+ assert.deepEqual(gate(stream, { ...idle, now: NOW + LIVE_MAX_WAIT_MS }), { action: "run" });
125+});
126+
127+test("never under someone typing, a dialog, or a navigation: it waits for them", () => {
128+ const due = { pendingSince: NOW - 10_000, lastChangeAt: NOW - 10_000, lastRunAt: null };
129+ for (const held of [{ busy: true, navigating: false }, { busy: false, navigating: true }]) {
130+ assert.deepEqual(gate(due, { now: NOW, visible: true, ...held }), { action: "wait", ms: LIVE_HOLD_CHECK_MS });
131+ }
132+ assert.deepEqual(gate(due, { now: NOW, visible: true, busy: false, navigating: false }), { action: "run" });
133+});
134+
135+test("a tab out of view refreshes at most once a minute", () => {
136+ const hidden = { now: NOW, visible: false, busy: false, navigating: false };
137+ const ranLately = { pendingSince: NOW - 5000, lastChangeAt: NOW - 5000, lastRunAt: NOW - 20_000 };
138+ assert.deepEqual(gate(ranLately, hidden), { action: "wait", ms: HIDDEN_MIN_MS - 20_000 });
139+ assert.deepEqual(gate({ ...ranLately, lastRunAt: NOW - HIDDEN_MIN_MS }, hidden), { action: "run" });
140+ // Back in view: at once.
141+ assert.deepEqual(gate(ranLately, { ...hidden, visible: true }), { action: "run" });
142+});
143+
144+test("without the socket: back in view every 30 s at most, a poll every minute in view, never with it", () => {
145+ const base = { live: false, visible: true, now: NOW };
146+ assert.equal(fallbackDue({ ...base, lastRunAt: null, reason: "focus" }), true);
147+ assert.equal(fallbackDue({ ...base, lastRunAt: NOW - FALLBACK_FOCUS_MS + 1, reason: "focus" }), false);
148+ assert.equal(fallbackDue({ ...base, lastRunAt: NOW - FALLBACK_FOCUS_MS, reason: "focus" }), true);
149+ assert.equal(fallbackDue({ ...base, lastRunAt: NOW - FALLBACK_FOCUS_MS, reason: "poll" }), false);
150+ assert.equal(fallbackDue({ ...base, lastRunAt: NOW - FALLBACK_POLL_MS, reason: "poll" }), true);
151+ assert.equal(fallbackDue({ ...base, visible: false, lastRunAt: null, reason: "poll" }), false, "a hidden tab is not polled");
152+ assert.equal(fallbackDue({ ...base, live: true, lastRunAt: null, reason: "focus" }), false);
153+});
154+
155+test("typing is in text boxes, text areas and editors", () => {
156+ assert.equal(isTyping({ tagName: "TEXTAREA" }), true);
157+ assert.equal(isTyping({ tagName: "input", type: "text" }), true);
158+ assert.equal(isTyping({ tagName: "INPUT", type: "search" }), true);
159+ assert.equal(isTyping({ tagName: "INPUT" }), true, "an input is text unless it says otherwise");
160+ assert.equal(isTyping({ tagName: "DIV", isContentEditable: true }), true);
161+ assert.equal(isTyping({ tagName: "INPUT", type: "checkbox" }), false);
162+ assert.equal(isTyping({ tagName: "BUTTON" }), false);
163+ assert.equal(isTyping({ tagName: "TEXTAREA", readOnly: true }), false);
164+ assert.equal(isTyping(null), false);
165+});
166+
167+test("the bottom of the page, with some slack", () => {
168+ assert.equal(atBottom({ top: 880, height: 2000, viewport: 1000 }), true);
169+ assert.equal(atBottom({ top: 500, height: 2000, viewport: 1000 }), false);
170+});
+218−0
1+/**
2+ * Repository pages that update live: which changes each page cares about,
3+ * and when a page may refresh itself. Pure; the hook that uses them is
4+ * lib/use-repo-live.ts.
5+ *
6+ * A tab on a repository page watches the repository over the live socket
7+ * (`/-/live`, services/notify): the site checks the person may read it,
8+ * then every push, pull request, issue, comment, review, check, run,
9+ * release and security alert there arrives as a small `RepoChange`. A page
10+ * refreshes its loader when a change matches it, a second after the last
11+ * one, and never under someone typing or with a dialog open. Signed out,
12+ * or while the socket is down, it refreshes when the tab comes back into
13+ * view and once a minute while it is in view instead.
14+ */
15+import type { RepoChange } from "@g1t/contracts";
16+
17+export type { RepoChange };
18+
19+/** Which changes a page shows. */
20+export type RepoMatch = (change: RepoChange) => boolean;
21+
22+/** How long after the last matching change the page refreshes. */
23+export const LIVE_DEBOUNCE_MS = 1000;
24+/** However steady the changes, a page waits no longer than this before refreshing. */
25+export const LIVE_MAX_WAIT_MS = 5000;
26+/** How often a page held back (someone typing, a dialog open) looks again. */
27+export const LIVE_HOLD_CHECK_MS = 1000;
28+/** A tab out of view refreshes at most this often. */
29+export const HIDDEN_MIN_MS = 60_000;
30+/** Without the socket: coming back into view refreshes at most this often. */
31+export const FALLBACK_FOCUS_MS = 30_000;
32+/** Without the socket: how often a page in view refreshes. */
33+export const FALLBACK_POLL_MS = 60_000;
34+/** A watch is asked for again this often while the page stays open (it lasts REPO_WATCH_MS). */
35+export const REWATCH_MS = 10 * 60_000;
36+
37+// ── Matching ─────────────────────────────────────────────────────────────
38+
39+const HEADS = "refs/heads/";
40+const TAGS = "refs/tags/";
41+
42+/** The branch a full ref names, or null for a tag or anything else. */
43+export function branchOf(ref: string | null | undefined): string | null {
44+ return ref?.startsWith(HEADS) ? ref.slice(HEADS.length) : null;
45+}
46+
47+/** The tag a full ref names, or null. */
48+export function tagOf(ref: string | null | undefined): string | null {
49+ return ref?.startsWith(TAGS) ? ref.slice(TAGS.length) : null;
50+}
51+
52+function family(change: RepoChange): string {
53+ return change.kind.split(".")[0] ?? "";
54+}
55+
56+/** A push or a deletion of a ref. */
57+export function isRefChange(change: RepoChange): boolean {
58+ return change.kind === "git.push" || change.kind === "git.deleted" || change.kind === "branch.renamed" || change.kind === "repo.default_branch_changed";
59+}
60+
61+/** A push to branch `branch` (or its deletion). */
62+export function isPushTo(change: RepoChange, branch: string | null | undefined): boolean {
63+ return (change.kind === "git.push" || change.kind === "git.deleted") && !!branch && branchOf(change.ref) === branch;
64+}
65+
66+/** Something reported on a commit: a status, a check, a run. */
67+export function isCheck(change: RepoChange): boolean {
68+ return ["status", "check_run", "check_suite", "checks", "workflow", "run"].includes(family(change));
69+}
70+
71+const ALERTS = ["secret_scanning_alert", "code_scanning_alert", "vulnerability_alert"];
72+
73+/** What each kind of repository page shows. */
74+export const repoPages = {
75+ /**
76+ * Files, the overview and a folder: any ref moving (the files and latest
77+ * commit, the branch menu, recent pushes), checks on commits, releases
78+ * and deployments.
79+ */
80+ files(): RepoMatch {
81+ return (change) => isRefChange(change) || isCheck(change) || ["release", "deployment", "deployment_status"].includes(family(change));
82+ },
83+ /** Commits: pushes to the branch shown, and checks on commits. */
84+ commits(branch: string | null): RepoMatch {
85+ return (change) => (branch == null ? change.kind === "git.push" && !tagOf(change.ref) : isPushTo(change, branch)) || isCheck(change);
86+ },
87+ /** Branches and tags: any ref moving, pull requests (a branch's), checks. */
88+ refs(): RepoMatch {
89+ return (change) => isRefChange(change) || isCheck(change) || family(change) === "pull" || family(change) === "release";
90+ },
91+ /** Tags and releases. */
92+ tags(): RepoMatch {
93+ return (change) => (isRefChange(change) && (tagOf(change.ref) != null || change.ref == null)) || family(change) === "release";
94+ },
95+ /** The pull requests list: pull requests and what is said and checked on them. */
96+ pulls(): RepoMatch {
97+ return (change) => ["pull", "review", "checks", "queue"].includes(family(change)) || (family(change) === "comment" && change.number != null) || isCheck(change);
98+ },
99+ /** The issues list: issues, comments on them, and pull requests that close them. */
100+ issues(): RepoMatch {
101+ return (change) => ["issue", "comment"].includes(family(change)) || ["pull.opened", "pull.merged", "pull.closed", "pull.reopened"].includes(change.kind);
102+ },
103+ /**
104+ * A pull request: its own changes (commits, reviews, comments, checks,
105+ * merged or closed), pushes to its head branch, and what is reported on
106+ * its head commit.
107+ */
108+ pull(number: number, head?: { branch?: string | null; sha?: string | null }): RepoMatch {
109+ return (change) =>
110+ (change.number === number && family(change) !== "issue") ||
111+ (!!head?.branch && isPushTo(change, head.branch)) ||
112+ (!!head?.sha && change.sha === head.sha && isCheck(change)) ||
113+ change.kind === "queue.changed";
114+ },
115+ /** An issue: its own changes and comments, and the pull requests linked to it. */
116+ issue(number: number, linked: number[] = []): RepoMatch {
117+ return (change) => change.number != null && (change.number === number || linked.includes(change.number));
118+ },
119+ /** The runs list: runs starting, moving and finishing, and what starts them. */
120+ runs(): RepoMatch {
121+ return (change) => ["workflow", "run", "status", "check_suite"].includes(family(change));
122+ },
123+ /** A run: its own jobs and its end, and what is reported on its commit. */
124+ run(id: string, sha?: string | null): RepoMatch {
125+ return (change) => change.run === id || (!!sha && change.sha === sha && family(change) === "status");
126+ },
127+ /** Security: alerts opening, closing and being dismissed, and the pull requests that fix them. */
128+ security(): RepoMatch {
129+ return (change) => ALERTS.includes(family(change)) || change.kind === "pull.merged" || change.kind === "pull.opened";
130+ },
131+};
132+
133+/** Commits a set of changes brought to a branch, for "N new commits": null when uncounted. */
134+export function newCommits(changes: RepoChange[], branch: string | null): number | null {
135+ let total = 0;
136+ for (const change of changes) {
137+ if (change.kind !== "git.push" || (branch != null && branchOf(change.ref) !== branch)) continue;
138+ if (change.commits == null) return null;
139+ total += change.commits;
140+ }
141+ return total;
142+}
143+
144+// ── When to refresh ──────────────────────────────────────────────────────
145+
146+/** What a page knows when deciding whether to refresh now. */
147+export type GateState = {
148+ /** When the first change still waiting arrived, or null when none is. */
149+ pendingSince: number | null;
150+ /** When the latest change arrived. */
151+ lastChangeAt: number | null;
152+ /** When the page last refreshed. */
153+ lastRunAt: number | null;
154+};
155+
156+export type GateInput = {
157+ now: number;
158+ /** Whether the tab is in view. */
159+ visible: boolean;
160+ /** Someone is typing on the page, or a dialog is open. */
161+ busy: boolean;
162+ /** A navigation or a submission is under way: the router reloads the page itself. */
163+ navigating: boolean;
164+};
165+
166+export type GateDecision = { action: "idle" } | { action: "run" } | { action: "wait"; ms: number };
167+
168+/**
169+ * Whether to refresh now for the changes waiting, or how long to wait
170+ * before asking again. A second after the last change (at most five after
171+ * the first), never under someone typing or with a dialog open, and at
172+ * most once a minute for a tab out of view, which refreshes as soon as it
173+ * comes back instead.
174+ */
175+export function gate(state: GateState, input: GateInput): GateDecision {
176+ if (state.pendingSince == null) return { action: "idle" };
177+ if (input.busy || input.navigating) return { action: "wait", ms: LIVE_HOLD_CHECK_MS };
178+ if (!input.visible && state.lastRunAt != null && input.now - state.lastRunAt < HIDDEN_MIN_MS) {
179+ return { action: "wait", ms: HIDDEN_MIN_MS - (input.now - state.lastRunAt) };
180+ }
181+ const quiet = input.now - (state.lastChangeAt ?? state.pendingSince);
182+ const waited = input.now - state.pendingSince;
183+ if (quiet < LIVE_DEBOUNCE_MS && waited < LIVE_MAX_WAIT_MS) {
184+ return { action: "wait", ms: Math.min(LIVE_DEBOUNCE_MS - quiet, LIVE_MAX_WAIT_MS - waited) };
185+ }
186+ return { action: "run" };
187+}
188+
189+/**
190+ * Without the socket (signed out, or while it is down): whether to refresh
191+ * now. `focus` when the tab comes back into view, at most every 30 s;
192+ * `poll` once a minute while it is in view. Never with the socket up.
193+ */
194+export function fallbackDue(input: { live: boolean; visible: boolean; now: number; lastRunAt: number | null; reason: "focus" | "poll" }): boolean {
195+ if (input.live || !input.visible) return false;
196+ const since = input.lastRunAt == null ? Infinity : input.now - input.lastRunAt;
197+ return since >= (input.reason === "focus" ? FALLBACK_FOCUS_MS : FALLBACK_POLL_MS);
198+}
199+
200+/** What has focus, as `isTyping` reads it. */
201+export type FocusedElement = { tagName: string; type?: string | null; isContentEditable?: boolean; readOnly?: boolean; disabled?: boolean } | null;
202+
203+const NOT_TYPED = new Set(["button", "checkbox", "radio", "submit", "reset", "file", "image", "color", "range", "hidden"]);
204+
205+/** Whether the focused element is somewhere one types: a text box, a text area, an editor. */
206+export function isTyping(focused: FocusedElement): boolean {
207+ if (!focused || focused.disabled || focused.readOnly) return false;
208+ if (focused.isContentEditable) return true;
209+ const tag = focused.tagName.toUpperCase();
210+ if (tag === "TEXTAREA") return true;
211+ if (tag === "INPUT") return !NOT_TYPED.has((focused.type ?? "text").toLowerCase());
212+ return false;
213+}
214+
215+/** Whether a page that tells it is at its bottom: then new timeline items are followed. */
216+export function atBottom(scroll: { top: number; height: number; viewport: number }, slack = 120): boolean {
217+ return scroll.top + scroll.viewport >= scroll.height - slack;
218+}
+247−0
1+/**
2+ * `useRepoLive`: a repository page that refreshes itself as the
3+ * repository changes (lib/repo-live.ts says which changes each page
4+ * cares about, and when it may refresh).
5+ *
6+ * Signed in, the tab watches the repository over its live socket: the
7+ * site checks the person may read it (`/-/notify`, `watch_repo`), then
8+ * every change there arrives as it happens, and the page's loader runs
9+ * again a second after the last matching one. Never under someone typing
10+ * on the page or with a dialog open (it waits until they are done), and
11+ * at most once a minute for a tab out of view. Signed out, or while the
12+ * socket is down, the page refreshes when the tab comes back into view
13+ * (at most every 30 s) and once a minute while it is in view.
14+ *
15+ * Browser-only work happens in effects; on the server the hook does
16+ * nothing.
17+ */
18+import { useCallback, useEffect, useRef, useState } from "react";
19+import { useLocation, useNavigation, useRevalidator, useRouteLoaderData } from "react-router";
20+
21+import { onRepoChanges, useFeedConnected } from "./notify-client";
22+import {
23+ FALLBACK_FOCUS_MS,
24+ LIVE_DEBOUNCE_MS,
25+ REWATCH_MS,
26+ type GateState,
27+ type RepoChange,
28+ type RepoMatch,
29+ atBottom,
30+ fallbackDue,
31+ gate,
32+ isTyping,
33+} from "./repo-live";
34+
35+/** What the project layout's loader gives every page under it (routes/repo/layout.tsx). */
36+type LayoutData = { repo?: { id: string; namespace: string; name: string }; signedIn?: boolean };
37+
38+export type RepoLiveOptions = {
39+ /** Which changes refresh the page. */
40+ match: RepoMatch;
41+ /**
42+ * Changes held back for the reader instead (the Commits page's "N new
43+ * commits"): `held` lists them, and `show` refreshes. While any are held,
44+ * other changes wait with them, so nothing moves under the reader.
45+ */
46+ hold?: RepoMatch;
47+ /** A reader at the bottom of the page stays there as new items arrive (a pull request's timeline). */
48+ followBottom?: boolean;
49+ /** Off: nothing is heard and nothing refreshes (a page that cannot change). */
50+ enabled?: boolean;
51+};
52+
53+export type RepoLive = {
54+ /** Whether changes arrive as they happen (signed in, socket up, watching). */
55+ live: boolean;
56+ /** Changes held back by `hold`, oldest first. */
57+ held: RepoChange[];
58+ /** Shows what was held: the page refreshes now. */
59+ show: () => void;
60+};
61+
62+// ── Watching ─────────────────────────────────────────────────────────────
63+
64+/** When each repository was last asked for, by `owner/name`, so pages and reconnects ask once. */
65+const asked = new Map<string, number>();
66+/** Asking again sooner than this is the same ask. */
67+const ASK_AGAIN_MS = 5000;
68+
69+async function watchRepo(path: string, force: boolean): Promise<boolean> {
70+ const now = Date.now();
71+ const last = asked.get(path);
72+ if (!force && last != null && now - last < ASK_AGAIN_MS) return true;
73+ asked.set(path, now);
74+ try {
75+ const answer = await fetch("/-/notify", {
76+ method: "POST",
77+ headers: { "content-type": "application/json", accept: "application/json" },
78+ credentials: "same-origin",
79+ body: JSON.stringify({ intent: "watch_repo", repo: path }),
80+ });
81+ if (!answer.ok) throw new Error(`status ${answer.status}`);
82+ const body = (await answer.json().catch(() => null)) as { ok?: unknown } | null;
83+ if (body?.ok !== true) throw new Error("refused");
84+ return true;
85+ } catch {
86+ asked.delete(path);
87+ return false;
88+ }
89+}
90+
91+// ── The page ─────────────────────────────────────────────────────────────
92+
93+/** Someone typing on the page, or a dialog (or a popover) open over it. */
94+function busyNow(): boolean {
95+ if (typeof document === "undefined") return false;
96+ const focused = document.activeElement as (HTMLElement & { type?: string; readOnly?: boolean; disabled?: boolean }) | null;
97+ if (isTyping(focused && focused !== document.body ? focused : null)) return true;
98+ return document.querySelector('[role="dialog"], [role="alertdialog"], dialog[open]') != null;
99+}
100+
101+function visibleNow(): boolean {
102+ return typeof document === "undefined" || document.visibilityState === "visible";
103+}
104+
105+function pageAtBottom(): boolean {
106+ const root = document.documentElement;
107+ return atBottom({ top: window.scrollY, height: root.scrollHeight, viewport: window.innerHeight });
108+}
109+
110+export function useRepoLive(options: RepoLiveOptions): RepoLive {
111+ const layout = useRouteLoaderData("routes/repo/layout") as LayoutData | undefined;
112+ const repo = layout?.repo ?? null;
113+ const signedIn = layout?.signedIn === true;
114+ const enabled = options.enabled !== false && !!repo;
115+ const path = repo ? `${repo.namespace}/${repo.name}` : null;
116+ const repoId = repo?.id ?? null;
117+
118+ const connected = useFeedConnected();
119+ const [watching, setWatching] = useState(false);
120+ const live = enabled && signedIn && connected && watching;
121+
122+ const revalidator = useRevalidator();
123+ const navigation = useNavigation();
124+ const { pathname, search } = useLocation();
125+ const [held, setHeld] = useState<RepoChange[]>([]);
126+
127+ // What the timers read: always the latest, without starting them again.
128+ const latest = useRef({ options, revalidator, navigating: false, live });
129+ latest.current = { options, revalidator, navigating: navigation.state !== "idle" || revalidator.state !== "idle", live };
130+ const state = useRef<GateState>({ pendingSince: null, lastChangeAt: null, lastRunAt: null });
131+ const heldCount = useRef(0);
132+ heldCount.current = held.length;
133+ const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
134+ const follow = useRef(false);
135+
136+ const run = useCallback(() => {
137+ const now = Date.now();
138+ state.current = { pendingSince: null, lastChangeAt: null, lastRunAt: now };
139+ follow.current = !!latest.current.options.followBottom && pageAtBottom();
140+ latest.current.revalidator.revalidate();
141+ }, []);
142+
143+ /** Refreshes when the rules allow, or looks again when they say. */
144+ const check = useCallback(() => {
145+ if (timer.current) clearTimeout(timer.current);
146+ timer.current = null;
147+ const decision = gate(state.current, { now: Date.now(), visible: visibleNow(), busy: busyNow(), navigating: latest.current.navigating });
148+ if (decision.action === "run") run();
149+ else if (decision.action === "wait") timer.current = setTimeout(check, decision.ms);
150+ }, [run]);
151+
152+ /** Something to refresh for: `now` skips the second's wait for more. */
153+ const queue = useCallback(
154+ (now = false) => {
155+ const at = Date.now();
156+ const s = state.current;
157+ state.current = { ...s, pendingSince: s.pendingSince ?? at, lastChangeAt: now ? at - LIVE_DEBOUNCE_MS : at };
158+ check();
159+ },
160+ [check],
161+ );
162+
163+ // Watching the repository: on opening the page, whenever the socket
164+ // (re)connects, and before the watch runs out.
165+ useEffect(() => {
166+ if (!enabled || !signedIn || !connected || !path) {
167+ setWatching(false);
168+ return;
169+ }
170+ let cancelled = false;
171+ const ask = (force: boolean) =>
172+ void watchRepo(path, force).then((ok) => {
173+ if (!cancelled) setWatching(ok);
174+ });
175+ ask(true);
176+ const renew = setInterval(() => ask(true), REWATCH_MS);
177+ return () => {
178+ cancelled = true;
179+ clearInterval(renew);
180+ };
181+ }, [enabled, signedIn, connected, path]);
182+
183+ // Changes as they arrive.
184+ useEffect(() => {
185+ if (!enabled || !repoId) return;
186+ return onRepoChanges((changes) => {
187+ const { match, hold } = latest.current.options;
188+ const mine = changes.filter((change) => change.repo_id === repoId);
189+ const holding = hold ? mine.filter((change) => hold(change)) : [];
190+ const matched = mine.filter((change) => !holding.includes(change) && match(change));
191+ if (holding.length) setHeld((before) => [...before, ...holding]);
192+ // While something is held, the rest waits with it: shown together.
193+ if (matched.length && !holding.length && heldCount.current === 0) queue();
194+ });
195+ }, [enabled, repoId, queue]);
196+
197+ // Another branch or page: what was held is no longer about it.
198+ useEffect(() => {
199+ setHeld([]);
200+ }, [pathname, search]);
201+
202+ // Back into view: whatever waited goes now; without the socket, the page
203+ // refreshes itself (at most every 30 s), and once a minute while in view.
204+ useEffect(() => {
205+ if (!enabled) return;
206+ const fallback = (reason: "focus" | "poll") => {
207+ if (fallbackDue({ live: latest.current.live, visible: visibleNow(), now: Date.now(), lastRunAt: state.current.lastRunAt ?? null, reason })) queue(true);
208+ };
209+ const back = () => {
210+ if (!visibleNow()) return;
211+ if (state.current.pendingSince != null) check();
212+ fallback("focus");
213+ };
214+ // Starts counting from the page's own load.
215+ if (state.current.lastRunAt == null) state.current = { ...state.current, lastRunAt: Date.now() };
216+ const poll = setInterval(() => fallback("poll"), FALLBACK_FOCUS_MS / 2);
217+ document.addEventListener("visibilitychange", back);
218+ window.addEventListener("focus", back);
219+ // Someone stopped typing, or closed a dialog: what waited goes.
220+ const settled = () => {
221+ if (state.current.pendingSince != null) setTimeout(check, 0);
222+ };
223+ document.addEventListener("focusout", settled);
224+ return () => {
225+ clearInterval(poll);
226+ document.removeEventListener("visibilitychange", back);
227+ window.removeEventListener("focus", back);
228+ document.removeEventListener("focusout", settled);
229+ if (timer.current) clearTimeout(timer.current);
230+ timer.current = null;
231+ };
232+ }, [enabled, check, queue]);
233+
234+ // A reader who was at the bottom follows what was added there.
235+ useEffect(() => {
236+ if (revalidator.state !== "idle" || !follow.current) return;
237+ follow.current = false;
238+ requestAnimationFrame(() => window.scrollTo({ top: document.documentElement.scrollHeight, behavior: "smooth" }));
239+ }, [revalidator.state]);
240+
241+ const show = useCallback(() => {
242+ setHeld([]);
243+ run();
244+ }, [run]);
245+
246+ return { live, held, show };
247+}
+17−2
55 import type { SoundSettingsChange } from "@g1t/contracts/sounds";
66
77 import type { Route } from "./+types/api";
8+import { repoFor } from "../../lib/access.server";
89 import { identity } from "../../lib/services.server";
910 import { assertSameOrigin, requireUser } from "../../lib/session.server";
1011
1920 * many phones do), `POST` a change: `subscribe`, `unsubscribe`,
2021 * `register_device` and `unregister_device` (the phone app, with its Expo
2122 * push token), `preferences`, `test`, `presence` (your status, being away,
22− * Do Not Disturb), or `sounds` (what you hear when chat moves, kept with
23− * the account by identity).
23+ * Do Not Disturb), `sounds` (what you hear when chat moves, kept with
24+ * the account by identity), or `watch_repo`, for a repository page that
25+ * updates live: its tabs then hear what changes there (`repo`, its
26+ * `owner/name`), once the person is found to be able to read it
27+ * (lib/repo-live.ts).
2428 *
2529 * The phone app calls this with its access token and no `Origin`, which
2630 * the same-origin check lets through (lib/same-origin.ts).
4044 token?: string;
4145 preferences?: NotifyPreferencesChange;
4246 change?: PresenceChange | SoundSettingsChange;
47+ repo?: string;
4348 };
4449
4550 export async function action({ context, request }: Route.ActionArgs) {
8186 const status = change.status ? { ...change.status, source: "manual" as const } : change.status;
8287 return Response.json(await notify.setPresence(user, { ...change, ...("status" in change ? { status } : {}) }));
8388 }
89+ case "watch_repo": {
90+ // Only a repository they can read: the same lookup its pages make.
91+ const [owner, name, ...rest] = String(sent.repo ?? "").split("/");
92+ if (!owner || !name || rest.length) return Response.json({ ok: false }, { status: 400 });
93+ const found = await repoFor(context, { owner, repo: name });
94+ if (!found.ok) return Response.json({ ok: false }, { status: 404 });
95+ const repo = found.value;
96+ const watched = await notify.watchRepo(user, repo.id, `${repo.namespace}/${repo.name}`);
97+ return Response.json({ ...watched, repo_id: repo.id });
98+ }
8499 default:
85100 return Response.json({ ok: false }, { status: 400 });
86101 }
+8−2
5858 import { accessTo, refusal } from "../../lib/access.server";
5959 import { useRefreshWhile } from "../../lib/refresh";
6060 import { type GraphJob, type Unit, groupJobs, jobsOf, unitStanding } from "../../lib/run-graph";
61+import { repoPages } from "../../lib/repo-live";
62+import { useRepoLive } from "../../lib/use-repo-live";
6163
6264 export function meta({ loaderData, params, ...args }: Route.MetaArgs) {
6365 const run = loaderData?.detail.run;
12331235 // A mirror standing by runs nothing; in CI failover it runs them again.
12341236 const rerunBlocked = useWorkflowReason();
12351237 const logsUrl = `${base}/actions/runs/${run.id}/logs.zip${latest ? "" : `?attempt=${run.attempt}`}`;
1236− // Waiting on a person needs no quick refresh; a running job does.
1237− useRefreshWhile(live, run.status === "action_required" || run.status === "waiting" ? 8000 : 2500);
1238+ // Its jobs starting and finishing, and its end, arrive live; the steps
1239+ // inside a job are read again every 10 s with the live socket (its log
1240+ // streams on its own), every few seconds without it. Waiting on a person
1241+ // needs no quick refresh.
1242+ const { live: heard } = useRepoLive({ match: repoPages.run(run.id, run.sha) });
1243+ useRefreshWhile(live, run.status === "action_required" || run.status === "waiting" ? 8000 : heard ? 10_000 : 2500);
12381244
12391245 // The job asked for; none is the run's summary.
12401246 const selected = jobs.find((job) => job.id === search.get("job")) ?? null;
+9−2
2323 import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server";
2424 import { accessFor, refusal, repoFor } from "../../lib/access.server";
2525 import { useRefreshWhile } from "../../lib/refresh";
26+import { repoPages } from "../../lib/repo-live";
27+import { useRepoLive } from "../../lib/use-repo-live";
2628
2729 export function meta({ params, ...args }: Route.MetaArgs) {
2830 return page(args, { title: `Workflows · ${params.owner}/${params.repo} · g1t` });
8486 return started.ok ? { started: started.value } : { error: started.error.message };
8587 }
8688
87−/** Re-reads the page every few seconds while something is still running. */
89+/**
90+ * Runs start, move and finish live (lib/use-repo-live.ts). While something
91+ * runs the page also asks again now and then, in case a change was missed:
92+ * every 30 s with the live socket, every few seconds without it.
93+ */
8894 function useLiveWhile(running: boolean) {
89− useRefreshWhile(running, 3000);
95+ const { live } = useRepoLive({ match: repoPages.runs() });
96+ useRefreshWhile(running, live ? 30_000 : 3000);
9097 }
9198
9299 const EVENT_WORDS: Record<string, string> = {
+4−0
1010 import { getViewer, unwrap } from "../../lib/session.server";
1111 import { projectHomepage } from "../../lib/about";
1212 import { useProject } from "./layout";
13+import { repoPages } from "../../lib/repo-live";
14+import { useRepoLive } from "../../lib/use-repo-live";
1315
1416 export function meta({ params, ...args }: Route.MetaArgs) {
1517 return page(args, { title: `Code · ${params.owner}/${params.repo} · g1t` });
3941
4042 export default function Code({ loaderData }: Route.ComponentProps) {
4143 const project = useProject();
44+ // The files and latest commit follow pushes; checks update as they report.
45+ useRepoLive({ match: repoPages.files() });
4246 return (
4347 <TreeView
4448 tree={loaderData.tree}
+12−0
88 import { page } from "../../lib/meta";
99 import { CommitChecksBadge } from "../../components/commit-checks";
1010 import { CommitAvatars, CommitNames } from "../../components/commit-person";
11+import { NewItemsBar } from "../../components/repo-live";
1112 import { EmptyState, TimeAgo } from "../../components/ui";
1213 import { Card } from "../../components/ui/card";
1314 import { commitChecksFor } from "../../lib/commit-checks.server";
1415 import { showCommits } from "../../lib/commit-people.server";
1516 import { repos } from "../../lib/services.server";
17+import { isPushTo, newCommits, repoPages } from "../../lib/repo-live";
18+import { useRepoLive } from "../../lib/use-repo-live";
1619 import { getViewer, unwrap } from "../../lib/session.server";
20+import { useProject } from "./layout";
1721
1822 const PAGE_SIZE = 50;
1923
5660 export default function Commits({ loaderData, params }: Route.ComponentProps) {
5761 const { commits, checks } = loaderData;
5862 const base = `/${params.owner}/${params.repo}`;
63+ // Live: new commits on the branch wait under "N new commits" rather than
64+ // moving the list under the reader; checks on the shown ones update.
65+ const branch = useProject()?.repo.defaultBranch ?? null;
66+ const pushed = (change: Parameters<typeof isPushTo>[0]) => change.kind === "git.push" && isPushTo(change, branch);
67+ const { held, show } = useRepoLive({ match: repoPages.commits(branch), hold: commits.length > 0 ? pushed : undefined });
5968 if (commits.length === 0) {
6069 return <EmptyState title="No commits yet" />;
6170 }
6271 return (
72+ <>
73+ <NewItemsBar held={held.length > 0} count={newCommits(held, branch)} noun={["commit", "commits"]} onShow={show} />
6374 <div className="relative space-y-8 before:absolute before:top-3 before:bottom-3 before:left-1.75 before:w-px before:bg-line">
6475 {byDay(commits).map(([day, list]) => (
6576 <section key={day} className="relative pl-7">
103114 </section>
104115 ))}
105116 </div>
117+ </>
106118 );
107119 }
+4−0
4444 import { Hint } from "../../components/ui/hint";
4545 import { useRefreshWhile } from "../../lib/refresh";
4646 import { DetailsDisclosure, detailsSummary } from "../../components/details-disclosure";
47+import { repoPages } from "../../lib/repo-live";
48+import { useRepoLive } from "../../lib/use-repo-live";
4749
4850
4951 export function meta({ loaderData, params, ...args }: Route.MetaArgs) {
294296 (pull.status === "draft" || pull.status === "open"),
295297 );
296298 useRefreshWhile(Boolean(running || assigned));
299+ // Comments, changes and its pull requests, as they happen; one reading at its end follows them.
300+ useRepoLive({ match: repoPages.issue(issue.number, pulls.map((pull) => pull.number)), followBottom: true });
297301
298302 // A refusal to start g1t shows by its button; every other error below the discussion.
299303 const runError = actionData && "action" in actionData && actionData.action === "run-hosted";
+4−0
2525 import { work } from "../../lib/services.server";
2626 import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server";
2727 import { accessTo, refusal, requireWritableRepo } from "../../lib/access.server";
28+import { repoPages } from "../../lib/repo-live";
29+import { useRepoLive } from "../../lib/use-repo-live";
2830
2931 /** As many sandboxes as one request may start. */
3032 const MAX_ASSIGNED_AT_ONCE = 10;
9799
98100 export default function Issues({ loaderData, actionData, params }: Route.ComponentProps) {
99101 const { issues, labels, milestones, state, label, milestone, agentsEnabled } = loaderData;
102+ // Issues opened, commented on and closed show as it happens.
103+ useRepoLive({ match: repoPages.issues() });
100104 const repo = `/${params.owner}/${params.repo}`;
101105 const base = `${repo}/issues`;
102106 const assignable = agentsEnabled && state === "open" && issues.length > 0;
+4−0
110110 import { showCommits } from "../../lib/commit-people.server";
111111 import { CommitAvatars, CommitNames } from "../../components/commit-person";
112112 import { CommitChecksBadge } from "../../components/commit-checks";
113+import { repoPages } from "../../lib/repo-live";
114+import { useRepoLive } from "../../lib/use-repo-live";
113115
114116 const MAX_LANDED = 6;
115117 /** Branches read for the Active branches list, and shown. */
827829 const source = project?.source.kind === "hosted" ? project.source : null;
828830 const moving = agentsLive.length > 0 || columns.working.length + columns.checking.length > 0;
829831 useLiveRefresh(moving);
832+ // Pushes, checks, releases and deployments show as they happen.
833+ useRepoLive({ match: repoPages.files() });
830834 const ages = health.ages;
831835 const oldest = ages ? Math.max(1, ...ages.map((a) => a.count)) : 1;
832836
+6−0
101101 import { SubscriptionBox } from "../../components/notifications";
102102 import { useMirrorReason } from "../../components/mirror";
103103 import { REFRESH_MS, useRefreshWhile } from "../../lib/refresh";
104+import { repoPages } from "../../lib/repo-live";
105+import { useRepoLive } from "../../lib/use-repo-live";
104106
105107 const EMPTY_COMPARISON: Comparison = { base: null, head: "", files: [], truncated: false };
106108 const TABS = ["conversation", "session", "changes"] as const;
755757 const probing = active && mergeable === "checking";
756758 const conflicting = active && mergeable === "conflicting";
757759 useRefreshWhile(working || checking || reviewPending || catchingUp || settling || moving || landing || probing);
760+ // New commits, checks, reviews and comments, and its merge or close, as
761+ // they happen: the timeline grows where the reader is, and one reading
762+ // at its end follows it.
763+ useRepoLive({ match: repoPages.pull(pull.number, { branch: pull.branch, sha: pull.headCommit }), followBottom: true });
758764 // The rules of its base it does not meet: checks show in their own
759765 // section, so the rest stop the button here. Someone a ruleset lets
760766 // bypass it may tick a box and merge past them.
+4−0
1616 import { accessTo, repoFor } from "../../lib/access.server";
1717 import { agents, work } from "../../lib/services.server";
1818 import { getViewer, unwrap } from "../../lib/session.server";
19+import { repoPages } from "../../lib/repo-live";
20+import { useRepoLive } from "../../lib/use-repo-live";
1921
2022 export function meta({ params, ...args }: Route.MetaArgs) {
2123 return page(args, { title: `Pull requests · ${params.owner}/${params.repo} · g1t` });
5658
5759 export default function Pulls({ loaderData, params }: Route.ComponentProps) {
5860 const { pulls, state, active, labels, milestones, label, milestone, defaultBranch } = loaderData;
61+ // Pull requests opened, updated, reviewed, checked, merged and closed show as it happens.
62+ useRepoLive({ match: repoPages.pulls() });
5963 const base = `/${params.owner}/${params.repo}`;
6064 const list = `${base}/pulls`;
6165 // Which pull requests an agent is working on this minute, and at what.
+4−0
1616 import { requireInsider } from "../../lib/access.server";
1717 import { planPrice } from "../../lib/security-suite.server";
1818 import { codeFilters, countStates, keepCode } from "../../lib/security-suite";
19+import { repoPages } from "../../lib/repo-live";
20+import { useRepoLive } from "../../lib/use-repo-live";
1921
2022 export function meta({ params, ...args }: Route.MetaArgs) {
2123 return page(args, { title: `Code scanning · ${params.owner}/${params.repo} · g1t` });
3941
4042 export default function CodeScanning({ loaderData, params }: Route.ComponentProps) {
4143 const { scanning, price, can, owner } = loaderData;
44+ // Alerts opening, fixed and dismissed show as it happens.
45+ useRepoLive({ match: repoPages.security() });
4246 const base = `/${params.owner}/${params.repo}`;
4347 const [search, setSearch] = useSearchParams();
4448 const navigation = useNavigation();
+4−0
2929 import { legacySecurityTarget, severityCounts } from "../../lib/security-suite";
3030 import { type OverviewRow, POLICY_PATHS, STATUS_LABEL, STATUS_TONE, overviewRows, rowsIn } from "../../lib/security-overview";
3131 import { SEVERITY_LABEL } from "../../lib/advisories";
32+import { repoPages } from "../../lib/repo-live";
33+import { useRepoLive } from "../../lib/use-repo-live";
3234
3335 export function meta({ params, ...args }: Route.MetaArgs) {
3436 return page(args, { title: `Security · ${params.owner}/${params.repo} · g1t` });
204206
205207 export default function SecurityOverview({ loaderData, params }: Route.ComponentProps) {
206208 const base = `/${params.owner}/${params.repo}`;
209+ // Alerts opening, fixed and dismissed show as it happens.
210+ useRepoLive({ match: repoPages.security() });
207211 if (loaderData.kind === "public") {
208212 const { policy, advisories: published, reporting, signedIn } = loaderData;
209213 return (
+4−0
1414 import { refusal, requireInsider } from "../../lib/access.server";
1515 import { planPrice } from "../../lib/security-suite.server";
1616 import { countStates, keepSecret, secretFilters, secretTypes } from "../../lib/security-suite";
17+import { repoPages } from "../../lib/repo-live";
18+import { useRepoLive } from "../../lib/use-repo-live";
1719
1820 export function meta({ params, ...args }: Route.MetaArgs) {
1921 return page(args, { title: `Secret scanning · ${params.owner}/${params.repo} · g1t` });
6466
6567 export default function SecretScanning({ loaderData, params }: Route.ComponentProps) {
6668 const { overview, patterns, price, can, owner } = loaderData;
69+ // Alerts opening, fixed and dismissed show as it happens.
70+ useRepoLive({ match: repoPages.security() });
6771 const base = `/${params.owner}/${params.repo}`;
6872 const [search, setSearch] = useSearchParams();
6973 const filters = secretFilters(search);
+4−0
2525 import { updatesWaitReason } from "../../lib/mirror";
2626 import { alertCapability, countByState, parseAlertState } from "../../lib/security-alerts";
2727 import { severityCounts } from "../../lib/security-suite";
28+import { repoPages } from "../../lib/repo-live";
29+import { useRepoLive } from "../../lib/use-repo-live";
2830
2931 export function meta({ params, ...args }: Route.MetaArgs) {
3032 return page(args, { title: `Vulnerabilities · ${params.owner}/${params.repo} · g1t` });
119121
120122 export default function ProjectSecurity({ loaderData, params }: Route.ComponentProps) {
121123 const { overview, fixes, pulls, can } = loaderData;
124+ // Alerts opening, fixed and dismissed show as it happens.
125+ useRepoLive({ match: repoPages.security() });
122126 const base = `/${params.owner}/${params.repo}`;
123127 const action = `${base}/security/vulnerabilities`;
124128 const [search, setSearch] = useSearchParams();
+4−0
1212 import { showCommits } from "../../lib/commit-people.server";
1313 import { repos } from "../../lib/services.server";
1414 import { getViewer, unwrap } from "../../lib/session.server";
15+import { repoPages } from "../../lib/repo-live";
16+import { useRepoLive } from "../../lib/use-repo-live";
1517
1618 export function meta({ params, ...args }: Route.MetaArgs) {
1719 return page(args, { title: `Tags · ${params.owner}/${params.repo} · g1t` });
3234
3335 export default function Tags({ loaderData, params }: Route.ComponentProps) {
3436 const { tags, checks } = loaderData;
37+ // New tags and releases show as they are pushed.
38+ useRepoLive({ match: repoPages.tags() });
3539 const base = `/${params.owner}/${params.repo}`;
3640 const path = (name: string) => name.split("/").map(encodeURIComponent).join("/");
3741 return (
+4−0
1111 import { getViewer, unwrap } from "../../lib/session.server";
1212 import { projectHomepage } from "../../lib/about";
1313 import { useProject } from "./layout";
14+import { repoPages } from "../../lib/repo-live";
15+import { useRepoLive } from "../../lib/use-repo-live";
1416
1517 export function meta({ params, ...args }: Route.MetaArgs) {
1618 const path = params["*"] ? `${params["*"]} · ` : "";
4345
4446 export default function Tree({ loaderData }: Route.ComponentProps) {
4547 const project = useProject();
48+ // The files and latest commit follow pushes; checks update as they report.
49+ useRepoLive({ match: repoPages.files() });
4650 return (
4751 <TreeView
4852 tree={loaderData.tree}
+1−0
2727 pub mod guardrails;
2828 pub mod identity;
2929 pub mod inbox;
30+pub mod live;
3031 pub mod integrations;
3132 pub mod members;
3233 pub mod mirrors;
+239−0
1+//! Live repository pages: what the notify service is told when something
2+//! happens in a repository, so the pages open on it refresh themselves.
3+//!
4+//! The events service tells it of every event a repository page shows
5+//! (`repo_changed`, with [`change_of`] for each), and the actions service
6+//! of a run's jobs starting and finishing, which are not events. Notify
7+//! passes each change to the people watching that repository's pages: a
8+//! tab on one asks to (`watch_repo`, through the site, which checks they
9+//! may read it first). See docs.g1t.sh/guides/notifications/, "Pages that
10+//! update live".
11+//!
12+//! A change says only what kind of thing moved and where: never a title, a
13+//! body, a name or anything else written in the repository.
14+
15+use serde::{Deserialize, Serialize};
16+
17+use crate::events::Event;
18+
19+/// The notify service's method that takes [`RepoChangedArgs`].
20+pub const REPO_CHANGED: &str = "repo_changed";
21+
22+/// What a run's job starting or finishing is told as: not an event, as
23+/// nothing else hears of it.
24+pub const RUN_UPDATED: &str = "run.updated";
25+
26+/// The most changes one call carries; a busier batch sends the rest in more.
27+pub const MAX_CHANGES: usize = 200;
28+
29+/// Something that happened in a repository, as live pages hear of it.
30+#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
31+pub struct RepoChange {
32+ pub repo_id: String,
33+ /// The event's type (`git.push`, `pull.updated`, `checks.completed`, …),
34+ /// or [`RUN_UPDATED`].
35+ pub kind: String,
36+ /// The full ref a push or deletion moved (`refs/heads/main`), or a run's.
37+ #[serde(rename = "ref", default, skip_serializing_if = "Option::is_none")]
38+ pub git_ref: Option<String>,
39+ /// The issue or pull request it is on, or the pull request a run ran for.
40+ #[serde(default, skip_serializing_if = "Option::is_none")]
41+ pub number: Option<u32>,
42+ /// The commit: where a push left its ref, the one checked or reported on.
43+ #[serde(default, skip_serializing_if = "Option::is_none")]
44+ pub sha: Option<String>,
45+ /// A workflow run's id.
46+ #[serde(default, skip_serializing_if = "Option::is_none")]
47+ pub run: Option<String>,
48+ /// How many commits a push brought to its branch, when it was counted.
49+ #[serde(default, skip_serializing_if = "Option::is_none")]
50+ pub commits: Option<u32>,
51+ /// RFC 3339.
52+ pub at: String,
53+}
54+
55+/// `repo_changed`.
56+#[derive(Clone, Debug, Default, Serialize, Deserialize)]
57+pub struct RepoChangedArgs {
58+ pub changes: Vec<RepoChange>,
59+}
60+
61+/// The events a repository page shows: exact types, and families ending
62+/// in a dot. Anything else (reads, fetches, settings, audit) changes no
63+/// page anyone is looking at.
64+const LIVE: [&str; 20] = [
65+ "git.push",
66+ "git.deleted",
67+ "branch.renamed",
68+ "repo.default_branch_changed",
69+ "pull.",
70+ "issue.",
71+ "comment.",
72+ "review.",
73+ "checks.",
74+ "check_run.",
75+ "check_suite.",
76+ "status.created",
77+ "workflow.",
78+ "release.",
79+ "queue.changed",
80+ "deployment.",
81+ "deployment_status.",
82+ "secret_scanning_alert.",
83+ "code_scanning_alert.",
84+ "vulnerability_alert.",
85+];
86+
87+/// Whether an event of type `kind` changes a repository page.
88+pub fn is_live(kind: &str) -> bool {
89+ LIVE.iter().any(|live| if live.ends_with('.') { kind.starts_with(live) } else { kind == *live })
90+}
91+
92+fn text(value: &serde_json::Value) -> Option<String> {
93+ value.as_str().filter(|text| !text.is_empty()).map(|text| text.chars().take(200).collect())
94+}
95+
96+fn number(value: &serde_json::Value) -> Option<u32> {
97+ value.as_u64().and_then(|n| u32::try_from(n).ok())
98+}
99+
100+/// What live pages hear of an event, or None when it is not about a
101+/// repository or changes no page.
102+pub fn change_of(event: &Event) -> Option<RepoChange> {
103+ if !is_live(&event.kind) {
104+ return None;
105+ }
106+ let data = &event.data;
107+ let repo_id = event.repo_id.clone().filter(|id| !id.is_empty()).or_else(|| text(&data["repoId"]))?;
108+ let family = event.kind.split('.').next().unwrap_or_default();
109+ let mut change = RepoChange { repo_id, kind: event.kind.clone(), at: event.time.clone(), ..RepoChange::default() };
110+ match family {
111+ "git" => {
112+ change.git_ref = text(&data["ref"]);
113+ change.sha = text(&data["after"]);
114+ change.commits = number(&data["commits"]);
115+ }
116+ "workflow" => {
117+ change.git_ref = text(&data["ref"]);
118+ change.sha = text(&data["sha"]);
119+ change.run = text(&data["runId"]);
120+ change.number = number(&data["pull"]);
121+ }
122+ "checks" => {
123+ change.number = number(&data["number"]);
124+ change.sha = text(&data["commit"]);
125+ }
126+ "status" => change.sha = text(&data["sha"]),
127+ "check_run" => change.sha = text(&data["checkRun"]["headSha"]),
128+ "check_suite" => change.sha = text(&data["checkSuite"]["headSha"]),
129+ "code_scanning_alert" => change.number = number(&data["alertNumber"]),
130+ "secret_scanning_alert" | "vulnerability_alert" | "release" | "queue" | "deployment" | "deployment_status" | "repo" | "branch" => {}
131+ // Issues, pull requests, comments and reviews name their number.
132+ _ => change.number = number(&data["number"]),
133+ }
134+ Some(change)
135+}
136+
137+/// The changes a batch of events makes, in order, in calls of at most
138+/// [`MAX_CHANGES`].
139+pub fn changes_of(events: &[Event]) -> Vec<Vec<RepoChange>> {
140+ let changes: Vec<RepoChange> = events.iter().filter_map(change_of).collect();
141+ changes.chunks(MAX_CHANGES).map(<[RepoChange]>::to_vec).collect()
142+}
143+
144+#[cfg(test)]
145+mod tests {
146+ use super::*;
147+ use serde_json::json;
148+
149+ fn event(kind: &str, repo_id: Option<&str>, data: serde_json::Value) -> Event {
150+ Event {
151+ id: "evt_1".into(),
152+ kind: kind.into(),
153+ source: "test".into(),
154+ time: "2026-10-11T10:00:00Z".into(),
155+ repo_id: repo_id.map(Into::into),
156+ actor: Some("usr_1".into()),
157+ data,
158+ }
159+ }
160+
161+ #[test]
162+ fn a_push_says_its_ref_where_it_went_and_how_many_commits() {
163+ let push = event(
164+ "git.push",
165+ Some("rep_1"),
166+ json!({ "repoId": "rep_1", "ref": "refs/heads/main", "before": "a", "after": "b", "defaultBranch": true, "commits": 3 }),
167+ );
168+ assert_eq!(
169+ change_of(&push),
170+ Some(RepoChange {
171+ repo_id: "rep_1".into(),
172+ kind: "git.push".into(),
173+ git_ref: Some("refs/heads/main".into()),
174+ sha: Some("b".into()),
175+ commits: Some(3),
176+ at: "2026-10-11T10:00:00Z".into(),
177+ ..RepoChange::default()
178+ })
179+ );
180+ }
181+
182+ #[test]
183+ fn pull_requests_issues_comments_and_checks_name_their_number() {
184+ for kind in ["pull.updated", "pull.merged", "issue.closed", "comment.created", "review.completed"] {
185+ let change = change_of(&event(kind, Some("rep_1"), json!({ "number": 7, "title": "secret plans" }))).unwrap();
186+ assert_eq!(change.number, Some(7), "{kind}");
187+ }
188+ let checks = change_of(&event("checks.completed", Some("rep_1"), json!({ "number": 4, "commit": "abc", "status": "passed" }))).unwrap();
189+ assert_eq!((checks.number, checks.sha.as_deref()), (Some(4), Some("abc")));
190+ }
191+
192+ #[test]
193+ fn runs_name_their_id_commit_and_pull_request() {
194+ let run = change_of(&event(
195+ "workflow.completed",
196+ Some("rep_1"),
197+ json!({ "runId": "run_9", "number": 12, "ref": "refs/heads/x", "sha": "def", "pull": 5, "conclusion": "success" }),
198+ ))
199+ .unwrap();
200+ assert_eq!(run.run.as_deref(), Some("run_9"));
201+ assert_eq!(run.number, Some(5), "the pull request, not the run's own number");
202+ assert_eq!(run.sha.as_deref(), Some("def"));
203+ let status = change_of(&event("status.created", Some("rep_1"), json!({ "sha": "def", "context": "ci", "state": "pending" }))).unwrap();
204+ assert_eq!(status.sha.as_deref(), Some("def"));
205+ let check = change_of(&event("check_run.completed", Some("rep_1"), json!({ "checkRun": { "headSha": "fff" } }))).unwrap();
206+ assert_eq!(check.sha.as_deref(), Some("fff"));
207+ }
208+
209+ #[test]
210+ fn nothing_written_in_the_repository_goes_along() {
211+ let change = change_of(&event("issue.opened", Some("rep_1"), json!({ "number": 1, "title": "Leaked title", "body": "Leaked body" }))).unwrap();
212+ let sent = serde_json::to_string(&change).unwrap();
213+ assert!(!sent.contains("Leaked"));
214+ assert_eq!(sent, r#"{"repo_id":"rep_1","kind":"issue.opened","number":1,"at":"2026-10-11T10:00:00Z"}"#);
215+ }
216+
217+ #[test]
218+ fn only_what_a_page_shows_and_only_with_a_repository() {
219+ assert!(change_of(&event("git.fetch", Some("rep_1"), json!({}))).is_none());
220+ assert!(change_of(&event("repo.collaborator_added", Some("rep_1"), json!({}))).is_none());
221+ assert!(change_of(&event("pull.opened", None, json!({ "number": 1 }))).is_none());
222+ // The repository from the data when the event has none.
223+ assert_eq!(change_of(&event("pull.opened", None, json!({ "repoId": "rep_2", "number": 1 }))).unwrap().repo_id, "rep_2");
224+ for kind in ["git.push", "git.deleted", "release.published", "secret_scanning_alert.created", "queue.changed", "deployment_status.created"] {
225+ assert!(is_live(kind), "{kind}");
226+ }
227+ for kind in ["git.pushed", "repo.created", "user.deleted", "workspace.renamed"] {
228+ assert!(!is_live(kind), "{kind}");
229+ }
230+ }
231+
232+ #[test]
233+ fn a_busy_batch_goes_in_several_calls() {
234+ let events: Vec<Event> = (0..450).map(|n| event("pull.updated", Some("rep_1"), json!({ "number": n }))).collect();
235+ let calls = changes_of(&events);
236+ assert_eq!(calls.iter().map(Vec::len).collect::<Vec<_>>(), vec![200, 200, 50]);
237+ assert!(changes_of(&[event("git.fetch", Some("rep_1"), json!({}))]).is_empty());
238+ }
239+}
+50−1
184184 dnd_until?: string | null;
185185 };
186186
187+// ── Live repository pages ────────────────────────────────────────────────
188+//
189+// A tab on a repository's page asks to watch it (`watchRepo`, through the
190+// site, which first checks the person may read it). The events service
191+// then tells notify of everything that happens there that a page shows
192+// (`repoChanged`, from crates/contracts/src/live.rs), and the actions
193+// service of runs' jobs starting and finishing; notify passes each change
194+// to the feeds watching that repository (one topic per repository), whose
195+// tabs refresh the page (apps/web/app/lib/repo-live.ts).
196+
197+/**
198+ * Something that happened in a repository, as live pages hear of it: what
199+ * kind of thing moved and where, never anything written in it.
200+ */
201+export type RepoChange = {
202+ repo_id: string;
203+ /** `owner/name`, as the person watching it asked for it; filled in by notify. */
204+ repo?: string | null;
205+ /** The event's type (`git.push`, `pull.updated`, `checks.completed`, …), or `run.updated` for a run's job starting or finishing. */
206+ kind: string;
207+ /** The full ref a push or deletion moved (`refs/heads/main`), or a run's. */
208+ ref?: string | null;
209+ /** The issue or pull request it is on, or the pull request a run ran for. */
210+ number?: number | null;
211+ /** The commit: where a push left its ref, the one checked or reported on. */
212+ sha?: string | null;
213+ /** A workflow run's id. */
214+ run?: string | null;
215+ /** How many commits a push brought to its branch, when it was counted. */
216+ commits?: number | null;
217+ /** RFC 3339. */
218+ at: string;
219+};
220+
221+/** How long one `watchRepo` lasts: a page open longer asks again before it runs out. */
222+export const REPO_WATCH_MS = 15 * 60_000;
223+
187224 /** What the feed socket sends a tab. */
188225 export type FeedEvent =
189226 | { type: "hello"; notifications: FeedNotification[]; vapid_public_key: string | null; preferences: NotifyPreferences }
198235 */
199236 | { type: "presence"; workspace: string; people: PresenceEntry[]; full: boolean }
200237 /** Your own presence, status and Do Not Disturb, on connect and after every change. */
201− | { type: "me"; me: OwnPresence };
238+ | { type: "me"; me: OwnPresence }
239+ /** What changed in repositories you are watching the pages of (`watchRepo`). */
240+ | { type: "repo"; changes: RepoChange[] };
202241
203242 /**
204243 * What a tab sends over the feed socket: plain `ping` every 25 s (answered
294333 * past now nor more than a day behind it.
295334 */
296335 markVisit(user: Pick<User, "id">, workspace: string, at: string): Promise<{ seen_at: string | null }>;
336+ /**
337+ * The person's tabs hear what changes in the repository, for
338+ * `REPO_WATCH_MS`. The caller must have checked they may read it: notify
339+ * checks nothing. `repo` is its `owner/name`, passed on with each change.
340+ */
341+ watchRepo(user: Pick<User, "id">, repoId: string, repo: string): Promise<{ ok: boolean; until: string | null }>;
342+ /** For services: what changed in repositories (`crates/contracts/src/live.rs`), told to whoever watches each. */
343+ repoChanged(changes: RepoChange[]): Promise<{ ok: boolean }>;
297344 };
298345
299346 async function rpc<T>(service: ServiceBinding, method: string, args: object): Promise<T> {
323370 workspacePresence: (workspace, userIds) => call("workspace_presence", { workspace, user_ids: userIds ?? null }),
324371 lastVisit: (user, workspace) => call("last_visit", { user_id: user.id, workspace }),
325372 markVisit: (user, workspace, at) => call("mark_visit", { user_id: user.id, workspace, at }),
373+ watchRepo: (user, repoId, repo) => call("watch_repo", { user_id: user.id, repo_id: repoId, repo }),
374+ repoChanged: (changes) => call("repo_changed", { changes }),
326375 };
327376 }
+5−0
112112 /// Deployments: a job with an `environment:` deploys to it, and its
113113 /// run's deployment is recorded there (plan.rs `report_deployment`).
114114 deployments: Fetcher,
115+ /// Notify, optional: pages open on a repository hear its runs' jobs
116+ /// start and finish (plan.rs `tell_live`). Without it they catch up
117+ /// on their own.
118+ notify: Option<Fetcher>,
115119 /// Where the API keeps cache entries, for deleting evicted ones.
116120 cache: Option<worker::Bucket>,
117121 /// Seals secrets; absent until `ACTIONS_KEY` is set, when secrets
133137 projects: env.service("PROJECTS")?,
134138 billing: env.service("BILLING")?,
135139 deployments: env.service("DEPLOYMENTS")?,
140+ notify: env.service("NOTIFY").ok(),
136141 cache: env.bucket("ACTIONS_CACHE").ok(),
137142 sealer: env.secret("ACTIONS_KEY").ok().and_then(|key| Sealer::new(&key.to_string())),
138143 blob_key: env.secret("ACTIONS_KEY").ok().map(|key| runtime::blob_key(&key.to_string())),
+23−0
16781678 .first::<JobRow>(None)
16791679 .await?;
16801680 let Some(job) = finished else { return Ok(()) };
1681+ self.tell_live(&job.repo_id, &job.run_id, None, None).await;
16811682 // Its G1T_TOKEN stops working with it.
16821683 self.revoke_job_tokens(&job.id).await;
16831684 // A job that deploys failed: so did its run's deployment, now.
19091910 return Ok(());
19101911 };
19111912 let Some(run) = self.run_row(&job.run_id).await? else { return Ok(()) };
1913+ self.tell_live(&run.repo_id, &run.id, Some(&run.sha), run.pull).await;
19121914 if let Some(env) = deploys_to(&run, &job) {
19131915 self.report_deployment(&run, &env, "in_progress", false).await;
19141916 }
19151917 Ok(())
19161918 }
19171919
1920+ /// Pages open on the repository hear that a run's job started or
1921+ /// finished, which is no event (`g1t_contracts::live`). Logged and left
1922+ /// when notify cannot be reached: the pages catch up by themselves.
1923+ pub(crate) async fn tell_live(&self, repo_id: &str, run_id: &str, sha: Option<&str>, pull: Option<u32>) {
1924+ let Some(notify) = &self.notify else { return };
1925+ let change = g1t_contracts::live::RepoChange {
1926+ repo_id: repo_id.to_owned(),
1927+ kind: g1t_contracts::live::RUN_UPDATED.to_owned(),
1928+ run: Some(run_id.to_owned()),
1929+ sha: sha.map(str::to_owned),
1930+ number: pull,
1931+ at: now(),
1932+ ..Default::default()
1933+ };
1934+ let args = g1t_contracts::live::RepoChangedArgs { changes: vec![change] };
1935+ let told: Result<Value> = g1t_kit::call(notify, g1t_contracts::live::REPO_CHANGED, &args).await;
1936+ if let Err(error) = told {
1937+ worker::console_error!("actions: live pages not told of run {run_id}: {error}");
1938+ }
1939+ }
1940+
19181941 /// The run is over: each environment its jobs deployed to takes the
19191942 /// outcome of those jobs (`deployment_outcome`).
19201943 async fn settle_deployments(&self, run: &RunRow, jobs: &[JobRow], cancelled: bool) {
+3−1
3030 // Self-hosted runners' time, recorded on usage at $0.
3131 { "binding": "BILLING", "service": "g1t-billing" },
3232 // A job with an `environment:` makes its run's deployment there.
33− { "binding": "DEPLOYMENTS", "service": "g1t-deployments" }
33+ { "binding": "DEPLOYMENTS", "service": "g1t-deployments" },
34+ // Pages open on a repository hear its runs' jobs start and finish.
35+ { "binding": "NOTIFY", "service": "g1t-notify" }
3436 ],
3537 // actions/cache entries and artifacts, which the API writes: deleted here when they
3638 // are evicted or expire.
+9−0
1616 //! arguments. `activity_digest` (digest.rs) counts a span of the log for
1717 //! Home. Security alerts that are not urgent wait for each person's
1818 //! security digest (security_digest.rs), sent by a cron of its own.
19+//!
20+//! Repository pages open in someone's browser refresh themselves: each
21+//! batch's events that such a page shows are passed to the notify service
22+//! (live.rs), which tells those watching the repository.
1923
2024 mod audit;
2125 mod digest;
2226 mod fanout;
2327 mod inbox;
28+mod live;
2429 mod security_digest;
2530 mod subscriptions;
2631
251256 );
252257 // Optional: without it the inbox still fills, nobody is told live.
253258 let notify = self.env.service("NOTIFY").ok();
259+ // Pages open on the repositories: told first, as nobody waits longer.
260+ if let Some(notify) = notify.as_ref() {
261+ live::tell(notify, events).await;
262+ }
254263 let sources = inbox::Sources {
255264 work: &work,
256265 repos: &repos,
+21−0
1+//! Live repository pages: every event a repository page shows is passed to
2+//! the notify service as a small change (`g1t_contracts::live`), which
3+//! tells the people watching that repository's pages. One call per batch
4+//! (more for a very busy one); nothing written in the repository goes
5+//! along, and who may see it is notify's to keep: a tab watches a
6+//! repository only after the site checked its person may read it.
7+
8+use g1t_contracts::events::Event;
9+use g1t_contracts::live::{REPO_CHANGED, RepoChangedArgs, changes_of};
10+use worker::{Fetcher, Result};
11+
12+/// Tells notify of the batch's changes. Logged and left when it cannot be
13+/// reached: pages then catch up by themselves, and nothing else waits on it.
14+pub(crate) async fn tell(notify: &Fetcher, events: &[Event]) {
15+ for changes in changes_of(events) {
16+ let told: Result<serde_json::Value> = g1t_kit::call(notify, REPO_CHANGED, &RepoChangedArgs { changes }).await;
17+ if let Err(error) = told {
18+ worker::console_error!("events: live pages not told: {error}");
19+ }
20+ }
21+}
+2−1
5555 { "binding": "WORK", "service": "g1t-work" },
5656 { "binding": "REPOS", "service": "g1t-repos" },
5757 { "binding": "IDENTITY", "service": "g1t-identity" },
58− // Each new inbox item, told live in the person's tabs and pushed (services/notify).
58+ // Each new inbox item, told live in the person's tabs and pushed, and
59+ // what changed in each repository, for the pages open on it (services/notify).
5960 { "binding": "NOTIFY", "service": "g1t-notify" }
6061 ],
6162 // Once a day, audit entries older than AUDIT_MAX_DAYS are removed for
+57−1
1313 * devices (the phone app, pushed through Expo: src/expopush.ts) and
1414 * preferences, and
1515 * the person's status, Do Not Disturb and whether they set themselves away,
16− * and when they were last on each workspace's Home page (src/visits.ts).
16+ * and when they were last on each workspace's Home page (src/visits.ts),
17+ * and which repositories' pages their tabs are watching (src/watch.ts).
1718 *
1819 * Presence is worked out here from the tabs (src/presence.ts) and told to
1920 * the room of every workspace the person belongs to (src/room.ts), which
2021 * passes it to everyone there who is online. A status or Do Not Disturb
2122 * that runs out is cleared by an alarm and told the same way.
2223 *
24+ * Live repository pages: a tab on a repository's page has the site ask
25+ * the feed to watch it (`watchRepo`), which tells the repository's topic
26+ * (src/topic.ts); the topic hands back what changes there (`repoChanged`),
27+ * and the feed sends it to the tabs as a `repo` event.
28+ *
2329 * The feed authorizes nothing: the Worker reaches it only for the person
2430 * the site checked (src/index.ts).
2531 */
3541 type FeedEvent,
3642 type FeedNotification,
3743 type FeedSeed,
44+ type RepoChange,
3845 type NotifyPreferences,
3946 type NotifyStatus,
4047 type PushDevice,
5966 type Kept,
6067 } from "./presence.ts";
6168 import type { Room } from "./room.ts";
69+import type { Topic } from "./topic.ts";
6270 import { type Visit, lastVisit, markVisit } from "./visits.ts";
71+import { MAX_WATCHING, stillWatching, watchUntil } from "./watch.ts";
6372 import { sendPush, type Vapid } from "./webpush.ts";
6473
6574 export type FeedEnv = {
8190 PHONE_PUSH?: string;
8291 /** One presence room per workspace (src/room.ts); without it, presence stays in the person's own tabs. */
8392 ROOMS?: DurableObjectNamespace<Room>;
93+ /** One live topic per repository (src/topic.ts); without it, repository pages refresh on their own. */
94+ TOPICS?: DurableObjectNamespace<Topic>;
8495 };
8596
8697 /** The headers the Worker passes the person's id and username in, with a socket (src/index.ts). */
122133 this.sql.exec(`CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)`);
123134 // When the person was last on each workspace's Home page (src/visits.ts).
124135 this.sql.exec(`CREATE TABLE IF NOT EXISTS visits (workspace TEXT PRIMARY KEY, seen_at INTEGER NOT NULL, previous_at INTEGER)`);
136+ // The repositories whose pages the person's tabs are watching, until when (src/watch.ts).
137+ this.sql.exec(`CREATE TABLE IF NOT EXISTS watching (repo_id TEXT PRIMARY KEY, until INTEGER NOT NULL)`);
125138 }
126139
127140 // ── Kept state ──────────────────────────────────────────────────────────
638651 if (person.username) this.setMeta("username", person.username.slice(0, 100));
639652 }
640653
654+ // ── Live repository pages ───────────────────────────────────────────────
655+
656+ private openSockets(): WebSocket[] {
657+ return this.ctx.getWebSockets().filter((socket) => socket.readyState === WebSocket.OPEN);
658+ }
659+
660+ /**
661+ * The person's tabs hear what changes in repository `repoId` (`repo` is
662+ * its `owner/name`) for REPO_WATCH_MS. The site checked they may read it.
663+ */
664+ async watchRepo(userId: string, repoId: string, repo: string): Promise<{ ok: boolean; until: string | null }> {
665+ const topics = this.env.TOPICS;
666+ if (!topics) return { ok: false, until: null };
667+ const now = Date.now();
668+ const until = watchUntil(now);
669+ this.sql.exec("DELETE FROM watching WHERE until <= ?", now);
670+ this.sql.exec("INSERT OR REPLACE INTO watching (repo_id, until) VALUES (?, ?)", repoId, until);
671+ this.sql.exec("DELETE FROM watching WHERE repo_id NOT IN (SELECT repo_id FROM watching ORDER BY until DESC LIMIT ?)", MAX_WATCHING);
672+ await topics.get(topics.idFromName(repoId)).watch(userId, repo, until);
673+ return { ok: true, until: new Date(until).toISOString() };
674+ }
675+
676+ /**
677+ * What changed in a repository, from its topic: sent to the tabs, which
678+ * each keep what is about the page they show. False when nobody here is
679+ * watching it any more, so the topic drops this feed.
680+ */
681+ async repoChanged(repoId: string, changes: RepoChange[]): Promise<boolean> {
682+ const row = this.sql.exec<{ until: number }>("SELECT until FROM watching WHERE repo_id = ?", repoId).toArray()[0];
683+ if (!stillWatching(row?.until, Date.now())) return false;
684+ const sockets = this.openSockets();
685+ if (!sockets.length) return false;
686+ const text = JSON.stringify({ type: "repo", changes } satisfies FeedEvent);
687+ for (const socket of sockets) {
688+ try {
689+ socket.send(text);
690+ } catch {
691+ // Closing already.
692+ }
693+ }
694+ return true;
695+ }
696+
641697 private visit(workspace: string): Visit | null {
642698 return this.sql.exec<Visit>("SELECT seen_at, previous_at FROM visits WHERE workspace = ?", workspace).toArray()[0] ?? null;
643699 }
+25−1
2222 * - agents and the site: `workspace_presence`, how a workspace's people
2323 * show now (away, in Do Not Disturb, offline, and their statuses);
2424 * - integrations, later: `set_presence` with a status of `source`
25− * `calendar` or `integration`, for the person they act for.
25+ * `calendar` or `integration`, for the person they act for;
26+ * - events and actions: `repo_changed`, what changed in repositories,
27+ * told to whoever watches their pages; and the site `watch_repo`, for a
28+ * tab on a repository page whose person it checked may read it.
2629 *
2730 * Presence: each feed works out its person's from their tabs and tells
2831 * one room per workspace (src/room.ts), which tells everyone there who is
2932 * online. See docs.g1t.sh/guides/chat/, "Presence and status".
33+ *
34+ * Live repository pages: one topic per repository (src/topic.ts) keeps
35+ * who is watching it, and hands each change to their feeds. See
36+ * docs.g1t.sh/guides/notifications/, "Pages that update live".
3037 */
3138 import {
3239 NOTIFY_VIEWER_HEADER,
3946 import { FEED_USERNAME_HEADER, FEED_USER_ID_HEADER, Feed, type FeedEnv } from "./feed.ts";
4047 import { onlyPeople } from "./presence.ts";
4148 import { visitWorkspace } from "./visits.ts";
49+import { byRepo, cleanRepoId, cleanRepoPath } from "./watch.ts";
4250
4351 export { Feed } from "./feed.ts";
4452 export { Room } from "./room.ts";
53+export { Topic } from "./topic.ts";
4554
4655 type Env = FeedEnv & {
4756 IDENTITY: ServiceBinding;
7685 const everyone = await env.ROOMS.get(env.ROOMS.idFromName(slug)).people();
7786 return Response.json(onlyPeople(everyone, Array.isArray(args.user_ids) ? args.user_ids : null));
7887 }
88+ if (method === "repo_changed") {
89+ // For services: each repository's changes to its topic, which tells its watchers.
90+ const topics = env.TOPICS;
91+ if (!topics) return Response.json({ ok: true });
92+ const grouped = byRepo(args?.changes);
93+ const results = await Promise.allSettled([...grouped].map(([id, changes]) => topics.get(topics.idFromName(id)).changed(id, changes)));
94+ for (const result of results) if (result.status === "rejected") console.error("notify: a repository's change was not told", result.reason);
95+ return Response.json({ ok: true });
96+ }
7997 if (method === "deliver") {
8098 const items = (Array.isArray(args?.items) ? args.items : []).slice(0, MAX_DELIVERIES) as FeedDelivery[];
8199 const byUser = new Map<string, FeedDelivery[]>();
113131 if (!workspace) return Response.json({ seen_at: null });
114132 return Response.json(method === "last_visit" ? await stub.lastVisit(workspace) : await stub.markVisit(workspace, args.at));
115133 }
134+ case "watch_repo": {
135+ const repoId = cleanRepoId(args.repo_id);
136+ const repo = cleanRepoPath(args.repo);
137+ if (!repoId || !repo) return Response.json({ ok: false, until: null });
138+ return Response.json(await stub.watchRepo(userId, repoId, repo));
139+ }
116140 case "test":
117141 return Response.json(await stub.test(typeof args.username === "string" ? args.username : ""));
118142 case "presence":
+68−0
1+/**
2+ * One repository's live topic, as a Durable Object named by its id: who
3+ * is watching its pages now.
4+ *
5+ * A feed (src/feed.ts) adds its person when one of their tabs opens a page
6+ * of the repository, and again before the watch runs out (REPO_WATCH_MS);
7+ * the site checked first that they may read it. When something changes in
8+ * the repository, the topic hands it to each of those feeds, which pass it
9+ * to their tabs. A feed with no tab open any more, or no longer watching,
10+ * is dropped, and so is a watch that ran out: someone who loses access
11+ * stops hearing of the repository at the latest when theirs does.
12+ *
13+ * Like the room, the topic authorizes nothing: only the Worker and feeds
14+ * reach it.
15+ */
16+import { DurableObject } from "cloudflare:workers";
17+
18+import type { RepoChange } from "@g1t/contracts";
19+
20+import type { Feed } from "./feed.ts";
21+import { MAX_WATCHERS, named } from "./watch.ts";
22+
23+export type TopicEnv = { FEEDS?: DurableObjectNamespace<Feed> };
24+
25+export class Topic extends DurableObject<TopicEnv> {
26+ private readonly sql: SqlStorage;
27+
28+ constructor(ctx: DurableObjectState, env: TopicEnv) {
29+ super(ctx, env);
30+ this.sql = ctx.storage.sql;
31+ this.sql.exec(`CREATE TABLE IF NOT EXISTS watchers (user_id TEXT PRIMARY KEY, repo TEXT NOT NULL, until INTEGER NOT NULL)`);
32+ }
33+
34+ /** `userId` watches the repository, as `repo` (`owner/name`), until `until` (ms). */
35+ async watch(userId: string, repo: string, until: number): Promise<void> {
36+ this.sql.exec("INSERT OR REPLACE INTO watchers (user_id, repo, until) VALUES (?, ?, ?)", userId, repo, until);
37+ this.sql.exec("DELETE FROM watchers WHERE user_id NOT IN (SELECT user_id FROM watchers ORDER BY until DESC LIMIT ?)", MAX_WATCHERS);
38+ }
39+
40+ /** `userId` stopped watching it. */
41+ async unwatch(userId: string): Promise<void> {
42+ this.sql.exec("DELETE FROM watchers WHERE user_id = ?", userId);
43+ }
44+
45+ /** Hands `changes` to everyone watching now; returns how many feeds passed them on. */
46+ async changed(repoId: string, changes: RepoChange[]): Promise<number> {
47+ const feeds = this.env.FEEDS;
48+ if (!feeds || !changes.length) return 0;
49+ const now = Date.now();
50+ this.sql.exec("DELETE FROM watchers WHERE until <= ?", now);
51+ const watchers = this.sql.exec<{ user_id: string; repo: string }>("SELECT user_id, repo FROM watchers").toArray();
52+ if (!watchers.length) return 0;
53+ const results = await Promise.allSettled(
54+ watchers.map((watcher) => feeds.get(feeds.idFromName(watcher.user_id)).repoChanged(repoId, named(changes, watcher.repo))),
55+ );
56+ let told = 0;
57+ results.forEach((result, i) => {
58+ if (result.status === "rejected") {
59+ console.error("notify: telling a feed of a repository failed", result.reason);
60+ return;
61+ }
62+ if (result.value) told++;
63+ // No tab open, or not watching any more: it asks again when it is.
64+ else this.sql.exec("DELETE FROM watchers WHERE user_id = ?", watchers[i]!.user_id);
65+ });
66+ return told;
67+ }
68+}
+81−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { MAX_CHANGES, WATCH_MS, byRepo, cleanChange, cleanRepoId, cleanRepoPath, named, stillWatching, watchUntil } from "./watch.ts";
5+
6+const AT = "2026-10-11T10:00:00Z";
7+
8+test("a change keeps only what a change carries", () => {
9+ const change = cleanChange({
10+ repo_id: "rep_1",
11+ kind: "git.push",
12+ ref: "refs/heads/main",
13+ sha: "abc123",
14+ commits: 3,
15+ at: AT,
16+ title: "a secret title",
17+ body: "and its body",
18+ });
19+ assert.deepEqual(change, { repo_id: "rep_1", kind: "git.push", ref: "refs/heads/main", sha: "abc123", commits: 3, at: AT });
20+});
21+
22+test("what is not a change is dropped", () => {
23+ assert.equal(cleanChange(null), null);
24+ assert.equal(cleanChange("git.push"), null);
25+ assert.equal(cleanChange({ kind: "git.push", at: AT }), null);
26+ assert.equal(cleanChange({ repo_id: "rep 1", kind: "git.push", at: AT }), null);
27+ assert.equal(cleanChange({ repo_id: "rep_1", kind: "<script>", at: AT }), null);
28+ // A field of the wrong shape goes; the change stays.
29+ assert.deepEqual(cleanChange({ repo_id: "rep_1", kind: "pull.updated", number: -1, sha: "not a sha", run: "run 9", at: AT }), {
30+ repo_id: "rep_1",
31+ kind: "pull.updated",
32+ at: AT,
33+ });
34+ assert.deepEqual(cleanChange({ repo_id: "rep_1", kind: "run.updated", run: "run_9", number: 4, at: AT }), {
35+ repo_id: "rep_1",
36+ kind: "run.updated",
37+ run: "run_9",
38+ number: 4,
39+ at: AT,
40+ });
41+});
42+
43+test("changes go to their repositories in the order they came", () => {
44+ const grouped = byRepo([
45+ { repo_id: "rep_1", kind: "pull.opened", number: 1, at: AT },
46+ { repo_id: "rep_2", kind: "issue.opened", number: 2, at: AT },
47+ { repo_id: "rep_1", kind: "pull.updated", number: 1, at: AT },
48+ { nonsense: true },
49+ ]);
50+ assert.deepEqual([...grouped.keys()], ["rep_1", "rep_2"]);
51+ assert.deepEqual(
52+ grouped.get("rep_1")!.map((change) => change.kind),
53+ ["pull.opened", "pull.updated"],
54+ );
55+ assert.equal(byRepo("nope").size, 0);
56+ const many = Array.from({ length: MAX_CHANGES + 10 }, (_, n) => ({ repo_id: "rep_1", kind: "pull.updated", number: n, at: AT }));
57+ assert.equal(byRepo(many).get("rep_1")!.length, MAX_CHANGES);
58+});
59+
60+test("each watcher hears the repository by the name it asked for", () => {
61+ const changes = [{ repo_id: "rep_1", kind: "git.push", at: AT }];
62+ assert.deepEqual(named(changes, "acme/rocket"), [{ repo_id: "rep_1", kind: "git.push", at: AT, repo: "acme/rocket" }]);
63+ assert.equal(Object.hasOwn(changes[0]!, "repo"), false, "the topic's own list is left as it was");
64+});
65+
66+test("repository ids and paths are checked", () => {
67+ assert.equal(cleanRepoId("rep_01J9"), "rep_01J9");
68+ assert.equal(cleanRepoId("../x"), null);
69+ assert.equal(cleanRepoPath("acme/rocket.js"), "acme/rocket.js");
70+ assert.equal(cleanRepoPath("acme"), null);
71+ assert.equal(cleanRepoPath("acme/rocket/extra"), null);
72+});
73+
74+test("a watch lasts its time and no longer", () => {
75+ const now = Date.parse(AT);
76+ const until = watchUntil(now);
77+ assert.equal(until, now + WATCH_MS);
78+ assert.equal(stillWatching(until, now + WATCH_MS - 1), true);
79+ assert.equal(stillWatching(until, now + WATCH_MS), false);
80+ assert.equal(stillWatching(null, now), false);
81+});
+88−0
1+/**
2+ * Live repository pages: the changes services tell of (`repo_changed`),
3+ * cleaned, and who hears them. Pure, for the Worker (src/index.ts), the
4+ * topics (src/topic.ts) and the feeds (src/feed.ts).
5+ *
6+ * A change says what kind of thing moved and where, never anything
7+ * written in the repository; anything else sent along is dropped here.
8+ */
9+import type { RepoChange } from "@g1t/contracts";
10+
11+/** How long one watch lasts: REPO_WATCH_MS in @g1t/contracts, which the pages renew before. */
12+export const WATCH_MS = 15 * 60_000;
13+
14+/** The most changes one call is taken with. */
15+export const MAX_CHANGES = 500;
16+/** The most people one repository's topic keeps watching it. */
17+export const MAX_WATCHERS = 5000;
18+/** The most repositories one person watches at once (tabs on many repositories). */
19+export const MAX_WATCHING = 50;
20+
21+const ID = /^[A-Za-z0-9_-]{1,100}$/;
22+const PATH = /^[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}$/;
23+
24+function text(value: unknown, max = 200): string | null {
25+ return typeof value === "string" && value ? value.slice(0, max) : null;
26+}
27+
28+function count(value: unknown): number | null {
29+ return typeof value === "number" && Number.isInteger(value) && value >= 0 && value < 2 ** 32 ? value : null;
30+}
31+
32+/** A repository id as services and the site send it, or null. */
33+export function cleanRepoId(value: unknown): string | null {
34+ return typeof value === "string" && ID.test(value) ? value : null;
35+}
36+
37+/** An `owner/name`, or null. */
38+export function cleanRepoPath(value: unknown): string | null {
39+ return typeof value === "string" && PATH.test(value) ? value : null;
40+}
41+
42+/** A change as a service sent it, with only what a change carries; null when it is not one. */
43+export function cleanChange(value: unknown): RepoChange | null {
44+ if (!value || typeof value !== "object") return null;
45+ const given = value as Record<string, unknown>;
46+ const repo_id = cleanRepoId(given.repo_id);
47+ const kind = typeof given.kind === "string" && /^[a-z_]{1,40}\.[a-z_]{1,40}$/.test(given.kind) ? given.kind : null;
48+ if (!repo_id || !kind) return null;
49+ const change: RepoChange = { repo_id, kind, at: text(given.at, 40) ?? new Date().toISOString() };
50+ const ref = text(given.ref);
51+ const number = count(given.number);
52+ const sha = typeof given.sha === "string" && /^[0-9a-f]{4,64}$/i.test(given.sha) ? given.sha : null;
53+ const run = cleanRepoId(given.run);
54+ const commits = count(given.commits);
55+ if (ref) change.ref = ref;
56+ if (number != null) change.number = number;
57+ if (sha) change.sha = sha;
58+ if (run) change.run = run;
59+ if (commits != null) change.commits = commits;
60+ return change;
61+}
62+
63+/** The changes of a call, cleaned, by repository, in the order they came. */
64+export function byRepo(values: unknown): Map<string, RepoChange[]> {
65+ const grouped = new Map<string, RepoChange[]>();
66+ const list = Array.isArray(values) ? values.slice(0, MAX_CHANGES) : [];
67+ for (const value of list) {
68+ const change = cleanChange(value);
69+ if (!change) continue;
70+ grouped.set(change.repo_id, [...(grouped.get(change.repo_id) ?? []), change]);
71+ }
72+ return grouped;
73+}
74+
75+/** Each change named with the repository's `owner/name`, as its watcher asked for it. */
76+export function named(changes: RepoChange[], repo: string): RepoChange[] {
77+ return changes.map((change) => ({ ...change, repo }));
78+}
79+
80+/** Until when a watch asked for at `now` lasts. */
81+export function watchUntil(now: number): number {
82+ return now + WATCH_MS;
83+}
84+
85+/** Whether a watch kept until `until` still holds at `now`. */
86+export function stillWatching(until: number | null | undefined, now: number): boolean {
87+ return typeof until === "number" && until > now;
88+}
+7−2
1919 // One presence room per workspace (src/room.ts): who is online, away or
2020 // in Do Not Disturb, and their statuses, passed to everyone there who is
2121 // online. Also in the object's own SQLite storage.
22+ // One live topic per repository (src/topic.ts): whose tabs are watching
23+ // its pages, handed each change the events and actions services tell of
24+ // (repo_changed). Also in the object's own SQLite storage.
2225 "durable_objects": {
2326 "bindings": [
2427 { "name": "FEEDS", "class_name": "Feed" },
25− { "name": "ROOMS", "class_name": "Room" }
28+ { "name": "ROOMS", "class_name": "Room" },
29+ { "name": "TOPICS", "class_name": "Topic" }
2630 ]
2731 },
2832 "migrations": [
2933 { "tag": "v1", "new_sqlite_classes": ["Feed"] },
30− { "tag": "v2", "new_sqlite_classes": ["Room"] }
34+ { "tag": "v2", "new_sqlite_classes": ["Room"] },
35+ { "tag": "v3", "new_sqlite_classes": ["Topic"] }
3136 ],
3237 // Web Push (RFC 8292): the public half of the VAPID key pair browsers
3338 // subscribe with, and who the push services may contact. The private