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.

g1t.sh
111 linesCodeBlameRaw
1// 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 /** Its project's description. */
29 description?: string | null;
30 topics?: string[];
31 isPrivate: boolean;
32 archivedAt?: string | null;
33};
34
35/**
36 * The repositories that pass `filter` and match `query`: every word of it
37 * appears in the name, the description or a topic. Order is kept.
38 */
39export function filterRepos<T extends Filterable>(repos: T[], filter: RepoFilter, query: string): T[] {
40 const words = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
41 return repos.filter((repo) => {
42 if (filter === "public" && repo.isPrivate) return false;
43 if (filter === "private" && !repo.isPrivate) return false;
44 if (filter === "archived" && !repo.archivedAt) return false;
45 if (words.length === 0) return true;
46 const text = [repo.name, repo.description ?? "", ...(repo.topics ?? [])].join(" ").toLowerCase();
47 return words.every((word) => text.includes(word));
48 });
49}
50
51/** How many repositories each filter shows, for the counts beside them. */
52export function filterCounts(repos: Filterable[]): Record<RepoFilter, number> {
53 return {
54 all: repos.length,
55 public: repos.filter((r) => !r.isPrivate).length,
56 private: repos.filter((r) => r.isPrivate).length,
57 archived: repos.filter((r) => r.archivedAt).length,
58 };
59}
60
61/** How one repository of a bulk action went. */
62export type BulkResult = { name: string; ok: boolean; error?: string };
63
64/**
65 * One line about a bulk action: how many were done, and for each that was
66 * not, its name and why. `done` is the past tense, such as "archived".
67 */
68export function summariseBulk(results: BulkResult[], done: string): { message: string; failures: string[] } {
69 const ok = results.filter((r) => r.ok).length;
70 const failed = results.filter((r) => !r.ok);
71 const noun = (n: number) => (n === 1 ? "repository" : "repositories");
72 const parts: string[] = [];
73 if (ok > 0) parts.push(`${ok} ${noun(ok)} ${done}.`);
74 if (failed.length > 0) parts.push(`${failed.length} ${noun(failed.length)} could not be.`);
75 if (parts.length === 0) parts.push("Nothing was selected.");
76 return {
77 message: parts.join(" "),
78 failures: failed.map((r) => `${r.name}: ${r.error ?? "Something went wrong."}`),
79 };
80}
81
82/**
83 * The address of a code page after the branch it names was renamed: the
84 * same page, with the branch segment that follows `tree` or `blob`
85 * replaced by `to`. Null when the path is not such a page.
86 */
87export function renamedBranchPath(pathname: string, search: string, to: string): string | null {
88 const segments = pathname.split("/");
89 // ["", owner, repo, "tree" | "blob", ref, ...path]
90 if (segments.length < 5 || (segments[3] !== "tree" && segments[3] !== "blob")) return null;
91 segments[4] = encodeURIComponent(to);
92 return segments.join("/") + search;
93}
94
95/** Whole days from `now` until `at`, never below zero. */
96export function daysUntil(at: string, now: number = Date.now()): number {
97 const ms = Date.parse(at) - now;
98 return Number.isFinite(ms) ? Math.max(0, Math.ceil(ms / 86_400_000)) : 0;
99}
100
101/** A date as "12 November 2026", in UTC so server and browser agree. */
102export function longDate(at: string): string {
103 const date = new Date(at);
104 if (Number.isNaN(date.getTime())) return at;
105 return date.toLocaleDateString("en-GB", { day: "numeric", month: "long", year: "numeric", timeZone: "UTC" });
106}
107
108/** A repository name as typed: trimmed, spaces to hyphens. The service has the final word. */
109export function tidyName(name: string): string {
110 return name.trim().replace(/\s+/g, "-");
111}