| 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 | } |