| 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 | */ |
| 6 | import 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 | */ |
| 13 | export 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. */ |
| 19 | export function licenseLabel(license: License): string { |
| 20 | return license.spdxId ? `${license.spdxId} license` : "View license"; |
| 21 | } |
| 22 | |
| 23 | /** "12", "1.2k", "15k". */ |
| 24 | export 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". */ |
| 29 | export 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. */ |
| 34 | export 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 | |
| 47 | /** The tallest week, for a chart's scale: never zero. */ |
| 48 | export function peak(weeks: WeekCommits[]): number { |
| 49 | return Math.max(1, ...weeks.map((week) => week.commits)); |
| 50 | } |
| 51 | |
| 52 | /** A contributor's weeks laid over the repository's, missing weeks as none. */ |
| 53 | export function alignWeeks(all: WeekCommits[], own: WeekCommits[]): number[] { |
| 54 | const byWeek = new Map(own.map((week) => [week.week, week.commits])); |
| 55 | return all.map((week) => byWeek.get(week.week) ?? 0); |
| 56 | } |
| 57 | |
| 58 | /** An Activity line: what happened, and the branch, tag or pull request it was to. */ |
| 59 | export type ActivityLine = |
| 60 | | { kind: "push"; branch: string; commit: string; before: string | null; created: boolean; defaultBranch: boolean } |
| 61 | | { kind: "deleted"; branch: string; commit: string } |
| 62 | | { kind: "tag"; tag: string; commit: string } |
| 63 | | { kind: "merge"; number: number; commit: string } |
| 64 | | { kind: "renamed"; from: string; to: string } |
| 65 | | { kind: "default"; from: string; to: string }; |
| 66 | |
| 67 | /** The events the Activity page lists. */ |
| 68 | export const ACTIVITY_TYPES = ["git.push", "git.deleted", "pull.merged", "branch.renamed", "repo.default_branch_changed"] as const; |
| 69 | |
| 70 | /** One event as an Activity line, or null for one it does not list. */ |
| 71 | export function activityLine(event: G1tEvent): ActivityLine | null { |
| 72 | switch (event.type) { |
| 73 | case "git.push": { |
| 74 | const data = event.data as { ref: string; before?: string | null; after: string; defaultBranch: boolean }; |
| 75 | if (data.ref.startsWith("refs/tags/")) return { kind: "tag", tag: data.ref.slice("refs/tags/".length), commit: data.after }; |
| 76 | if (!data.ref.startsWith("refs/heads/")) return null; |
| 77 | return { |
| 78 | kind: "push", |
| 79 | branch: data.ref.slice("refs/heads/".length), |
| 80 | commit: data.after, |
| 81 | before: data.before ?? null, |
| 82 | created: !data.before, |
| 83 | defaultBranch: data.defaultBranch, |
| 84 | }; |
| 85 | } |
| 86 | case "git.deleted": { |
| 87 | // A deleted tag is not listed; a deleted branch is, with the commit |
| 88 | // it pointed to, which restores it. |
| 89 | const data = event.data as { ref: string; before: string }; |
| 90 | if (!data.ref.startsWith("refs/heads/")) return null; |
| 91 | return { kind: "deleted", branch: data.ref.slice("refs/heads/".length), commit: data.before }; |
| 92 | } |
| 93 | case "pull.merged": { |
| 94 | const data = event.data as { number: number; commit: string }; |
| 95 | return { kind: "merge", number: data.number, commit: data.commit }; |
| 96 | } |
| 97 | case "branch.renamed": { |
| 98 | const data = event.data as { from: string; to: string }; |
| 99 | return { kind: "renamed", from: data.from, to: data.to }; |
| 100 | } |
| 101 | case "repo.default_branch_changed": { |
| 102 | const data = event.data as { from: string; to: string; renamed: boolean }; |
| 103 | // A rename of the default branch says so once, as a rename. |
| 104 | return data.renamed ? null : { kind: "default", from: data.from, to: data.to }; |
| 105 | } |
| 106 | default: |
| 107 | return null; |
| 108 | } |
| 109 | } |
| 110 | |
| 111 | /** |
| 112 | * Whether an Activity line is about `branch`: pushed to, made, deleted or |
| 113 | * renamed, or made or replaced as the default. Merges land on the default |
| 114 | * branch (`defaultBranch`). |
| 115 | */ |
| 116 | export function aboutBranch(line: ActivityLine, branch: string, defaultBranch: string): boolean { |
| 117 | switch (line.kind) { |
| 118 | case "push": |
| 119 | case "deleted": |
| 120 | return line.branch === branch; |
| 121 | case "tag": |
| 122 | return false; |
| 123 | case "merge": |
| 124 | return branch === defaultBranch; |
| 125 | case "renamed": |
| 126 | case "default": |
| 127 | return line.from === branch || line.to === branch; |
| 128 | } |
| 129 | } |