import * as Schema from "effect/Schema";import { describe, expect, it } from "vite-plus/test"; import { PullRequestActionInput, PullRequestCapabilities, PullRequestListInput, PullRequestListResult, PullRequestReviewerRequestInput, resolvePullRequestAuthorFilter,} from "./pullRequest.ts"; const decodeListResult = Schema.decodeUnknownSync(PullRequestListResult);const decodeListInput = Schema.decodeUnknownSync(PullRequestListInput);const decodeReviewerRequest = Schema.decodeUnknownSync(PullRequestReviewerRequestInput);const decodeAction = Schema.decodeUnknownSync(PullRequestActionInput); const LIST_RESULT: PullRequestListResult = { viewers: { "github.com": "bilal", "gitlab.com": "bilal.hassan" }, providers: [ { host: "github.com", kind: "github", searchesOnHost: true, projectCount: 1, configured: true, detail: null, }, { host: "gitlab.com", kind: "gitlab", searchesOnHost: true, projectCount: 1, configured: false, detail: "glab is not installed.", }, ], entries: [ { provider: "github", host: "github.com", projectId: "project-1" as PullRequestListResult["entries"][number]["projectId"], projectTitle: "t3code", repository: "pingdotgg/t3code", number: 1, title: "Add a pull requests page", url: "https://github.com/pingdotgg/t3code/pull/1", author: { login: "octocat", name: null, avatarUrl: null }, headBranch: "feat/page", baseBranch: "main", state: "open", isDraft: false, mergeability: "mergeable", additions: 1, deletions: 0, createdAt: "2026-07-01T00:00:00Z", updatedAt: "2026-07-02T00:00:00Z", viewerReviewRequested: false, labels: [{ name: "backend", color: null }], }, ], errors: [], truncated: false, nextCursors: { "github.com pingdotgg/t3code": "2026-07-02T00:00:00Z|1|1" },}; describe("PullRequestListResult", () => { /** * The RPC builds this codec at call time, so a shape it cannot lower — an open-keyed record * with an optional value, for one — fails as an interrupted request rather than as a schema * error. Building it here turns that into a test failure instead. */ it("round-trips through the JSON codec the RPC serializes with", () => { const codec = Schema.toCodecJson(PullRequestListResult); const decoded = Schema.decodeUnknownSync(codec)(Schema.encodeUnknownSync(codec)(LIST_RESULT)); expect(decoded).toStrictEqual(LIST_RESULT); }); it("keys a viewer by host, so two hosts of one kind stay separate accounts", () => { const decoded = decodeListResult({ ...LIST_RESULT, viewers: { "github.com": "bilal", "github.acme.dev": "b.hassan" }, }); expect(decoded.viewers["github.com"]).toBe("bilal"); expect(decoded.viewers["github.acme.dev"]).toBe("b.hassan"); });}); describe("PullRequestListInput", () => { it("trims a search, so what is sent is what was typed", () => { expect(decodeListInput({ state: "open", query: " page " }).query).toBe("page"); }); it("has no search when none was asked for", () => { expect(decodeListInput({ state: "open" }).query).toBeUndefined(); }); it("bounds a search, because it travels into a command and a query string", () => { expect(decodeListInput({ state: "open", query: "p".repeat(200) }).query).toHaveLength(200); expect(() => decodeListInput({ state: "open", query: "p".repeat(201) })).toThrow(); }); it("takes back the continuation a result handed out, keyed the way it arrived", () => { const cursors = { "github.com pingdotgg/t3code": "2026-07-02T00:00:00Z|99|1,2" }; expect(decodeListInput({ state: "open", cursors }).cursors).toStrictEqual(cursors); }); it("has no continuation when the listing is being read from the top", () => { expect(decodeListInput({ state: "open" }).cursors).toBeUndefined(); }); it("bounds a continuation, because it comes back from the page and goes into a filter", () => { const long = (length: number) => ({ "github.com acme/web": "c".repeat(length) }); expect(decodeListInput({ state: "open", cursors: long(4096) })).toBeDefined(); expect(() => decodeListInput({ state: "open", cursors: long(4097) })).toThrow(); });}); describe("PullRequestReviewerRequestInput", () => { const ref = { projectId: "p1", repository: "acme/web", number: 1 }; const reviewer = { id: "octocat", kind: "user" }; it("carries the same shape whichever direction the request goes", () => { // One operation, turned around: `requested` is the whole difference between asking somebody // for a review and taking the request back. for (const requested of [true, false]) { expect(decodeReviewerRequest({ ...ref, reviewers: [reviewer], requested }).requested).toBe( requested, ); } }); it("refuses a request that names nobody, which no host would do anything with", () => { expect(() => decodeReviewerRequest({ ...ref, reviewers: [], requested: true })).toThrow(); }); it("bounds the reviewers, because they travel into a body the page composed", () => { const many = (count: number) => Array.from({ length: count }, (_, index) => ({ id: `user${index}`, kind: "user" })); expect( decodeReviewerRequest({ ...ref, reviewers: many(25), requested: true }).reviewers, ).toHaveLength(25); expect(() => decodeReviewerRequest({ ...ref, reviewers: many(26), requested: true })).toThrow(); }); it("keeps a team apart from a person, which is a different thing to ask", () => { expect( decodeReviewerRequest({ ...ref, reviewers: [reviewer, { id: "web-platform", kind: "team" }], requested: true, }).reviewers.map((entry) => entry.kind), ).toEqual(["user", "team"]); });}); describe("updating a branch that has fallen behind its base", () => { const ref = { projectId: "project-1", repository: "acme/web", number: 7 }; it("carries the way the branch should be brought up to date", () => { expect(decodeAction({ ...ref, action: "update-branch", updateMethod: "rebase" })).toMatchObject( { action: "update-branch", updateMethod: "rebase", }, ); }); it("takes the action without a method, which is the host's own default", () => { expect(decodeAction({ ...ref, action: "update-branch" }).updateMethod).toBeUndefined(); }); it("refuses a way no host offers", () => { expect(() => decodeAction({ ...ref, action: "update-branch", updateMethod: "squash" }), ).toThrow(); });}); describe("leaving a merge for the host to make once it is ready", () => { const ref = { projectId: "project-1", repository: "acme/web", number: 7 }; it("carries the strategy the deferred merge should use, as merging now does", () => { expect( decodeAction({ ...ref, action: "enable-auto-merge", mergeMethod: "squash" }), ).toMatchObject({ action: "enable-auto-merge", mergeMethod: "squash" }); }); it("takes the arming back without a strategy, because there is nothing to choose", () => { expect(decodeAction({ ...ref, action: "disable-auto-merge" }).mergeMethod).toBeUndefined(); });}); describe("reverting a merged pull request", () => { it("carries the revert action without merge options", () => { const action = decodeAction({ projectId: "project-1", repository: "acme/web", number: 7, action: "revert", }); expect(action.action).toBe("revert"); expect(action.mergeMethod).toBeUndefined(); });}); describe("approving fork workflows", () => { it("carries workflow approval as its own action", () => { const action = decodeAction({ projectId: "project-1", repository: "acme/web", number: 7, action: "approve-workflows", }); expect(action.action).toBe("approve-workflows"); });}); describe("PullRequestCapabilities", () => { const decodeCapabilities = Schema.decodeUnknownSync(PullRequestCapabilities); const base = { diff: true, comment: true, actions: [], mergeMethods: [], search: true, review: { inlineComment: true, reply: true, resolve: true, verdicts: [] }, reviewers: { request: true, listCandidates: true }, }; it("decodes a server that says nothing about reactions as a server with none", () => { expect(decodeCapabilities(base).reactions).toBeUndefined(); });}); describe("naming the reader as the author to narrow by", () => { it("reads me as whoever is signed in, however it is written", () => { expect(resolvePullRequestAuthorFilter("me", "octocat")).toBe("octocat"); expect(resolvePullRequestAuthorFilter("@me", "octocat")).toBe("octocat"); expect(resolvePullRequestAuthorFilter(" ME ", "octocat")).toBe("octocat"); }); it("leaves any other name as it was typed", () => { expect(resolvePullRequestAuthorFilter(" mercedes ", "octocat")).toBe("mercedes"); expect(resolvePullRequestAuthorFilter("me-too", "octocat")).toBe("me-too"); }); it("stands as typed where the host has not said who the reader is", () => { expect(resolvePullRequestAuthorFilter("me", null)).toBe("me"); expect(resolvePullRequestAuthorFilter("me", " ")).toBe("me"); });});
import * as Schema from "effect/Schema";
import { describe, expect, it } from "vite-plus/test";
import {
PullRequestActionInput,
PullRequestCapabilities,
PullRequestListInput,
PullRequestListResult,
PullRequestReviewerRequestInput,
resolvePullRequestAuthorFilter,
} from "./pullRequest.ts";
const decodeListResult = Schema.decodeUnknownSync(PullRequestListResult);
const decodeListInput = Schema.decodeUnknownSync(PullRequestListInput);
const decodeReviewerRequest = Schema.decodeUnknownSync(PullRequestReviewerRequestInput);
const decodeAction = Schema.decodeUnknownSync(PullRequestActionInput);
const LIST_RESULT: PullRequestListResult = {
viewers: { "github.com": "bilal", "gitlab.com": "bilal.hassan" },
providers: [
{
host: "github.com",
kind: "github",
searchesOnHost: true,
projectCount: 1,
configured: true,
detail: null,
},
{
host: "gitlab.com",
kind: "gitlab",
searchesOnHost: true,
projectCount: 1,
configured: false,
detail: "glab is not installed.",
},
],
entries: [
{
provider: "github",
host: "github.com",
projectId: "project-1" as PullRequestListResult["entries"][number]["projectId"],
projectTitle: "t3code",
repository: "pingdotgg/t3code",
number: 1,
title: "Add a pull requests page",
url: "https://github.com/pingdotgg/t3code/pull/1",
author: { login: "octocat", name: null, avatarUrl: null },
headBranch: "feat/page",
baseBranch: "main",
state: "open",
isDraft: false,
mergeability: "mergeable",
additions: 1,
deletions: 0,
createdAt: "2026-07-01T00:00:00Z",
updatedAt: "2026-07-02T00:00:00Z",
viewerReviewRequested: false,
labels: [{ name: "backend", color: null }],
},
],
errors: [],
truncated: false,
nextCursors: { "github.com pingdotgg/t3code": "2026-07-02T00:00:00Z|1|1" },
};
describe("PullRequestListResult", () => {
/**
* The RPC builds this codec at call time, so a shape it cannot lower — an open-keyed record
* with an optional value, for one — fails as an interrupted request rather than as a schema
* error. Building it here turns that into a test failure instead.
*/
it("round-trips through the JSON codec the RPC serializes with", () => {
const codec = Schema.toCodecJson(PullRequestListResult);
const decoded = Schema.decodeUnknownSync(codec)(Schema.encodeUnknownSync(codec)(LIST_RESULT));
expect(decoded).toStrictEqual(LIST_RESULT);
});
it("keys a viewer by host, so two hosts of one kind stay separate accounts", () => {
const decoded = decodeListResult({
...LIST_RESULT,
viewers: { "github.com": "bilal", "github.acme.dev": "b.hassan" },
});
expect(decoded.viewers["github.com"]).toBe("bilal");
expect(decoded.viewers["github.acme.dev"]).toBe("b.hassan");
});
});
describe("PullRequestListInput", () => {
it("trims a search, so what is sent is what was typed", () => {
expect(decodeListInput({ state: "open", query: " page " }).query).toBe("page");
});
it("has no search when none was asked for", () => {
expect(decodeListInput({ state: "open" }).query).toBeUndefined();
});
it("bounds a search, because it travels into a command and a query string", () => {
expect(decodeListInput({ state: "open", query: "p".repeat(200) }).query).toHaveLength(200);
expect(() => decodeListInput({ state: "open", query: "p".repeat(201) })).toThrow();
});
it("takes back the continuation a result handed out, keyed the way it arrived", () => {
const cursors = { "github.com pingdotgg/t3code": "2026-07-02T00:00:00Z|99|1,2" };
expect(decodeListInput({ state: "open", cursors }).cursors).toStrictEqual(cursors);
});
it("has no continuation when the listing is being read from the top", () => {
expect(decodeListInput({ state: "open" }).cursors).toBeUndefined();
});
it("bounds a continuation, because it comes back from the page and goes into a filter", () => {
const long = (length: number) => ({ "github.com acme/web": "c".repeat(length) });
expect(decodeListInput({ state: "open", cursors: long(4096) })).toBeDefined();
expect(() => decodeListInput({ state: "open", cursors: long(4097) })).toThrow();
});
});
describe("PullRequestReviewerRequestInput", () => {
const ref = { projectId: "p1", repository: "acme/web", number: 1 };
const reviewer = { id: "octocat", kind: "user" };
it("carries the same shape whichever direction the request goes", () => {
// One operation, turned around: `requested` is the whole difference between asking somebody
// for a review and taking the request back.
for (const requested of [true, false]) {
expect(decodeReviewerRequest({ ...ref, reviewers: [reviewer], requested }).requested).toBe(
requested,
);
}
});
it("refuses a request that names nobody, which no host would do anything with", () => {
expect(() => decodeReviewerRequest({ ...ref, reviewers: [], requested: true })).toThrow();
});
it("bounds the reviewers, because they travel into a body the page composed", () => {
const many = (count: number) =>
Array.from({ length: count }, (_, index) => ({ id: `user${index}`, kind: "user" }));
expect(
decodeReviewerRequest({ ...ref, reviewers: many(25), requested: true }).reviewers,
).toHaveLength(25);
expect(() => decodeReviewerRequest({ ...ref, reviewers: many(26), requested: true })).toThrow();
});
it("keeps a team apart from a person, which is a different thing to ask", () => {
expect(
decodeReviewerRequest({
...ref,
reviewers: [reviewer, { id: "web-platform", kind: "team" }],
requested: true,
}).reviewers.map((entry) => entry.kind),
).toEqual(["user", "team"]);
});
});
describe("updating a branch that has fallen behind its base", () => {
const ref = { projectId: "project-1", repository: "acme/web", number: 7 };
it("carries the way the branch should be brought up to date", () => {
expect(decodeAction({ ...ref, action: "update-branch", updateMethod: "rebase" })).toMatchObject(
{
action: "update-branch",
updateMethod: "rebase",
},
);
});
it("takes the action without a method, which is the host's own default", () => {
expect(decodeAction({ ...ref, action: "update-branch" }).updateMethod).toBeUndefined();
});
it("refuses a way no host offers", () => {
expect(() =>
decodeAction({ ...ref, action: "update-branch", updateMethod: "squash" }),
).toThrow();
});
});
describe("leaving a merge for the host to make once it is ready", () => {
const ref = { projectId: "project-1", repository: "acme/web", number: 7 };
it("carries the strategy the deferred merge should use, as merging now does", () => {
expect(
decodeAction({ ...ref, action: "enable-auto-merge", mergeMethod: "squash" }),
).toMatchObject({ action: "enable-auto-merge", mergeMethod: "squash" });
});
it("takes the arming back without a strategy, because there is nothing to choose", () => {
expect(decodeAction({ ...ref, action: "disable-auto-merge" }).mergeMethod).toBeUndefined();
});
});
describe("reverting a merged pull request", () => {
it("carries the revert action without merge options", () => {
const action = decodeAction({
projectId: "project-1",
repository: "acme/web",
number: 7,
action: "revert",
});
expect(action.action).toBe("revert");
expect(action.mergeMethod).toBeUndefined();
});
});
describe("approving fork workflows", () => {
it("carries workflow approval as its own action", () => {
const action = decodeAction({
projectId: "project-1",
repository: "acme/web",
number: 7,
action: "approve-workflows",
});
expect(action.action).toBe("approve-workflows");
});
});
describe("PullRequestCapabilities", () => {
const decodeCapabilities = Schema.decodeUnknownSync(PullRequestCapabilities);
const base = {
diff: true,
comment: true,
actions: [],
mergeMethods: [],
search: true,
review: { inlineComment: true, reply: true, resolve: true, verdicts: [] },
reviewers: { request: true, listCandidates: true },
};
it("decodes a server that says nothing about reactions as a server with none", () => {
expect(decodeCapabilities(base).reactions).toBeUndefined();
});
});
describe("naming the reader as the author to narrow by", () => {
it("reads me as whoever is signed in, however it is written", () => {
expect(resolvePullRequestAuthorFilter("me", "octocat")).toBe("octocat");
expect(resolvePullRequestAuthorFilter("@me", "octocat")).toBe("octocat");
expect(resolvePullRequestAuthorFilter(" ME ", "octocat")).toBe("octocat");
});
it("leaves any other name as it was typed", () => {
expect(resolvePullRequestAuthorFilter(" mercedes ", "octocat")).toBe("mercedes");
expect(resolvePullRequestAuthorFilter("me-too", "octocat")).toBe("me-too");
});
it("stands as typed where the host has not said who the reader is", () => {
expect(resolvePullRequestAuthorFilter("me", null)).toBe("me");
expect(resolvePullRequestAuthorFilter("me", " ")).toBe("me");
});
});