| 1 | /** |
| 2 | * The shaping behind Code's Overview: why each thing needs the viewer, |
| 3 | * what is known about it, the tabs, the week of landed changes, and the |
| 4 | * one sentence under the title. Pure, so it is tested on its own; it |
| 5 | * imports only types. |
| 6 | */ |
| 7 | import type { CheckStatus, ChangedFile, RepoPath } from "@g1t/contracts"; |
| 8 | |
| 9 | import type { Need } from "./mission"; |
| 10 | |
| 11 | const DAY = 24 * 60 * 60 * 1000; |
| 12 | |
| 13 | /** Accounts that are g1t's own machinery: `isAgent` in ./mission, kept here so this module imports only types. Older rows still name them. */ |
| 14 | export function isAgent(name: string | null | undefined): boolean { |
| 15 | return name === "g1t" || (name ?? "").endsWith("-agent"); |
| 16 | } |
| 17 | |
| 18 | // --- Reasons ---------------------------------------------------------------- |
| 19 | |
| 20 | /** Why something is waiting on a person, as the chip beside it says. */ |
| 21 | export type Reason = "blocking" | "invited" | "needs_review" | "checks_failing" | "stalled"; |
| 22 | |
| 23 | export const REASON_LABEL: Record<Reason, string> = { |
| 24 | blocking: "Blocking", |
| 25 | invited: "Invitation", |
| 26 | needs_review: "Review requested", |
| 27 | checks_failing: "Checks failing", |
| 28 | stalled: "Stalled", |
| 29 | }; |
| 30 | |
| 31 | /** The reason a need is shown, from its kind. */ |
| 32 | export function reasonFor(need: Pick<Need, "kind">): Reason { |
| 33 | switch (need.kind) { |
| 34 | case "deploy": |
| 35 | case "conflict": |
| 36 | return "blocking"; |
| 37 | case "invitation": |
| 38 | return "invited"; |
| 39 | case "review": |
| 40 | case "ready": |
| 41 | return "needs_review"; |
| 42 | case "checks": |
| 43 | return "checks_failing"; |
| 44 | case "stalled": |
| 45 | case "stuck": |
| 46 | case "runner": |
| 47 | return "stalled"; |
| 48 | } |
| 49 | } |
| 50 | |
| 51 | /** Why only a person can move it: the callout beside what is known. */ |
| 52 | export function whyFor(reason: Reason, need: Pick<Need, "kind">): string { |
| 53 | if (need.kind === "deploy") |
| 54 | return "Production still serves the build before this one. Every push to the default branch builds again, so this fails until what broke is fixed."; |
| 55 | if (need.kind === "invitation") return "An invitation is to you. No one else can accept it."; |
| 56 | if (need.kind === "review") return "You were asked to review it by name, so it waits for your verdict."; |
| 57 | if (need.kind === "runner") |
| 58 | return "The job runs only on a self-hosted runner with those labels, and none is connected. It starts as soon as one is; nothing else picks it up."; |
| 59 | switch (reason) { |
| 60 | case "blocking": |
| 61 | return "Nothing moves until someone decides how it lands, or whether it should."; |
| 62 | case "checks_failing": |
| 63 | return "It was taken out of the merge queue because its checks failed with what was ahead of it. Push a fix, then merge it again."; |
| 64 | case "needs_review": |
| 65 | return "It waits for a verdict before it can land."; |
| 66 | case "invited": |
| 67 | return "It is addressed to you."; |
| 68 | case "stalled": |
| 69 | return "It has stopped moving and needs a look."; |
| 70 | } |
| 71 | } |
| 72 | |
| 73 | /** Reasons where nothing moves until a person acts on it. */ |
| 74 | export const BLOCKING: ReadonlySet<Reason> = new Set<Reason>(["blocking"]); |
| 75 | |
| 76 | // --- What is known ---------------------------------------------------------- |
| 77 | |
| 78 | export type FactTone = "good" | "warn" | "bad" | null; |
| 79 | /** One thing known; a `wide` one takes the whole row and wraps. */ |
| 80 | export type Fact = { label: string; value: string; tone: FactTone; wide?: boolean }; |
| 81 | |
| 82 | /** How the checks the default branch requires stand on a pull request, from what a list knows without opening it. */ |
| 83 | export function checksFact(checkStatus: CheckStatus | null): Fact { |
| 84 | const fact = (value: string, tone: FactTone): Fact => ({ label: "Required checks", value, tone }); |
| 85 | if (checkStatus === "failed") return fact("Failed in the merge queue", "bad"); |
| 86 | if (checkStatus === "passed") return fact("Passing", "good"); |
| 87 | if (checkStatus === "queued" || checkStatus === "running") return fact("Running", null); |
| 88 | return fact("Not known yet", null); |
| 89 | } |
| 90 | |
| 91 | /** Test files, by the names test runners look for. */ |
| 92 | export function isTestFile(path: string): boolean { |
| 93 | return ( |
| 94 | /(^|\/)(__tests__|tests?|spec)\//i.test(path) || |
| 95 | /[._-](test|spec)\.[a-z0-9]+$/i.test(path) || |
| 96 | /_test\.(go|rs|py)$/i.test(path) || |
| 97 | // A file named for what it is: test.js, tests.py, spec.rb. |
| 98 | /(^|\/)(tests?|specs?)\.[a-z0-9]+$/i.test(path) |
| 99 | ); |
| 100 | } |
| 101 | |
| 102 | const plural = (n: number, one: string, many = `${one}s`) => `${n.toLocaleString("en-US")} ${n === 1 ? one : many}`; |
| 103 | |
| 104 | /** |
| 105 | * What is known about a pull request without opening it: its checks, how |
| 106 | * much it changes and the tests it touches. Only what is known is listed. |
| 107 | */ |
| 108 | export function pullFacts(input: { checkStatus: CheckStatus | null; files: ChangedFile[] }): Fact[] { |
| 109 | const facts: Fact[] = [checksFact(input.checkStatus)]; |
| 110 | if (input.files.length > 0) { |
| 111 | const added = input.files.reduce((sum, f) => sum + f.additions, 0); |
| 112 | const removed = input.files.reduce((sum, f) => sum + f.deletions, 0); |
| 113 | facts.push({ label: "Files changed", value: input.files.length.toLocaleString("en-US"), tone: null }); |
| 114 | facts.push({ label: "Lines", value: `+${added.toLocaleString("en-US")} −${removed.toLocaleString("en-US")}`, tone: null }); |
| 115 | const tests = input.files.filter((f) => isTestFile(f.path)).length; |
| 116 | facts.push({ label: "Tests", value: tests > 0 ? plural(tests, "file") : "None touched", tone: tests > 0 ? "good" : "warn" }); |
| 117 | } |
| 118 | return facts; |
| 119 | } |
| 120 | |
| 121 | // --- Rows ------------------------------------------------------------------- |
| 122 | |
| 123 | /** Someone on a row: a person, or g1t itself on older rows. */ |
| 124 | export type Who = { name: string; agent: boolean }; |
| 125 | |
| 126 | export const who = (name: string | null | undefined): Who | null => (name ? { name, agent: isAgent(name) } : null); |
| 127 | |
| 128 | /** A form on the page that acts without leaving it. */ |
| 129 | export type QuickAction = { label: string; to: string; fields: Record<string, string>; done: string }; |
| 130 | |
| 131 | /** One thing that needs the viewer, with what is known about it. */ |
| 132 | export type NeedRow = { |
| 133 | key: string; |
| 134 | reason: Reason; |
| 135 | repo: RepoPath | null; |
| 136 | /** "#41", when it is an issue or pull request. */ |
| 137 | ref: string | null; |
| 138 | title: string; |
| 139 | /** The ask, in one sentence. */ |
| 140 | ask: string; |
| 141 | /** Who is waiting: the author, or the person who asked. */ |
| 142 | by: Who | null; |
| 143 | at: number; |
| 144 | to: string; |
| 145 | open: string; |
| 146 | facts: Fact[]; |
| 147 | why: string; |
| 148 | quick: QuickAction | null; |
| 149 | /** Somewhere else to act, when that is the next step. */ |
| 150 | link: { label: string; to: string } | null; |
| 151 | }; |
| 152 | |
| 153 | const pathKey = (repo: RepoPath, number: number | null) => `${repo.namespace}/${repo.name}#${number ?? "-"}`.toLowerCase(); |
| 154 | |
| 155 | // --- Landed ----------------------------------------------------------------- |
| 156 | |
| 157 | /** |
| 158 | * The accounts g1t itself acts as on the event log: the account older rows |
| 159 | * name, and the policy that merges and pushes for it. An event's `actor` |
| 160 | * is an account id, so this is what tells g1t apart, whatever name a |
| 161 | * commit carries. |
| 162 | */ |
| 163 | export const G1T_ACCOUNT_IDS: ReadonlySet<string> = new Set(["usr_g1t_agent", "g1t_policy"]); |
| 164 | |
| 165 | /** A `git.push` from the log, as far as placing its commits needs it. */ |
| 166 | export type PushRecord = { |
| 167 | time: string; |
| 168 | /** The account that pushed; null when the log does not say. */ |
| 169 | actor?: string | null; |
| 170 | data: { after: string; before?: string; causedByJob?: string }; |
| 171 | }; |
| 172 | |
| 173 | /** |
| 174 | * Whether a person pushed: not g1t itself (by the account that signed in |
| 175 | * to push), and not a workflow job's own token. |
| 176 | */ |
| 177 | export function pushedByPerson(push: Pick<PushRecord, "actor" | "data">): boolean { |
| 178 | if (push.data.causedByJob) return false; |
| 179 | return !(push.actor && G1T_ACCOUNT_IDS.has(push.actor)); |
| 180 | } |
| 181 | |
| 182 | /** |
| 183 | * The commits a person's push to the default branch brought, from the |
| 184 | * history at its `after` (newest first) back to its `before`: their own, |
| 185 | * not merges (a pull request landing) and not g1t's (`byG1t`: a commit |
| 186 | * g1t wrote, told by its author address, which a person may fast-forward |
| 187 | * onto the branch). A push whose `before` is not in what was read gives |
| 188 | * what was read. Who pushed is the caller's to decide (`pushedByPerson`); |
| 189 | * a commit's author name says nothing about it. |
| 190 | */ |
| 191 | export function pushedCommits<C extends { hash: string; parents: string[] }>( |
| 192 | history: C[], |
| 193 | before: string | undefined, |
| 194 | byG1t: (commit: C) => boolean = () => false, |
| 195 | ): C[] { |
| 196 | const end = before ? history.findIndex((commit) => commit.hash === before) : -1; |
| 197 | const brought = end === -1 ? history : history.slice(0, end); |
| 198 | return brought.filter((commit) => commit.parents.length <= 1 && !byG1t(commit)); |
| 199 | } |
| 200 | |
| 201 | /** |
| 202 | * Each commit of `history` (newest first) a person's push in `pushes` |
| 203 | * (newest first) brought, at that push's time. A commit pushed twice |
| 204 | * (after a force push, say) counts once, at its first landing; a push |
| 205 | * whose `after` is no longer in the history (rewritten) brings nothing, |
| 206 | * and g1t's own pushes bring nothing here: pull requests count those. |
| 207 | */ |
| 208 | export function placePushes<C extends { hash: string; parents: string[] }>( |
| 209 | history: C[], |
| 210 | pushes: PushRecord[], |
| 211 | byG1t: (commit: C) => boolean = () => false, |
| 212 | ): { hash: string; at: string }[] { |
| 213 | const index = new Map(history.map((commit, i) => [commit.hash, i])); |
| 214 | // Each commit's first landing: the time, or null when g1t landed it. |
| 215 | const seen = new Map<string, string | null>(); |
| 216 | for (const push of [...pushes].reverse()) { |
| 217 | const start = index.get(push.data.after); |
| 218 | if (start === undefined) continue; |
| 219 | const end = push.data.before ? index.get(push.data.before) : undefined; |
| 220 | const range = history.slice(start, end ?? history.length); |
| 221 | const at = pushedByPerson(push) ? push.time : null; |
| 222 | for (const commit of pushedCommits(range, undefined, byG1t)) { |
| 223 | if (!seen.has(commit.hash)) seen.set(commit.hash, at); |
| 224 | } |
| 225 | } |
| 226 | return [...seen].flatMap(([hash, at]) => (at ? [{ hash, at }] : [])); |
| 227 | } |
| 228 | |
| 229 | /** |
| 230 | * Whether a read of the default branch reaches back far enough to place |
| 231 | * every push, newest first: it holds the oldest push's `before`, or it is |
| 232 | * the whole branch. A shorter read than `asked` is the whole branch. |
| 233 | */ |
| 234 | export function historyCovers(history: { hash: string }[], pushes: PushRecord[], asked: number): boolean { |
| 235 | if (history.length < asked) return true; |
| 236 | const oldest = pushes.at(-1)?.data.before; |
| 237 | return oldest != null && history.some((commit) => commit.hash === oldest); |
| 238 | } |
| 239 | |
| 240 | /** A merged pull request, as the week counts it. */ |
| 241 | export type Merged = { |
| 242 | repo: RepoPath; |
| 243 | number: number; |
| 244 | title: string; |
| 245 | mergedBy: string | null; |
| 246 | mergedAt: string; |
| 247 | files: ChangedFile[]; |
| 248 | }; |
| 249 | |
| 250 | /** |
| 251 | * Whether a change landed without anyone pressing merge: g1t merged it, by |
| 252 | * auto-merge or from the merge queue. |
| 253 | */ |
| 254 | export function mergedAutomatically(change: Pick<Merged, "mergedBy">): boolean { |
| 255 | return change.mergedBy == null || isAgent(change.mergedBy); |
| 256 | } |
| 257 | |
| 258 | /** A day's date in a time zone, as `YYYY-MM-DD`; UTC when the zone is unknown. */ |
| 259 | export function dayKey(at: number, timeZone: string | null): string { |
| 260 | try { |
| 261 | return new Intl.DateTimeFormat("en-CA", { year: "numeric", month: "2-digit", day: "2-digit", timeZone: timeZone || "UTC" }).format(at); |
| 262 | } catch { |
| 263 | return new Date(at).toISOString().slice(0, 10); |
| 264 | } |
| 265 | } |
| 266 | |
| 267 | /** One day's landed changes. */ |
| 268 | export type WeekDay = { key: string; label: string; count: number }; |
| 269 | |
| 270 | export type Week = { |
| 271 | days: WeekDay[]; |
| 272 | /** Changes landed in the last seven days. */ |
| 273 | total: number; |
| 274 | /** The seven days before, or null when the lists read do not reach back that far. */ |
| 275 | previous: number | null; |
| 276 | }; |
| 277 | |
| 278 | /** |
| 279 | * The last seven days in the viewer's zone, oldest first, each with how |
| 280 | * many changes landed, and the week before as one number. `complete` says |
| 281 | * whether what was read reaches back two weeks; when it does not, the week |
| 282 | * before is not guessed. |
| 283 | */ |
| 284 | export function weekOf(changes: Pick<Merged, "mergedAt">[], now: number, timeZone: string | null, complete = true): Week { |
| 285 | const days: WeekDay[] = []; |
| 286 | const index = new Map<string, number>(); |
| 287 | for (let back = 6; back >= 0; back -= 1) { |
| 288 | const at = now - back * DAY; |
| 289 | const key = dayKey(at, timeZone); |
| 290 | if (index.has(key)) continue; |
| 291 | index.set(key, days.length); |
| 292 | let label: string; |
| 293 | try { |
| 294 | label = new Intl.DateTimeFormat("en-US", { weekday: "short", timeZone: timeZone || "UTC" }).format(at); |
| 295 | } catch { |
| 296 | label = new Intl.DateTimeFormat("en-US", { weekday: "short", timeZone: "UTC" }).format(at); |
| 297 | } |
| 298 | days.push({ key, label, count: 0 }); |
| 299 | } |
| 300 | const oldest = days[0]?.key ?? ""; |
| 301 | const twoWeeks = dayKey(now - 13 * DAY, timeZone); |
| 302 | let previous = 0; |
| 303 | for (const change of changes) { |
| 304 | const at = Date.parse(change.mergedAt); |
| 305 | if (!Number.isFinite(at) || at > now) continue; |
| 306 | const key = dayKey(at, timeZone); |
| 307 | const slot = index.get(key); |
| 308 | if (slot != null) days[slot].count += 1; |
| 309 | else if (key < oldest && key >= twoWeeks) previous += 1; |
| 310 | } |
| 311 | return { days, total: days.reduce((sum, day) => sum + day.count, 0), previous: complete ? previous : null }; |
| 312 | } |
| 313 | |
| 314 | /** The change from one number to another, as a share; null from nothing. */ |
| 315 | export function change(current: number, previous: number | null): number | null { |
| 316 | if (previous == null || previous === 0) return null; |
| 317 | return (current - previous) / previous; |
| 318 | } |
| 319 | |
| 320 | /** "+18%", "−5%", "0%". */ |
| 321 | export function signedPercent(share: number): string { |
| 322 | const n = Math.round(share * 100); |
| 323 | return n > 0 ? `+${n}%` : n < 0 ? `−${Math.abs(n)}%` : "0%"; |
| 324 | } |
| 325 | |
| 326 | /** |
| 327 | * Whether the merged pull requests read for one repository reach back to |
| 328 | * `since`: the list is not full, or its oldest goes back that far. |
| 329 | */ |
| 330 | export function reachesBack(list: { mergedAt: string | null; updatedAt: string }[], since: number, page: number): boolean { |
| 331 | if (list.length < page) return true; |
| 332 | const oldest = Math.min(...list.map((pull) => Date.parse(pull.mergedAt ?? pull.updatedAt)).filter(Number.isFinite)); |
| 333 | return oldest <= since; |
| 334 | } |
| 335 | |
| 336 | /** One change that landed, as the Landed tab lists it. */ |
| 337 | export type LandedRow = { |
| 338 | key: string; |
| 339 | repo: RepoPath; |
| 340 | ref: string; |
| 341 | title: string; |
| 342 | /** Who merged it; g1t for a change that merged automatically. */ |
| 343 | by: Who | null; |
| 344 | automatic: boolean; |
| 345 | at: number; |
| 346 | to: string; |
| 347 | facts: Fact[]; |
| 348 | }; |
| 349 | |
| 350 | /** What landed on the viewer's calendar day, newest first. */ |
| 351 | export function landedToday(changes: Merged[], now: number, timeZone: string | null): LandedRow[] { |
| 352 | const today = dayKey(now, timeZone); |
| 353 | return changes |
| 354 | .filter((change) => dayKey(Date.parse(change.mergedAt), timeZone) === today) |
| 355 | .sort((a, b) => Date.parse(b.mergedAt) - Date.parse(a.mergedAt)) |
| 356 | .map((change) => ({ |
| 357 | key: pathKey(change.repo, change.number), |
| 358 | repo: change.repo, |
| 359 | ref: `#${change.number}`, |
| 360 | title: change.title, |
| 361 | by: who(change.mergedBy ?? "g1t"), |
| 362 | automatic: mergedAutomatically(change), |
| 363 | at: Date.parse(change.mergedAt), |
| 364 | to: `/${change.repo.namespace}/${change.repo.name}/pull/${change.number}`, |
| 365 | facts: pullFacts({ checkStatus: "passed", files: change.files }).filter((fact) => fact.label !== "Required checks"), |
| 366 | })); |
| 367 | } |
| 368 | |
| 369 | // --- Tabs and sorting ------------------------------------------------------- |
| 370 | |
| 371 | export type Tab = "needs" | "landed"; |
| 372 | export type Sort = "impact" | "newest"; |
| 373 | |
| 374 | export const TABS: Tab[] = ["needs", "landed"]; |
| 375 | |
| 376 | export function parseTab(value: string | null): Tab | null { |
| 377 | return value === "needs" || value === "landed" ? value : null; |
| 378 | } |
| 379 | |
| 380 | export function parseSort(value: string | null): Sort { |
| 381 | return value === "newest" ? "newest" : "impact"; |
| 382 | } |
| 383 | |
| 384 | /** Rows in the order chosen: as ranked (most urgent first), or newest first. */ |
| 385 | export function sortRows<T extends { at: number }>(rows: T[], sort: Sort): T[] { |
| 386 | return sort === "newest" ? [...rows].sort((a, b) => b.at - a.at) : rows; |
| 387 | } |
| 388 | |
| 389 | // --- The summary ------------------------------------------------------------ |
| 390 | |
| 391 | /** The sentence under the title: the week, in one line. */ |
| 392 | export function summaryLine(input: { total: number; needs: number }): string { |
| 393 | const { total, needs } = input; |
| 394 | if (total > 0) return `${plural(total, "change")} landed in the last 7 days.`; |
| 395 | if (needs > 0) return "Nothing has landed this week. What is waiting on you is below."; |
| 396 | return "Nothing has landed this week yet."; |
| 397 | } |
| 398 | |
| 399 | /** "Monday, Oct 5", in the viewer's zone. */ |
| 400 | export function dateLine(now: number, timeZone: string | null): string { |
| 401 | const options: Intl.DateTimeFormatOptions = { weekday: "long", month: "short", day: "numeric" }; |
| 402 | try { |
| 403 | return new Intl.DateTimeFormat("en-US", { ...options, timeZone: timeZone || "UTC" }).format(now); |
| 404 | } catch { |
| 405 | return new Intl.DateTimeFormat("en-US", { ...options, timeZone: "UTC" }).format(now); |
| 406 | } |
| 407 | } |