import * as Result from "effect/Result"; import { describe, expect, it } from "vite-plus/test"; import { decodeCommentsJson, decodeCommitsJson, decodeConflictsJson, decodeDiffstatJson, decodePullRequestJson, decodePullRequestPageJson, decodeRepositoryPermissionJson, decodeStatusesJson, decodeViewerJson, } from "./bitbucketPullRequestJson.ts"; /** Shaped after a real api.bitbucket.org pull request, trimmed to the fields that are read. */ function pullRequest(overrides: Record = {}): Record { return { id: 897, title: "Add trustabl-pipe", description: "# Add trustabl-pipe", state: "OPEN", draft: false, created_on: "2026-06-16T05:04:32.258456+00:00", updated_on: "2026-06-16T05:04:33.750542+00:00", author: { display_name: "Bilal Hassan", nickname: "bilal", type: "user" }, source: { branch: { name: "feat/page" }, repository: { full_name: "fork/web" } }, destination: { branch: { name: "master" } }, links: { html: { href: "https://bitbucket.org/acme/web/pull-requests/897" } }, ...overrides, }; } function page(values: ReadonlyArray, extra: Record = {}): string { return JSON.stringify({ pagelen: 50, page: 1, size: values.length, values, ...extra }); } function expectSuccess(result: Result.Result): A { expect(Result.isSuccess(result)).toBe(true); if (!Result.isSuccess(result)) throw new Error("expected a successful decode"); return result.success; } describe("decodePullRequestPageJson", () => { it("reads a pull request as a change request", () => { const decoded = expectSuccess(decodePullRequestPageJson(page([pullRequest()]))); expect(decoded.items).toHaveLength(1); expect(decoded.items[0]).toMatchObject({ number: 897, title: "Add trustabl-pipe", url: "https://bitbucket.org/acme/web/pull-requests/897", author: { login: "bilal", name: "Bilal Hassan" }, headBranch: "feat/page", headRepositoryNameWithOwner: "fork/web", baseBranch: "master", state: "open", isDraft: false, // Bitbucket says nothing about conflicts on the pull request itself. mergeability: "unknown", }); expect(decoded.next).toBeNull(); }); it("normalizes Bitbucket's offset timestamps, which the page sorts against other hosts", () => { const decoded = expectSuccess(decodePullRequestPageJson(page([pullRequest()]))); expect(decoded.items[0]).toMatchObject({ createdAt: "2026-06-16T05:04:32.258Z", updatedAt: "2026-06-16T05:04:33.750Z", }); }); it("reports the next page as the whole URL Bitbucket sends", () => { const next = "https://api.bitbucket.org/2.0/repositories/acme/web/pullrequests?page=2"; const decoded = expectSuccess(decodePullRequestPageJson(page([pullRequest()], { next }))); expect(decoded.next).toBe(next); }); it.each([ ["MERGED", "merged"], ["DECLINED", "closed"], ["SUPERSEDED", "closed"], ["OPEN", "open"], ["something new", "open"], ])("reads the %s state as %s", (state, expected) => { const decoded = expectSuccess(decodePullRequestPageJson(page([pullRequest({ state })]))); expect(decoded.items[0]?.state).toBe(expected); }); it("skips a malformed row rather than failing the page", () => { const decoded = expectSuccess( decodePullRequestPageJson(page([{ id: "not a number" }, pullRequest()])), ); expect(decoded.items).toHaveLength(1); }); it("fails when Bitbucket did not answer with a page", () => { expect(Result.isFailure(decodePullRequestPageJson(JSON.stringify({ error: "nope" })))).toBe( true, ); }); }); describe("decodePullRequestJson", () => { it("reads reviewers as review requests", () => { const decoded = expectSuccess( decodePullRequestJson( JSON.stringify( pullRequest({ reviewers: [{ nickname: "julius", display_name: "Julius" }], }), ), ), ); expect(decoded.reviewRequestLogins).toEqual(["julius"]); expect(decoded.reviewers).toEqual([{ login: "julius", name: "Julius", avatarUrl: null }]); }); it("reads a participant's vote as a review", () => { const decoded = expectSuccess( decodePullRequestJson( JSON.stringify( pullRequest({ participants: [ { user: { nickname: "julius", display_name: "Julius" }, role: "REVIEWER", approved: true, state: "approved", participated_on: "2026-06-17T09:00:00+00:00", }, // Added as a reviewer but has not voted, so there is no verdict to show. { user: { nickname: "sam", display_name: "Sam" }, role: "REVIEWER", approved: false, state: null, participated_on: null, }, ], }), ), ), ); expect(decoded.reviews).toHaveLength(1); expect(decoded.reviews[0]).toMatchObject({ kind: "review", author: { login: "julius" }, reviewState: "approved", createdAt: "2026-06-17T09:00:00.000Z", }); }); }); describe("decodeViewerJson", () => { it("reads the signed-in nickname", () => { const decoded = decodeViewerJson(JSON.stringify({ nickname: "bilal", display_name: "Bilal" })); expect(expectSuccess(decoded)).toBe("bilal"); }); it("falls back to the display name, which app accounts have instead", () => { const decoded = decodeViewerJson(JSON.stringify({ display_name: "Release Bot" })); expect(expectSuccess(decoded)).toBe("Release Bot"); }); it("returns nothing when the account has neither", () => { expect(expectSuccess(decodeViewerJson(JSON.stringify({})))).toBeNull(); }); }); describe("decodeCommentsJson", () => { it("keeps a posted comment and drops deleted and unposted ones", () => { const decoded = expectSuccess( decodeCommentsJson( page([ { id: 797230941, content: { raw: "The issue is ready for review." }, user: { display_name: "Release Bot", type: "app_user" }, created_on: "2026-05-15T01:58:38.220690+00:00", deleted: false, pending: false, links: { html: { href: "https://bitbucket.org/acme/web/pull-requests/892#c1" } }, }, { id: 2, content: { raw: "gone" }, created_on: "2026-05-15T02:00:00+00:00", deleted: true, }, { id: 3, content: { raw: "wip" }, created_on: "2026-05-15T02:00:00+00:00", pending: true, }, { id: 4, content: { raw: " " }, created_on: "2026-05-15T02:00:00+00:00" }, ]), ), ); expect(decoded.comments).toHaveLength(1); expect(decoded.comments[0]).toMatchObject({ id: "797230941", kind: "issue-comment", // An app account has no nickname, so its display name is the only handle it has. author: { login: "Release Bot" }, createdAt: "2026-05-15T01:58:38.220Z", }); }); it("reads a comment pinned to a file as a review comment", () => { const decoded = expectSuccess( decodeCommentsJson( page([ { id: 5, content: { raw: "Rename this." }, created_on: "2026-05-15T02:00:00+00:00", inline: { path: "src/app.ts" }, }, ]), ), ); expect(decoded.comments[0]).toMatchObject({ kind: "review-comment", path: "src/app.ts" }); }); }); describe("decodeCommitsJson", () => { it("returns commits oldest first with only the subject line", () => { const decoded = expectSuccess( decodeCommitsJson( page([ { hash: "bbb", message: "second\n\nbody text\n", date: "2026-06-16T04:51:00+00:00" }, { hash: "aaa", message: "first\n", date: "2026-06-16T04:50:49+00:00", author: { raw: "Ada Lovelace ", user: { nickname: "ada", display_name: "Ada Lovelace" }, }, }, ]), ), ); expect(decoded.items.map((commit) => commit.oid)).toEqual(["aaa", "bbb"]); expect(decoded.items[0]?.authors).toEqual([ { login: "ada", name: "Ada Lovelace", avatarUrl: null }, ]); expect(decoded.items[1]?.messageHeadline).toBe("second"); expect(decoded.next).toBeNull(); }); it("skips commits whose hash is empty", () => { const decoded = expectSuccess( decodeCommitsJson( page([ { hash: " ", message: "invalid", date: "2026-06-16T04:51:00+00:00" }, { hash: "aaa", date: "2026-06-16T04:50:49+00:00" }, ]), ), ); expect(decoded.items.map((commit) => commit.oid)).toEqual(["aaa"]); }); }); describe("decodeStatusesJson", () => { it("reads a build status as a check", () => { const decoded = expectSuccess( decodeStatusesJson( page([ { key: "custom:check-version-and-pr", name: "Pipeline - custom: check-version-and-pr", state: "SUCCESSFUL", description: "", url: "https://bitbucket.org/acme/web/pipelines/results/8126", }, ]), ), ); expect(decoded).toEqual({ items: [ { name: "Pipeline - custom: check-version-and-pr", status: "success", description: null, url: "https://bitbucket.org/acme/web/pipelines/results/8126", }, ], next: null, }); }); it.each([ ["SUCCESSFUL", "success"], ["FAILED", "failure"], ["INPROGRESS", "pending"], ["STOPPED", "cancelled"], ["something new", "neutral"], ])("reads the %s build state as %s", (state, expected) => { const decoded = expectSuccess(decodeStatusesJson(page([{ name: "Pipeline", state }]))); expect(decoded.items[0]?.status).toBe(expected); }); it("keeps two statuses that share a display name but have different keys", () => { const decoded = expectSuccess( decodeStatusesJson( page([ { key: "build", name: "Pipeline", state: "SUCCESSFUL" }, { key: "deploy", name: "Pipeline", state: "FAILED" }, ]), ), ); expect(decoded.items.map((check) => [check.name, check.status])).toEqual([ ["build / Pipeline", "success"], ["deploy / Pipeline", "failure"], ]); }); }); describe("decodeDiffstatJson", () => { it("adds up the per-file counts", () => { const decoded = expectSuccess( decodeDiffstatJson( page([ { lines_added: 9, lines_removed: 2 }, { lines_added: 32, lines_removed: 14 }, ]), ), ); expect(decoded).toEqual({ additions: 41, deletions: 16, changedFiles: 2, next: null }); }); }); describe("decodeConflictsJson", () => { it("calls an empty conflict list mergeable", () => { expect(expectSuccess(decodeConflictsJson(page([])))).toBe("mergeable"); }); it("calls any reported conflict conflicting", () => { expect(expectSuccess(decodeConflictsJson(page([{ path: "src/app.ts" }])))).toBe("conflicting"); }); }); describe("repository permission decoding", () => { const permissionPage = (permission: string) => page([{ type: "repository_permission", permission }]); it("counts admin and write as write, and read as not", () => { expect(expectSuccess(decodeRepositoryPermissionJson(permissionPage("admin")))).toBe(true); expect(expectSuccess(decodeRepositoryPermissionJson(permissionPage("write")))).toBe(true); expect(expectSuccess(decodeRepositoryPermissionJson(permissionPage("read")))).toBe(false); }); it("grants write where Bitbucket named no permission at all", () => { // An empty page is Bitbucket declining to say, which is an unknown standing rather than a // refusal — and an unknown one is granted. expect(expectSuccess(decodeRepositoryPermissionJson(page([])))).toBe(true); }); });