110 lines4,711 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1// Pure helpers for a repository's lifecycle pages: renaming, archiving,
2// deleting and restoring, and the workspace's list of repositories. Kept
3// free of services so they can be tested with `node --test`.
4
5/** Whether what someone typed names the repository `full` (`owner/name`), ignoring case and spaces around it. */
6export function confirmsName(typed: string | null | undefined, full: string): boolean {
7 return (typed ?? "").trim().toLowerCase() === full.trim().toLowerCase();
8}
9
10/** The repositories a list shows: all of them, the public or private ones, or the archived ones. */
11export type RepoFilter = "all" | "public" | "private" | "archived";
12
13export const REPO_FILTERS: { value: RepoFilter; label: string }[] = [
14 { value: "all", label: "All" },
15 { value: "public", label: "Public" },
16 { value: "private", label: "Private" },
17 { value: "archived", label: "Archived" },
18];
19
20/** A filter from an address's `?filter=`, `all` for anything else. */
21export function parseFilter(value: string | null | undefined): RepoFilter {
22 return REPO_FILTERS.some((f) => f.value === value) ? (value as RepoFilter) : "all";
23}
24
25/** What filtering needs to know about a repository. */
26export type Filterable = {
27 name: string;
28 description?: string | null;
29 topics?: string[];
30 isPrivate: boolean;
31 archivedAt?: string | null;
32};
33
34/**
35 * The repositories that pass `filter` and match `query`: every word of it
36 * appears in the name, the description or a topic. Order is kept.
37 */
38export function filterRepos<T extends Filterable>(repos: T[], filter: RepoFilter, query: string): T[] {
39 const words = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
40 return repos.filter((repo) => {
41 if (filter === "public" && repo.isPrivate) return false;
42 if (filter === "private" && !repo.isPrivate) return false;
43 if (filter === "archived" && !repo.archivedAt) return false;
44 if (words.length === 0) return true;
45 const text = [repo.name, repo.description ?? "", ...(repo.topics ?? [])].join(" ").toLowerCase();
46 return words.every((word) => text.includes(word));
47 });
48}
49
50/** How many repositories each filter shows, for the counts beside them. */
51export function filterCounts(repos: Filterable[]): Record<RepoFilter, number> {
52 return {
53 all: repos.length,
54 public: repos.filter((r) => !r.isPrivate).length,
55 private: repos.filter((r) => r.isPrivate).length,
56 archived: repos.filter((r) => r.archivedAt).length,
57 };
58}
59
60/** How one repository of a bulk action went. */
61export type BulkResult = { name: string; ok: boolean; error?: string };
62
63/**
64 * One line about a bulk action: how many were done, and for each that was
65 * not, its name and why. `done` is the past tense, such as "archived".
66 */
67export function summariseBulk(results: BulkResult[], done: string): { message: string; failures: string[] } {
68 const ok = results.filter((r) => r.ok).length;
69 const failed = results.filter((r) => !r.ok);
70 const noun = (n: number) => (n === 1 ? "repository" : "repositories");
71 const parts: string[] = [];
72 if (ok > 0) parts.push(`${ok} ${noun(ok)} ${done}.`);
73 if (failed.length > 0) parts.push(`${failed.length} ${noun(failed.length)} could not be.`);
74 if (parts.length === 0) parts.push("Nothing was selected.");
75 return {
76 message: parts.join(" "),
77 failures: failed.map((r) => `${r.name}: ${r.error ?? "Something went wrong."}`),
78 };
79}
80
81/**
82 * The address of a code page after the branch it names was renamed: the
83 * same page, with the branch segment that follows `tree` or `blob`
84 * replaced by `to`. Null when the path is not such a page.
85 */
86export function renamedBranchPath(pathname: string, search: string, to: string): string | null {
87 const segments = pathname.split("/");
88 // ["", owner, repo, "tree" | "blob", ref, ...path]
89 if (segments.length < 5 || (segments[3] !== "tree" && segments[3] !== "blob")) return null;
90 segments[4] = encodeURIComponent(to);
91 return segments.join("/") + search;
92}
93
94/** Whole days from `now` until `at`, never below zero. */
95export function daysUntil(at: string, now: number = Date.now()): number {
96 const ms = Date.parse(at) - now;
97 return Number.isFinite(ms) ? Math.max(0, Math.ceil(ms / 86_400_000)) : 0;
98}
99
100/** A date as "12 November 2026", in UTC so server and browser agree. */
101export function longDate(at: string): string {
102 const date = new Date(at);
103 if (Number.isNaN(date.getTime())) return at;
104 return date.toLocaleDateString("en-GB", { day: "numeric", month: "long", year: "numeric", timeZone: "UTC" });
105}
106
107/** A repository name as typed: trimmed, spaces to hyphens. The service has the final word. */
108export function tidyName(name: string): string {
109 return name.trim().replace(/\s+/g, "-");
110}