Skip to content
88 linesCodeBlameRaw
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 */
9import type { RepoChange } from "@g1t/contracts";
10
11/** How long one watch lasts: REPO_WATCH_MS in @g1t/contracts, which the pages renew before. */
12export const WATCH_MS = 15 * 60_000;
13
14/** The most changes one call is taken with. */
15export const MAX_CHANGES = 500;
16/** The most people one repository's topic keeps watching it. */
17export const MAX_WATCHERS = 5000;
18/** The most repositories one person watches at once (tabs on many repositories). */
19export const MAX_WATCHING = 50;
20
21const ID = /^[A-Za-z0-9_-]{1,100}$/;
22const PATH = /^[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}$/;
23
24function text(value: unknown, max = 200): string | null {
25 return typeof value === "string" && value ? value.slice(0, max) : null;
26}
27
28function 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. */
33export function cleanRepoId(value: unknown): string | null {
34 return typeof value === "string" && ID.test(value) ? value : null;
35}
36
37/** An `owner/name`, or null. */
38export 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. */
43export 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. */
64export 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. */
76export 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. */
81export function watchUntil(now: number): number {
82 return now + WATCH_MS;
83}
84
85/** Whether a watch kept until `until` still holds at `now`. */
86export function stillWatching(until: number | null | undefined, now: number): boolean {
87 return typeof until === "number" && until > now;
88}