Merge branch 'worktree-agent-a5c94df323642630e'
37 files+1497−140/37 viewed
| 167 | 167 | A repository you create is watched the way you choose in | |
| 168 | 168 | [your settings](#settings): **All activity** unless you change it. | |
| 169 | 169 | ||
| 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 | + | ||
| 170 | 208 | ## Security | |
| 171 | 209 | ||
| 172 | 210 | Security alerts are told the way they can be lived with. A scan that finds |
| 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 | + | } |
| 16 | 16 | * for `IDLE_MS`) or comes back, the feed sends how everyone in the | |
| 17 | 17 | * workspace shows (`presence`) and your own (`me`), and `setPresence` | |
| 18 | 18 | * 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`. | |
| 19 | 23 | */ | |
| 20 | 24 | import { useEffect, useRef, useState, useSyncExternalStore } from "react"; | |
| 21 | 25 | ||
| 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"; | |
| 23 | 27 | ||
| 24 | 28 | import { openLive } from "./live-socket"; | |
| 25 | 29 | import { dndOn, isIdle, mergePeople } from "./presence"; | |
| ⋯ | |||
| 431 | 435 | if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type: "inbox", unread })); | |
| 432 | 436 | } | |
| 433 | 437 | ||
| 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 | + | ||
| 434 | 448 | function onEvent(event: FeedEvent): void { | |
| 435 | 449 | 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; | |
| 436 | 459 | case "hello": | |
| 437 | 460 | set({ preferences: event.preferences, vapidKey: event.vapid_public_key, recent: addRecent(state.recent, event.notifications ?? []) }); | |
| 438 | 461 | break; | |
| 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 | + | }); |
| 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 | + | } |
| 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 | + | } |
| 5 | 5 | import type { SoundSettingsChange } from "@g1t/contracts/sounds"; | |
| 6 | 6 | ||
| 7 | 7 | import type { Route } from "./+types/api"; | |
| 8 | + | import { repoFor } from "../../lib/access.server"; | |
| 8 | 9 | import { identity } from "../../lib/services.server"; | |
| 9 | 10 | import { assertSameOrigin, requireUser } from "../../lib/session.server"; | |
| 10 | 11 | ||
| ⋯ | |||
| 19 | 20 | * many phones do), `POST` a change: `subscribe`, `unsubscribe`, | |
| 20 | 21 | * `register_device` and `unregister_device` (the phone app, with its Expo | |
| 21 | 22 | * 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). | |
| 24 | 28 | * | |
| 25 | 29 | * The phone app calls this with its access token and no `Origin`, which | |
| 26 | 30 | * the same-origin check lets through (lib/same-origin.ts). | |
| ⋯ | |||
| 40 | 44 | token?: string; | |
| 41 | 45 | preferences?: NotifyPreferencesChange; | |
| 42 | 46 | change?: PresenceChange | SoundSettingsChange; | |
| 47 | + | repo?: string; | |
| 43 | 48 | }; | |
| 44 | 49 | ||
| 45 | 50 | export async function action({ context, request }: Route.ActionArgs) { | |
| ⋯ | |||
| 81 | 86 | const status = change.status ? { ...change.status, source: "manual" as const } : change.status; | |
| 82 | 87 | return Response.json(await notify.setPresence(user, { ...change, ...("status" in change ? { status } : {}) })); | |
| 83 | 88 | } | |
| 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 | + | } | |
| 84 | 99 | default: | |
| 85 | 100 | return Response.json({ ok: false }, { status: 400 }); | |
| 86 | 101 | } | |
| 58 | 58 | import { accessTo, refusal } from "../../lib/access.server"; | |
| 59 | 59 | import { useRefreshWhile } from "../../lib/refresh"; | |
| 60 | 60 | 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"; | |
| 61 | 63 | ||
| 62 | 64 | export function meta({ loaderData, params, ...args }: Route.MetaArgs) { | |
| 63 | 65 | const run = loaderData?.detail.run; | |
| ⋯ | |||
| 1233 | 1235 | // A mirror standing by runs nothing; in CI failover it runs them again. | |
| 1234 | 1236 | const rerunBlocked = useWorkflowReason(); | |
| 1235 | 1237 | 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); | |
| 1238 | 1244 | ||
| 1239 | 1245 | // The job asked for; none is the run's summary. | |
| 1240 | 1246 | const selected = jobs.find((job) => job.id === search.get("job")) ?? null; | |
| 23 | 23 | import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server"; | |
| 24 | 24 | import { accessFor, refusal, repoFor } from "../../lib/access.server"; | |
| 25 | 25 | import { useRefreshWhile } from "../../lib/refresh"; | |
| 26 | + | import { repoPages } from "../../lib/repo-live"; | |
| 27 | + | import { useRepoLive } from "../../lib/use-repo-live"; | |
| 26 | 28 | ||
| 27 | 29 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 28 | 30 | return page(args, { title: `Workflows · ${params.owner}/${params.repo} · g1t` }); | |
| ⋯ | |||
| 84 | 86 | return started.ok ? { started: started.value } : { error: started.error.message }; | |
| 85 | 87 | } | |
| 86 | 88 | ||
| 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 | + | */ | |
| 88 | 94 | function useLiveWhile(running: boolean) { | |
| 89 | − | useRefreshWhile(running, 3000); | |
| 95 | + | const { live } = useRepoLive({ match: repoPages.runs() }); | |
| 96 | + | useRefreshWhile(running, live ? 30_000 : 3000); | |
| 90 | 97 | } | |
| 91 | 98 | ||
| 92 | 99 | const EVENT_WORDS: Record<string, string> = { | |
| 10 | 10 | import { getViewer, unwrap } from "../../lib/session.server"; | |
| 11 | 11 | import { projectHomepage } from "../../lib/about"; | |
| 12 | 12 | import { useProject } from "./layout"; | |
| 13 | + | import { repoPages } from "../../lib/repo-live"; | |
| 14 | + | import { useRepoLive } from "../../lib/use-repo-live"; | |
| 13 | 15 | ||
| 14 | 16 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 15 | 17 | return page(args, { title: `Code · ${params.owner}/${params.repo} · g1t` }); | |
| ⋯ | |||
| 39 | 41 | ||
| 40 | 42 | export default function Code({ loaderData }: Route.ComponentProps) { | |
| 41 | 43 | const project = useProject(); | |
| 44 | + | // The files and latest commit follow pushes; checks update as they report. | |
| 45 | + | useRepoLive({ match: repoPages.files() }); | |
| 42 | 46 | return ( | |
| 43 | 47 | <TreeView | |
| 44 | 48 | tree={loaderData.tree} | |
| 8 | 8 | import { page } from "../../lib/meta"; | |
| 9 | 9 | import { CommitChecksBadge } from "../../components/commit-checks"; | |
| 10 | 10 | import { CommitAvatars, CommitNames } from "../../components/commit-person"; | |
| 11 | + | import { NewItemsBar } from "../../components/repo-live"; | |
| 11 | 12 | import { EmptyState, TimeAgo } from "../../components/ui"; | |
| 12 | 13 | import { Card } from "../../components/ui/card"; | |
| 13 | 14 | import { commitChecksFor } from "../../lib/commit-checks.server"; | |
| 14 | 15 | import { showCommits } from "../../lib/commit-people.server"; | |
| 15 | 16 | import { repos } from "../../lib/services.server"; | |
| 17 | + | import { isPushTo, newCommits, repoPages } from "../../lib/repo-live"; | |
| 18 | + | import { useRepoLive } from "../../lib/use-repo-live"; | |
| 16 | 19 | import { getViewer, unwrap } from "../../lib/session.server"; | |
| 20 | + | import { useProject } from "./layout"; | |
| 17 | 21 | ||
| 18 | 22 | const PAGE_SIZE = 50; | |
| 19 | 23 | ||
| ⋯ | |||
| 56 | 60 | export default function Commits({ loaderData, params }: Route.ComponentProps) { | |
| 57 | 61 | const { commits, checks } = loaderData; | |
| 58 | 62 | 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 }); | |
| 59 | 68 | if (commits.length === 0) { | |
| 60 | 69 | return <EmptyState title="No commits yet" />; | |
| 61 | 70 | } | |
| 62 | 71 | return ( | |
| 72 | + | <> | |
| 73 | + | <NewItemsBar held={held.length > 0} count={newCommits(held, branch)} noun={["commit", "commits"]} onShow={show} /> | |
| 63 | 74 | <div className="relative space-y-8 before:absolute before:top-3 before:bottom-3 before:left-1.75 before:w-px before:bg-line"> | |
| 64 | 75 | {byDay(commits).map(([day, list]) => ( | |
| 65 | 76 | <section key={day} className="relative pl-7"> | |
| ⋯ | |||
| 103 | 114 | </section> | |
| 104 | 115 | ))} | |
| 105 | 116 | </div> | |
| 117 | + | </> | |
| 106 | 118 | ); | |
| 107 | 119 | } | |
| 44 | 44 | import { Hint } from "../../components/ui/hint"; | |
| 45 | 45 | import { useRefreshWhile } from "../../lib/refresh"; | |
| 46 | 46 | import { DetailsDisclosure, detailsSummary } from "../../components/details-disclosure"; | |
| 47 | + | import { repoPages } from "../../lib/repo-live"; | |
| 48 | + | import { useRepoLive } from "../../lib/use-repo-live"; | |
| 47 | 49 | ||
| 48 | 50 | ||
| 49 | 51 | export function meta({ loaderData, params, ...args }: Route.MetaArgs) { | |
| ⋯ | |||
| 294 | 296 | (pull.status === "draft" || pull.status === "open"), | |
| 295 | 297 | ); | |
| 296 | 298 | 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 }); | |
| 297 | 301 | ||
| 298 | 302 | // A refusal to start g1t shows by its button; every other error below the discussion. | |
| 299 | 303 | const runError = actionData && "action" in actionData && actionData.action === "run-hosted"; | |
| 25 | 25 | import { work } from "../../lib/services.server"; | |
| 26 | 26 | import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server"; | |
| 27 | 27 | import { accessTo, refusal, requireWritableRepo } from "../../lib/access.server"; | |
| 28 | + | import { repoPages } from "../../lib/repo-live"; | |
| 29 | + | import { useRepoLive } from "../../lib/use-repo-live"; | |
| 28 | 30 | ||
| 29 | 31 | /** As many sandboxes as one request may start. */ | |
| 30 | 32 | const MAX_ASSIGNED_AT_ONCE = 10; | |
| ⋯ | |||
| 97 | 99 | ||
| 98 | 100 | export default function Issues({ loaderData, actionData, params }: Route.ComponentProps) { | |
| 99 | 101 | 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() }); | |
| 100 | 104 | const repo = `/${params.owner}/${params.repo}`; | |
| 101 | 105 | const base = `${repo}/issues`; | |
| 102 | 106 | const assignable = agentsEnabled && state === "open" && issues.length > 0; | |
| 110 | 110 | import { showCommits } from "../../lib/commit-people.server"; | |
| 111 | 111 | import { CommitAvatars, CommitNames } from "../../components/commit-person"; | |
| 112 | 112 | import { CommitChecksBadge } from "../../components/commit-checks"; | |
| 113 | + | import { repoPages } from "../../lib/repo-live"; | |
| 114 | + | import { useRepoLive } from "../../lib/use-repo-live"; | |
| 113 | 115 | ||
| 114 | 116 | const MAX_LANDED = 6; | |
| 115 | 117 | /** Branches read for the Active branches list, and shown. */ | |
| ⋯ | |||
| 827 | 829 | const source = project?.source.kind === "hosted" ? project.source : null; | |
| 828 | 830 | const moving = agentsLive.length > 0 || columns.working.length + columns.checking.length > 0; | |
| 829 | 831 | useLiveRefresh(moving); | |
| 832 | + | // Pushes, checks, releases and deployments show as they happen. | |
| 833 | + | useRepoLive({ match: repoPages.files() }); | |
| 830 | 834 | const ages = health.ages; | |
| 831 | 835 | const oldest = ages ? Math.max(1, ...ages.map((a) => a.count)) : 1; | |
| 832 | 836 | ||
| 101 | 101 | import { SubscriptionBox } from "../../components/notifications"; | |
| 102 | 102 | import { useMirrorReason } from "../../components/mirror"; | |
| 103 | 103 | import { REFRESH_MS, useRefreshWhile } from "../../lib/refresh"; | |
| 104 | + | import { repoPages } from "../../lib/repo-live"; | |
| 105 | + | import { useRepoLive } from "../../lib/use-repo-live"; | |
| 104 | 106 | ||
| 105 | 107 | const EMPTY_COMPARISON: Comparison = { base: null, head: "", files: [], truncated: false }; | |
| 106 | 108 | const TABS = ["conversation", "session", "changes"] as const; | |
| ⋯ | |||
| 755 | 757 | const probing = active && mergeable === "checking"; | |
| 756 | 758 | const conflicting = active && mergeable === "conflicting"; | |
| 757 | 759 | 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 }); | |
| 758 | 764 | // The rules of its base it does not meet: checks show in their own | |
| 759 | 765 | // section, so the rest stop the button here. Someone a ruleset lets | |
| 760 | 766 | // bypass it may tick a box and merge past them. | |
| 16 | 16 | import { accessTo, repoFor } from "../../lib/access.server"; | |
| 17 | 17 | import { agents, work } from "../../lib/services.server"; | |
| 18 | 18 | import { getViewer, unwrap } from "../../lib/session.server"; | |
| 19 | + | import { repoPages } from "../../lib/repo-live"; | |
| 20 | + | import { useRepoLive } from "../../lib/use-repo-live"; | |
| 19 | 21 | ||
| 20 | 22 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 21 | 23 | return page(args, { title: `Pull requests · ${params.owner}/${params.repo} · g1t` }); | |
| ⋯ | |||
| 56 | 58 | ||
| 57 | 59 | export default function Pulls({ loaderData, params }: Route.ComponentProps) { | |
| 58 | 60 | 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() }); | |
| 59 | 63 | const base = `/${params.owner}/${params.repo}`; | |
| 60 | 64 | const list = `${base}/pulls`; | |
| 61 | 65 | // Which pull requests an agent is working on this minute, and at what. | |
| 16 | 16 | import { requireInsider } from "../../lib/access.server"; | |
| 17 | 17 | import { planPrice } from "../../lib/security-suite.server"; | |
| 18 | 18 | import { codeFilters, countStates, keepCode } from "../../lib/security-suite"; | |
| 19 | + | import { repoPages } from "../../lib/repo-live"; | |
| 20 | + | import { useRepoLive } from "../../lib/use-repo-live"; | |
| 19 | 21 | ||
| 20 | 22 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 21 | 23 | return page(args, { title: `Code scanning · ${params.owner}/${params.repo} · g1t` }); | |
| ⋯ | |||
| 39 | 41 | ||
| 40 | 42 | export default function CodeScanning({ loaderData, params }: Route.ComponentProps) { | |
| 41 | 43 | const { scanning, price, can, owner } = loaderData; | |
| 44 | + | // Alerts opening, fixed and dismissed show as it happens. | |
| 45 | + | useRepoLive({ match: repoPages.security() }); | |
| 42 | 46 | const base = `/${params.owner}/${params.repo}`; | |
| 43 | 47 | const [search, setSearch] = useSearchParams(); | |
| 44 | 48 | const navigation = useNavigation(); | |
| 29 | 29 | import { legacySecurityTarget, severityCounts } from "../../lib/security-suite"; | |
| 30 | 30 | import { type OverviewRow, POLICY_PATHS, STATUS_LABEL, STATUS_TONE, overviewRows, rowsIn } from "../../lib/security-overview"; | |
| 31 | 31 | import { SEVERITY_LABEL } from "../../lib/advisories"; | |
| 32 | + | import { repoPages } from "../../lib/repo-live"; | |
| 33 | + | import { useRepoLive } from "../../lib/use-repo-live"; | |
| 32 | 34 | ||
| 33 | 35 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 34 | 36 | return page(args, { title: `Security · ${params.owner}/${params.repo} · g1t` }); | |
| ⋯ | |||
| 204 | 206 | ||
| 205 | 207 | export default function SecurityOverview({ loaderData, params }: Route.ComponentProps) { | |
| 206 | 208 | const base = `/${params.owner}/${params.repo}`; | |
| 209 | + | // Alerts opening, fixed and dismissed show as it happens. | |
| 210 | + | useRepoLive({ match: repoPages.security() }); | |
| 207 | 211 | if (loaderData.kind === "public") { | |
| 208 | 212 | const { policy, advisories: published, reporting, signedIn } = loaderData; | |
| 209 | 213 | return ( | |
| 14 | 14 | import { refusal, requireInsider } from "../../lib/access.server"; | |
| 15 | 15 | import { planPrice } from "../../lib/security-suite.server"; | |
| 16 | 16 | import { countStates, keepSecret, secretFilters, secretTypes } from "../../lib/security-suite"; | |
| 17 | + | import { repoPages } from "../../lib/repo-live"; | |
| 18 | + | import { useRepoLive } from "../../lib/use-repo-live"; | |
| 17 | 19 | ||
| 18 | 20 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 19 | 21 | return page(args, { title: `Secret scanning · ${params.owner}/${params.repo} · g1t` }); | |
| ⋯ | |||
| 64 | 66 | ||
| 65 | 67 | export default function SecretScanning({ loaderData, params }: Route.ComponentProps) { | |
| 66 | 68 | const { overview, patterns, price, can, owner } = loaderData; | |
| 69 | + | // Alerts opening, fixed and dismissed show as it happens. | |
| 70 | + | useRepoLive({ match: repoPages.security() }); | |
| 67 | 71 | const base = `/${params.owner}/${params.repo}`; | |
| 68 | 72 | const [search, setSearch] = useSearchParams(); | |
| 69 | 73 | const filters = secretFilters(search); | |
| 25 | 25 | import { updatesWaitReason } from "../../lib/mirror"; | |
| 26 | 26 | import { alertCapability, countByState, parseAlertState } from "../../lib/security-alerts"; | |
| 27 | 27 | import { severityCounts } from "../../lib/security-suite"; | |
| 28 | + | import { repoPages } from "../../lib/repo-live"; | |
| 29 | + | import { useRepoLive } from "../../lib/use-repo-live"; | |
| 28 | 30 | ||
| 29 | 31 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 30 | 32 | return page(args, { title: `Vulnerabilities · ${params.owner}/${params.repo} · g1t` }); | |
| ⋯ | |||
| 119 | 121 | ||
| 120 | 122 | export default function ProjectSecurity({ loaderData, params }: Route.ComponentProps) { | |
| 121 | 123 | const { overview, fixes, pulls, can } = loaderData; | |
| 124 | + | // Alerts opening, fixed and dismissed show as it happens. | |
| 125 | + | useRepoLive({ match: repoPages.security() }); | |
| 122 | 126 | const base = `/${params.owner}/${params.repo}`; | |
| 123 | 127 | const action = `${base}/security/vulnerabilities`; | |
| 124 | 128 | const [search, setSearch] = useSearchParams(); | |
| 12 | 12 | import { showCommits } from "../../lib/commit-people.server"; | |
| 13 | 13 | import { repos } from "../../lib/services.server"; | |
| 14 | 14 | import { getViewer, unwrap } from "../../lib/session.server"; | |
| 15 | + | import { repoPages } from "../../lib/repo-live"; | |
| 16 | + | import { useRepoLive } from "../../lib/use-repo-live"; | |
| 15 | 17 | ||
| 16 | 18 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 17 | 19 | return page(args, { title: `Tags · ${params.owner}/${params.repo} · g1t` }); | |
| ⋯ | |||
| 32 | 34 | ||
| 33 | 35 | export default function Tags({ loaderData, params }: Route.ComponentProps) { | |
| 34 | 36 | const { tags, checks } = loaderData; | |
| 37 | + | // New tags and releases show as they are pushed. | |
| 38 | + | useRepoLive({ match: repoPages.tags() }); | |
| 35 | 39 | const base = `/${params.owner}/${params.repo}`; | |
| 36 | 40 | const path = (name: string) => name.split("/").map(encodeURIComponent).join("/"); | |
| 37 | 41 | return ( | |
| 11 | 11 | import { getViewer, unwrap } from "../../lib/session.server"; | |
| 12 | 12 | import { projectHomepage } from "../../lib/about"; | |
| 13 | 13 | import { useProject } from "./layout"; | |
| 14 | + | import { repoPages } from "../../lib/repo-live"; | |
| 15 | + | import { useRepoLive } from "../../lib/use-repo-live"; | |
| 14 | 16 | ||
| 15 | 17 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 16 | 18 | const path = params["*"] ? `${params["*"]} · ` : ""; | |
| ⋯ | |||
| 43 | 45 | ||
| 44 | 46 | export default function Tree({ loaderData }: Route.ComponentProps) { | |
| 45 | 47 | const project = useProject(); | |
| 48 | + | // The files and latest commit follow pushes; checks update as they report. | |
| 49 | + | useRepoLive({ match: repoPages.files() }); | |
| 46 | 50 | return ( | |
| 47 | 51 | <TreeView | |
| 48 | 52 | tree={loaderData.tree} | |
| 27 | 27 | pub mod guardrails; | |
| 28 | 28 | pub mod identity; | |
| 29 | 29 | pub mod inbox; | |
| 30 | + | pub mod live; | |
| 30 | 31 | pub mod integrations; | |
| 31 | 32 | pub mod members; | |
| 32 | 33 | pub mod mirrors; |
| 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 | + | } |
| 184 | 184 | dnd_until?: string | null; | |
| 185 | 185 | }; | |
| 186 | 186 | ||
| 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 | + | ||
| 187 | 224 | /** What the feed socket sends a tab. */ | |
| 188 | 225 | export type FeedEvent = | |
| 189 | 226 | | { type: "hello"; notifications: FeedNotification[]; vapid_public_key: string | null; preferences: NotifyPreferences } | |
| ⋯ | |||
| 198 | 235 | */ | |
| 199 | 236 | | { type: "presence"; workspace: string; people: PresenceEntry[]; full: boolean } | |
| 200 | 237 | /** 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[] }; | |
| 202 | 241 | ||
| 203 | 242 | /** | |
| 204 | 243 | * What a tab sends over the feed socket: plain `ping` every 25 s (answered | |
| ⋯ | |||
| 294 | 333 | * past now nor more than a day behind it. | |
| 295 | 334 | */ | |
| 296 | 335 | 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 }>; | |
| 297 | 344 | }; | |
| 298 | 345 | ||
| 299 | 346 | async function rpc<T>(service: ServiceBinding, method: string, args: object): Promise<T> { | |
| ⋯ | |||
| 323 | 370 | workspacePresence: (workspace, userIds) => call("workspace_presence", { workspace, user_ids: userIds ?? null }), | |
| 324 | 371 | lastVisit: (user, workspace) => call("last_visit", { user_id: user.id, workspace }), | |
| 325 | 372 | 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 }), | |
| 326 | 375 | }; | |
| 327 | 376 | } | |
| 112 | 112 | /// Deployments: a job with an `environment:` deploys to it, and its | |
| 113 | 113 | /// run's deployment is recorded there (plan.rs `report_deployment`). | |
| 114 | 114 | 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>, | |
| 115 | 119 | /// Where the API keeps cache entries, for deleting evicted ones. | |
| 116 | 120 | cache: Option<worker::Bucket>, | |
| 117 | 121 | /// Seals secrets; absent until `ACTIONS_KEY` is set, when secrets | |
| ⋯ | |||
| 133 | 137 | projects: env.service("PROJECTS")?, | |
| 134 | 138 | billing: env.service("BILLING")?, | |
| 135 | 139 | deployments: env.service("DEPLOYMENTS")?, | |
| 140 | + | notify: env.service("NOTIFY").ok(), | |
| 136 | 141 | cache: env.bucket("ACTIONS_CACHE").ok(), | |
| 137 | 142 | sealer: env.secret("ACTIONS_KEY").ok().and_then(|key| Sealer::new(&key.to_string())), | |
| 138 | 143 | blob_key: env.secret("ACTIONS_KEY").ok().map(|key| runtime::blob_key(&key.to_string())), | |
| 1678 | 1678 | .first::<JobRow>(None) | |
| 1679 | 1679 | .await?; | |
| 1680 | 1680 | let Some(job) = finished else { return Ok(()) }; | |
| 1681 | + | self.tell_live(&job.repo_id, &job.run_id, None, None).await; | |
| 1681 | 1682 | // Its G1T_TOKEN stops working with it. | |
| 1682 | 1683 | self.revoke_job_tokens(&job.id).await; | |
| 1683 | 1684 | // A job that deploys failed: so did its run's deployment, now. | |
| ⋯ | |||
| 1909 | 1910 | return Ok(()); | |
| 1910 | 1911 | }; | |
| 1911 | 1912 | 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; | |
| 1912 | 1914 | if let Some(env) = deploys_to(&run, &job) { | |
| 1913 | 1915 | self.report_deployment(&run, &env, "in_progress", false).await; | |
| 1914 | 1916 | } | |
| 1915 | 1917 | Ok(()) | |
| 1916 | 1918 | } | |
| 1917 | 1919 | ||
| 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 | + | ||
| 1918 | 1941 | /// The run is over: each environment its jobs deployed to takes the | |
| 1919 | 1942 | /// outcome of those jobs (`deployment_outcome`). | |
| 1920 | 1943 | async fn settle_deployments(&self, run: &RunRow, jobs: &[JobRow], cancelled: bool) { | |
| 30 | 30 | // Self-hosted runners' time, recorded on usage at $0. | |
| 31 | 31 | { "binding": "BILLING", "service": "g1t-billing" }, | |
| 32 | 32 | // 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" } | |
| 34 | 36 | ], | |
| 35 | 37 | // actions/cache entries and artifacts, which the API writes: deleted here when they | |
| 36 | 38 | // are evicted or expire. |
| 16 | 16 | //! arguments. `activity_digest` (digest.rs) counts a span of the log for | |
| 17 | 17 | //! Home. Security alerts that are not urgent wait for each person's | |
| 18 | 18 | //! 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. | |
| 19 | 23 | ||
| 20 | 24 | mod audit; | |
| 21 | 25 | mod digest; | |
| 22 | 26 | mod fanout; | |
| 23 | 27 | mod inbox; | |
| 28 | + | mod live; | |
| 24 | 29 | mod security_digest; | |
| 25 | 30 | mod subscriptions; | |
| 26 | 31 | ||
| ⋯ | |||
| 251 | 256 | ); | |
| 252 | 257 | // Optional: without it the inbox still fills, nobody is told live. | |
| 253 | 258 | 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 | + | } | |
| 254 | 263 | let sources = inbox::Sources { | |
| 255 | 264 | work: &work, | |
| 256 | 265 | repos: &repos, | |
| 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 | + | } |
| 55 | 55 | { "binding": "WORK", "service": "g1t-work" }, | |
| 56 | 56 | { "binding": "REPOS", "service": "g1t-repos" }, | |
| 57 | 57 | { "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). | |
| 59 | 60 | { "binding": "NOTIFY", "service": "g1t-notify" } | |
| 60 | 61 | ], | |
| 61 | 62 | // Once a day, audit entries older than AUDIT_MAX_DAYS are removed for |
| 13 | 13 | * devices (the phone app, pushed through Expo: src/expopush.ts) and | |
| 14 | 14 | * preferences, and | |
| 15 | 15 | * 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). | |
| 17 | 18 | * | |
| 18 | 19 | * Presence is worked out here from the tabs (src/presence.ts) and told to | |
| 19 | 20 | * the room of every workspace the person belongs to (src/room.ts), which | |
| 20 | 21 | * passes it to everyone there who is online. A status or Do Not Disturb | |
| 21 | 22 | * that runs out is cleared by an alarm and told the same way. | |
| 22 | 23 | * | |
| 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 | + | * | |
| 23 | 29 | * The feed authorizes nothing: the Worker reaches it only for the person | |
| 24 | 30 | * the site checked (src/index.ts). | |
| 25 | 31 | */ | |
| ⋯ | |||
| 35 | 41 | type FeedEvent, | |
| 36 | 42 | type FeedNotification, | |
| 37 | 43 | type FeedSeed, | |
| 44 | + | type RepoChange, | |
| 38 | 45 | type NotifyPreferences, | |
| 39 | 46 | type NotifyStatus, | |
| 40 | 47 | type PushDevice, | |
| ⋯ | |||
| 59 | 66 | type Kept, | |
| 60 | 67 | } from "./presence.ts"; | |
| 61 | 68 | import type { Room } from "./room.ts"; | |
| 69 | + | import type { Topic } from "./topic.ts"; | |
| 62 | 70 | import { type Visit, lastVisit, markVisit } from "./visits.ts"; | |
| 71 | + | import { MAX_WATCHING, stillWatching, watchUntil } from "./watch.ts"; | |
| 63 | 72 | import { sendPush, type Vapid } from "./webpush.ts"; | |
| 64 | 73 | ||
| 65 | 74 | export type FeedEnv = { | |
| ⋯ | |||
| 81 | 90 | PHONE_PUSH?: string; | |
| 82 | 91 | /** One presence room per workspace (src/room.ts); without it, presence stays in the person's own tabs. */ | |
| 83 | 92 | ROOMS?: DurableObjectNamespace<Room>; | |
| 93 | + | /** One live topic per repository (src/topic.ts); without it, repository pages refresh on their own. */ | |
| 94 | + | TOPICS?: DurableObjectNamespace<Topic>; | |
| 84 | 95 | }; | |
| 85 | 96 | ||
| 86 | 97 | /** The headers the Worker passes the person's id and username in, with a socket (src/index.ts). */ | |
| ⋯ | |||
| 122 | 133 | this.sql.exec(`CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)`); | |
| 123 | 134 | // When the person was last on each workspace's Home page (src/visits.ts). | |
| 124 | 135 | 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)`); | |
| 125 | 138 | } | |
| 126 | 139 | ||
| 127 | 140 | // ── Kept state ────────────────────────────────────────────────────────── | |
| ⋯ | |||
| 638 | 651 | if (person.username) this.setMeta("username", person.username.slice(0, 100)); | |
| 639 | 652 | } | |
| 640 | 653 | ||
| 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 | + | ||
| 641 | 697 | private visit(workspace: string): Visit | null { | |
| 642 | 698 | return this.sql.exec<Visit>("SELECT seen_at, previous_at FROM visits WHERE workspace = ?", workspace).toArray()[0] ?? null; | |
| 643 | 699 | } | |
| 22 | 22 | * - agents and the site: `workspace_presence`, how a workspace's people | |
| 23 | 23 | * show now (away, in Do Not Disturb, offline, and their statuses); | |
| 24 | 24 | * - 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. | |
| 26 | 29 | * | |
| 27 | 30 | * Presence: each feed works out its person's from their tabs and tells | |
| 28 | 31 | * one room per workspace (src/room.ts), which tells everyone there who is | |
| 29 | 32 | * 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". | |
| 30 | 37 | */ | |
| 31 | 38 | import { | |
| 32 | 39 | NOTIFY_VIEWER_HEADER, | |
| ⋯ | |||
| 39 | 46 | import { FEED_USERNAME_HEADER, FEED_USER_ID_HEADER, Feed, type FeedEnv } from "./feed.ts"; | |
| 40 | 47 | import { onlyPeople } from "./presence.ts"; | |
| 41 | 48 | import { visitWorkspace } from "./visits.ts"; | |
| 49 | + | import { byRepo, cleanRepoId, cleanRepoPath } from "./watch.ts"; | |
| 42 | 50 | ||
| 43 | 51 | export { Feed } from "./feed.ts"; | |
| 44 | 52 | export { Room } from "./room.ts"; | |
| 53 | + | export { Topic } from "./topic.ts"; | |
| 45 | 54 | ||
| 46 | 55 | type Env = FeedEnv & { | |
| 47 | 56 | IDENTITY: ServiceBinding; | |
| ⋯ | |||
| 76 | 85 | const everyone = await env.ROOMS.get(env.ROOMS.idFromName(slug)).people(); | |
| 77 | 86 | return Response.json(onlyPeople(everyone, Array.isArray(args.user_ids) ? args.user_ids : null)); | |
| 78 | 87 | } | |
| 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 | + | } | |
| 79 | 97 | if (method === "deliver") { | |
| 80 | 98 | const items = (Array.isArray(args?.items) ? args.items : []).slice(0, MAX_DELIVERIES) as FeedDelivery[]; | |
| 81 | 99 | const byUser = new Map<string, FeedDelivery[]>(); | |
| ⋯ | |||
| 113 | 131 | if (!workspace) return Response.json({ seen_at: null }); | |
| 114 | 132 | return Response.json(method === "last_visit" ? await stub.lastVisit(workspace) : await stub.markVisit(workspace, args.at)); | |
| 115 | 133 | } | |
| 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 | + | } | |
| 116 | 140 | case "test": | |
| 117 | 141 | return Response.json(await stub.test(typeof args.username === "string" ? args.username : "")); | |
| 118 | 142 | case "presence": | |
| 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 | + | } |
| 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 | + | }); |
| 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 | + | } |
| 19 | 19 | // One presence room per workspace (src/room.ts): who is online, away or | |
| 20 | 20 | // in Do Not Disturb, and their statuses, passed to everyone there who is | |
| 21 | 21 | // 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. | |
| 22 | 25 | "durable_objects": { | |
| 23 | 26 | "bindings": [ | |
| 24 | 27 | { "name": "FEEDS", "class_name": "Feed" }, | |
| 25 | − | { "name": "ROOMS", "class_name": "Room" } | |
| 28 | + | { "name": "ROOMS", "class_name": "Room" }, | |
| 29 | + | { "name": "TOPICS", "class_name": "Topic" } | |
| 26 | 30 | ] | |
| 27 | 31 | }, | |
| 28 | 32 | "migrations": [ | |
| 29 | 33 | { "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"] } | |
| 31 | 36 | ], | |
| 32 | 37 | // Web Push (RFC 8292): the public half of the VAPID key pair browsers | |
| 33 | 38 | // subscribe with, and who the push services may contact. The private |