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.

128 linesCodeBlameRaw
1/**
2 * What the Files page's About, the Contributors, Releases and Activity
3 * pages say, worked out without the server: labels, the language bar,
4 * and an activity line for each event worth one.
5 */
6import type { G1tEvent, LanguageShare, License, Project, WeekCommits } from "@g1t/contracts";
7
8/**
9 * The address the About links to: the project's homepage (its own, or its
10 * repository's website it follows), else production when it runs
11 * elsewhere. Null leaves the repository's website.
12 */
13export function projectHomepage(project: Pick<Project, "links" | "runs" | "productionUrl"> | null): string | null {
14 if (!project) return null;
15 return project.links?.homepage ?? (project.runs === "elsewhere" ? project.productionUrl : null) ?? null;
16}
17
18/** "MIT license", as the About names it; "View license" for one not recognized. */
19export function licenseLabel(license: License): string {
20 return license.spdxId ? `${license.spdxId} license` : "View license";
21}
22
23/** "12", "1.2k", "15k". */
24export function compact(n: number): string {
25 return n >= 10_000 ? `${Math.round(n / 1000)}k` : n >= 1000 ? `${(n / 1000).toFixed(1).replace(/\.0$/, "")}k` : String(n);
26}
27
28/** "1 star", "1.2k stars". */
29export function count(n: number, one: string, many = `${one}s`): string {
30 return `${compact(n)} ${n === 1 ? one : many}`;
31}
32
33/** The language bar's pieces: the largest, then the rest under 1% as Other. */
34export function languageBar(languages: LanguageShare[], least = 0.1, most = 8): LanguageShare[] {
35 // Every language down to a tenth of a percent, the largest `most` of them; the rest as Other.
36 const sorted = [...languages].sort((a, b) => b.percent - a.percent);
37 const shown = sorted.filter((language, index) => language.percent >= least && index < most);
38 const rest = sorted.filter((language) => !shown.includes(language));
39 if (rest.length === 0) return shown;
40 const bytes = rest.reduce((sum, language) => sum + language.bytes, 0);
41 const percent = Math.round(rest.reduce((sum, language) => sum + language.percent, 0) * 10) / 10;
42 if (percent === 0 && shown.length > 0) return shown;
43 return [...shown, { name: "Other", color: null, bytes, percent }];
44}
45
46/** The tallest week, for a chart's scale: never zero. */
47export function peak(weeks: WeekCommits[]): number {
48 return Math.max(1, ...weeks.map((week) => week.commits));
49}
50
51/** A contributor's weeks laid over the repository's, missing weeks as none. */
52export function alignWeeks(all: WeekCommits[], own: WeekCommits[]): number[] {
53 const byWeek = new Map(own.map((week) => [week.week, week.commits]));
54 return all.map((week) => byWeek.get(week.week) ?? 0);
55}
56
57/** An Activity line: what happened, and the branch, tag or pull request it was to. */
58export type ActivityLine =
59 | { kind: "push"; branch: string; commit: string; before: string | null; created: boolean; defaultBranch: boolean }
60 | { kind: "deleted"; branch: string; commit: string }
61 | { kind: "tag"; tag: string; commit: string }
62 | { kind: "merge"; number: number; commit: string }
63 | { kind: "renamed"; from: string; to: string }
64 | { kind: "default"; from: string; to: string };
65
66/** The events the Activity page lists. */
67export const ACTIVITY_TYPES = ["git.push", "git.deleted", "pull.merged", "branch.renamed", "repo.default_branch_changed"] as const;
68
69/** One event as an Activity line, or null for one it does not list. */
70export function activityLine(event: G1tEvent): ActivityLine | null {
71 switch (event.type) {
72 case "git.push": {
73 const data = event.data as { ref: string; before?: string | null; after: string; defaultBranch: boolean };
74 if (data.ref.startsWith("refs/tags/")) return { kind: "tag", tag: data.ref.slice("refs/tags/".length), commit: data.after };
75 if (!data.ref.startsWith("refs/heads/")) return null;
76 return {
77 kind: "push",
78 branch: data.ref.slice("refs/heads/".length),
79 commit: data.after,
80 before: data.before ?? null,
81 created: !data.before,
82 defaultBranch: data.defaultBranch,
83 };
84 }
85 case "git.deleted": {
86 // A deleted tag is not listed; a deleted branch is, with the commit
87 // it pointed to, which restores it.
88 const data = event.data as { ref: string; before: string };
89 if (!data.ref.startsWith("refs/heads/")) return null;
90 return { kind: "deleted", branch: data.ref.slice("refs/heads/".length), commit: data.before };
91 }
92 case "pull.merged": {
93 const data = event.data as { number: number; commit: string };
94 return { kind: "merge", number: data.number, commit: data.commit };
95 }
96 case "branch.renamed": {
97 const data = event.data as { from: string; to: string };
98 return { kind: "renamed", from: data.from, to: data.to };
99 }
100 case "repo.default_branch_changed": {
101 const data = event.data as { from: string; to: string; renamed: boolean };
102 // A rename of the default branch says so once, as a rename.
103 return data.renamed ? null : { kind: "default", from: data.from, to: data.to };
104 }
105 default:
106 return null;
107 }
108}
109
110/**
111 * Whether an Activity line is about `branch`: pushed to, made, deleted or
112 * renamed, or made or replaced as the default. Merges land on the default
113 * branch (`defaultBranch`).
114 */
115export function aboutBranch(line: ActivityLine, branch: string, defaultBranch: string): boolean {
116 switch (line.kind) {
117 case "push":
118 case "deleted":
119 return line.branch === branch;
120 case "tag":
121 return false;
122 case "merge":
123 return branch === defaultBranch;
124 case "renamed":
125 case "default":
126 return line.from === branch || line.to === branch;
127 }
128}