Files
João Salavisa ad21460007 feat(github): add merge queue actions (#2755)
Co-authored-by: Kandev Agent <agent@kandev.dev>
2026-08-17 23:28:59 +00:00

685 lines
23 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
vi.mock("@/lib/config", () => ({
getBackendConfig: () => ({ apiBaseUrl: "http://api.test" }),
}));
import {
clearGitHubToken,
configureGitHubToken,
copyGitHubWorkspaceSettings,
createTaskPR,
deleteTaskPR,
deletePRWatch,
deleteGitHubAppRegistration,
disconnectGitHubPersonal,
disconnectGitHubWorkspace,
fetchAccessibleRepos,
fetchGitHubCLIAccounts,
fetchGitHubAppRegistrations,
fetchGitHubStatus,
fetchIssueInfo,
fetchPRInfo,
fetchRepoBranches,
getPRFeedback,
getPRStatus,
getPRStatusesBatch,
getRepoMergeMethods,
getTaskCIAutomationOptions,
GitHubUnavailableError,
importGitHubAppRegistration,
linkTaskIssue,
listUserOrgs,
listIssueWatches,
listPRWatches,
listReviewWatches,
listWorkspaceTaskIssues,
mergePR,
prepareGitHubAppImport,
renameGitHubAppRegistration,
searchOrgRepos,
setGitHubWorkspaceConnection,
startGitHubAppInstall,
startGitHubAppManifest,
startGitHubPersonalConnect,
submitPRReview,
unlinkTaskIssue,
updateTaskCIAutomationOptions,
type AccessibleRepo,
} from "./github-api";
import * as githubAuthApi from "./github-auth-api";
const workspaceID = "ws/1";
const appRegistrationID = "registration/one";
const publicKandevURL = "https://kandev.example";
type FetchInput = Parameters<typeof fetch>[0];
type FetchInit = Parameters<typeof fetch>[1];
const fetchSpy = vi.fn<(...args: [FetchInput, FetchInit?]) => Promise<Response>>();
beforeEach(() => {
fetchSpy.mockReset();
vi.stubGlobal("fetch", fetchSpy);
});
afterEach(() => {
vi.unstubAllGlobals();
});
function jsonResponse(body: unknown, init?: ResponseInit): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { "Content-Type": "application/json" },
...init,
});
}
function lastCallUrl(): string {
const call = fetchSpy.mock.calls.at(-1);
if (!call) throw new Error("expected fetch to have been called");
return String(call[0]);
}
describe("workspace GitHub authentication", () => {
it("scopes status reads to the requested workspace", async () => {
fetchSpy.mockResolvedValueOnce(
jsonResponse({ workspace_id: "workspace/one", automation: null, personal: null }),
);
await fetchGitHubStatus("workspace/one", { cache: "no-store" });
const call = fetchSpy.mock.calls.at(-1);
expect(String(call?.[0])).toBe(
"http://api.test/api/v1/github/status?workspace_id=workspace%2Fone",
);
expect(call?.[1]).toMatchObject({ cache: "no-store" });
});
it("requests a fresh rate-limit snapshot when requested", async () => {
fetchSpy.mockResolvedValueOnce(jsonResponse({ workspace_id: workspaceID }));
await fetchGitHubStatus(workspaceID, { cache: "no-store" }, true);
expect(lastCallUrl()).toBe(
"http://api.test/api/v1/github/status?workspace_id=ws%2F1&refresh_rate_limit=true",
);
});
it("selects an exact named CLI account", async () => {
fetchSpy.mockResolvedValueOnce(jsonResponse({ workspace_id: "workspace one" }));
await setGitHubWorkspaceConnection("workspace one", {
source: "gh_cli",
host: "github.example.com",
login: "alice",
});
const call = fetchSpy.mock.calls.at(-1);
expect(String(call?.[0])).toBe(
"http://api.test/api/v1/github/workspace-connection?workspace_id=workspace+one",
);
expect(call?.[1]?.method).toBe("PUT");
expect(JSON.parse(String(call?.[1]?.body))).toEqual({
source: "gh_cli",
host: "github.example.com",
login: "alice",
});
});
it("lists all CLI accounts without collapsing hosts", async () => {
fetchSpy.mockResolvedValueOnce(
jsonResponse({
accounts: [
{ host: "github.com", login: "alice", active: true, state: "active" },
{ host: "github.example.com", login: "alice", active: false, state: "inactive" },
],
}),
);
await expect(fetchGitHubCLIAccounts(workspaceID)).resolves.toHaveLength(2);
expect(lastCallUrl()).toBe(
"http://api.test/api/v1/github/auth/gh-cli/accounts?workspace_id=ws%2F1",
);
});
it("uses workspace-scoped App and personal lifecycle routes", async () => {
fetchSpy
.mockResolvedValueOnce(
jsonResponse({ URL: "https://github.com/apps/kandev/installations/new" }),
)
.mockResolvedValueOnce(jsonResponse({ url: "https://github.com/login/oauth/authorize" }))
.mockResolvedValueOnce(jsonResponse({ disconnected: true }))
.mockResolvedValueOnce(jsonResponse({ disconnected: true }))
.mockResolvedValueOnce(jsonResponse({ disconnected: true }));
await startGitHubAppInstall(workspaceID, appRegistrationID);
await startGitHubPersonalConnect(workspaceID);
await disconnectGitHubPersonal(workspaceID);
await disconnectGitHubWorkspace(workspaceID);
expect(fetchSpy.mock.calls.map((call) => [String(call[0]), call[1]?.method])).toEqual([
["http://api.test/api/v1/github/app/install/start", "POST"],
["http://api.test/api/v1/github/personal-connection/start", "POST"],
["http://api.test/api/v1/github/personal-connection?workspace_id=ws%2F1", "DELETE"],
["http://api.test/api/v1/github/workspace-connection?workspace_id=ws%2F1", "DELETE"],
]);
expect(JSON.parse(String(fetchSpy.mock.calls[0]?.[1]?.body))).toEqual({
workspace_id: workspaceID,
app_registration_id: appRegistrationID,
});
expect(JSON.parse(String(fetchSpy.mock.calls[1]?.[1]?.body))).toEqual({
workspace_id: workspaceID,
});
});
it("keeps compatibility token mutations workspace-scoped", async () => {
fetchSpy
.mockResolvedValueOnce(jsonResponse({ configured: true }))
.mockResolvedValueOnce(jsonResponse({ cleared: true }));
await configureGitHubToken(workspaceID, "github_pat_test");
await clearGitHubToken(workspaceID);
expect(fetchSpy.mock.calls.map((call) => [String(call[0]), call[1]?.method])).toEqual([
["http://api.test/api/v1/github/token?workspace_id=ws%2F1", "POST"],
["http://api.test/api/v1/github/token?workspace_id=ws%2F1", "DELETE"],
]);
});
});
describe("workspace-scoped GitHub watches", () => {
it("includes the workspace in PR and issue watch list and delete requests", async () => {
fetchSpy
.mockResolvedValueOnce(jsonResponse({ watches: [] }))
.mockResolvedValueOnce(jsonResponse({ success: true }))
.mockResolvedValueOnce(jsonResponse({ watches: [] }));
await listPRWatches(workspaceID);
await deletePRWatch("watch/1", workspaceID);
await listIssueWatches(workspaceID);
expect(fetchSpy.mock.calls.map((call) => [String(call[0]), call[1]?.method])).toEqual([
["http://api.test/api/v1/github/watches/pr?workspace_id=ws%2F1", undefined],
["http://api.test/api/v1/github/watches/pr/watch/1?workspace_id=ws%2F1", "DELETE"],
["http://api.test/api/v1/github/watches/issue?workspace_id=ws%2F1", undefined],
]);
});
it("includes the workspace in review watch list requests", async () => {
fetchSpy.mockResolvedValueOnce(jsonResponse({ watches: [] }));
await listReviewWatches(workspaceID);
expect(lastCallUrl()).toBe("http://api.test/api/v1/github/watches/review?workspace_id=ws%2F1");
});
});
describe("workspace GitHub App registrations", () => {
it("exposes the registration catalog API", () => {
expect(githubAuthApi).toMatchObject({
fetchGitHubAppRegistrations: expect.any(Function),
startGitHubAppManifest: expect.any(Function),
prepareGitHubAppImport: expect.any(Function),
importGitHubAppRegistration: expect.any(Function),
renameGitHubAppRegistration: expect.any(Function),
deleteGitHubAppRegistration: expect.any(Function),
});
});
it("uses workspace-scoped catalog and registration lifecycle routes", async () => {
fetchSpy
.mockResolvedValueOnce(jsonResponse({ workspace_id: workspaceID, registrations: [] }))
.mockResolvedValueOnce(
jsonResponse({
registration_id: appRegistrationID,
workspace_id: workspaceID,
state: "state",
expires_at: "2026-07-20T22:00:00Z",
revision: 1,
registration_url: "https://github.com/organizations/acme/settings/apps/new",
manifest: { name: "Kandev acme" },
}),
)
.mockResolvedValueOnce(
jsonResponse({
registration_id: appRegistrationID,
public_base_url: publicKandevURL,
webhook_url: "https://kandev.example/webhook",
}),
)
.mockResolvedValueOnce(jsonResponse({ id: appRegistrationID }))
.mockResolvedValueOnce(jsonResponse({ id: appRegistrationID, display_name: "Renamed" }))
.mockResolvedValueOnce(jsonResponse({ deleted: true }));
await fetchGitHubAppRegistrations(workspaceID);
await startGitHubAppManifest({
workspace_id: workspaceID,
display_name: "Work App",
owner_type: "organization",
owner_login: "acme",
visibility: "private",
public_base_url: publicKandevURL,
});
await prepareGitHubAppImport({
workspace_id: workspaceID,
public_base_url: publicKandevURL,
});
await importGitHubAppRegistration({
registration_id: "registration-1",
workspace_id: workspaceID,
display_name: "Existing App",
github_host: "github.com",
app_id: 42,
client_id: "Iv1.client",
client_secret: "client-secret",
private_key: "private-key",
webhook_secret: "webhook-secret",
slug: "existing-app",
owner_login: "acme",
owner_type: "Organization",
visibility: "private",
public_base_url: publicKandevURL,
});
await renameGitHubAppRegistration(appRegistrationID, "Renamed");
await deleteGitHubAppRegistration(appRegistrationID);
expect(fetchSpy.mock.calls.map((call) => [String(call[0]), call[1]?.method])).toEqual([
["http://api.test/api/v1/github/app/registrations?workspace_id=ws%2F1", undefined],
["http://api.test/api/v1/github/app/registrations/manifest/start", "POST"],
["http://api.test/api/v1/github/app/registrations/import/prepare", "POST"],
["http://api.test/api/v1/github/app/registrations/import", "POST"],
["http://api.test/api/v1/github/app/registrations/registration%2Fone", "PATCH"],
["http://api.test/api/v1/github/app/registrations/registration%2Fone", "DELETE"],
]);
expect(fetchSpy.mock.calls[0]?.[1]?.cache).toBe("no-store");
expect(JSON.parse(String(fetchSpy.mock.calls[1]?.[1]?.body))).toEqual({
workspace_id: workspaceID,
display_name: "Work App",
owner_type: "organization",
owner_login: "acme",
visibility: "private",
public_base_url: publicKandevURL,
});
expect(JSON.parse(String(fetchSpy.mock.calls[2]?.[1]?.body))).toEqual({
workspace_id: workspaceID,
public_base_url: publicKandevURL,
});
expect(JSON.parse(String(fetchSpy.mock.calls[4]?.[1]?.body))).toEqual({
display_name: "Renamed",
});
});
});
describe("workspace-scoped GitHub operations", () => {
it("scopes PR reads, review, merge, and repository metadata", async () => {
fetchSpy.mockImplementation(async () => jsonResponse({}));
await getPRFeedback(workspaceID, "acme", "site", 42);
await getPRStatus(workspaceID, "acme", "site", 42);
await getPRStatusesBatch(workspaceID, [{ owner: "acme", repo: "site", number: 42 }]);
await submitPRReview(workspaceID, { owner: "acme", repo: "site", number: 42 }, "APPROVE");
await mergePR(workspaceID, "acme", "site", 42, "squash");
await getRepoMergeMethods(workspaceID, "acme", "site");
await listUserOrgs(workspaceID);
await searchOrgRepos(workspaceID, "acme", "site");
expect(fetchSpy.mock.calls.map((call) => String(call[0]))).toEqual([
"http://api.test/api/v1/github/prs/acme/site/42?workspace_id=ws%2F1",
"http://api.test/api/v1/github/prs/acme/site/42/status?workspace_id=ws%2F1",
"http://api.test/api/v1/github/prs/statuses",
"http://api.test/api/v1/github/prs/acme/site/42/reviews?workspace_id=ws%2F1",
"http://api.test/api/v1/github/prs/acme/site/42/merge?workspace_id=ws%2F1",
"http://api.test/api/v1/github/repos/acme/site/merge-methods?workspace_id=ws%2F1",
"http://api.test/api/v1/github/orgs?workspace_id=ws%2F1",
"http://api.test/api/v1/github/repos/search?workspace_id=ws%2F1&org=acme&q=site",
]);
expect(JSON.parse(String(fetchSpy.mock.calls[2]?.[1]?.body))).toEqual({
workspace_id: workspaceID,
refs: [{ owner: "acme", repo: "site", number: 42 }],
});
});
});
describe("remote repository reads", () => {
it("retries a transient network failure when loading branches", async () => {
fetchSpy.mockRejectedValueOnce(new TypeError("fetch failed"));
fetchSpy.mockResolvedValueOnce(jsonResponse({ branches: [{ name: "main" }] }));
await expect(fetchRepoBranches(workspaceID, "acme", "site")).resolves.toEqual({
branches: [{ name: "main" }],
});
expect(fetchSpy).toHaveBeenCalledTimes(2);
});
it("retries a transient network failure when loading PR title metadata", async () => {
fetchSpy.mockRejectedValueOnce(new TypeError("fetch failed"));
fetchSpy.mockResolvedValueOnce(jsonResponse({ number: 42, title: "Recovered" }));
await expect(fetchPRInfo(workspaceID, "acme", "site", 42)).resolves.toMatchObject({
number: 42,
title: "Recovered",
});
expect(fetchSpy).toHaveBeenCalledTimes(2);
});
});
describe("fetchAccessibleRepos — URL & parsing", () => {
it("builds the correct URL with both q and limit", async () => {
fetchSpy.mockResolvedValueOnce(jsonResponse({ repos: [] }));
await fetchAccessibleRepos({ workspaceId: workspaceID, q: "next", limit: 25 });
const url = lastCallUrl();
expect(url).toContain("/api/v1/github/repos");
expect(url).toContain("q=next");
expect(url).toContain("limit=25");
expect(url).toContain("workspace_id=ws%2F1");
});
it("omits empty query and missing limit from the URL", async () => {
fetchSpy.mockResolvedValueOnce(jsonResponse({ repos: [] }));
await fetchAccessibleRepos({ workspaceId: workspaceID });
const url = lastCallUrl();
expect(url).toContain("/api/v1/github/repos");
expect(url).not.toContain("q=");
expect(url).not.toContain("limit=");
});
it("parses the 200 response and injects provider: 'github' on each entry", async () => {
fetchSpy.mockResolvedValueOnce(
jsonResponse({
repos: [
{
full_name: "kdlbs/kandev",
owner: "kdlbs",
name: "kandev",
private: false,
default_branch: "main",
description: "Kandev mainline",
pushed_at: "2026-05-20T10:00:00Z",
},
{
full_name: "acme/site",
owner: "acme",
name: "site",
private: true,
default_branch: "trunk",
},
],
}),
);
const repos: AccessibleRepo[] = await fetchAccessibleRepos({ workspaceId: workspaceID });
expect(repos).toHaveLength(2);
expect(repos[0]).toMatchObject({
provider: "github",
full_name: "kdlbs/kandev",
owner: "kdlbs",
name: "kandev",
private: false,
default_branch: "main",
description: "Kandev mainline",
pushed_at: "2026-05-20T10:00:00Z",
});
expect(repos[1]).toMatchObject({
provider: "github",
full_name: "acme/site",
owner: "acme",
name: "site",
private: true,
default_branch: "trunk",
});
expect(repos[1].pushed_at).toBeUndefined();
expect(repos[1].description).toBeUndefined();
});
});
describe("fetchIssueInfo", () => {
it("builds the encoded issue info endpoint and forwards options", async () => {
fetchSpy.mockResolvedValueOnce(jsonResponse({ number: 1456, title: "Fix picker" }));
await fetchIssueInfo("workspace/1", "acme org", "site/repo", 1456, {
cache: "no-store",
});
const call = fetchSpy.mock.calls.at(-1);
expect(String(call?.[0])).toBe(
"http://api.test/api/v1/github/issues/acme%20org/site%2Frepo/1456/info?workspace_id=workspace%2F1",
);
expect(call?.[1]).toMatchObject({ cache: "no-store" });
});
});
describe("task issue link helpers", () => {
it("lists task issue links for a workspace", async () => {
fetchSpy.mockResolvedValueOnce(jsonResponse({ task_issues: {} }));
await listWorkspaceTaskIssues("workspace/1", { cache: "no-store" });
const call = fetchSpy.mock.calls.at(-1);
expect(String(call?.[0])).toBe(
"http://api.test/api/v1/github/task-issues?workspace_id=workspace%2F1",
);
expect(call?.[1]).toMatchObject({ cache: "no-store" });
});
it("links a GitHub pull request to a task", async () => {
fetchSpy.mockResolvedValueOnce(
jsonResponse({
id: "task-pr-1",
task_id: "task-1",
repository_id: "task-repo-1",
owner: "kdlbs",
repo: "kandev",
pr_number: 1471,
pr_url: "https://github.com/kdlbs/kandev/pull/1471",
pr_title: "Link references",
}),
);
await createTaskPR({
workspace_id: "workspace-1",
task_id: "task-1",
repository_id: "task-repo-1",
pr_url: "https://github.com/kdlbs/kandev/pull/1471",
});
const call = fetchSpy.mock.calls.at(-1);
expect(String(call?.[0])).toBe("http://api.test/api/v1/github/task-prs");
expect(call?.[1]?.method).toBe("POST");
expect(JSON.parse(String(call?.[1]?.body))).toEqual({
workspace_id: "workspace-1",
task_id: "task-1",
repository_id: "task-repo-1",
pr_url: "https://github.com/kdlbs/kandev/pull/1471",
});
});
it("links a GitHub issue to a task", async () => {
fetchSpy.mockResolvedValueOnce(
jsonResponse({
task_id: "task-1",
owner: "kdlbs",
repo: "kandev",
issue_number: 1470,
issue_url: "https://github.com/kdlbs/kandev/issues/1470",
issue_title: "Link issue",
}),
);
await linkTaskIssue("task-1", {
issue: "#1470",
owner: "kdlbs",
repo: "kandev",
number: 1470,
});
const call = fetchSpy.mock.calls.at(-1);
expect(String(call?.[0])).toBe("http://api.test/api/v1/github/tasks/task-1/issue");
expect(call?.[1]?.method).toBe("PUT");
expect(JSON.parse(String(call?.[1]?.body))).toEqual({
issue: "#1470",
owner: "kdlbs",
repo: "kandev",
number: 1470,
});
});
it("unlinks a GitHub issue from a task", async () => {
fetchSpy.mockResolvedValueOnce(jsonResponse({ unlinked: true }));
await unlinkTaskIssue("task-1");
const call = fetchSpy.mock.calls.at(-1);
expect(String(call?.[0])).toBe("http://api.test/api/v1/github/tasks/task-1/issue");
expect(call?.[1]?.method).toBe("DELETE");
});
});
describe("task PR association helpers", () => {
it("deletes a task PR association scoped to the workspace", async () => {
fetchSpy.mockResolvedValueOnce(jsonResponse({ deleted: true }));
await deleteTaskPR("association/1", "workspace/1");
const call = fetchSpy.mock.calls.at(-1);
expect(String(call?.[0])).toBe(
"http://api.test/api/v1/github/task-prs/association%2F1?workspace_id=workspace%2F1",
);
expect(call?.[1]?.method).toBe("DELETE");
});
});
describe("fetchAccessibleRepos — errors & signal", () => {
it("throws GitHubUnavailableError on 503 with code: github_not_configured", async () => {
fetchSpy.mockResolvedValueOnce(
new Response(
JSON.stringify({
error: "GitHub is not configured.",
code: "github_not_configured",
}),
{ status: 503, headers: { "Content-Type": "application/json" } },
),
);
await expect(fetchAccessibleRepos({ workspaceId: "ws-1" })).rejects.toBeInstanceOf(
GitHubUnavailableError,
);
});
it("throws a plain Error (not GitHubUnavailableError) on 503 without the github_not_configured code", async () => {
fetchSpy.mockResolvedValueOnce(
new Response(JSON.stringify({ error: "transient outage" }), {
status: 503,
headers: { "Content-Type": "application/json" },
}),
);
const err = await fetchAccessibleRepos({ workspaceId: "ws-1" }).catch((e) => e);
expect(err).toBeInstanceOf(Error);
expect(err).not.toBeInstanceOf(GitHubUnavailableError);
});
it("throws a plain Error on 500", async () => {
fetchSpy.mockResolvedValueOnce(
new Response(JSON.stringify({ error: "boom" }), {
status: 500,
headers: { "Content-Type": "application/json" },
}),
);
const err = await fetchAccessibleRepos({ workspaceId: "ws-1" }).catch((e) => e);
expect(err).toBeInstanceOf(Error);
expect(err).not.toBeInstanceOf(GitHubUnavailableError);
});
it("forwards AbortSignal: aborting causes the promise to reject", async () => {
const controller = new AbortController();
fetchSpy.mockImplementationOnce((_input, init) => {
return new Promise((_resolve, reject) => {
const signal = init?.signal;
if (signal?.aborted) {
reject(new DOMException("Aborted", "AbortError"));
return;
}
signal?.addEventListener("abort", () => {
reject(new DOMException("Aborted", "AbortError"));
});
});
});
const promise = fetchAccessibleRepos({ workspaceId: "ws-1", signal: controller.signal });
controller.abort();
await expect(promise).rejects.toThrow();
});
});
describe("task CI automation options", () => {
it("fetches options for a task", async () => {
fetchSpy.mockResolvedValueOnce(
jsonResponse({
task_id: "task-1",
auto_fix_enabled: true,
auto_merge_enabled: false,
auto_fix_prompt_override: null,
effective_auto_fix_prompt: "Fix CI.",
using_default_prompt: true,
updated_at: "2026-06-18T10:00:00Z",
pr_states: [],
}),
);
const response = await getTaskCIAutomationOptions("task-1");
expect(lastCallUrl()).toBe("http://api.test/api/v1/github/tasks/task-1/ci-options");
expect(response.auto_fix_enabled).toBe(true);
expect(response.using_default_prompt).toBe(true);
});
it("patches task options and allows clearing the prompt override", async () => {
fetchSpy.mockResolvedValueOnce(
jsonResponse({
task_id: "task-1",
auto_fix_enabled: false,
auto_merge_enabled: true,
auto_fix_prompt_override: null,
effective_auto_fix_prompt: "Default prompt",
using_default_prompt: true,
updated_at: "2026-06-18T10:01:00Z",
pr_states: [],
}),
);
await updateTaskCIAutomationOptions("task-1", {
auto_fix_enabled: false,
auto_merge_enabled: true,
auto_fix_prompt_override: null,
});
const call = fetchSpy.mock.calls.at(-1);
expect(String(call?.[0])).toBe("http://api.test/api/v1/github/tasks/task-1/ci-options");
expect(call?.[1]?.method).toBe("PATCH");
expect(JSON.parse(String(call?.[1]?.body))).toEqual({
auto_fix_enabled: false,
auto_merge_enabled: true,
auto_fix_prompt_override: null,
});
});
});
describe("copyGitHubWorkspaceSettings", () => {
it("POSTs targetWorkspaceId to /workspace-settings/copy scoped to the source", async () => {
fetchSpy.mockResolvedValueOnce(jsonResponse({ workspace_id: "ws-dst" }));
await copyGitHubWorkspaceSettings("ws-dst", { workspaceId: "ws-src" });
const call = fetchSpy.mock.calls.at(-1);
expect(String(call?.[0])).toBe(
"http://api.test/api/v1/github/workspace-settings/copy?workspace_id=ws-src",
);
expect(call?.[1]?.method).toBe("POST");
expect(JSON.parse(String(call?.[1]?.body))).toEqual({ targetWorkspaceId: "ws-dst" });
});
});