Skip to content
193 linesCodeBlameRaw
1/**
2 * Branches as the overview and the Branches page show them, from the
3 * repos service's `branch_drift` answer. The counting itself is done
4 * there (services/repos/src/drift.rs), kept by the pair of head commits.
5 */
6import type { Branch, BranchDrifts, Commit, Pull } from "@g1t/contracts";
7
8import type { ActiveBranch } from "../components/branches";
9import type { ShownCommit } from "./commit-people";
10
11/**
12 * How far a branch has moved from the default branch: commits it has that
13 * the default branch does not (ahead), and commits the default branch has
14 * that it does not (behind), the way `git rev-list --left-right --count`
15 * says it.
16 */
17export type Drift = { ahead: number; behind: number };
18
19type Preview = { branch?: string | null; number?: number | null; url: string };
20
21type PullOn = Pick<Pull, "branch" | "number" | "title" | "checkStatus" | "status">;
22
23/** A commit's people, as lib/commit-people.ts `showCommit` finds them. */
24export type People = (commit: Commit) => Pick<ShownCommit, "author" | "coAuthors">;
25
26/** Nobody matched to an account: the name on the commit. */
27const byName: People = (commit) => ({
28 author: { kind: "author", name: commit.author.name, username: null, avatar: null },
29 coAuthors: [],
30});
31
32/** Whether a pull request is still open: ready for review or a draft. */
33const isOpen = (pull: Pick<Pull, "status">) => pull.status === "open" || pull.status === "draft";
34
35/**
36 * A commit as a branch row shows it: its first line, and its people as
37 * `people` finds them (lib/branches.server.ts passes the accounts).
38 */
39export function summary(commit: Commit | null | undefined, people: People = byName): ActiveBranch["commit"] {
40 if (!commit) return null;
41 const shown = people(commit);
42 return { hash: commit.hash, message: commit.message.split("\n")[0] ?? "", author: shown.author, coAuthors: shown.coAuthors, at: commit.authoredAt };
43}
44
45/**
46 * The branches other than the default that are read (at most `read`, those
47 * with an open pull request first), how many there are, and the default
48 * branch's head commit.
49 */
50export function branchesToRead(
51 input: { defaultBranch: string; branches: Branch[]; pulls: Pick<Pull, "branch" | "status">[] },
52 read: number,
53): { reading: Branch[]; total: number; mainHead: string | null } {
54 const pullOn = new Set(input.pulls.flatMap((pull) => (pull.branch && isOpen(pull) ? [pull.branch] : [])));
55 const others = input.branches.filter((branch) => branch.name !== input.defaultBranch);
56 const reading = [...others.filter((b) => pullOn.has(b.name)), ...others.filter((b) => !pullOn.has(b.name))].slice(0, read);
57 const mainHead = input.branches.find((branch) => branch.name === input.defaultBranch)?.hash || null;
58 return { reading, total: others.length, mainHead };
59}
60
61/**
62 * Each branch's pull request: the open one, else the newest that was
63 * merged or closed. `pulls` come newest first, as `listPulls` answers.
64 */
65function pullsByBranch(pulls: PullOn[]): Map<string, PullOn> {
66 const found = new Map<string, PullOn>();
67 for (const pull of pulls) {
68 if (!pull.branch) continue;
69 const kept = found.get(pull.branch);
70 if (!kept || (!isOpen(kept) && isOpen(pull))) found.set(pull.branch, pull);
71 }
72 return found;
73}
74
75/**
76 * Each branch read, newest commit first, with what repos measured (null
77 * when that could not be had: no commits, no counts), its pull request and
78 * its preview.
79 */
80export function activeBranches(
81 reading: Branch[],
82 measured: BranchDrifts | null,
83 input: { pulls: PullOn[]; previews: Preview[] },
84 people: People = byName,
85): ActiveBranch[] {
86 const pullOn = pullsByBranch(input.pulls);
87 const byHead = new Map((measured?.branches ?? []).map((found) => [found.head, found]));
88 return reading
89 .map((branch): ActiveBranch => {
90 const pull = pullOn.get(branch.name);
91 const found = byHead.get(branch.hash);
92 return {
93 name: branch.name,
94 head: branch.hash,
95 commit: summary(found?.commit, people),
96 drift: found?.drift ?? null,
97 pull: pull ? { number: pull.number, title: pull.title, checkStatus: pull.checkStatus, draft: pull.status === "draft", status: pull.status } : null,
98 preview: input.previews.find((app) => app.branch === branch.name || (pull != null && isOpen(pull) && app.number === pull.number))?.url ?? null,
99 };
100 })
101 .sort((a, b) => Date.parse(b.commit?.at ?? "0") - Date.parse(a.commit?.at ?? "0"));
102}
103
104// --- The Branches page: its tabs and sections --------------------------------
105
106/** The Branches page's tabs, kept in the address as `?tab=`. */
107export const BRANCH_TABS = ["overview", "yours", "active", "stale", "all"] as const;
108export type BranchTab = (typeof BRANCH_TABS)[number];
109
110/** A tab from the address: the overview for anything else. */
111export function branchTab(value: string | null | undefined): BranchTab {
112 return (BRANCH_TABS as readonly string[]).includes(value ?? "") ? (value as BranchTab) : "overview";
113}
114
115/** A branch whose last commit is older than this is stale. */
116export const STALE_DAYS = 90;
117
118/** Whether a branch's last commit is more than `STALE_DAYS` old. A branch whose commit was not read is not. */
119export function isStale(branch: Pick<ActiveBranch, "commit">, now: number): boolean {
120 const at = branch.commit ? Date.parse(branch.commit.at) : Number.NaN;
121 return Number.isFinite(at) && at < now - STALE_DAYS * 86_400_000;
122}
123
124/** Whether `username` made a branch's latest commit. */
125export function isYours(branch: Pick<ActiveBranch, "commit">, username: string | null | undefined): boolean {
126 return Boolean(username) && branch.commit?.author.username?.toLowerCase() === username?.toLowerCase();
127}
128
129/**
130 * Whether deleting a branch loses nothing the default branch lacks: every
131 * commit on it is on the default branch, or its pull request was merged.
132 * Such a branch is deleted without asking.
133 */
134export function isMerged(branch: Pick<ActiveBranch, "drift" | "pull">): boolean {
135 return branch.drift?.ahead === 0 || branch.pull?.status === "merged";
136}
137
138export type BranchSection = {
139 tab: Exclude<BranchTab, "overview">;
140 title: string;
141 /** Under the title, when it needs saying. */
142 note?: string;
143 branches: ActiveBranch[];
144 /** How many more the section's own tab lists. */
145 more: number;
146};
147
148/** How many branches each section of the overview shows. */
149export const OVERVIEW_SHOWN = 5;
150
151/**
152 * The sections a tab lists, besides the default branch: the overview
153 * shows a few of the viewer's branches, the active ones and the stale ones; each other
154 * tab lists its own.
155 */
156export function branchSections(
157 branches: ActiveBranch[],
158 tab: BranchTab,
159 viewer: { username: string | null; now: number },
160 shown = OVERVIEW_SHOWN,
161): BranchSection[] {
162 const yours = branches.filter((branch) => isYours(branch, viewer.username));
163 const active = branches.filter((branch) => !isStale(branch, viewer.now));
164 const stale = branches.filter((branch) => isStale(branch, viewer.now));
165 const whole = (section: Omit<BranchSection, "more">): BranchSection => ({ ...section, more: 0 });
166 switch (tab) {
167 case "overview": {
168 const few = (section: Omit<BranchSection, "more">): BranchSection => ({
169 ...section,
170 branches: section.branches.slice(0, shown),
171 more: Math.max(0, section.branches.length - shown),
172 });
173 return [
174 ...(yours.length > 0 ? [few({ tab: "yours", title: "Your branches", branches: yours })] : []),
175 few({ tab: "active", title: "Active branches", branches: active }),
176 ...(stale.length > 0 ? [few({ tab: "stale", title: "Stale branches", branches: stale })] : []),
177 ];
178 }
179 case "yours":
180 return [whole({ tab, title: "Your branches", note: "Branches whose latest commit is yours.", branches: yours })];
181 case "active":
182 return [whole({ tab, title: "Active branches", note: `Commits in the last ${STALE_DAYS} days.`, branches: active })];
183 case "stale":
184 return [whole({ tab, title: "Stale branches", note: `No commits in the last ${STALE_DAYS} days.`, branches: stale })];
185 case "all":
186 return [whole({ tab, title: "All branches", branches })];
187 }
188}
189
190/** The longest behind or ahead count among `branches`, which a full bar stands for. */
191export function driftScale(branches: Pick<ActiveBranch, "drift">[]): number {
192 return Math.max(1, ...branches.map((branch) => Math.max(branch.drift?.ahead ?? 0, branch.drift?.behind ?? 0)));
193}