Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

130 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, WorkspaceChange } 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}
89
90/** The topic a workspace's Work pages are watched on: one per workspace, beside the repositories'. */
91export function workspaceTopic(workspace: string): string {
92 return `workspace:${workspace}`;
93}
94
95/** The workspace a topic key names, or null when it is a repository's. */
96export function topicWorkspace(key: string): string | null {
97 return key.startsWith("workspace:") ? key.slice("workspace:".length) : null;
98}
99
100/** A workspace's slug as services and the site send it, lowercase, or null. */
101export function cleanWorkspace(value: unknown): string | null {
102 if (typeof value !== "string") return null;
103 const slug = value.trim().toLowerCase();
104 return /^[a-z0-9][a-z0-9-]{0,99}$/.test(slug) ? slug : null;
105}
106
107/** A workspace change as a service sent it, with only what one carries; null when it is not one. */
108export function cleanWorkspaceChange(value: unknown): WorkspaceChange | null {
109 if (!value || typeof value !== "object") return null;
110 const given = value as Record<string, unknown>;
111 const workspace = cleanWorkspace(given.workspace);
112 const kind = typeof given.kind === "string" && /^[a-z_]{1,40}\.[a-z_]{1,40}$/.test(given.kind) ? given.kind : null;
113 if (!workspace || !kind) return null;
114 const change: WorkspaceChange = { workspace, kind, at: text(given.at, 40) ?? new Date().toISOString() };
115 const number = count(given.number);
116 if (number != null) change.number = number;
117 return change;
118}
119
120/** The workspace changes of a call, cleaned, by workspace, in the order they came. */
121export function byWorkspace(values: unknown): Map<string, WorkspaceChange[]> {
122 const grouped = new Map<string, WorkspaceChange[]>();
123 const list = Array.isArray(values) ? values.slice(0, MAX_CHANGES) : [];
124 for (const value of list) {
125 const change = cleanWorkspaceChange(value);
126 if (!change) continue;
127 grouped.set(change.workspace, [...(grouped.get(change.workspace) ?? []), change]);
128 }
129 return grouped;
130}