| 1 | import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; |
| 2 | import { fetchFeed, lastPageFromLink, relativeAge, relativeTime } from "./github"; |
| 3 | |
| 4 | // We test the pure helper functions directly. |
| 5 | // The async fetch functions require mocking the global fetch. |
| 6 | |
| 7 | // ── relativeTime ────────────────────────────────────────────────────── |
| 8 | |
| 9 | describe("relativeTime", () => { |
| 10 | beforeEach(() => { |
| 11 | vi.useFakeTimers(); |
| 12 | vi.setSystemTime(new Date("2026-06-01T12:00:00Z")); |
| 13 | }); |
| 14 | |
| 15 | afterEach(() => { |
| 16 | vi.useRealTimers(); |
| 17 | }); |
| 18 | |
| 19 | it("returns 'just now' for less than 30 seconds ago", () => { |
| 20 | expect(relativeTime("2026-06-01T11:59:45Z")).toBe("just now"); |
| 21 | }); |
| 22 | |
| 23 | it("returns 'just now' for dates in the future", () => { |
| 24 | expect(relativeTime("2026-06-02T12:00:00Z")).toBe("just now"); |
| 25 | }); |
| 26 | |
| 27 | it("returns minutes for < 1 hour", () => { |
| 28 | expect(relativeTime("2026-06-01T11:55:00Z")).toBe("5m"); |
| 29 | expect(relativeTime("2026-06-01T11:30:00Z")).toBe("30m"); |
| 30 | }); |
| 31 | |
| 32 | it("returns hours for < 1 day", () => { |
| 33 | expect(relativeTime("2026-06-01T09:00:00Z")).toBe("3h"); |
| 34 | expect(relativeTime("2026-05-31T18:00:00Z")).toBe("18h"); |
| 35 | }); |
| 36 | |
| 37 | it("returns days for < 30 days", () => { |
| 38 | expect(relativeTime("2026-05-25T12:00:00Z")).toBe("7d"); |
| 39 | expect(relativeTime("2026-05-03T12:00:00Z")).toBe("29d"); |
| 40 | }); |
| 41 | |
| 42 | it("returns months for < 12 months", () => { |
| 43 | expect(relativeTime("2026-03-01T12:00:00Z")).toBe("3mo"); |
| 44 | expect(relativeTime("2025-08-01T12:00:00Z")).toBe("10mo"); |
| 45 | }); |
| 46 | |
| 47 | it("returns years for >= 12 months", () => { |
| 48 | expect(relativeTime("2024-06-01T12:00:00Z")).toBe("2y"); |
| 49 | expect(relativeTime("2025-01-01T00:00:00Z")).toBe("1y"); |
| 50 | }); |
| 51 | }); |
| 52 | |
| 53 | // ── relativeAge ─────────────────────────────────────────────────────── |
| 54 | |
| 55 | describe("relativeAge", () => { |
| 56 | beforeEach(() => { |
| 57 | vi.useFakeTimers(); |
| 58 | vi.setSystemTime(new Date("2026-06-01T12:00:00Z")); |
| 59 | }); |
| 60 | |
| 61 | afterEach(() => { |
| 62 | vi.useRealTimers(); |
| 63 | }); |
| 64 | |
| 65 | it("reports past ages as the negative counts Intl.RelativeTimeFormat wants", () => { |
| 66 | expect(relativeAge("2026-06-01T11:55:00Z")).toEqual({ value: -5, unit: "minute" }); |
| 67 | expect(relativeAge("2026-06-01T09:00:00Z")).toEqual({ value: -3, unit: "hour" }); |
| 68 | expect(relativeAge("2026-05-25T12:00:00Z")).toEqual({ value: -7, unit: "day" }); |
| 69 | expect(relativeAge("2026-03-01T12:00:00Z")).toEqual({ value: -3, unit: "month" }); |
| 70 | expect(relativeAge("2024-06-01T12:00:00Z")).toEqual({ value: -2, unit: "year" }); |
| 71 | }); |
| 72 | |
| 73 | it("collapses sub-minute, future, and unparseable dates to the locale's 'now'", () => { |
| 74 | expect(relativeAge("2026-06-01T11:59:45Z")).toEqual({ value: 0, unit: "second" }); |
| 75 | expect(relativeAge("2026-06-02T12:00:00Z")).toEqual({ value: 0, unit: "second" }); |
| 76 | expect(relativeAge("not-a-date")).toEqual({ value: 0, unit: "second" }); |
| 77 | }); |
| 78 | |
| 79 | it("agrees with the compact English form it backs", () => { |
| 80 | for (const iso of [ |
| 81 | "2026-06-01T11:59:45Z", |
| 82 | "2026-06-01T11:30:00Z", |
| 83 | "2026-05-31T18:00:00Z", |
| 84 | "2026-05-03T12:00:00Z", |
| 85 | "2025-08-01T12:00:00Z", |
| 86 | "2025-01-01T00:00:00Z", |
| 87 | ]) { |
| 88 | const age = relativeAge(iso); |
| 89 | const compact = relativeTime(iso); |
| 90 | if (age.unit === "second") expect(compact).toBe("just now"); |
| 91 | else expect(compact.startsWith(String(Math.abs(age.value)))).toBe(true); |
| 92 | } |
| 93 | }); |
| 94 | }); |
| 95 | |
| 96 | // ── fetchFeed ───────────────────────────────────────────────────────── |
| 97 | |
| 98 | function json(body: unknown): Response { |
| 99 | return new Response(JSON.stringify(body), { |
| 100 | status: 200, |
| 101 | headers: { "content-type": "application/json" }, |
| 102 | }); |
| 103 | } |
| 104 | |
| 105 | describe("fetchFeed", () => { |
| 106 | const ISSUES = [ |
| 107 | { |
| 108 | number: 4901, |
| 109 | title: "Ticker shows nothing on first paint", |
| 110 | html_url: "https://github.com/Hmbown/CodeWhale/issues/4901", |
| 111 | state: "open", |
| 112 | user: { login: "asto18089", avatar_url: "https://avatars/1" }, |
| 113 | created_at: "2026-06-01T09:00:00Z", |
| 114 | updated_at: "2026-06-01T11:00:00Z", |
| 115 | comments: 2, |
| 116 | labels: [{ name: "bug", color: "d73a4a" }], |
| 117 | author_association: "CONTRIBUTOR", |
| 118 | }, |
| 119 | { |
| 120 | number: 4880, |
| 121 | title: "Fixed upstream", |
| 122 | html_url: "https://github.com/Hmbown/CodeWhale/issues/4880", |
| 123 | state: "closed", |
| 124 | user: { login: "Inference1", avatar_url: "https://avatars/2" }, |
| 125 | created_at: "2026-03-02T09:00:00Z", |
| 126 | updated_at: "2026-06-01T10:00:00Z", |
| 127 | closed_at: "2026-05-30T09:00:00Z", |
| 128 | comments: 0, |
| 129 | labels: [], |
| 130 | author_association: "NONE", |
| 131 | }, |
| 132 | { |
| 133 | // The issues endpoint returns pull requests too; they must not double up. |
| 134 | number: 4899, |
| 135 | title: "PR echoed by the issues endpoint", |
| 136 | html_url: "https://github.com/Hmbown/CodeWhale/pull/4899", |
| 137 | state: "open", |
| 138 | user: { login: "bistack", avatar_url: "https://avatars/3" }, |
| 139 | created_at: "2026-06-01T08:00:00Z", |
| 140 | updated_at: "2026-06-01T08:30:00Z", |
| 141 | comments: 0, |
| 142 | labels: [], |
| 143 | pull_request: { url: "https://api.github.com/…" }, |
| 144 | }, |
| 145 | ]; |
| 146 | |
| 147 | const PULLS = [ |
| 148 | { |
| 149 | number: 4899, |
| 150 | title: "fix(tui): keep the composer caret on resize", |
| 151 | html_url: "https://github.com/Hmbown/CodeWhale/pull/4899", |
| 152 | state: "closed", |
| 153 | user: { login: "shenjackyuanjie", avatar_url: "https://avatars/4" }, |
| 154 | created_at: "2026-05-29T09:00:00Z", |
| 155 | updated_at: "2026-06-01T11:30:00Z", |
| 156 | closed_at: "2026-05-31T18:00:00Z", |
| 157 | merged_at: "2026-05-31T18:00:00Z", |
| 158 | comments: 4, |
| 159 | labels: [], |
| 160 | author_association: "FIRST_TIME_CONTRIBUTOR", |
| 161 | }, |
| 162 | { |
| 163 | number: 4902, |
| 164 | title: "wip: sandbox notes", |
| 165 | html_url: "https://github.com/Hmbown/CodeWhale/pull/4902", |
| 166 | state: "open", |
| 167 | user: { login: "h3c-hexin", avatar_url: "https://avatars/5" }, |
| 168 | created_at: "2026-06-01T07:00:00Z", |
| 169 | updated_at: "2026-06-01T07:30:00Z", |
| 170 | draft: true, |
| 171 | comments: 0, |
| 172 | labels: [], |
| 173 | author_association: "CONTRIBUTOR", |
| 174 | }, |
| 175 | ]; |
| 176 | |
| 177 | const RELEASES = [ |
| 178 | { |
| 179 | tag_name: "v0.9.4", |
| 180 | name: "v0.9.4", |
| 181 | html_url: "https://github.com/Hmbown/CodeWhale/releases/tag/v0.9.4", |
| 182 | created_at: "2026-05-28T09:00:00Z", |
| 183 | published_at: "2026-05-28T12:00:00Z", |
| 184 | author: { login: "Hmbown", avatar_url: "https://avatars/6" }, |
| 185 | }, |
| 186 | { |
| 187 | tag_name: "v0.9.5-rc1", |
| 188 | name: "v0.9.5-rc1", |
| 189 | html_url: "https://github.com/Hmbown/CodeWhale/releases/tag/v0.9.5-rc1", |
| 190 | created_at: "2026-06-01T09:00:00Z", |
| 191 | published_at: null, |
| 192 | draft: true, |
| 193 | author: { login: "Hmbown", avatar_url: "https://avatars/6" }, |
| 194 | }, |
| 195 | ]; |
| 196 | |
| 197 | const requested: string[] = []; |
| 198 | |
| 199 | beforeEach(() => { |
| 200 | requested.length = 0; |
| 201 | vi.stubGlobal( |
| 202 | "fetch", |
| 203 | vi.fn(async (url: string) => { |
| 204 | requested.push(url); |
| 205 | if (url.includes("/issues?")) return json(ISSUES); |
| 206 | if (url.includes("/pulls?")) return json(PULLS); |
| 207 | if (url.includes("/releases?")) return json(RELEASES); |
| 208 | return new Response("{}", { status: 404 }); |
| 209 | }), |
| 210 | ); |
| 211 | }); |
| 212 | |
| 213 | afterEach(() => { |
| 214 | vi.unstubAllGlobals(); |
| 215 | }); |
| 216 | |
| 217 | it("covers issues, pulls, and releases in three calls — no per-item follow-ups", async () => { |
| 218 | await fetchFeed(undefined, 10); |
| 219 | expect(requested).toHaveLength(3); |
| 220 | expect(requested.some((u) => u.includes("/issues?"))).toBe(true); |
| 221 | expect(requested.some((u) => u.includes("/pulls?"))).toBe(true); |
| 222 | expect(requested.some((u) => u.includes("/releases?"))).toBe(true); |
| 223 | }); |
| 224 | |
| 225 | it("names the contributor and dates each verb by the event, not the last comment", async () => { |
| 226 | const feed = await fetchFeed(undefined, 10); |
| 227 | |
| 228 | const merged = feed.find((f) => f.number === 4899 && f.kind === "pull"); |
| 229 | expect(merged?.state).toBe("merged"); |
| 230 | expect(merged?.author).toBe("shenjackyuanjie"); |
| 231 | // Merged at 18:00 on the 31st; last touched at 11:30 the next day. |
| 232 | expect(merged?.eventAt).toBe("2026-05-31T18:00:00Z"); |
| 233 | |
| 234 | const openIssue = feed.find((f) => f.number === 4901); |
| 235 | expect(openIssue?.state).toBe("open"); |
| 236 | expect(openIssue?.eventAt).toBe(openIssue?.createdAt); |
| 237 | |
| 238 | const closedIssue = feed.find((f) => f.number === 4880); |
| 239 | expect(closedIssue?.state).toBe("closed"); |
| 240 | expect(closedIssue?.eventAt).toBe("2026-05-30T09:00:00Z"); |
| 241 | |
| 242 | const draft = feed.find((f) => f.number === 4902); |
| 243 | expect(draft?.state).toBe("draft"); |
| 244 | }); |
| 245 | |
| 246 | it("passes GitHub's first-time-contributor verdict through verbatim", async () => { |
| 247 | const feed = await fetchFeed(undefined, 10); |
| 248 | expect(feed.find((f) => f.number === 4899 && f.kind === "pull")?.firstTimeContributor).toBe( |
| 249 | true, |
| 250 | ); |
| 251 | expect(feed.find((f) => f.number === 4901)?.firstTimeContributor).toBe(false); |
| 252 | // "NONE" is not a first-timer — only GitHub's own FIRST_TIME_CONTRIBUTOR is. |
| 253 | expect(feed.find((f) => f.number === 4880)?.firstTimeContributor).toBe(false); |
| 254 | }); |
| 255 | |
| 256 | it("carries published releases and drops unpublished drafts", async () => { |
| 257 | const feed = await fetchFeed(undefined, 10); |
| 258 | const releases = feed.filter((f) => f.kind === "release"); |
| 259 | expect(releases).toHaveLength(1); |
| 260 | expect(releases[0].tag).toBe("v0.9.4"); |
| 261 | expect(releases[0].state).toBe("published"); |
| 262 | expect(releases[0].eventAt).toBe("2026-05-28T12:00:00Z"); |
| 263 | expect(feed.some((f) => f.tag === "v0.9.5-rc1")).toBe(false); |
| 264 | }); |
| 265 | |
| 266 | it("keeps a recent release in view when a busy window would bury it", async () => { |
| 267 | vi.useFakeTimers(); |
| 268 | vi.setSystemTime(new Date("2026-06-01T12:00:00Z")); |
| 269 | try { |
| 270 | // Only two slots: pure recency would fill both with the newest threads. |
| 271 | const feed = await fetchFeed(undefined, 2); |
| 272 | expect(feed).toHaveLength(2); |
| 273 | expect(feed.filter((f) => f.kind === "release").map((f) => f.tag)).toEqual(["v0.9.4"]); |
| 274 | } finally { |
| 275 | vi.useRealTimers(); |
| 276 | } |
| 277 | }); |
| 278 | |
| 279 | it("does not pin a stale release beside today's threads", async () => { |
| 280 | vi.useFakeTimers(); |
| 281 | // Two years on from the last tag: a quiet quarter reads as a quiet quarter. |
| 282 | vi.setSystemTime(new Date("2028-06-01T12:00:00Z")); |
| 283 | try { |
| 284 | const feed = await fetchFeed(undefined, 2); |
| 285 | expect(feed).toHaveLength(2); |
| 286 | expect(feed.some((f) => f.kind === "release")).toBe(false); |
| 287 | } finally { |
| 288 | vi.useRealTimers(); |
| 289 | } |
| 290 | }); |
| 291 | |
| 292 | it("does not double-count pull requests echoed by the issues endpoint", async () => { |
| 293 | const feed = await fetchFeed(undefined, 10); |
| 294 | expect(feed.filter((f) => f.number === 4899)).toHaveLength(1); |
| 295 | expect(feed.filter((f) => f.kind === "issue").map((f) => f.number).sort()).toEqual([ |
| 296 | 4880, 4901, |
| 297 | ]); |
| 298 | }); |
| 299 | |
| 300 | it("returns an empty feed — never a placeholder — when GitHub is unreachable", async () => { |
| 301 | vi.stubGlobal( |
| 302 | "fetch", |
| 303 | vi.fn(async () => new Response("rate limited", { status: 403 })), |
| 304 | ); |
| 305 | expect(await fetchFeed(undefined, 10)).toEqual([]); |
| 306 | }); |
| 307 | }); |
| 308 | |
| 309 | describe("fetchFeed — bot authors", () => { |
| 310 | // The wire is contributor-forward: GitHub's own `[bot]` login marker keeps |
| 311 | // automated maintenance off the strip, while a bot-published release keeps |
| 312 | // its slot and drops its byline. |
| 313 | const ISSUES = [ |
| 314 | { |
| 315 | number: 101, |
| 316 | title: "Automated close sweep", |
| 317 | html_url: "https://github.com/Hmbown/CodeWhale/issues/101", |
| 318 | state: "closed", |
| 319 | user: { login: "github-actions[bot]", avatar_url: "https://avatars/bot1" }, |
| 320 | created_at: "2026-06-01T09:00:00Z", |
| 321 | updated_at: "2026-06-01T11:00:00Z", |
| 322 | closed_at: "2026-06-01T10:30:00Z", |
| 323 | comments: 0, |
| 324 | labels: [], |
| 325 | author_association: "NONE", |
| 326 | }, |
| 327 | { |
| 328 | number: 102, |
| 329 | title: "Real report", |
| 330 | html_url: "https://github.com/Hmbown/CodeWhale/issues/102", |
| 331 | state: "open", |
| 332 | user: { login: "vFONGv", avatar_url: "https://avatars/h1" }, |
| 333 | created_at: "2026-06-01T08:00:00Z", |
| 334 | updated_at: "2026-06-01T08:00:00Z", |
| 335 | comments: 0, |
| 336 | labels: [], |
| 337 | author_association: "FIRST_TIME_CONTRIBUTOR", |
| 338 | }, |
| 339 | ]; |
| 340 | |
| 341 | const PULLS = [ |
| 342 | { |
| 343 | number: 103, |
| 344 | title: "chore(deps): bump serde from 1.0.219 to 1.0.220", |
| 345 | html_url: "https://github.com/Hmbown/CodeWhale/pull/103", |
| 346 | state: "closed", |
| 347 | user: { login: "dependabot[bot]", avatar_url: "https://avatars/bot2" }, |
| 348 | created_at: "2026-05-31T09:00:00Z", |
| 349 | updated_at: "2026-06-01T07:00:00Z", |
| 350 | merged_at: "2026-06-01T07:00:00Z", |
| 351 | comments: 0, |
| 352 | labels: [], |
| 353 | author_association: "CONTRIBUTOR", |
| 354 | }, |
| 355 | { |
| 356 | number: 104, |
| 357 | title: "fix(tui): keep the whale rail steady", |
| 358 | html_url: "https://github.com/Hmbown/CodeWhale/pull/104", |
| 359 | state: "closed", |
| 360 | user: { login: "bistack", avatar_url: "https://avatars/h2" }, |
| 361 | created_at: "2026-05-30T09:00:00Z", |
| 362 | updated_at: "2026-06-01T06:00:00Z", |
| 363 | merged_at: "2026-06-01T06:00:00Z", |
| 364 | comments: 1, |
| 365 | labels: [], |
| 366 | author_association: "CONTRIBUTOR", |
| 367 | }, |
| 368 | ]; |
| 369 | |
| 370 | const RELEASES = [ |
| 371 | { |
| 372 | tag_name: "v0.9.4", |
| 373 | name: "v0.9.4", |
| 374 | html_url: "https://github.com/Hmbown/CodeWhale/releases/tag/v0.9.4", |
| 375 | created_at: "2026-05-28T09:00:00Z", |
| 376 | published_at: "2026-05-28T12:00:00Z", |
| 377 | author: { login: "release-train[bot]", avatar_url: "https://avatars/bot3" }, |
| 378 | }, |
| 379 | ]; |
| 380 | |
| 381 | beforeEach(() => { |
| 382 | vi.stubGlobal( |
| 383 | "fetch", |
| 384 | vi.fn(async (url: string) => { |
| 385 | if (url.includes("/issues?")) return json(ISSUES); |
| 386 | if (url.includes("/pulls?")) return json(PULLS); |
| 387 | if (url.includes("/releases?")) return json(RELEASES); |
| 388 | return new Response("{}", { status: 404 }); |
| 389 | }), |
| 390 | ); |
| 391 | }); |
| 392 | |
| 393 | afterEach(() => { |
| 394 | vi.unstubAllGlobals(); |
| 395 | }); |
| 396 | |
| 397 | it("keeps bot-authored issues and pulls off the contributor wire", async () => { |
| 398 | const feed = await fetchFeed(undefined, 10); |
| 399 | expect(feed.some((f) => f.author === "github-actions[bot]")).toBe(false); |
| 400 | expect(feed.some((f) => f.author === "dependabot[bot]")).toBe(false); |
| 401 | expect(feed.some((f) => f.number === 102 && f.kind === "issue")).toBe(true); |
| 402 | expect(feed.some((f) => f.number === 104 && f.kind === "pull")).toBe(true); |
| 403 | }); |
| 404 | |
| 405 | it("keeps a bot-published release in view, minus its byline", async () => { |
| 406 | const feed = await fetchFeed(undefined, 10); |
| 407 | const release = feed.find((f) => f.kind === "release"); |
| 408 | expect(release?.tag).toBe("v0.9.4"); |
| 409 | expect(release?.author).toBe(""); |
| 410 | expect(release?.authorAvatar).toBe(""); |
| 411 | }); |
| 412 | }); |
| 413 | |
| 414 | // ── lastPageFromLink (via re-export test) ────────────────────────────── |
| 415 | |
| 416 | describe("lastPageFromLink", () => { |
| 417 | it("returns undefined for null input", () => { |
| 418 | expect(lastPageFromLink(null)).toBeUndefined(); |
| 419 | }); |
| 420 | |
| 421 | it("returns undefined for empty string", () => { |
| 422 | expect(lastPageFromLink("")).toBeUndefined(); |
| 423 | }); |
| 424 | |
| 425 | it("extracts page from Link header with last rel", () => { |
| 426 | const link = |
| 427 | '<https://api.github.com/repos/Hmbown/CodeWhale/issues?page=5>; rel="last"'; |
| 428 | expect(lastPageFromLink(link)).toBe(5); |
| 429 | }); |
| 430 | |
| 431 | it("extracts page from multi-part Link header", () => { |
| 432 | const link = [ |
| 433 | '<https://api.github.com/repos/Hmbown/CodeWhale/issues?page=1>; rel="prev"', |
| 434 | '<https://api.github.com/repos/Hmbown/CodeWhale/issues?page=3>; rel="last"', |
| 435 | ].join(", "); |
| 436 | expect(lastPageFromLink(link)).toBe(3); |
| 437 | }); |
| 438 | |
| 439 | it("returns undefined when no last rel present", () => { |
| 440 | const link = |
| 441 | '<https://api.github.com/repos/Hmbown/CodeWhale/issues?page=1>; rel="prev"'; |
| 442 | expect(lastPageFromLink(link)).toBeUndefined(); |
| 443 | }); |
| 444 | |
| 445 | it("returns undefined for invalid URL format", () => { |
| 446 | const link = "not-a-valid-link-header; rel=last"; |
| 447 | expect(lastPageFromLink(link)).toBeUndefined(); |
| 448 | }); |
| 449 | }); |
| 450 |