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.

240 linesCodeBlameRaw
1/**
2 * Words and links for a project that is a library or a tool rather than an
3 * app: which of its repository's packages to show, and how to publish a
4 * first one. Whether a project is one is decided by the projects service
5 * (`Project.kind`); this only says it on the pages.
6 */
7import type {
8 Ecosystem,
9 PackageSummary,
10 Project,
11 ProjectChanges,
12 ProjectEcosystem,
13 ProjectKind,
14 ProjectLinks,
15 ProjectRuns,
16 ProjectSetting,
17} from "@g1t/contracts";
18
19/** As lib/packages.ts names each registry; repeated so this module stands alone in tests. */
20const REGISTRY: Record<Ecosystem, string> = {
21 container: "Container",
22 npm: "npm",
23 composer: "Composer",
24 cargo: "Cargo",
25 go: "Go",
26 maven: "Maven",
27 nuget: "NuGet",
28 rubygems: "RubyGems",
29};
30
31const DOCS = "https://docs.g1t.sh";
32
33/** One choice of what a project is, as its settings and overview offer it. */
34export type KindChoice = "auto" | "g1t" | "elsewhere" | "library" | "tool" | "docs" | "other";
35
36/** What each choice sets: a kind and where it runs, `auto` for what is left to detection. */
37export const KIND_CHOICE_SETS: Record<KindChoice, { kind: ProjectKind | "auto"; runs: ProjectRuns | "auto" }> = {
38 auto: { kind: "auto", runs: "auto" },
39 g1t: { kind: "app", runs: "g1t" },
40 elsewhere: { kind: "app", runs: "elsewhere" },
41 library: { kind: "library", runs: "auto" },
42 tool: { kind: "tool", runs: "auto" },
43 docs: { kind: "docs", runs: "auto" },
44 other: { kind: "other", runs: "auto" },
45};
46
47/** The choices for "What it is" in a project's settings, in order. */
48export const KIND_CHOICES: { value: KindChoice; label: string; hint: string }[] = [
49 { value: "auto", label: "Detect automatically", hint: "From whether Deployments are on, the packages it publishes and the files at its root." },
50 {
51 value: "g1t",
52 label: "App or site, hosted on g1t.page",
53 hint: "g1t builds and hosts it: production on g1t.page from the default branch, and a preview for every pull request.",
54 },
55 {
56 value: "elsewhere",
57 label: "App or site that deploys itself",
58 hint: "Its own workflows on g1t Actions, or another pipeline, deploy it to your hosting. Its overview shows production at the address it reports or you give.",
59 },
60 { value: "library", label: "Library or package", hint: "Installed by other code: its overview shows its packages and releases." },
61 { value: "tool", label: "Tool or CLI", hint: "Installed and run by people: its overview shows its releases and packages." },
62 { value: "docs", label: "Documentation", hint: "Docs or site content: its overview shows where they are read." },
63 { value: "other", label: "Something else", hint: "Configuration, research, notes: its overview shows its links and its work." },
64];
65
66/** The choice a project's setting is; null when it is one no choice makes (set through the API). */
67export function choiceOf(setting: ProjectSetting): KindChoice | null {
68 if (!setting.kind && !setting.runs) return "auto";
69 if (setting.kind && setting.kind !== "app") return setting.kind;
70 if (setting.runs) return setting.runs;
71 return null;
72}
73
74/** Whether it is set to be something that never deploys: a library, a tool or other. */
75export function neverDeploys(project: Pick<Project, "setting">): boolean {
76 const kind = project.setting?.kind;
77 return kind != null && kind !== "app" && kind !== "docs";
78}
79
80/** What a project is, in a word or three, for its badge. */
81export function kindLabel(project: Pick<Project, "kind" | "runs">): string {
82 switch (project.kind) {
83 case "app":
84 return project.runs === "g1t" ? "App on g1t.page" : project.runs === "elsewhere" ? "App, deploys itself" : "App";
85 case "library":
86 return "Library";
87 case "tool":
88 return "Tool";
89 case "docs":
90 return project.runs === "g1t" ? "Docs on g1t.page" : "Docs";
91 default:
92 return "Project";
93 }
94}
95
96/** What a project's overview leads with: production, a release, or what it says of itself. */
97export type ProjectPlan = "production" | "release" | "elsewhere" | "unknown" | "docs" | "other";
98
99/**
100 * Which head a project's overview shows, from what it is and where it
101 * runs. Only what g1t deploys gets production's.
102 */
103export function projectPlan(project: Pick<Project, "kind" | "runs">): ProjectPlan {
104 if (project.runs === "g1t") return "production";
105 if (project.kind === "library" || project.kind === "tool") return "release";
106 if (project.kind === "app") return project.runs === "elsewhere" ? "elsewhere" : "unknown";
107 return project.kind;
108}
109
110/** A link as pages list it: what it is, its label and address. */
111export type ShownLink = { key: string; type: "homepage" | "docs" | "production" | "custom"; label: string; url: string };
112
113/** An address without its scheme, or a trailing slash: `g1t.sh/docs`. */
114export function bare(url: string): string {
115 return url.replace(/^https?:\/\//i, "").replace(/\/$/, "");
116}
117
118/**
119 * A project's links in the order pages show them: its homepage, its docs,
120 * then the rest. An address already shown elsewhere on the page (such as
121 * production's) is given in `shown` and left out, so nothing is listed twice.
122 */
123export function linksToShow(links: ProjectLinks, shown: (string | null | undefined)[] = []): ShownLink[] {
124 const seen = new Set(shown.filter((url): url is string => !!url).map((url) => bare(url).toLowerCase()));
125 const out: ShownLink[] = [];
126 const add = (link: ShownLink) => {
127 const key = bare(link.url).toLowerCase();
128 if (seen.has(key)) return;
129 seen.add(key);
130 out.push(link);
131 };
132 if (links.homepage) add({ key: "homepage", type: "homepage", label: bare(links.homepage), url: links.homepage });
133 if (links.docs) add({ key: "docs", type: "docs", label: "Docs", url: links.docs });
134 links.custom.forEach((link, index) => add({ key: `custom:${index}`, type: "custom", label: link.label, url: link.url }));
135 return out;
136}
137
138/**
139 * The one address a project's card or row shows: production as g1t
140 * serves it (`live`, from Deployments), production deployed elsewhere,
141 * then its homepage, then its docs. Null when it has none.
142 */
143export function primaryLink(project: Pick<Project, "runs" | "productionUrl" | "links">, live: string | null | undefined): string | null {
144 if (project.runs === "g1t" && live) return live;
145 if (project.runs === "elsewhere" && project.productionUrl) return project.productionUrl;
146 return live ?? project.links?.homepage ?? project.links?.docs ?? null;
147}
148
149/**
150 * The change a form on the overview asks for: only the fields it carries.
151 * `choice` sets what it is and where it runs; `links` says its rows are the
152 * whole list of other links, so a form with every row removed clears them.
153 */
154export function projectChanges(form: Pick<FormData, "get" | "getAll" | "has">): ProjectChanges {
155 const changes: ProjectChanges = {};
156 const choice = form.get("choice");
157 if (typeof choice === "string" && choice in KIND_CHOICE_SETS) Object.assign(changes, KIND_CHOICE_SETS[choice as KindChoice]);
158 const text = (name: string) => (form.has(name) ? String(form.get(name) ?? "") : undefined);
159 const fields = { description: text("description"), productionUrl: text("productionUrl"), homepage: text("homepage"), docsUrl: text("docsUrl") };
160 for (const [key, value] of Object.entries(fields)) if (value !== undefined) (changes as Record<string, unknown>)[key] = value;
161 if (form.has("links")) changes.links = linksFromForm(form);
162 return changes;
163}
164
165/** A repository's newest tag, by its commit's date: its latest release. */
166export function latestTag(tags: { name: string; commit: { authoredAt: string } | null }[]): { name: string; at: string | null } | null {
167 const dated = tags.filter((tag) => tag.commit).sort((a, b) => b.commit!.authoredAt.localeCompare(a.commit!.authoredAt));
168 const newest = dated[0] ?? tags[0];
169 return newest ? { name: newest.name, at: newest.commit?.authoredAt ?? null } : null;
170}
171
172/** The custom links a form gives, as rows of `linkLabel` and `linkUrl` fields, in order. */
173export function linksFromForm(form: Pick<FormData, "getAll">): { label: string; url: string }[] {
174 const labels = form.getAll("linkLabel").map(String);
175 const urls = form.getAll("linkUrl").map(String);
176 return urls.map((url, index) => ({ label: labels[index] ?? "", url })).filter((link) => link.label.trim() || link.url.trim());
177}
178
179/** A registry's guide, and the command that publishes a first version there. */
180export type PublishGuide = { label: string; guide: string; start: string | null };
181
182/** The registries with a guide, for a library whose files do not say which it is. */
183export const PUBLISH_GUIDES: PublishGuide[] = [
184 { label: "Composer", guide: `${DOCS}/guides/composer/`, start: "git tag v1.0.0 && git push --tags" },
185 { label: "npm", guide: `${DOCS}/guides/npm/`, start: "npm publish" },
186 { label: "Go", guide: `${DOCS}/guides/go/`, start: "git tag v1.0.0 && git push --tags" },
187 { label: "Containers", guide: `${DOCS}/guides/containers/`, start: null },
188];
189
190/** How a library of `ecosystem` publishes its first version; null when its files do not say. */
191export function publishGuide(ecosystem: ProjectEcosystem | null): PublishGuide | null {
192 switch (ecosystem) {
193 case "composer":
194 return PUBLISH_GUIDES[0]!;
195 case "npm":
196 return PUBLISH_GUIDES[1]!;
197 case "go":
198 return PUBLISH_GUIDES[2]!;
199 case "cargo":
200 return { label: "Cargo", guide: `${DOCS}/guides/packages/`, start: null };
201 case "python":
202 return { label: "Python", guide: `${DOCS}/guides/packages/`, start: null };
203 default:
204 return null;
205 }
206}
207
208/** A package as its own tool names it: `@acme/ui` for npm, the name otherwise. */
209export function packageName(pkg: Pick<PackageSummary, "ecosystem" | "name" | "workspace">): string {
210 return pkg.ecosystem === "npm" ? `@${pkg.workspace}/${pkg.name}` : pkg.name;
211}
212
213/** `Composer · psr/log 3.0.2`, for a project's card. */
214export function packageLine(pkg: Pick<PackageSummary, "ecosystem" | "name" | "workspace" | "latest">): string {
215 return `${REGISTRY[pkg.ecosystem]} · ${packageName(pkg)}${pkg.latest ? ` ${pkg.latest}` : ""}`;
216}
217
218/** The package's page. */
219export function packagePath(pkg: Pick<PackageSummary, "ecosystem" | "name" | "workspace">): string {
220 return `/${pkg.workspace}/-/packages/${pkg.ecosystem}/${pkg.name}`;
221}
222
223/**
224 * The packages a library's overview shows, from its repository's: what it
225 * publishes for others to install first, a container image last, as an
226 * image is how apps ship too.
227 */
228export function libraryPackages<T extends Pick<PackageSummary, "ecosystem" | "versions" | "updated_at">>(list: T[]): T[] {
229 return [...list].sort(
230 (a, b) =>
231 Number(a.ecosystem === "container") - Number(b.ecosystem === "container") ||
232 Number(b.versions > 0) - Number(a.versions > 0) ||
233 b.updated_at.localeCompare(a.updated_at),
234 );
235}
236
237/** Whether any of the packages has a version: a release is out. */
238export function hasRelease(list: Pick<PackageSummary, "versions">[]): boolean {
239 return list.some((pkg) => pkg.versions > 0);
240}