| 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 | */ |
| 6 | import type { Branch, BranchDrifts, Commit, Pull } from "@g1t/contracts"; |
| 7 | |
| 8 | import type { ActiveBranch } from "../components/branches"; |
| 9 | import 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 | */ |
| 17 | export type Drift = { ahead: number; behind: number }; |
| 18 | |
| 19 | type Preview = { branch?: string | null; number?: number | null; url: string }; |
| 20 | |
| 21 | type PullOn = Pick<Pull, "branch" | "number" | "title" | "checkStatus" | "status">; |
| 22 | |
| 23 | /** A commit's people, as lib/commit-people.ts `showCommit` finds them. */ |
| 24 | export type People = (commit: Commit) => Pick<ShownCommit, "author" | "coAuthors">; |
| 25 | |
| 26 | /** Nobody matched to an account: the name on the commit. */ |
| 27 | const 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. */ |
| 33 | const 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 | */ |
| 39 | export 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 | */ |
| 50 | export 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 | */ |
| 65 | function 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 | */ |
| 80 | export 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=`. */ |
| 107 | export const BRANCH_TABS = ["overview", "yours", "active", "stale", "all"] as const; |
| 108 | export type BranchTab = (typeof BRANCH_TABS)[number]; |
| 109 | |
| 110 | /** A tab from the address: the overview for anything else. */ |
| 111 | export 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. */ |
| 116 | export 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. */ |
| 119 | export 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. */ |
| 125 | export 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 | */ |
| 134 | export function isMerged(branch: Pick<ActiveBranch, "drift" | "pull">): boolean { |
| 135 | return branch.drift?.ahead === 0 || branch.pull?.status === "merged"; |
| 136 | } |
| 137 | |
| 138 | export 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. */ |
| 149 | export 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 | */ |
| 156 | export 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. */ |
| 191 | export 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 | } |