| 1 | import * as Schema from "effect/Schema"; |
| 2 | import * as Struct from "effect/Struct"; |
| 3 | import { describe, expect, it } from "vite-plus/test"; |
| 4 | import { |
| 5 | MergebotPullRequestDetails, |
| 6 | isMergebotRepository, |
| 7 | mergebotPullRequestStatus, |
| 8 | mergebotPullRequestUrl, |
| 9 | mergebotPullRequestBrowserThreadId, |
| 10 | parseMergebotPullRequestUrl, |
| 11 | type MergebotRepoQueue, |
| 12 | } from "./mergebot.ts"; |
| 13 | |
| 14 | const decodeDetails = Schema.decodeSync(MergebotPullRequestDetails); |
| 15 | |
| 16 | const entry = { |
| 17 | number: 123, |
| 18 | headSha: "current-head", |
| 19 | lifecycle: "open", |
| 20 | queueStatus: "waiting_ci", |
| 21 | position: 1, |
| 22 | mergeEtaAt: null, |
| 23 | url: "https://mergebot.example/work/app/pull/123", |
| 24 | } as const; |
| 25 | const queue: MergebotRepoQueue = { |
| 26 | repo: "work/app", |
| 27 | status: "ready", |
| 28 | url: "https://mergebot.example/work/app", |
| 29 | processing: true, |
| 30 | pullRequests: [entry], |
| 31 | }; |
| 32 | |
| 33 | describe("mergebotPullRequestStatus", () => { |
| 34 | it("uses Mergebot's verified head unless a known T3 head disagrees", () => { |
| 35 | expect(mergebotPullRequestStatus(queue, 123)).toEqual(entry); |
| 36 | expect(mergebotPullRequestStatus(queue, 123, "current-head")).toEqual(entry); |
| 37 | expect(mergebotPullRequestStatus(queue, 123, "new-head")).toBeNull(); |
| 38 | }); |
| 39 | |
| 40 | it("keeps absent PRs, unknown queue heads and loading snapshots unknown", () => { |
| 41 | expect(mergebotPullRequestStatus(queue, 456)).toBeNull(); |
| 42 | expect(mergebotPullRequestStatus(null, 123)).toBeNull(); |
| 43 | expect( |
| 44 | mergebotPullRequestStatus({ ...queue, status: "loading", pullRequests: null }, 123), |
| 45 | ).toBeNull(); |
| 46 | expect( |
| 47 | mergebotPullRequestStatus({ ...queue, pullRequests: [{ ...entry, queueStatus: null }] }, 123), |
| 48 | ).toBeNull(); |
| 49 | }); |
| 50 | }); |
| 51 | |
| 52 | const configuration = { |
| 53 | origin: "https://mergebot.example", |
| 54 | repositories: ["work/app", "work/research.sh", "work/research"], |
| 55 | }; |
| 56 | |
| 57 | it("gives each standalone PR its own browser owner with case-insensitive repository identity", () => { |
| 58 | const owner = mergebotPullRequestBrowserThreadId({ repository: "Work/App", number: 12 }); |
| 59 | expect(owner).toBe(mergebotPullRequestBrowserThreadId({ repository: "work/app", number: 12 })); |
| 60 | expect(owner).not.toBe( |
| 61 | mergebotPullRequestBrowserThreadId({ repository: "work/app", number: 13 }), |
| 62 | ); |
| 63 | expect(owner).not.toBe( |
| 64 | mergebotPullRequestBrowserThreadId({ repository: "work/other", number: 12 }), |
| 65 | ); |
| 66 | }); |
| 67 | |
| 68 | describe("isMergebotRepository", () => { |
| 69 | it("scopes native badges to configured work repositories", () => { |
| 70 | expect(isMergebotRepository(undefined, "work/app")).toBe(false); |
| 71 | expect(isMergebotRepository(configuration, "WORK/APP")).toBe(true); |
| 72 | expect(isMergebotRepository(configuration, "work/app")).toBe(true); |
| 73 | expect(isMergebotRepository(configuration, "work/research.sh")).toBe(true); |
| 74 | expect(isMergebotRepository(configuration, "work/research")).toBe(true); |
| 75 | expect(isMergebotRepository(configuration, "work/mergebot-test")).toBe(false); |
| 76 | expect(isMergebotRepository(configuration, "another/app")).toBe(false); |
| 77 | expect(isMergebotRepository(configuration, "work/app/extra")).toBe(false); |
| 78 | }); |
| 79 | }); |
| 80 | |
| 81 | describe("MergebotPullRequestDetails", () => { |
| 82 | it("preserves check rerun identity and lifecycle timestamps without inventing absent dates", () => { |
| 83 | const details = { |
| 84 | ...entry, |
| 85 | title: "Ship change", |
| 86 | createdAt: "2026-10-14T10:00:00Z", |
| 87 | author: null, |
| 88 | additions: 3, |
| 89 | deletions: 1, |
| 90 | changedFilesCount: 2, |
| 91 | labels: [], |
| 92 | comments: [], |
| 93 | observedAt: 1791972120000, |
| 94 | body: null, |
| 95 | updatedAt: "2026-10-14T10:02:00Z", |
| 96 | mergedAt: "2026-10-14T10:01:00Z", |
| 97 | closedAt: null, |
| 98 | draft: false, |
| 99 | baseBranch: "main", |
| 100 | headBranch: "feature", |
| 101 | ciStatus: "failed", |
| 102 | hasConflicts: false, |
| 103 | checks: [ |
| 104 | { |
| 105 | id: "check:42", |
| 106 | name: "Tests", |
| 107 | workflowName: "CI", |
| 108 | status: "completed", |
| 109 | conclusion: "failure", |
| 110 | url: null, |
| 111 | startedAt: null, |
| 112 | completedAt: "2026-10-14T10:00:00Z", |
| 113 | }, |
| 114 | ], |
| 115 | } as const; |
| 116 | expect(decodeDetails(details)).toEqual(details); |
| 117 | const withoutDates = Struct.omit(details, ["mergedAt", "closedAt"]); |
| 118 | expect(decodeDetails(withoutDates)).not.toHaveProperty("mergedAt"); |
| 119 | }); |
| 120 | }); |
| 121 | |
| 122 | describe("mergebotPullRequestUrl", () => { |
| 123 | const config = { origin: "https://mergebot.example", repositories: ["Work/App"] }; |
| 124 | it("uses configured casing and matches GitHub and repositories case-insensitively", () => { |
| 125 | expect( |
| 126 | mergebotPullRequestUrl(config, { host: "GitHub.Com", repository: "work/APP", number: 12 }), |
| 127 | ).toBe("https://mergebot.example/Work/App/pull/12"); |
| 128 | }); |
| 129 | it("rejects lookalike and enterprise hosts, unconfigured repositories and invalid numbers", () => { |
| 130 | for (const host of ["github.com.example", "example.com", "github.com:443"]) { |
| 131 | expect( |
| 132 | mergebotPullRequestUrl(config, { host, repository: "work/app", number: 12 }), |
| 133 | ).toBeNull(); |
| 134 | } |
| 135 | expect(mergebotPullRequestUrl(undefined, { repository: "work/app", number: 12 })).toBeNull(); |
| 136 | expect(mergebotPullRequestUrl(config, { repository: "work/other", number: 12 })).toBeNull(); |
| 137 | expect(mergebotPullRequestUrl(config, { repository: "work/app", number: 0 })).toBeNull(); |
| 138 | }); |
| 139 | }); |
| 140 | |
| 141 | describe("parseMergebotPullRequestUrl", () => { |
| 142 | const config = { origin: "https://mergebot.example", repositories: ["Work/App"] }; |
| 143 | it("recognizes configured Mergebot PR URLs as their GitHub repository", () => { |
| 144 | expect( |
| 145 | parseMergebotPullRequestUrl(config, "https://mergebot.example/work/APP/pull/12#checks"), |
| 146 | ).toEqual({ host: "github.com", repository: "Work/App", number: 12 }); |
| 147 | }); |
| 148 | it("leaves lookalike origins, credentials and unconfigured repository paths alone", () => { |
| 149 | for (const url of [ |
| 150 | "https://mergebot.example.evil/work/app/pull/12", |
| 151 | "http://mergebot.example/work/app/pull/12", |
| 152 | "https://mergebot.example:444/work/app/pull/12", |
| 153 | "https://user@mergebot.example/work/app/pull/12", |
| 154 | "https://mergebot.example/work/other/pull/12", |
| 155 | ]) |
| 156 | expect(parseMergebotPullRequestUrl(config, url)).toBeNull(); |
| 157 | }); |
| 158 | }); |