merge: resolve v1.22.0 conflicts with custom
This commit is contained in:
@@ -0,0 +1,480 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { RuntimeUrlQuery, RuntimeUrlResolver } from '@openchamber/ui/lib/runtime-url';
|
||||
|
||||
const runtimeFetchMock = vi.fn();
|
||||
|
||||
vi.mock('@openchamber/ui/lib/runtime-fetch', () => ({
|
||||
runtimeFetch: runtimeFetchMock,
|
||||
}));
|
||||
|
||||
const toUrl = (path: string, query?: RuntimeUrlQuery): string => {
|
||||
const params = query instanceof URLSearchParams ? query : new URLSearchParams();
|
||||
const queryString = params.toString();
|
||||
return queryString ? `${path}?${queryString}` : path;
|
||||
};
|
||||
|
||||
const urls: RuntimeUrlResolver = {
|
||||
api: toUrl,
|
||||
authenticatedAsset: toUrl,
|
||||
auth: toUrl,
|
||||
health: (query?: RuntimeUrlQuery) => toUrl('/health', query),
|
||||
rawFile: (path: string) => toUrl('/api/fs/raw', new URLSearchParams({ path })),
|
||||
sse: toUrl,
|
||||
websocket: toUrl,
|
||||
};
|
||||
|
||||
const createAPI = async () => {
|
||||
const { createWebGiteaAPI } = await import('./gitea');
|
||||
return createWebGiteaAPI({ urls });
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.resetModules();
|
||||
runtimeFetchMock.mockReset();
|
||||
});
|
||||
|
||||
describe('createWebGiteaAPI', () => {
|
||||
it('parses auth status payloads', async () => {
|
||||
const status = {
|
||||
connected: true,
|
||||
user: { username: 'octocat', id: 1, name: 'Octo Cat' },
|
||||
accounts: [],
|
||||
};
|
||||
runtimeFetchMock.mockResolvedValueOnce(Response.json(status));
|
||||
|
||||
const api = await createAPI();
|
||||
await expect(api.authStatus()).resolves.toEqual(status);
|
||||
|
||||
expect(runtimeFetchMock).toHaveBeenCalledWith('/api/gitea/auth/status', {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
});
|
||||
|
||||
it('sends the access token and base URL when connecting', async () => {
|
||||
const status = {
|
||||
connected: true,
|
||||
user: { username: 'octocat', id: 1 },
|
||||
accounts: [],
|
||||
};
|
||||
runtimeFetchMock.mockResolvedValueOnce(Response.json(status));
|
||||
|
||||
const api = await createAPI();
|
||||
await expect(api.authConnect({ accessToken: 'gitea-123', baseUrl: 'https://gitea.example' })).resolves.toEqual(status);
|
||||
|
||||
expect(runtimeFetchMock).toHaveBeenCalledWith('/api/gitea/auth/connect', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({ accessToken: 'gitea-123', baseUrl: 'https://gitea.example' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('passes directory, number, owner and repo query params to issueGet', async () => {
|
||||
const result = {
|
||||
connected: true,
|
||||
repo: null,
|
||||
issue: { number: 42, title: 'Broken build', url: 'https://gitea.example/o/r/issues/42', state: 'open', author: { username: 'octocat', id: 1 }, labels: [] },
|
||||
};
|
||||
runtimeFetchMock.mockResolvedValueOnce(Response.json(result));
|
||||
|
||||
const api = await createAPI();
|
||||
await expect(api.issueGet('/workspace', 42, { owner: 'group', repo: 'repo' })).resolves.toEqual(result);
|
||||
|
||||
const params = new URLSearchParams({ directory: '/workspace', number: '42', owner: 'group', repo: 'repo' });
|
||||
expect(runtimeFetchMock).toHaveBeenCalledWith(`/api/gitea/issues/get?${params.toString()}`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
});
|
||||
|
||||
it('passes includeDiff and repo params to prContext', async () => {
|
||||
const result = { connected: true, repo: null, pr: undefined };
|
||||
runtimeFetchMock.mockResolvedValueOnce(Response.json(result));
|
||||
|
||||
const api = await createAPI();
|
||||
await expect(api.prContext('/workspace', 7, { includeDiff: true, owner: 'group', repo: 'repo' })).resolves.toEqual(result);
|
||||
|
||||
const params = new URLSearchParams({ directory: '/workspace', number: '7', includeDiff: '1', owner: 'group', repo: 'repo' });
|
||||
expect(runtimeFetchMock).toHaveBeenCalledWith(`/api/gitea/pr/context?${params.toString()}`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
});
|
||||
|
||||
it('passes sourceBranch query param to prsList', async () => {
|
||||
const result = {
|
||||
connected: true,
|
||||
repo: null,
|
||||
prs: [],
|
||||
page: 1,
|
||||
hasMore: false,
|
||||
};
|
||||
runtimeFetchMock.mockResolvedValueOnce(Response.json(result));
|
||||
|
||||
const api = await createAPI();
|
||||
await expect(api.prsList('/workspace', { page: 1, query: 'search', sourceBranch: 'feat/api' })).resolves.toEqual(result);
|
||||
|
||||
const params = new URLSearchParams({ directory: '/workspace', page: '1', query: 'search', sourceBranch: 'feat/api' });
|
||||
expect(runtimeFetchMock).toHaveBeenCalledWith(`/api/gitea/prs/list?${params.toString()}`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
});
|
||||
|
||||
it('omits sourceBranch when not provided to prsList', async () => {
|
||||
const result = {
|
||||
connected: true,
|
||||
repo: null,
|
||||
prs: [],
|
||||
page: 1,
|
||||
hasMore: false,
|
||||
};
|
||||
runtimeFetchMock.mockResolvedValueOnce(Response.json(result));
|
||||
|
||||
const api = await createAPI();
|
||||
await expect(api.prsList('/workspace', { page: 1 })).resolves.toEqual(result);
|
||||
|
||||
const params = new URLSearchParams({ directory: '/workspace', page: '1' });
|
||||
expect(runtimeFetchMock).toHaveBeenCalledWith(`/api/gitea/prs/list?${params.toString()}`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
});
|
||||
|
||||
it('posts to /api/gitea/pr/create with the input body and returns the created PR', async () => {
|
||||
const created = {
|
||||
connected: true,
|
||||
repo: null,
|
||||
pr: {
|
||||
number: 12,
|
||||
title: 'Add feature',
|
||||
url: 'https://gitea.example/owner/repo/pulls/12',
|
||||
state: 'open',
|
||||
draft: false,
|
||||
author: { username: 'octocat', id: 1 },
|
||||
labels: [],
|
||||
sourceBranch: 'feat/add',
|
||||
targetBranch: 'main',
|
||||
},
|
||||
};
|
||||
runtimeFetchMock.mockResolvedValueOnce(Response.json(created));
|
||||
|
||||
const api = await createAPI();
|
||||
await expect(api.prCreate({
|
||||
directory: '/workspace',
|
||||
title: 'Add feature',
|
||||
sourceBranch: 'feat/add',
|
||||
targetBranch: 'main',
|
||||
})).resolves.toEqual(created.pr);
|
||||
|
||||
expect(runtimeFetchMock).toHaveBeenCalledWith('/api/gitea/pr/create', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({
|
||||
directory: '/workspace',
|
||||
title: 'Add feature',
|
||||
sourceBranch: 'feat/add',
|
||||
targetBranch: 'main',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('throws the server error when prCreate fails', async () => {
|
||||
runtimeFetchMock.mockResolvedValueOnce(Response.json(
|
||||
{ error: 'Your Gitea token needs write:repository scope to create pull requests' },
|
||||
{ status: 400 },
|
||||
));
|
||||
|
||||
const api = await createAPI();
|
||||
await expect(api.prCreate({
|
||||
directory: '/workspace',
|
||||
title: 'Add feature',
|
||||
sourceBranch: 'feat/add',
|
||||
targetBranch: 'main',
|
||||
})).rejects.toThrow('Your Gitea token needs write:repository scope to create pull requests');
|
||||
});
|
||||
|
||||
it('PATCHes to /api/gitea/pr/update with the input body and returns the updated PR', async () => {
|
||||
const updated = {
|
||||
connected: true,
|
||||
repo: null,
|
||||
pr: { number: 12, title: 'Renamed', url: 'u', state: 'open', draft: false, author: { username: 'octocat', id: 1 }, labels: [], sourceBranch: 'feat/add', targetBranch: 'main' },
|
||||
};
|
||||
runtimeFetchMock.mockResolvedValueOnce(Response.json(updated));
|
||||
|
||||
const api = await createAPI();
|
||||
await expect(api.prUpdate({ directory: '/workspace', number: 12, title: 'Renamed', description: 'New body' })).resolves.toEqual(updated.pr);
|
||||
|
||||
expect(runtimeFetchMock).toHaveBeenCalledWith('/api/gitea/pr/update', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({ directory: '/workspace', number: 12, title: 'Renamed', description: 'New body' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('returns merged:false without throwing when the server rejects a merge', async () => {
|
||||
runtimeFetchMock.mockResolvedValueOnce(Response.json(
|
||||
{ connected: true, merged: false, message: '409 Conflict: already merged' },
|
||||
{ status: 409 },
|
||||
));
|
||||
|
||||
const api = await createAPI();
|
||||
await expect(api.prMerge({ directory: '/workspace', number: 12, method: 'merge' })).resolves.toEqual({
|
||||
connected: true,
|
||||
merged: false,
|
||||
message: '409 Conflict: already merged',
|
||||
});
|
||||
|
||||
expect(runtimeFetchMock).toHaveBeenCalledWith('/api/gitea/pr/merge', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({ directory: '/workspace', number: 12, method: 'merge' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves merged:true on a successful merge', async () => {
|
||||
runtimeFetchMock.mockResolvedValueOnce(Response.json({ connected: true, merged: true }));
|
||||
|
||||
const api = await createAPI();
|
||||
await expect(api.prMerge({ directory: '/workspace', number: 12 })).resolves.toEqual({ connected: true, merged: true });
|
||||
});
|
||||
|
||||
it('throws the server error when prMerge hits a real error payload', async () => {
|
||||
runtimeFetchMock.mockResolvedValueOnce(Response.json(
|
||||
{ error: 'Your Gitea token needs write:repository scope to merge pull requests' },
|
||||
{ status: 400 },
|
||||
));
|
||||
|
||||
const api = await createAPI();
|
||||
await expect(api.prMerge({ directory: '/workspace', number: 12 })).rejects.toThrow(
|
||||
'Your Gitea token needs write:repository scope to merge pull requests',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws the response status text when prMerge has no parseable payload', async () => {
|
||||
runtimeFetchMock.mockResolvedValueOnce(new Response('upstream gone', { status: 502, statusText: 'Bad Gateway' }));
|
||||
|
||||
const api = await createAPI();
|
||||
await expect(api.prMerge({ directory: '/workspace', number: 12 })).rejects.toThrow('Bad Gateway');
|
||||
});
|
||||
|
||||
it('posts to /api/gitea/issues/create with the input body and returns the created issue', async () => {
|
||||
const created = {
|
||||
connected: true,
|
||||
repo: null,
|
||||
issue: {
|
||||
number: 42,
|
||||
title: 'Broken build',
|
||||
url: 'https://gitea.example/owner/repo/issues/42',
|
||||
state: 'open',
|
||||
author: { username: 'octocat', id: 1 },
|
||||
labels: ['bug'],
|
||||
},
|
||||
};
|
||||
runtimeFetchMock.mockResolvedValueOnce(Response.json(created));
|
||||
|
||||
const api = await createAPI();
|
||||
await expect(api.issueCreate!({
|
||||
directory: '/workspace',
|
||||
title: 'Broken build',
|
||||
body: 'The build is failing',
|
||||
labels: ['bug'],
|
||||
owner: 'group',
|
||||
repo: 'repo',
|
||||
})).resolves.toEqual(created);
|
||||
|
||||
expect(runtimeFetchMock).toHaveBeenCalledWith('/api/gitea/issues/create', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({
|
||||
directory: '/workspace',
|
||||
title: 'Broken build',
|
||||
body: 'The build is failing',
|
||||
labels: ['bug'],
|
||||
owner: 'group',
|
||||
repo: 'repo',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('omits optional body/labels/owner/repo from the issueCreate body when absent', async () => {
|
||||
const created = {
|
||||
connected: true,
|
||||
repo: null,
|
||||
issue: {
|
||||
number: 43,
|
||||
title: 'Title only',
|
||||
url: 'https://gitea.example/owner/repo/issues/43',
|
||||
state: 'open',
|
||||
author: { username: 'octocat', id: 1 },
|
||||
labels: [],
|
||||
},
|
||||
};
|
||||
runtimeFetchMock.mockResolvedValueOnce(Response.json(created));
|
||||
|
||||
const api = await createAPI();
|
||||
await expect(api.issueCreate!({ directory: '/workspace', title: 'Title only' })).resolves.toEqual(created);
|
||||
|
||||
expect(runtimeFetchMock).toHaveBeenCalledWith('/api/gitea/issues/create', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({ directory: '/workspace', title: 'Title only' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('throws the server error when issueCreate fails', async () => {
|
||||
runtimeFetchMock.mockResolvedValueOnce(Response.json(
|
||||
{ error: 'Your Gitea token needs write:repository scope to create issues' },
|
||||
{ status: 400 },
|
||||
));
|
||||
|
||||
const api = await createAPI();
|
||||
await expect(api.issueCreate!({
|
||||
directory: '/workspace',
|
||||
title: 'Broken build',
|
||||
})).rejects.toThrow('Your Gitea token needs write:repository scope to create issues');
|
||||
});
|
||||
|
||||
it('parses branches and the default branch from repoBranches', async () => {
|
||||
runtimeFetchMock.mockResolvedValueOnce(Response.json({ branches: ['main', 'feat/api'], defaultBranch: 'main' }));
|
||||
|
||||
const api = await createAPI();
|
||||
await expect(api.repoBranches('group', 'sub')).resolves.toEqual({ branches: ['main', 'feat/api'], defaultBranch: 'main' });
|
||||
|
||||
expect(runtimeFetchMock).toHaveBeenCalledWith('/api/gitea/repo/branches?owner=group&repo=sub', {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults defaultBranch to null when repoBranches omits it', async () => {
|
||||
runtimeFetchMock.mockResolvedValueOnce(Response.json({ branches: ['main'] }));
|
||||
|
||||
const api = await createAPI();
|
||||
await expect(api.repoBranches('group', 'sub')).resolves.toEqual({ branches: ['main'], defaultBranch: null });
|
||||
});
|
||||
|
||||
it('throws the server error message when repoBranches fails', async () => {
|
||||
runtimeFetchMock.mockResolvedValueOnce(Response.json({ error: 'Gitea rate limited' }, { status: 503 }));
|
||||
|
||||
const api = await createAPI();
|
||||
await expect(api.repoBranches('group', 'sub')).rejects.toThrow('Gitea rate limited');
|
||||
});
|
||||
|
||||
it('throws the response status text when repoBranches has no parseable payload', async () => {
|
||||
runtimeFetchMock.mockResolvedValueOnce(new Response('upstream gone', { status: 502, statusText: 'Bad Gateway' }));
|
||||
|
||||
const api = await createAPI();
|
||||
await expect(api.repoBranches('group', 'sub')).rejects.toThrow('Bad Gateway');
|
||||
});
|
||||
|
||||
it('throws the server error message on {error} payloads', async () => {
|
||||
runtimeFetchMock.mockResolvedValueOnce(Response.json({ error: 'Not connected to Gitea' }, { status: 401 }));
|
||||
|
||||
const api = await createAPI();
|
||||
await expect(api.authStatus()).rejects.toThrow('Not connected to Gitea');
|
||||
});
|
||||
|
||||
it('throws the response status text when no error payload is present', async () => {
|
||||
runtimeFetchMock.mockResolvedValueOnce(Response.json({}, { status: 500, statusText: 'Internal Server Error' }));
|
||||
|
||||
const api = await createAPI();
|
||||
await expect(api.issuesList('/workspace')).rejects.toThrow('Internal Server Error');
|
||||
});
|
||||
|
||||
it('passes directory/query/owner/repo to searchUsers and maps the response', async () => {
|
||||
const result = {
|
||||
connected: true,
|
||||
repo: null,
|
||||
users: [{ username: 'octocat', id: 1, name: 'Octo Cat', avatarUrl: 'https://gitea.example/octocat.png' }],
|
||||
};
|
||||
runtimeFetchMock.mockResolvedValueOnce(Response.json(result));
|
||||
|
||||
const api = await createAPI();
|
||||
await expect(api.searchUsers!('/workspace', 'octo', { owner: 'group', repo: 'repo' })).resolves.toEqual(result);
|
||||
|
||||
const params = new URLSearchParams({ directory: '/workspace', query: 'octo', owner: 'group', repo: 'repo' });
|
||||
expect(runtimeFetchMock).toHaveBeenCalledWith(`/api/gitea/users/search?${params.toString()}`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
});
|
||||
|
||||
it('omits owner/repo from searchUsers when not provided', async () => {
|
||||
runtimeFetchMock.mockResolvedValueOnce(Response.json({ connected: true, repo: null, users: [] }));
|
||||
|
||||
const api = await createAPI();
|
||||
await expect(api.searchUsers!('/workspace', 'octo')).resolves.toEqual({ connected: true, repo: null, users: [] });
|
||||
|
||||
const params = new URLSearchParams({ directory: '/workspace', query: 'octo' });
|
||||
expect(runtimeFetchMock).toHaveBeenCalledWith(`/api/gitea/users/search?${params.toString()}`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
});
|
||||
|
||||
it('throws the server error message when searchUsers fails', async () => {
|
||||
runtimeFetchMock.mockResolvedValueOnce(Response.json({ error: 'Gitea rate limited' }, { status: 503 }));
|
||||
|
||||
const api = await createAPI();
|
||||
await expect(api.searchUsers!('/workspace', 'octo')).rejects.toThrow('Gitea rate limited');
|
||||
});
|
||||
|
||||
it('passes directory/query to searchLabels and maps the response', async () => {
|
||||
const result = { connected: true, repo: null, labels: [{ name: 'bug', color: 'd73a4a' }] };
|
||||
runtimeFetchMock.mockResolvedValueOnce(Response.json(result));
|
||||
|
||||
const api = await createAPI();
|
||||
await expect(api.searchLabels!('/workspace', 'feat', { owner: 'group', repo: 'repo' })).resolves.toEqual(result);
|
||||
|
||||
const params = new URLSearchParams({ directory: '/workspace', query: 'feat', owner: 'group', repo: 'repo' });
|
||||
expect(runtimeFetchMock).toHaveBeenCalledWith(`/api/gitea/labels/search?${params.toString()}`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
});
|
||||
|
||||
it('passes directory/query to searchMilestones and maps the response', async () => {
|
||||
const result = { connected: true, repo: null, milestones: [{ title: 'v2.0', state: 'open' }] };
|
||||
runtimeFetchMock.mockResolvedValueOnce(Response.json(result));
|
||||
|
||||
const api = await createAPI();
|
||||
await expect(api.searchMilestones!('/workspace', 'v2', { owner: 'group', repo: 'repo' })).resolves.toEqual(result);
|
||||
|
||||
const params = new URLSearchParams({ directory: '/workspace', query: 'v2', owner: 'group', repo: 'repo' });
|
||||
expect(runtimeFetchMock).toHaveBeenCalledWith(`/api/gitea/milestones/search?${params.toString()}`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
});
|
||||
|
||||
it('passes directory/query to searchBranches and maps the response', async () => {
|
||||
const result = { connected: true, repo: null, branches: ['main', 'feat/api'] };
|
||||
runtimeFetchMock.mockResolvedValueOnce(Response.json(result));
|
||||
|
||||
const api = await createAPI();
|
||||
await expect(api.searchBranches!('/workspace', 'feat', { owner: 'group', repo: 'repo' })).resolves.toEqual(result);
|
||||
|
||||
const params = new URLSearchParams({ directory: '/workspace', query: 'feat', owner: 'group', repo: 'repo' });
|
||||
expect(runtimeFetchMock).toHaveBeenCalledWith(`/api/gitea/branches/search?${params.toString()}`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
});
|
||||
|
||||
it('passes directory/query to searchTags and maps the response', async () => {
|
||||
const result = { connected: true, repo: null, tags: ['v1.0', 'v1.1'] };
|
||||
runtimeFetchMock.mockResolvedValueOnce(Response.json(result));
|
||||
|
||||
const api = await createAPI();
|
||||
await expect(api.searchTags!('/workspace', 'v1', { owner: 'group', repo: 'repo' })).resolves.toEqual(result);
|
||||
|
||||
const params = new URLSearchParams({ directory: '/workspace', query: 'v1', owner: 'group', repo: 'repo' });
|
||||
expect(runtimeFetchMock).toHaveBeenCalledWith(`/api/gitea/tags/search?${params.toString()}`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,462 @@
|
||||
import type {
|
||||
GiteaAPI,
|
||||
GiteaAuthStatus,
|
||||
GiteaBranchesResult,
|
||||
GiteaBranchesSearchResult,
|
||||
GiteaIssueCommentInput,
|
||||
GiteaIssueCommentResult,
|
||||
GiteaIssueCommentsResult,
|
||||
GiteaIssueCreateInput,
|
||||
GiteaIssueCreateResult,
|
||||
GiteaIssueGetResult,
|
||||
GiteaIssuesListResult,
|
||||
GiteaIssueUpdateInput,
|
||||
GiteaIssueUpdateResult,
|
||||
GiteaLabelsSearchResult,
|
||||
GiteaMilestonesSearchResult,
|
||||
GiteaPullRequest,
|
||||
GiteaPullRequestCommitsResult,
|
||||
GiteaPullRequestContextResult,
|
||||
GiteaPullRequestCreateInput,
|
||||
GiteaPullRequestMergeInput,
|
||||
GiteaPullRequestMergeResult,
|
||||
GiteaPullRequestReviewsResult,
|
||||
GiteaPullRequestsListResult,
|
||||
GiteaPullRequestStatusesResult,
|
||||
GiteaPullRequestUpdateInput,
|
||||
GiteaPullReviewInput,
|
||||
GiteaPullReviewResult,
|
||||
GiteaRepoLabelsResult,
|
||||
GiteaTagsSearchResult,
|
||||
GiteaUserSummary,
|
||||
GiteaUsersSearchResult,
|
||||
} from '@openchamber/ui/lib/api/types';
|
||||
import { runtimeFetch } from '@openchamber/ui/lib/runtime-fetch';
|
||||
import type { RuntimeUrlResolver } from '@openchamber/ui/lib/runtime-url';
|
||||
|
||||
interface WebGiteaAPIOptions {
|
||||
urls: RuntimeUrlResolver;
|
||||
}
|
||||
|
||||
interface GiteaPullRequestWriteResult {
|
||||
connected: boolean;
|
||||
repo?: { owner: string; repo: string; url?: string } | null;
|
||||
pr?: GiteaPullRequest;
|
||||
}
|
||||
|
||||
const jsonOrNull = async <T>(response: Response): Promise<T | null> => {
|
||||
return (await response.json().catch(() => null)) as T | null;
|
||||
};
|
||||
|
||||
export const createWebGiteaAPI = ({ urls }: WebGiteaAPIOptions): GiteaAPI => ({
|
||||
async authStatus(): Promise<GiteaAuthStatus> {
|
||||
const response = await runtimeFetch('/api/gitea/auth/status', { method: 'GET', headers: { Accept: 'application/json' } });
|
||||
const payload = await jsonOrNull<GiteaAuthStatus & { error?: string }>(response);
|
||||
if (!response.ok || !payload) {
|
||||
throw new Error(payload?.error || response.statusText || 'Failed to load Gitea status');
|
||||
}
|
||||
return payload;
|
||||
},
|
||||
|
||||
async authConnect(input: { accessToken: string; baseUrl: string }): Promise<GiteaAuthStatus> {
|
||||
const response = await runtimeFetch('/api/gitea/auth/connect', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const payload = await jsonOrNull<GiteaAuthStatus & { error?: string }>(response);
|
||||
if (!response.ok || !payload) {
|
||||
throw new Error(payload?.error || response.statusText || 'Failed to connect Gitea');
|
||||
}
|
||||
return payload;
|
||||
},
|
||||
|
||||
async authActivate(accountId: string): Promise<GiteaAuthStatus> {
|
||||
const response = await runtimeFetch('/api/gitea/auth/activate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({ accountId }),
|
||||
});
|
||||
const payload = await jsonOrNull<GiteaAuthStatus & { error?: string }>(response);
|
||||
if (!response.ok || !payload) {
|
||||
throw new Error(payload?.error || response.statusText || 'Failed to activate Gitea account');
|
||||
}
|
||||
return payload;
|
||||
},
|
||||
|
||||
async authDisconnect(): Promise<{ removed: boolean }> {
|
||||
const response = await runtimeFetch('/api/gitea/auth', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
});
|
||||
const payload = await jsonOrNull<{ removed?: boolean; error?: string }>(response);
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.error || response.statusText || 'Failed to disconnect Gitea');
|
||||
}
|
||||
return { removed: Boolean(payload?.removed) };
|
||||
},
|
||||
|
||||
async me(): Promise<GiteaUserSummary> {
|
||||
const response = await runtimeFetch('/api/gitea/me', { method: 'GET', headers: { Accept: 'application/json' } });
|
||||
const payload = await jsonOrNull<GiteaUserSummary & { error?: string }>(response);
|
||||
if (!response.ok || !payload) {
|
||||
throw new Error(payload?.error || response.statusText || 'Failed to fetch Gitea user');
|
||||
}
|
||||
return payload;
|
||||
},
|
||||
|
||||
async searchUsers(directory, query, options): Promise<GiteaUsersSearchResult> {
|
||||
const params = new URLSearchParams({ directory, query });
|
||||
if (options?.owner) params.set('owner', options.owner);
|
||||
if (options?.repo) params.set('repo', options.repo);
|
||||
const response = await runtimeFetch(urls.api('/api/gitea/users/search', params), { method: 'GET', headers: { Accept: 'application/json' } });
|
||||
const body = await jsonOrNull<GiteaUsersSearchResult & { error?: string }>(response);
|
||||
if (!response.ok || !body) {
|
||||
throw new Error(body?.error || response.statusText || 'Failed to search Gitea users');
|
||||
}
|
||||
return { connected: body.connected, repo: body.repo ?? null, users: body.users ?? [] };
|
||||
},
|
||||
|
||||
async searchLabels(directory, query, options): Promise<GiteaLabelsSearchResult> {
|
||||
const params = new URLSearchParams({ directory, query });
|
||||
if (options?.owner) params.set('owner', options.owner);
|
||||
if (options?.repo) params.set('repo', options.repo);
|
||||
const response = await runtimeFetch(urls.api('/api/gitea/labels/search', params), { method: 'GET', headers: { Accept: 'application/json' } });
|
||||
const body = await jsonOrNull<GiteaLabelsSearchResult & { error?: string }>(response);
|
||||
if (!response.ok || !body) {
|
||||
throw new Error(body?.error || response.statusText || 'Failed to search Gitea labels');
|
||||
}
|
||||
return { connected: body.connected, repo: body.repo ?? null, labels: body.labels ?? [] };
|
||||
},
|
||||
|
||||
async searchMilestones(directory, query, options): Promise<GiteaMilestonesSearchResult> {
|
||||
const params = new URLSearchParams({ directory, query });
|
||||
if (options?.owner) params.set('owner', options.owner);
|
||||
if (options?.repo) params.set('repo', options.repo);
|
||||
const response = await runtimeFetch(urls.api('/api/gitea/milestones/search', params), { method: 'GET', headers: { Accept: 'application/json' } });
|
||||
const body = await jsonOrNull<GiteaMilestonesSearchResult & { error?: string }>(response);
|
||||
if (!response.ok || !body) {
|
||||
throw new Error(body?.error || response.statusText || 'Failed to search Gitea milestones');
|
||||
}
|
||||
return { connected: body.connected, repo: body.repo ?? null, milestones: body.milestones ?? [] };
|
||||
},
|
||||
|
||||
async searchBranches(directory, query, options): Promise<GiteaBranchesSearchResult> {
|
||||
const params = new URLSearchParams({ directory, query });
|
||||
if (options?.owner) params.set('owner', options.owner);
|
||||
if (options?.repo) params.set('repo', options.repo);
|
||||
const response = await runtimeFetch(urls.api('/api/gitea/branches/search', params), { method: 'GET', headers: { Accept: 'application/json' } });
|
||||
const body = await jsonOrNull<GiteaBranchesSearchResult & { error?: string }>(response);
|
||||
if (!response.ok || !body) {
|
||||
throw new Error(body?.error || response.statusText || 'Failed to search Gitea branches');
|
||||
}
|
||||
return { connected: body.connected, repo: body.repo ?? null, branches: body.branches ?? [] };
|
||||
},
|
||||
|
||||
async searchTags(directory, query, options): Promise<GiteaTagsSearchResult> {
|
||||
const params = new URLSearchParams({ directory, query });
|
||||
if (options?.owner) params.set('owner', options.owner);
|
||||
if (options?.repo) params.set('repo', options.repo);
|
||||
const response = await runtimeFetch(urls.api('/api/gitea/tags/search', params), { method: 'GET', headers: { Accept: 'application/json' } });
|
||||
const body = await jsonOrNull<GiteaTagsSearchResult & { error?: string }>(response);
|
||||
if (!response.ok || !body) {
|
||||
throw new Error(body?.error || response.statusText || 'Failed to search Gitea tags');
|
||||
}
|
||||
return { connected: body.connected, repo: body.repo ?? null, tags: body.tags ?? [] };
|
||||
},
|
||||
|
||||
async issuesList(directory: string, options?: { page?: number; query?: string }): Promise<GiteaIssuesListResult> {
|
||||
const page = options?.page ?? 1;
|
||||
const params = new URLSearchParams({
|
||||
directory,
|
||||
page: String(page),
|
||||
});
|
||||
if (options?.query) {
|
||||
params.set('query', options.query);
|
||||
}
|
||||
const response = await runtimeFetch(
|
||||
`/api/gitea/issues/list?${params.toString()}`,
|
||||
{ method: 'GET', headers: { Accept: 'application/json' } }
|
||||
);
|
||||
const payload = await jsonOrNull<GiteaIssuesListResult & { error?: string }>(response);
|
||||
if (!response.ok || !payload) {
|
||||
throw new Error(payload?.error || response.statusText || 'Failed to load Gitea issues');
|
||||
}
|
||||
return payload;
|
||||
},
|
||||
|
||||
async issueGet(directory: string, number: number, options?: { owner?: string; repo?: string }): Promise<GiteaIssueGetResult> {
|
||||
const params = new URLSearchParams({ directory, number: String(number) });
|
||||
if (options?.owner) {
|
||||
params.set('owner', options.owner);
|
||||
}
|
||||
if (options?.repo) {
|
||||
params.set('repo', options.repo);
|
||||
}
|
||||
const response = await runtimeFetch(urls.api('/api/gitea/issues/get', params), { method: 'GET', headers: { Accept: 'application/json' } });
|
||||
const payload = await jsonOrNull<GiteaIssueGetResult & { error?: string }>(response);
|
||||
if (!response.ok || !payload) {
|
||||
throw new Error(payload?.error || response.statusText || 'Failed to load Gitea issue');
|
||||
}
|
||||
return payload;
|
||||
},
|
||||
|
||||
async issueComments(directory: string, number: number, options?: { owner?: string; repo?: string }): Promise<GiteaIssueCommentsResult> {
|
||||
const params = new URLSearchParams({ directory, number: String(number) });
|
||||
if (options?.owner) {
|
||||
params.set('owner', options.owner);
|
||||
}
|
||||
if (options?.repo) {
|
||||
params.set('repo', options.repo);
|
||||
}
|
||||
const response = await runtimeFetch(urls.api('/api/gitea/issues/comments', params), { method: 'GET', headers: { Accept: 'application/json' } });
|
||||
const payload = await jsonOrNull<GiteaIssueCommentsResult & { error?: string }>(response);
|
||||
if (!response.ok || !payload) {
|
||||
throw new Error(payload?.error || response.statusText || 'Failed to load Gitea issue comments');
|
||||
}
|
||||
return payload;
|
||||
},
|
||||
|
||||
async prsList(directory: string, options?: { page?: number; query?: string; sourceBranch?: string }): Promise<GiteaPullRequestsListResult> {
|
||||
const page = options?.page ?? 1;
|
||||
const params = new URLSearchParams({
|
||||
directory,
|
||||
page: String(page),
|
||||
});
|
||||
if (options?.query) {
|
||||
params.set('query', options.query);
|
||||
}
|
||||
if (options?.sourceBranch) {
|
||||
params.set('sourceBranch', options.sourceBranch);
|
||||
}
|
||||
const response = await runtimeFetch(
|
||||
`/api/gitea/prs/list?${params.toString()}`,
|
||||
{ method: 'GET', headers: { Accept: 'application/json' } }
|
||||
);
|
||||
const payload = await jsonOrNull<GiteaPullRequestsListResult & { error?: string }>(response);
|
||||
if (!response.ok || !payload) {
|
||||
throw new Error(payload?.error || response.statusText || 'Failed to load Gitea pull requests');
|
||||
}
|
||||
return payload;
|
||||
},
|
||||
|
||||
async prContext(
|
||||
directory: string,
|
||||
number: number,
|
||||
options?: { includeDiff?: boolean; owner?: string; repo?: string }
|
||||
): Promise<GiteaPullRequestContextResult> {
|
||||
const params = new URLSearchParams({ directory, number: String(number) });
|
||||
if (options?.includeDiff) {
|
||||
params.set('includeDiff', '1');
|
||||
}
|
||||
if (options?.owner) {
|
||||
params.set('owner', options.owner);
|
||||
}
|
||||
if (options?.repo) {
|
||||
params.set('repo', options.repo);
|
||||
}
|
||||
const response = await runtimeFetch(urls.api('/api/gitea/pr/context', params), { method: 'GET', headers: { Accept: 'application/json' } });
|
||||
const payload = await jsonOrNull<GiteaPullRequestContextResult & { error?: string }>(response);
|
||||
if (!response.ok || !payload) {
|
||||
throw new Error(payload?.error || response.statusText || 'Failed to load Gitea pull request context');
|
||||
}
|
||||
return payload;
|
||||
},
|
||||
|
||||
async prCommits(directory: string, number: number, options?: { owner?: string; repo?: string }): Promise<GiteaPullRequestCommitsResult> {
|
||||
const params = new URLSearchParams({ directory, number: String(number) });
|
||||
if (options?.owner) {
|
||||
params.set('owner', options.owner);
|
||||
}
|
||||
if (options?.repo) {
|
||||
params.set('repo', options.repo);
|
||||
}
|
||||
const response = await runtimeFetch(urls.api('/api/gitea/prs/commits', params), { method: 'GET', headers: { Accept: 'application/json' } });
|
||||
const payload = await jsonOrNull<GiteaPullRequestCommitsResult & { error?: string }>(response);
|
||||
if (!response.ok || !payload) {
|
||||
throw new Error(payload?.error || response.statusText || 'Failed to load Gitea pull request commits');
|
||||
}
|
||||
return payload;
|
||||
},
|
||||
|
||||
async prStatuses(directory: string, number: number, options?: { owner?: string; repo?: string }): Promise<GiteaPullRequestStatusesResult> {
|
||||
const params = new URLSearchParams({ directory, number: String(number) });
|
||||
if (options?.owner) {
|
||||
params.set('owner', options.owner);
|
||||
}
|
||||
if (options?.repo) {
|
||||
params.set('repo', options.repo);
|
||||
}
|
||||
const response = await runtimeFetch(urls.api('/api/gitea/prs/statuses', params), { method: 'GET', headers: { Accept: 'application/json' } });
|
||||
const payload = await jsonOrNull<GiteaPullRequestStatusesResult & { error?: string }>(response);
|
||||
if (!response.ok || !payload) {
|
||||
throw new Error(payload?.error || response.statusText || 'Failed to load Gitea pull request statuses');
|
||||
}
|
||||
return payload;
|
||||
},
|
||||
|
||||
async prReviews(directory: string, number: number, options?: { owner?: string; repo?: string }): Promise<GiteaPullRequestReviewsResult> {
|
||||
const params = new URLSearchParams({ directory, number: String(number) });
|
||||
if (options?.owner) {
|
||||
params.set('owner', options.owner);
|
||||
}
|
||||
if (options?.repo) {
|
||||
params.set('repo', options.repo);
|
||||
}
|
||||
const response = await runtimeFetch(urls.api('/api/gitea/prs/reviews', params), { method: 'GET', headers: { Accept: 'application/json' } });
|
||||
const payload = await jsonOrNull<GiteaPullRequestReviewsResult & { error?: string }>(response);
|
||||
if (!response.ok || !payload) {
|
||||
throw new Error(payload?.error || response.statusText || 'Failed to load Gitea pull request reviews');
|
||||
}
|
||||
return payload;
|
||||
},
|
||||
|
||||
async prCreate(input: GiteaPullRequestCreateInput): Promise<GiteaPullRequest> {
|
||||
const response = await runtimeFetch('/api/gitea/pr/create', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const payload = await jsonOrNull<GiteaPullRequestWriteResult & { error?: string }>(response);
|
||||
if (!response.ok || !payload?.pr) {
|
||||
throw new Error(payload?.error || response.statusText || 'Failed to create Gitea pull request');
|
||||
}
|
||||
return payload.pr;
|
||||
},
|
||||
|
||||
async prUpdate(input: GiteaPullRequestUpdateInput): Promise<GiteaPullRequest> {
|
||||
const response = await runtimeFetch('/api/gitea/pr/update', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const payload = await jsonOrNull<GiteaPullRequestWriteResult & { error?: string }>(response);
|
||||
if (!response.ok || !payload?.pr) {
|
||||
throw new Error(payload?.error || response.statusText || 'Failed to update Gitea pull request');
|
||||
}
|
||||
return payload.pr;
|
||||
},
|
||||
|
||||
async prMerge(input: GiteaPullRequestMergeInput): Promise<GiteaPullRequestMergeResult> {
|
||||
const response = await runtimeFetch('/api/gitea/pr/merge', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const payload = await jsonOrNull<GiteaPullRequestMergeResult & { error?: string }>(response);
|
||||
// The server rejects non-mergeable requests with 405/409/422 and a
|
||||
// `{ connected, merged: false, message }` body — parse it and return it
|
||||
// instead of throwing. Only throw when there is no parseable payload
|
||||
// (network failure) or the server surfaced a real `{ error }`.
|
||||
if (!payload) {
|
||||
throw new Error(response.statusText || 'Failed to merge Gitea pull request');
|
||||
}
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
return {
|
||||
connected: Boolean(payload.connected),
|
||||
merged: Boolean(payload.merged),
|
||||
...(payload.message ? { message: payload.message } : {}),
|
||||
};
|
||||
},
|
||||
|
||||
async issueComment(input: GiteaIssueCommentInput): Promise<GiteaIssueCommentResult> {
|
||||
const response = await runtimeFetch('/api/gitea/issues/comment', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const body = await jsonOrNull<GiteaIssueCommentResult & { error?: string }>(response);
|
||||
if (!response.ok || !body) {
|
||||
throw new Error(body?.error || response.statusText || 'Failed to post Gitea issue comment');
|
||||
}
|
||||
return body;
|
||||
},
|
||||
|
||||
async issueCreate(input: GiteaIssueCreateInput): Promise<GiteaIssueCreateResult> {
|
||||
const response = await runtimeFetch('/api/gitea/issues/create', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const body = await jsonOrNull<GiteaIssueCreateResult & { error?: string }>(response);
|
||||
if (!response.ok || !body) {
|
||||
throw new Error(body?.error || response.statusText || 'Failed to create Gitea issue');
|
||||
}
|
||||
return body;
|
||||
},
|
||||
|
||||
async issueUpdate(input: GiteaIssueUpdateInput): Promise<GiteaIssueUpdateResult> {
|
||||
const response = await runtimeFetch('/api/gitea/issues/update', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const body = await jsonOrNull<GiteaIssueUpdateResult & { error?: string }>(response);
|
||||
if (!response.ok || !body) {
|
||||
throw new Error(body?.error || response.statusText || 'Failed to update Gitea issue');
|
||||
}
|
||||
return body;
|
||||
},
|
||||
|
||||
async prComment(input: GiteaIssueCommentInput): Promise<GiteaIssueCommentResult> {
|
||||
const response = await runtimeFetch('/api/gitea/prs/comment', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const body = await jsonOrNull<GiteaIssueCommentResult & { error?: string }>(response);
|
||||
if (!response.ok || !body) {
|
||||
throw new Error(body?.error || response.statusText || 'Failed to post Gitea pull request comment');
|
||||
}
|
||||
return body;
|
||||
},
|
||||
|
||||
async prSubmitReview(input: GiteaPullReviewInput): Promise<GiteaPullReviewResult> {
|
||||
const response = await runtimeFetch('/api/gitea/prs/review', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const body = await jsonOrNull<GiteaPullReviewResult & { error?: string }>(response);
|
||||
if (!response.ok || !body) {
|
||||
throw new Error(body?.error || response.statusText || 'Failed to submit Gitea pull request review');
|
||||
}
|
||||
return body;
|
||||
},
|
||||
|
||||
async repoLabels(directory: string, options?: { owner?: string; repo?: string }): Promise<GiteaRepoLabelsResult> {
|
||||
const params = new URLSearchParams({ directory });
|
||||
if (options?.owner) {
|
||||
params.set('owner', options.owner);
|
||||
}
|
||||
if (options?.repo) {
|
||||
params.set('repo', options.repo);
|
||||
}
|
||||
const response = await runtimeFetch(
|
||||
`/api/gitea/repo/labels?${params.toString()}`,
|
||||
{ method: 'GET', headers: { Accept: 'application/json' } }
|
||||
);
|
||||
const body = await jsonOrNull<GiteaRepoLabelsResult & { error?: string }>(response);
|
||||
if (!response.ok || !body) {
|
||||
throw new Error(body?.error || response.statusText || 'Failed to fetch Gitea repo labels');
|
||||
}
|
||||
return body;
|
||||
},
|
||||
|
||||
async repoBranches(owner: string, repo: string): Promise<GiteaBranchesResult> {
|
||||
const response = await runtimeFetch(
|
||||
`/api/gitea/repo/branches?owner=${encodeURIComponent(owner)}&repo=${encodeURIComponent(repo)}`,
|
||||
{ method: 'GET', headers: { Accept: 'application/json' } }
|
||||
);
|
||||
const body = await jsonOrNull<GiteaBranchesResult & { error?: string }>(response);
|
||||
if (!response.ok || !body) {
|
||||
throw new Error(body?.error || response.statusText || 'Failed to fetch Gitea repo branches');
|
||||
}
|
||||
return {
|
||||
branches: body.branches ?? [],
|
||||
defaultBranch: body.defaultBranch ?? null,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { RuntimeUrlQuery, RuntimeUrlResolver } from '@openchamber/ui/lib/runtime-url';
|
||||
|
||||
const runtimeFetchMock = vi.fn();
|
||||
|
||||
vi.mock('@openchamber/ui/lib/runtime-fetch', () => ({
|
||||
runtimeFetch: runtimeFetchMock,
|
||||
}));
|
||||
|
||||
const toUrl = (path: string, query?: RuntimeUrlQuery): string => {
|
||||
const params = query instanceof URLSearchParams ? query : new URLSearchParams();
|
||||
const queryString = params.toString();
|
||||
return queryString ? `${path}?${queryString}` : path;
|
||||
};
|
||||
|
||||
const urls: RuntimeUrlResolver = {
|
||||
api: toUrl,
|
||||
authenticatedAsset: toUrl,
|
||||
auth: toUrl,
|
||||
health: (query?: RuntimeUrlQuery) => toUrl('/health', query),
|
||||
rawFile: (path: string) => toUrl('/api/fs/raw', new URLSearchParams({ path })),
|
||||
sse: toUrl,
|
||||
websocket: toUrl,
|
||||
};
|
||||
|
||||
const createAPI = async () => {
|
||||
const { createWebGitHubAPI } = await import('./github');
|
||||
return createWebGitHubAPI({ urls });
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.resetModules();
|
||||
runtimeFetchMock.mockReset();
|
||||
});
|
||||
|
||||
describe('createWebGitHubAPI', () => {
|
||||
it('passes directory/query/owner/repo to searchUsers and maps the response', async () => {
|
||||
const result = {
|
||||
connected: true,
|
||||
repo: null,
|
||||
users: [{ login: 'octocat', id: 1, name: 'Octo Cat', avatarUrl: 'https://avatars.example/octocat.png' }],
|
||||
};
|
||||
runtimeFetchMock.mockResolvedValueOnce(Response.json(result));
|
||||
|
||||
const api = await createAPI();
|
||||
await expect(api.searchUsers!('/workspace', 'octo', { sourceRepo: { owner: 'acme', repo: 'widget' } }))
|
||||
.resolves.toEqual(result);
|
||||
|
||||
const params = new URLSearchParams({ directory: '/workspace', query: 'octo', owner: 'acme', repo: 'widget' });
|
||||
expect(runtimeFetchMock).toHaveBeenCalledWith(`/api/github/users/search?${params.toString()}`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
});
|
||||
|
||||
it('omits owner/repo from searchUsers when no sourceRepo is provided', async () => {
|
||||
runtimeFetchMock.mockResolvedValueOnce(Response.json({ connected: true, repo: null, users: [] }));
|
||||
|
||||
const api = await createAPI();
|
||||
await expect(api.searchUsers!('/workspace', 'octo')).resolves.toEqual({ connected: true, repo: null, users: [] });
|
||||
|
||||
const params = new URLSearchParams({ directory: '/workspace', query: 'octo' });
|
||||
expect(runtimeFetchMock).toHaveBeenCalledWith(`/api/github/users/search?${params.toString()}`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults missing repo/users fields to null/empty when the response omits them', async () => {
|
||||
runtimeFetchMock.mockResolvedValueOnce(Response.json({ connected: true }));
|
||||
|
||||
const api = await createAPI();
|
||||
await expect(api.searchUsers!('/workspace', 'octo')).resolves.toEqual({ connected: true, repo: null, users: [] });
|
||||
});
|
||||
|
||||
it('throws the server error message when searchUsers fails', async () => {
|
||||
runtimeFetchMock.mockResolvedValueOnce(Response.json({ error: 'GitHub rate limited' }, { status: 503 }));
|
||||
|
||||
const api = await createAPI();
|
||||
await expect(api.searchUsers!('/workspace', 'octo')).rejects.toThrow('GitHub rate limited');
|
||||
});
|
||||
|
||||
it('passes directory/query to searchLabels and maps the response', async () => {
|
||||
const result = { connected: true, repo: null, labels: [{ name: 'bug', color: 'd73a4a' }] };
|
||||
runtimeFetchMock.mockResolvedValueOnce(Response.json(result));
|
||||
|
||||
const api = await createAPI();
|
||||
await expect(api.searchLabels!('/workspace', 'feat', { sourceRepo: { owner: 'acme', repo: 'widget' } }))
|
||||
.resolves.toEqual(result);
|
||||
|
||||
const params = new URLSearchParams({ directory: '/workspace', query: 'feat', owner: 'acme', repo: 'widget' });
|
||||
expect(runtimeFetchMock).toHaveBeenCalledWith(`/api/github/labels/search?${params.toString()}`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,21 @@
|
||||
import type {
|
||||
GitHubAPI,
|
||||
GitHubAuthStatus,
|
||||
GitHubBranchesSearchResult,
|
||||
GitHubIssueCommentsResult,
|
||||
GitHubIssueCommentInput,
|
||||
GitHubIssueCommentResult,
|
||||
GitHubIssueCreateInput,
|
||||
GitHubIssueCreateResult,
|
||||
GitHubIssueGetResult,
|
||||
GitHubIssueUpdateInput,
|
||||
GitHubIssueUpdateResult,
|
||||
GitHubIssuesListResult,
|
||||
GitHubLabelsSearchResult,
|
||||
GitHubMilestonesSearchResult,
|
||||
GitHubPullRequestContextResult,
|
||||
GitHubPullRequestCommitsResult,
|
||||
GitHubPullRequestTimelineResult,
|
||||
GitHubPullRequestsListResult,
|
||||
GitHubPullRequest,
|
||||
GitHubPullRequestCreateInput,
|
||||
@@ -14,10 +25,16 @@ import type {
|
||||
GitHubPullRequestReadyResult,
|
||||
GitHubPullRequestUpdateInput,
|
||||
GitHubPullRequestStatus,
|
||||
GitHubPullRequestReviewInput,
|
||||
GitHubPullRequestReviewResult,
|
||||
GitHubRepoUpstreamResult,
|
||||
GitHubReviewCommentInput,
|
||||
GitHubReviewCommentResult,
|
||||
GitHubDeviceFlowComplete,
|
||||
GitHubDeviceFlowStart,
|
||||
GitHubTagsSearchResult,
|
||||
GitHubUserSummary,
|
||||
GitHubUsersSearchResult,
|
||||
} from '@openchamber/ui/lib/api/types';
|
||||
import { runtimeFetch } from '@openchamber/ui/lib/runtime-fetch';
|
||||
import type { RuntimeUrlResolver } from '@openchamber/ui/lib/runtime-url';
|
||||
@@ -110,6 +127,76 @@ export const createWebGitHubAPI = ({ urls }: WebGitHubAPIOptions): GitHubAPI =>
|
||||
return payload;
|
||||
},
|
||||
|
||||
async searchUsers(directory, query, options): Promise<GitHubUsersSearchResult> {
|
||||
const params = new URLSearchParams({ directory, query });
|
||||
if (options?.sourceRepo?.owner && options.sourceRepo.repo) {
|
||||
params.set('owner', options.sourceRepo.owner);
|
||||
params.set('repo', options.sourceRepo.repo);
|
||||
}
|
||||
const response = await runtimeFetch(urls.api('/api/github/users/search', params), { method: 'GET', headers: { Accept: 'application/json' } });
|
||||
const body = await jsonOrNull<GitHubUsersSearchResult & { error?: string }>(response);
|
||||
if (!response.ok || !body) {
|
||||
throw new Error(body?.error || response.statusText || 'Failed to search GitHub users');
|
||||
}
|
||||
return { connected: body.connected, repo: body.repo ?? null, users: body.users ?? [] };
|
||||
},
|
||||
|
||||
async searchLabels(directory, query, options): Promise<GitHubLabelsSearchResult> {
|
||||
const params = new URLSearchParams({ directory, query });
|
||||
if (options?.sourceRepo?.owner && options.sourceRepo.repo) {
|
||||
params.set('owner', options.sourceRepo.owner);
|
||||
params.set('repo', options.sourceRepo.repo);
|
||||
}
|
||||
const response = await runtimeFetch(urls.api('/api/github/labels/search', params), { method: 'GET', headers: { Accept: 'application/json' } });
|
||||
const body = await jsonOrNull<GitHubLabelsSearchResult & { error?: string }>(response);
|
||||
if (!response.ok || !body) {
|
||||
throw new Error(body?.error || response.statusText || 'Failed to search GitHub labels');
|
||||
}
|
||||
return { connected: body.connected, repo: body.repo ?? null, labels: body.labels ?? [] };
|
||||
},
|
||||
|
||||
async searchMilestones(directory, query, options): Promise<GitHubMilestonesSearchResult> {
|
||||
const params = new URLSearchParams({ directory, query });
|
||||
if (options?.sourceRepo?.owner && options.sourceRepo.repo) {
|
||||
params.set('owner', options.sourceRepo.owner);
|
||||
params.set('repo', options.sourceRepo.repo);
|
||||
}
|
||||
const response = await runtimeFetch(urls.api('/api/github/milestones/search', params), { method: 'GET', headers: { Accept: 'application/json' } });
|
||||
const body = await jsonOrNull<GitHubMilestonesSearchResult & { error?: string }>(response);
|
||||
if (!response.ok || !body) {
|
||||
throw new Error(body?.error || response.statusText || 'Failed to search GitHub milestones');
|
||||
}
|
||||
return { connected: body.connected, repo: body.repo ?? null, milestones: body.milestones ?? [] };
|
||||
},
|
||||
|
||||
async searchBranches(directory, query, options): Promise<GitHubBranchesSearchResult> {
|
||||
const params = new URLSearchParams({ directory, query });
|
||||
if (options?.sourceRepo?.owner && options.sourceRepo.repo) {
|
||||
params.set('owner', options.sourceRepo.owner);
|
||||
params.set('repo', options.sourceRepo.repo);
|
||||
}
|
||||
const response = await runtimeFetch(urls.api('/api/github/branches/search', params), { method: 'GET', headers: { Accept: 'application/json' } });
|
||||
const body = await jsonOrNull<GitHubBranchesSearchResult & { error?: string }>(response);
|
||||
if (!response.ok || !body) {
|
||||
throw new Error(body?.error || response.statusText || 'Failed to search GitHub branches');
|
||||
}
|
||||
return { connected: body.connected, repo: body.repo ?? null, branches: body.branches ?? [] };
|
||||
},
|
||||
|
||||
async searchTags(directory, query, options): Promise<GitHubTagsSearchResult> {
|
||||
const params = new URLSearchParams({ directory, query });
|
||||
if (options?.sourceRepo?.owner && options.sourceRepo.repo) {
|
||||
params.set('owner', options.sourceRepo.owner);
|
||||
params.set('repo', options.sourceRepo.repo);
|
||||
}
|
||||
const response = await runtimeFetch(urls.api('/api/github/tags/search', params), { method: 'GET', headers: { Accept: 'application/json' } });
|
||||
const body = await jsonOrNull<GitHubTagsSearchResult & { error?: string }>(response);
|
||||
if (!response.ok || !body) {
|
||||
throw new Error(body?.error || response.statusText || 'Failed to search GitHub tags');
|
||||
}
|
||||
return { connected: body.connected, repo: body.repo ?? null, tags: body.tags ?? [] };
|
||||
},
|
||||
|
||||
async prStatus(directory: string, branch: string, remote?: string, options?: { force?: boolean }): Promise<GitHubPullRequestStatus> {
|
||||
const params = new URLSearchParams({
|
||||
directory,
|
||||
@@ -295,4 +382,110 @@ export const createWebGitHubAPI = ({ urls }: WebGitHubAPIOptions): GitHubAPI =>
|
||||
}
|
||||
return payload;
|
||||
},
|
||||
|
||||
async prCommits(directory: string, number: number, options?: { sourceRepo?: { owner: string; repo: string } | null }): Promise<GitHubPullRequestCommitsResult> {
|
||||
const params = new URLSearchParams({ directory, number: String(number) });
|
||||
if (options?.sourceRepo?.owner && options.sourceRepo.repo) {
|
||||
params.set('owner', options.sourceRepo.owner);
|
||||
params.set('repo', options.sourceRepo.repo);
|
||||
}
|
||||
const response = await runtimeFetch(urls.api('/api/github/pulls/commits', params), { method: 'GET', headers: { Accept: 'application/json' } });
|
||||
const payload = await jsonOrNull<GitHubPullRequestCommitsResult & { error?: string }>(response);
|
||||
if (!response.ok || !payload) {
|
||||
throw new Error(payload?.error || response.statusText || 'Failed to load pull request commits');
|
||||
}
|
||||
return payload;
|
||||
},
|
||||
|
||||
async prTimeline(directory: string, number: number, options?: { sourceRepo?: { owner: string; repo: string } | null }): Promise<GitHubPullRequestTimelineResult> {
|
||||
const params = new URLSearchParams({ directory, number: String(number) });
|
||||
if (options?.sourceRepo?.owner && options.sourceRepo.repo) {
|
||||
params.set('owner', options.sourceRepo.owner);
|
||||
params.set('repo', options.sourceRepo.repo);
|
||||
}
|
||||
const response = await runtimeFetch(urls.api('/api/github/pulls/timeline', params), { method: 'GET', headers: { Accept: 'application/json' } });
|
||||
const payload = await jsonOrNull<GitHubPullRequestTimelineResult & { error?: string }>(response);
|
||||
if (!response.ok || !payload) {
|
||||
throw new Error(payload?.error || response.statusText || 'Failed to load pull request timeline');
|
||||
}
|
||||
return payload;
|
||||
},
|
||||
|
||||
async issueComment(input: GitHubIssueCommentInput): Promise<GitHubIssueCommentResult> {
|
||||
const response = await runtimeFetch('/api/github/issues/comment', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const body = await jsonOrNull<GitHubIssueCommentResult & { error?: string }>(response);
|
||||
if (!response.ok || !body) {
|
||||
throw new Error(body?.error || response.statusText || 'Failed to post GitHub comment');
|
||||
}
|
||||
return body;
|
||||
},
|
||||
|
||||
async issueCreate(input: GitHubIssueCreateInput): Promise<GitHubIssueCreateResult> {
|
||||
const response = await runtimeFetch('/api/github/issues/create', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const body = await jsonOrNull<GitHubIssueCreateResult & { error?: string }>(response);
|
||||
if (!response.ok || !body) {
|
||||
throw new Error(body?.error || response.statusText || 'Failed to create GitHub issue');
|
||||
}
|
||||
return body;
|
||||
},
|
||||
|
||||
async issueUpdate(input: GitHubIssueUpdateInput): Promise<GitHubIssueUpdateResult> {
|
||||
const response = await runtimeFetch('/api/github/issues/update', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const body = await jsonOrNull<GitHubIssueUpdateResult & { error?: string }>(response);
|
||||
if (!response.ok || !body) {
|
||||
throw new Error(body?.error || response.statusText || 'Failed to update GitHub issue');
|
||||
}
|
||||
return body;
|
||||
},
|
||||
|
||||
async prComment(input: GitHubIssueCommentInput): Promise<GitHubIssueCommentResult> {
|
||||
const response = await runtimeFetch('/api/github/pulls/comment', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const body = await jsonOrNull<GitHubIssueCommentResult & { error?: string }>(response);
|
||||
if (!response.ok || !body) {
|
||||
throw new Error(body?.error || response.statusText || 'Failed to post GitHub PR comment');
|
||||
}
|
||||
return body;
|
||||
},
|
||||
|
||||
async prReviewComment(input: GitHubReviewCommentInput): Promise<GitHubReviewCommentResult> {
|
||||
const response = await runtimeFetch('/api/github/pulls/review-comment', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const body = await jsonOrNull<GitHubReviewCommentResult & { error?: string }>(response);
|
||||
if (!response.ok || !body) {
|
||||
throw new Error(body?.error || response.statusText || 'Failed to post GitHub review comment');
|
||||
}
|
||||
return body;
|
||||
},
|
||||
|
||||
async prSubmitReview(input: GitHubPullRequestReviewInput): Promise<GitHubPullRequestReviewResult> {
|
||||
const response = await runtimeFetch('/api/github/pulls/review', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const body = await jsonOrNull<GitHubPullRequestReviewResult & { error?: string }>(response);
|
||||
if (!response.ok || !body) {
|
||||
throw new Error(body?.error || response.statusText || 'Failed to submit GitHub review');
|
||||
}
|
||||
return body;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { RuntimeUrlQuery, RuntimeUrlResolver } from '@openchamber/ui/lib/runtime-url';
|
||||
|
||||
const runtimeFetchMock = vi.fn();
|
||||
|
||||
vi.mock('@openchamber/ui/lib/runtime-fetch', () => ({
|
||||
runtimeFetch: runtimeFetchMock,
|
||||
}));
|
||||
|
||||
const toUrl = (path: string, query?: RuntimeUrlQuery): string => {
|
||||
const params = query instanceof URLSearchParams ? query : new URLSearchParams();
|
||||
const queryString = params.toString();
|
||||
return queryString ? `${path}?${queryString}` : path;
|
||||
};
|
||||
|
||||
const urls: RuntimeUrlResolver = {
|
||||
api: toUrl,
|
||||
authenticatedAsset: toUrl,
|
||||
auth: toUrl,
|
||||
health: (query?: RuntimeUrlQuery) => toUrl('/health', query),
|
||||
rawFile: (path: string) => toUrl('/api/fs/raw', new URLSearchParams({ path })),
|
||||
sse: toUrl,
|
||||
websocket: toUrl,
|
||||
};
|
||||
|
||||
const createAPI = async () => {
|
||||
const { createWebGitLabAPI } = await import('./gitlab');
|
||||
return createWebGitLabAPI({ urls });
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.resetModules();
|
||||
runtimeFetchMock.mockReset();
|
||||
});
|
||||
|
||||
describe('createWebGitLabAPI', () => {
|
||||
it('parses auth status payloads', async () => {
|
||||
const status = {
|
||||
connected: true,
|
||||
user: { username: 'octocat', id: 1, name: 'Octo Cat' },
|
||||
accounts: [],
|
||||
defaultBaseUrl: 'https://gitlab.com',
|
||||
};
|
||||
runtimeFetchMock.mockResolvedValueOnce(Response.json(status));
|
||||
|
||||
const api = await createAPI();
|
||||
await expect(api.authStatus()).resolves.toEqual(status);
|
||||
|
||||
expect(runtimeFetchMock).toHaveBeenCalledWith('/api/gitlab/auth/status', {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
});
|
||||
|
||||
it('sends the access token and base URL when connecting', async () => {
|
||||
const status = {
|
||||
connected: true,
|
||||
user: { username: 'octocat', id: 1 },
|
||||
accounts: [],
|
||||
defaultBaseUrl: 'https://gitlab.com',
|
||||
};
|
||||
runtimeFetchMock.mockResolvedValueOnce(Response.json(status));
|
||||
|
||||
const api = await createAPI();
|
||||
await expect(api.authConnect({ accessToken: 'glpat-123', baseUrl: 'https://gitlab.example' })).resolves.toEqual(status);
|
||||
|
||||
expect(runtimeFetchMock).toHaveBeenCalledWith('/api/gitlab/auth/connect', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({ accessToken: 'glpat-123', baseUrl: 'https://gitlab.example' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('passes directory, number, namespace and project query params to issueGet', async () => {
|
||||
const result = {
|
||||
connected: true,
|
||||
repo: null,
|
||||
issue: { number: 42, title: 'Broken build', url: 'https://gitlab.com/g/repo/-/issues/42', state: 'opened', author: { username: 'octocat', id: 1 }, labels: [] },
|
||||
};
|
||||
runtimeFetchMock.mockResolvedValueOnce(Response.json(result));
|
||||
|
||||
const api = await createAPI();
|
||||
await expect(api.issueGet('/workspace', 42, { namespace: 'group', project: 'repo' })).resolves.toEqual(result);
|
||||
|
||||
const params = new URLSearchParams({ directory: '/workspace', number: '42', namespace: 'group', project: 'repo' });
|
||||
expect(runtimeFetchMock).toHaveBeenCalledWith(`/api/gitlab/issues/get?${params.toString()}`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
});
|
||||
|
||||
it('passes diff=1 and repo params to mrContext', async () => {
|
||||
const result = { connected: true, repo: null, mr: undefined };
|
||||
runtimeFetchMock.mockResolvedValueOnce(Response.json(result));
|
||||
|
||||
const api = await createAPI();
|
||||
await expect(api.mrContext('/workspace', 7, { includeDiff: true, namespace: 'group', project: 'repo' })).resolves.toEqual(result);
|
||||
|
||||
const params = new URLSearchParams({ directory: '/workspace', number: '7', diff: '1', namespace: 'group', project: 'repo' });
|
||||
expect(runtimeFetchMock).toHaveBeenCalledWith(`/api/gitlab/mrs/context?${params.toString()}`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
});
|
||||
|
||||
it('passes sourceBranch query param to mrsList', async () => {
|
||||
const result = {
|
||||
connected: true,
|
||||
repo: null,
|
||||
mrs: [],
|
||||
page: 1,
|
||||
hasMore: false,
|
||||
};
|
||||
runtimeFetchMock.mockResolvedValueOnce(Response.json(result));
|
||||
|
||||
const api = await createAPI();
|
||||
await expect(api.mrsList('/workspace', { page: 1, query: 'search', sourceBranch: 'feat/api' })).resolves.toEqual(result);
|
||||
|
||||
const params = new URLSearchParams({ directory: '/workspace', page: '1', query: 'search', sourceBranch: 'feat/api' });
|
||||
expect(runtimeFetchMock).toHaveBeenCalledWith(`/api/gitlab/mrs/list?${params.toString()}`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
});
|
||||
|
||||
it('omits sourceBranch when not provided to mrsList', async () => {
|
||||
const result = {
|
||||
connected: true,
|
||||
repo: null,
|
||||
mrs: [],
|
||||
page: 1,
|
||||
hasMore: false,
|
||||
};
|
||||
runtimeFetchMock.mockResolvedValueOnce(Response.json(result));
|
||||
|
||||
const api = await createAPI();
|
||||
await expect(api.mrsList('/workspace', { page: 1 })).resolves.toEqual(result);
|
||||
|
||||
const params = new URLSearchParams({ directory: '/workspace', page: '1' });
|
||||
expect(runtimeFetchMock).toHaveBeenCalledWith(`/api/gitlab/mrs/list?${params.toString()}`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
});
|
||||
|
||||
it('posts to /api/gitlab/mrs/create with the input body and returns the created MR', async () => {
|
||||
const created = {
|
||||
connected: true,
|
||||
repo: null,
|
||||
mr: {
|
||||
number: 12,
|
||||
title: 'Add feature',
|
||||
url: 'https://gitlab.com/group/sub/-/merge_requests/12',
|
||||
state: 'opened',
|
||||
draft: false,
|
||||
author: { username: 'octocat', id: 1 },
|
||||
sourceBranch: 'feat/add',
|
||||
targetBranch: 'main',
|
||||
},
|
||||
};
|
||||
runtimeFetchMock.mockResolvedValueOnce(Response.json(created));
|
||||
|
||||
const api = await createAPI();
|
||||
await expect(api.mrCreate({
|
||||
directory: '/workspace',
|
||||
title: 'Add feature',
|
||||
sourceBranch: 'feat/add',
|
||||
targetBranch: 'main',
|
||||
removeSourceBranch: true,
|
||||
})).resolves.toEqual(created.mr);
|
||||
|
||||
expect(runtimeFetchMock).toHaveBeenCalledWith('/api/gitlab/mrs/create', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({
|
||||
directory: '/workspace',
|
||||
title: 'Add feature',
|
||||
sourceBranch: 'feat/add',
|
||||
targetBranch: 'main',
|
||||
removeSourceBranch: true,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('throws the server error when mrCreate fails', async () => {
|
||||
runtimeFetchMock.mockResolvedValueOnce(Response.json(
|
||||
{ error: 'Your GitLab token needs the api scope to create merge requests' },
|
||||
{ status: 400 },
|
||||
));
|
||||
|
||||
const api = await createAPI();
|
||||
await expect(api.mrCreate({
|
||||
directory: '/workspace',
|
||||
title: 'Add feature',
|
||||
sourceBranch: 'feat/add',
|
||||
targetBranch: 'main',
|
||||
})).rejects.toThrow('Your GitLab token needs the api scope to create merge requests');
|
||||
});
|
||||
|
||||
it('PUTs to /api/gitlab/mrs/update with the input body and returns the updated MR', async () => {
|
||||
const updated = {
|
||||
connected: true,
|
||||
repo: null,
|
||||
mr: { number: 12, title: 'Renamed', url: 'u', state: 'opened', draft: false, author: { username: 'octocat', id: 1 }, sourceBranch: 'feat/add', targetBranch: 'main' },
|
||||
};
|
||||
runtimeFetchMock.mockResolvedValueOnce(Response.json(updated));
|
||||
|
||||
const api = await createAPI();
|
||||
await expect(api.mrUpdate({ directory: '/workspace', number: 12, title: 'Renamed', description: 'New body' })).resolves.toEqual(updated.mr);
|
||||
|
||||
expect(runtimeFetchMock).toHaveBeenCalledWith('/api/gitlab/mrs/update', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({ directory: '/workspace', number: 12, title: 'Renamed', description: 'New body' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('returns merged:false without throwing when the server rejects a merge', async () => {
|
||||
runtimeFetchMock.mockResolvedValueOnce(Response.json(
|
||||
{ connected: true, merged: false, message: '405 Method Not Allowed: not open' },
|
||||
{ status: 405 },
|
||||
));
|
||||
|
||||
const api = await createAPI();
|
||||
await expect(api.mrMerge({ directory: '/workspace', number: 12, squash: true })).resolves.toEqual({
|
||||
connected: true,
|
||||
merged: false,
|
||||
message: '405 Method Not Allowed: not open',
|
||||
});
|
||||
|
||||
expect(runtimeFetchMock).toHaveBeenCalledWith('/api/gitlab/mrs/merge', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({ directory: '/workspace', number: 12, squash: true }),
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves merged:true on a successful merge', async () => {
|
||||
runtimeFetchMock.mockResolvedValueOnce(Response.json({ connected: true, merged: true }));
|
||||
|
||||
const api = await createAPI();
|
||||
await expect(api.mrMerge({ directory: '/workspace', number: 12 })).resolves.toEqual({ connected: true, merged: true });
|
||||
});
|
||||
|
||||
it('throws the server error when mrMerge hits a real error payload', async () => {
|
||||
runtimeFetchMock.mockResolvedValueOnce(Response.json(
|
||||
{ error: 'Your GitLab token needs the api scope to create merge requests' },
|
||||
{ status: 400 },
|
||||
));
|
||||
|
||||
const api = await createAPI();
|
||||
await expect(api.mrMerge({ directory: '/workspace', number: 12 })).rejects.toThrow(
|
||||
'Your GitLab token needs the api scope to create merge requests',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws the response status text when mrMerge has no parseable payload', async () => {
|
||||
runtimeFetchMock.mockResolvedValueOnce(new Response('upstream gone', { status: 502, statusText: 'Bad Gateway' }));
|
||||
|
||||
const api = await createAPI();
|
||||
await expect(api.mrMerge({ directory: '/workspace', number: 12 })).rejects.toThrow('Bad Gateway');
|
||||
});
|
||||
|
||||
it('parses branches and the default branch from repoBranches', async () => {
|
||||
runtimeFetchMock.mockResolvedValueOnce(Response.json({ branches: ['main', 'feat/api'], defaultBranch: 'main' }));
|
||||
|
||||
const api = await createAPI();
|
||||
await expect(api.repoBranches('group', 'sub')).resolves.toEqual({ branches: ['main', 'feat/api'], defaultBranch: 'main' });
|
||||
|
||||
expect(runtimeFetchMock).toHaveBeenCalledWith('/api/gitlab/repo/branches?namespace=group&project=sub', {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults defaultBranch to null when repoBranches omits it', async () => {
|
||||
runtimeFetchMock.mockResolvedValueOnce(Response.json({ branches: ['main'] }));
|
||||
|
||||
const api = await createAPI();
|
||||
await expect(api.repoBranches('group', 'sub')).resolves.toEqual({ branches: ['main'], defaultBranch: null });
|
||||
});
|
||||
|
||||
it('throws the server error message when repoBranches fails', async () => {
|
||||
runtimeFetchMock.mockResolvedValueOnce(Response.json({ error: 'GitLab rate limited' }, { status: 503 }));
|
||||
|
||||
const api = await createAPI();
|
||||
await expect(api.repoBranches('group', 'sub')).rejects.toThrow('GitLab rate limited');
|
||||
});
|
||||
|
||||
it('throws the response status text when repoBranches has no parseable payload', async () => {
|
||||
runtimeFetchMock.mockResolvedValueOnce(new Response('upstream gone', { status: 502, statusText: 'Bad Gateway' }));
|
||||
|
||||
const api = await createAPI();
|
||||
await expect(api.repoBranches('group', 'sub')).rejects.toThrow('Bad Gateway');
|
||||
});
|
||||
|
||||
it('throws the server error message on {error} payloads', async () => {
|
||||
runtimeFetchMock.mockResolvedValueOnce(Response.json({ error: 'Not connected to GitLab' }, { status: 401 }));
|
||||
|
||||
const api = await createAPI();
|
||||
await expect(api.authStatus()).rejects.toThrow('Not connected to GitLab');
|
||||
});
|
||||
|
||||
it('throws the response status text when no error payload is present', async () => {
|
||||
runtimeFetchMock.mockResolvedValueOnce(Response.json({}, { status: 500, statusText: 'Internal Server Error' }));
|
||||
|
||||
const api = await createAPI();
|
||||
await expect(api.issuesList('/workspace')).rejects.toThrow('Internal Server Error');
|
||||
});
|
||||
|
||||
it('passes directory/query/namespace/project to searchUsers and maps the response', async () => {
|
||||
const result = {
|
||||
connected: true,
|
||||
repo: null,
|
||||
users: [{ username: 'octocat', id: 1, name: 'Octo Cat', avatarUrl: 'https://gitlab.example/octocat.png' }],
|
||||
};
|
||||
runtimeFetchMock.mockResolvedValueOnce(Response.json(result));
|
||||
|
||||
const api = await createAPI();
|
||||
await expect(api.searchUsers!('/workspace', 'octo', { namespace: 'group', project: 'repo' })).resolves.toEqual(result);
|
||||
|
||||
const params = new URLSearchParams({ directory: '/workspace', query: 'octo', namespace: 'group', project: 'repo' });
|
||||
expect(runtimeFetchMock).toHaveBeenCalledWith(`/api/gitlab/users/search?${params.toString()}`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
});
|
||||
|
||||
it('omits namespace/project from searchUsers when not provided', async () => {
|
||||
runtimeFetchMock.mockResolvedValueOnce(Response.json({ connected: true, repo: null, users: [] }));
|
||||
|
||||
const api = await createAPI();
|
||||
await expect(api.searchUsers!('/workspace', 'octo')).resolves.toEqual({ connected: true, repo: null, users: [] });
|
||||
|
||||
const params = new URLSearchParams({ directory: '/workspace', query: 'octo' });
|
||||
expect(runtimeFetchMock).toHaveBeenCalledWith(`/api/gitlab/users/search?${params.toString()}`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
});
|
||||
|
||||
it('throws the server error message when searchUsers fails', async () => {
|
||||
runtimeFetchMock.mockResolvedValueOnce(Response.json({ error: 'GitLab rate limited' }, { status: 503 }));
|
||||
|
||||
const api = await createAPI();
|
||||
await expect(api.searchUsers!('/workspace', 'octo')).rejects.toThrow('GitLab rate limited');
|
||||
});
|
||||
|
||||
it('passes directory/query to searchLabels and maps the response', async () => {
|
||||
const result = { connected: true, repo: null, labels: ['bug', 'frontend'] };
|
||||
runtimeFetchMock.mockResolvedValueOnce(Response.json(result));
|
||||
|
||||
const api = await createAPI();
|
||||
await expect(api.searchLabels!('/workspace', 'feat', { namespace: 'group', project: 'repo' })).resolves.toEqual(result);
|
||||
|
||||
const params = new URLSearchParams({ directory: '/workspace', query: 'feat', namespace: 'group', project: 'repo' });
|
||||
expect(runtimeFetchMock).toHaveBeenCalledWith(`/api/gitlab/labels/search?${params.toString()}`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
});
|
||||
|
||||
it('passes directory/query to searchMilestones and maps the response', async () => {
|
||||
const result = { connected: true, repo: null, milestones: [{ title: 'v2.0', state: 'active' }] };
|
||||
runtimeFetchMock.mockResolvedValueOnce(Response.json(result));
|
||||
|
||||
const api = await createAPI();
|
||||
await expect(api.searchMilestones!('/workspace', 'v2', { namespace: 'group', project: 'repo' })).resolves.toEqual(result);
|
||||
|
||||
const params = new URLSearchParams({ directory: '/workspace', query: 'v2', namespace: 'group', project: 'repo' });
|
||||
expect(runtimeFetchMock).toHaveBeenCalledWith(`/api/gitlab/milestones/search?${params.toString()}`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
});
|
||||
|
||||
it('passes directory/query to searchBranches and maps the response', async () => {
|
||||
const result = { connected: true, repo: null, branches: ['main', 'feat/api'] };
|
||||
runtimeFetchMock.mockResolvedValueOnce(Response.json(result));
|
||||
|
||||
const api = await createAPI();
|
||||
await expect(api.searchBranches!('/workspace', 'feat', { namespace: 'group', project: 'repo' })).resolves.toEqual(result);
|
||||
|
||||
const params = new URLSearchParams({ directory: '/workspace', query: 'feat', namespace: 'group', project: 'repo' });
|
||||
expect(runtimeFetchMock).toHaveBeenCalledWith(`/api/gitlab/branches/search?${params.toString()}`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
});
|
||||
|
||||
it('passes directory/query to searchTags and maps the response', async () => {
|
||||
const result = { connected: true, repo: null, tags: ['v1.0', 'v1.1'] };
|
||||
runtimeFetchMock.mockResolvedValueOnce(Response.json(result));
|
||||
|
||||
const api = await createAPI();
|
||||
await expect(api.searchTags!('/workspace', 'v1', { namespace: 'group', project: 'repo' })).resolves.toEqual(result);
|
||||
|
||||
const params = new URLSearchParams({ directory: '/workspace', query: 'v1', namespace: 'group', project: 'repo' });
|
||||
expect(runtimeFetchMock).toHaveBeenCalledWith(`/api/gitlab/tags/search?${params.toString()}`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,423 @@
|
||||
import type {
|
||||
GitLabAPI,
|
||||
GitLabAuthStatus,
|
||||
GitLabBranchesResult,
|
||||
GitLabBranchesSearchResult,
|
||||
GitLabIssueCommentResult,
|
||||
GitLabIssueCommentsResult,
|
||||
GitLabIssueCommentInput,
|
||||
GitLabIssueCreateInput,
|
||||
GitLabIssueCreateResult,
|
||||
GitLabIssueGetResult,
|
||||
GitLabIssuesListResult,
|
||||
GitLabIssueUpdateInput,
|
||||
GitLabIssueUpdateResult,
|
||||
GitLabLabelsSearchResult,
|
||||
GitLabMergeRequest,
|
||||
GitLabMergeRequestCommitsResult,
|
||||
GitLabMergeRequestContextResult,
|
||||
GitLabMergeRequestCreateInput,
|
||||
GitLabMergeRequestCreateResult,
|
||||
GitLabMergeRequestMergeInput,
|
||||
GitLabMergeRequestMergeResult,
|
||||
GitLabMergeRequestsListResult,
|
||||
GitLabMergeRequestTimelineResult,
|
||||
GitLabMergeRequestUpdateInput,
|
||||
GitLabMergeRequestUpdateResult,
|
||||
GitLabMilestonesSearchResult,
|
||||
GitLabMrApproveInput,
|
||||
GitLabMrApproveResult,
|
||||
GitLabMrNoteInput,
|
||||
GitLabMrNoteResult,
|
||||
GitLabTagsSearchResult,
|
||||
GitLabUserSummary,
|
||||
GitLabUsersSearchResult,
|
||||
} from '@openchamber/ui/lib/api/types';
|
||||
import { runtimeFetch } from '@openchamber/ui/lib/runtime-fetch';
|
||||
import type { RuntimeUrlResolver } from '@openchamber/ui/lib/runtime-url';
|
||||
|
||||
interface WebGitLabAPIOptions {
|
||||
urls: RuntimeUrlResolver;
|
||||
}
|
||||
|
||||
const jsonOrNull = async <T>(response: Response): Promise<T | null> => {
|
||||
return (await response.json().catch(() => null)) as T | null;
|
||||
};
|
||||
|
||||
export const createWebGitLabAPI = ({ urls }: WebGitLabAPIOptions): GitLabAPI => ({
|
||||
async authStatus(): Promise<GitLabAuthStatus> {
|
||||
const response = await runtimeFetch('/api/gitlab/auth/status', { method: 'GET', headers: { Accept: 'application/json' } });
|
||||
const payload = await jsonOrNull<GitLabAuthStatus & { error?: string }>(response);
|
||||
if (!response.ok || !payload) {
|
||||
throw new Error(payload?.error || response.statusText || 'Failed to load GitLab status');
|
||||
}
|
||||
return payload;
|
||||
},
|
||||
|
||||
async authConnect(input: { accessToken: string; baseUrl?: string }): Promise<GitLabAuthStatus> {
|
||||
const response = await runtimeFetch('/api/gitlab/auth/connect', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const payload = await jsonOrNull<GitLabAuthStatus & { error?: string }>(response);
|
||||
if (!response.ok || !payload) {
|
||||
throw new Error(payload?.error || response.statusText || 'Failed to connect GitLab');
|
||||
}
|
||||
return payload;
|
||||
},
|
||||
|
||||
async authActivate(accountId: string): Promise<GitLabAuthStatus> {
|
||||
const response = await runtimeFetch('/api/gitlab/auth/activate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({ accountId }),
|
||||
});
|
||||
const payload = await jsonOrNull<GitLabAuthStatus & { error?: string }>(response);
|
||||
if (!response.ok || !payload) {
|
||||
throw new Error(payload?.error || response.statusText || 'Failed to activate GitLab account');
|
||||
}
|
||||
return payload;
|
||||
},
|
||||
|
||||
async authDisconnect(): Promise<{ removed: boolean }> {
|
||||
const response = await runtimeFetch('/api/gitlab/auth', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
});
|
||||
const payload = await jsonOrNull<{ removed?: boolean; error?: string }>(response);
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.error || response.statusText || 'Failed to disconnect GitLab');
|
||||
}
|
||||
return { removed: Boolean(payload?.removed) };
|
||||
},
|
||||
|
||||
async me(): Promise<GitLabUserSummary> {
|
||||
const response = await runtimeFetch('/api/gitlab/me', { method: 'GET', headers: { Accept: 'application/json' } });
|
||||
const payload = await jsonOrNull<GitLabUserSummary & { error?: string }>(response);
|
||||
if (!response.ok || !payload) {
|
||||
throw new Error(payload?.error || response.statusText || 'Failed to fetch GitLab user');
|
||||
}
|
||||
return payload;
|
||||
},
|
||||
|
||||
async searchUsers(directory, query, options): Promise<GitLabUsersSearchResult> {
|
||||
const params = new URLSearchParams({ directory, query });
|
||||
if (options?.namespace) params.set('namespace', options.namespace);
|
||||
if (options?.project) params.set('project', options.project);
|
||||
const response = await runtimeFetch(urls.api('/api/gitlab/users/search', params), { method: 'GET', headers: { Accept: 'application/json' } });
|
||||
const body = await jsonOrNull<GitLabUsersSearchResult & { error?: string }>(response);
|
||||
if (!response.ok || !body) {
|
||||
throw new Error(body?.error || response.statusText || 'Failed to search GitLab users');
|
||||
}
|
||||
return { connected: body.connected, repo: body.repo ?? null, users: body.users ?? [] };
|
||||
},
|
||||
|
||||
async searchLabels(directory, query, options): Promise<GitLabLabelsSearchResult> {
|
||||
const params = new URLSearchParams({ directory, query });
|
||||
if (options?.namespace) params.set('namespace', options.namespace);
|
||||
if (options?.project) params.set('project', options.project);
|
||||
const response = await runtimeFetch(urls.api('/api/gitlab/labels/search', params), { method: 'GET', headers: { Accept: 'application/json' } });
|
||||
const body = await jsonOrNull<GitLabLabelsSearchResult & { error?: string }>(response);
|
||||
if (!response.ok || !body) {
|
||||
throw new Error(body?.error || response.statusText || 'Failed to search GitLab labels');
|
||||
}
|
||||
return { connected: body.connected, repo: body.repo ?? null, labels: body.labels ?? [] };
|
||||
},
|
||||
|
||||
async searchMilestones(directory, query, options): Promise<GitLabMilestonesSearchResult> {
|
||||
const params = new URLSearchParams({ directory, query });
|
||||
if (options?.namespace) params.set('namespace', options.namespace);
|
||||
if (options?.project) params.set('project', options.project);
|
||||
const response = await runtimeFetch(urls.api('/api/gitlab/milestones/search', params), { method: 'GET', headers: { Accept: 'application/json' } });
|
||||
const body = await jsonOrNull<GitLabMilestonesSearchResult & { error?: string }>(response);
|
||||
if (!response.ok || !body) {
|
||||
throw new Error(body?.error || response.statusText || 'Failed to search GitLab milestones');
|
||||
}
|
||||
return { connected: body.connected, repo: body.repo ?? null, milestones: body.milestones ?? [] };
|
||||
},
|
||||
|
||||
async searchBranches(directory, query, options): Promise<GitLabBranchesSearchResult> {
|
||||
const params = new URLSearchParams({ directory, query });
|
||||
if (options?.namespace) params.set('namespace', options.namespace);
|
||||
if (options?.project) params.set('project', options.project);
|
||||
const response = await runtimeFetch(urls.api('/api/gitlab/branches/search', params), { method: 'GET', headers: { Accept: 'application/json' } });
|
||||
const body = await jsonOrNull<GitLabBranchesSearchResult & { error?: string }>(response);
|
||||
if (!response.ok || !body) {
|
||||
throw new Error(body?.error || response.statusText || 'Failed to search GitLab branches');
|
||||
}
|
||||
return { connected: body.connected, repo: body.repo ?? null, branches: body.branches ?? [] };
|
||||
},
|
||||
|
||||
async searchTags(directory, query, options): Promise<GitLabTagsSearchResult> {
|
||||
const params = new URLSearchParams({ directory, query });
|
||||
if (options?.namespace) params.set('namespace', options.namespace);
|
||||
if (options?.project) params.set('project', options.project);
|
||||
const response = await runtimeFetch(urls.api('/api/gitlab/tags/search', params), { method: 'GET', headers: { Accept: 'application/json' } });
|
||||
const body = await jsonOrNull<GitLabTagsSearchResult & { error?: string }>(response);
|
||||
if (!response.ok || !body) {
|
||||
throw new Error(body?.error || response.statusText || 'Failed to search GitLab tags');
|
||||
}
|
||||
return { connected: body.connected, repo: body.repo ?? null, tags: body.tags ?? [] };
|
||||
},
|
||||
|
||||
async issuesList(directory: string, options?: { page?: number; query?: string }): Promise<GitLabIssuesListResult> {
|
||||
const page = options?.page ?? 1;
|
||||
const params = new URLSearchParams({
|
||||
directory,
|
||||
page: String(page),
|
||||
});
|
||||
if (options?.query) {
|
||||
params.set('query', options.query);
|
||||
}
|
||||
const response = await runtimeFetch(
|
||||
`/api/gitlab/issues/list?${params.toString()}`,
|
||||
{ method: 'GET', headers: { Accept: 'application/json' } }
|
||||
);
|
||||
const payload = await jsonOrNull<GitLabIssuesListResult & { error?: string }>(response);
|
||||
if (!response.ok || !payload) {
|
||||
throw new Error(payload?.error || response.statusText || 'Failed to load GitLab issues');
|
||||
}
|
||||
return payload;
|
||||
},
|
||||
|
||||
async issueGet(directory: string, number: number, options?: { namespace?: string; project?: string }): Promise<GitLabIssueGetResult> {
|
||||
const params = new URLSearchParams({ directory, number: String(number) });
|
||||
if (options?.namespace) {
|
||||
params.set('namespace', options.namespace);
|
||||
}
|
||||
if (options?.project) {
|
||||
params.set('project', options.project);
|
||||
}
|
||||
const response = await runtimeFetch(urls.api('/api/gitlab/issues/get', params), { method: 'GET', headers: { Accept: 'application/json' } });
|
||||
const payload = await jsonOrNull<GitLabIssueGetResult & { error?: string }>(response);
|
||||
if (!response.ok || !payload) {
|
||||
throw new Error(payload?.error || response.statusText || 'Failed to load GitLab issue');
|
||||
}
|
||||
return payload;
|
||||
},
|
||||
|
||||
async issueComments(directory: string, number: number, options?: { namespace?: string; project?: string }): Promise<GitLabIssueCommentsResult> {
|
||||
const params = new URLSearchParams({ directory, number: String(number) });
|
||||
if (options?.namespace) {
|
||||
params.set('namespace', options.namespace);
|
||||
}
|
||||
if (options?.project) {
|
||||
params.set('project', options.project);
|
||||
}
|
||||
const response = await runtimeFetch(urls.api('/api/gitlab/issues/comments', params), { method: 'GET', headers: { Accept: 'application/json' } });
|
||||
const payload = await jsonOrNull<GitLabIssueCommentsResult & { error?: string }>(response);
|
||||
if (!response.ok || !payload) {
|
||||
throw new Error(payload?.error || response.statusText || 'Failed to load GitLab issue comments');
|
||||
}
|
||||
return payload;
|
||||
},
|
||||
|
||||
async mrsList(directory: string, options?: { page?: number; query?: string; sourceBranch?: string }): Promise<GitLabMergeRequestsListResult> {
|
||||
const page = options?.page ?? 1;
|
||||
const params = new URLSearchParams({
|
||||
directory,
|
||||
page: String(page),
|
||||
});
|
||||
if (options?.query) {
|
||||
params.set('query', options.query);
|
||||
}
|
||||
if (options?.sourceBranch) {
|
||||
params.set('sourceBranch', options.sourceBranch);
|
||||
}
|
||||
const response = await runtimeFetch(
|
||||
`/api/gitlab/mrs/list?${params.toString()}`,
|
||||
{ method: 'GET', headers: { Accept: 'application/json' } }
|
||||
);
|
||||
const payload = await jsonOrNull<GitLabMergeRequestsListResult & { error?: string }>(response);
|
||||
if (!response.ok || !payload) {
|
||||
throw new Error(payload?.error || response.statusText || 'Failed to load GitLab merge requests');
|
||||
}
|
||||
return payload;
|
||||
},
|
||||
|
||||
async mrContext(
|
||||
directory: string,
|
||||
number: number,
|
||||
options?: { includeDiff?: boolean; namespace?: string; project?: string }
|
||||
): Promise<GitLabMergeRequestContextResult> {
|
||||
const params = new URLSearchParams({ directory, number: String(number) });
|
||||
if (options?.includeDiff) {
|
||||
params.set('diff', '1');
|
||||
}
|
||||
if (options?.namespace) {
|
||||
params.set('namespace', options.namespace);
|
||||
}
|
||||
if (options?.project) {
|
||||
params.set('project', options.project);
|
||||
}
|
||||
const response = await runtimeFetch(urls.api('/api/gitlab/mrs/context', params), { method: 'GET', headers: { Accept: 'application/json' } });
|
||||
const payload = await jsonOrNull<GitLabMergeRequestContextResult & { error?: string }>(response);
|
||||
if (!response.ok || !payload) {
|
||||
throw new Error(payload?.error || response.statusText || 'Failed to load GitLab merge request context');
|
||||
}
|
||||
return payload;
|
||||
},
|
||||
|
||||
async mrCommits(directory: string, number: number, options?: { namespace?: string; project?: string }): Promise<GitLabMergeRequestCommitsResult> {
|
||||
const params = new URLSearchParams({ directory, number: String(number) });
|
||||
if (options?.namespace) {
|
||||
params.set('namespace', options.namespace);
|
||||
}
|
||||
if (options?.project) {
|
||||
params.set('project', options.project);
|
||||
}
|
||||
const response = await runtimeFetch(urls.api('/api/gitlab/mrs/commits', params), { method: 'GET', headers: { Accept: 'application/json' } });
|
||||
const payload = await jsonOrNull<GitLabMergeRequestCommitsResult & { error?: string }>(response);
|
||||
if (!response.ok || !payload) {
|
||||
throw new Error(payload?.error || response.statusText || 'Failed to load GitLab merge request commits');
|
||||
}
|
||||
return payload;
|
||||
},
|
||||
|
||||
async mrTimeline(directory: string, number: number, options?: { namespace?: string; project?: string }): Promise<GitLabMergeRequestTimelineResult> {
|
||||
const params = new URLSearchParams({ directory, number: String(number) });
|
||||
if (options?.namespace) {
|
||||
params.set('namespace', options.namespace);
|
||||
}
|
||||
if (options?.project) {
|
||||
params.set('project', options.project);
|
||||
}
|
||||
const response = await runtimeFetch(urls.api('/api/gitlab/mrs/timeline', params), { method: 'GET', headers: { Accept: 'application/json' } });
|
||||
const payload = await jsonOrNull<GitLabMergeRequestTimelineResult & { error?: string }>(response);
|
||||
if (!response.ok || !payload) {
|
||||
throw new Error(payload?.error || response.statusText || 'Failed to load GitLab merge request timeline');
|
||||
}
|
||||
return payload;
|
||||
},
|
||||
|
||||
async mrCreate(input: GitLabMergeRequestCreateInput): Promise<GitLabMergeRequest> {
|
||||
const response = await runtimeFetch('/api/gitlab/mrs/create', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const payload = await jsonOrNull<GitLabMergeRequestCreateResult & { error?: string }>(response);
|
||||
if (!response.ok || !payload?.mr) {
|
||||
throw new Error(payload?.error || response.statusText || 'Failed to create GitLab merge request');
|
||||
}
|
||||
return payload.mr;
|
||||
},
|
||||
|
||||
async mrUpdate(input: GitLabMergeRequestUpdateInput): Promise<GitLabMergeRequest> {
|
||||
const response = await runtimeFetch('/api/gitlab/mrs/update', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const payload = await jsonOrNull<GitLabMergeRequestUpdateResult & { error?: string }>(response);
|
||||
if (!response.ok || !payload?.mr) {
|
||||
throw new Error(payload?.error || response.statusText || 'Failed to update GitLab merge request');
|
||||
}
|
||||
return payload.mr;
|
||||
},
|
||||
|
||||
async mrMerge(input: GitLabMergeRequestMergeInput): Promise<GitLabMergeRequestMergeResult> {
|
||||
const response = await runtimeFetch('/api/gitlab/mrs/merge', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const payload = await jsonOrNull<GitLabMergeRequestMergeResult & { error?: string }>(response);
|
||||
// The server rejects non-mergeable requests with 405/409/422 and a
|
||||
// `{ connected, merged: false, message }` body — parse it and return it
|
||||
// instead of throwing. Only throw when there is no parseable payload
|
||||
// (network failure) or the server surfaced a real `{ error }`.
|
||||
if (!payload) {
|
||||
throw new Error(response.statusText || 'Failed to merge GitLab merge request');
|
||||
}
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
return {
|
||||
connected: Boolean(payload.connected),
|
||||
merged: Boolean(payload.merged),
|
||||
...(payload.message ? { message: payload.message } : {}),
|
||||
};
|
||||
},
|
||||
|
||||
async issueComment(input: GitLabIssueCommentInput): Promise<GitLabIssueCommentResult> {
|
||||
const response = await runtimeFetch('/api/gitlab/issues/comment', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const body = await jsonOrNull<GitLabIssueCommentResult & { error?: string }>(response);
|
||||
if (!response.ok || !body) {
|
||||
throw new Error(body?.error || response.statusText || 'Failed to post GitLab issue comment');
|
||||
}
|
||||
return body;
|
||||
},
|
||||
|
||||
async issueCreate(input: GitLabIssueCreateInput): Promise<GitLabIssueCreateResult> {
|
||||
const response = await runtimeFetch('/api/gitlab/issues/create', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const body = await jsonOrNull<GitLabIssueCreateResult & { error?: string }>(response);
|
||||
if (!response.ok || !body) {
|
||||
throw new Error(body?.error || response.statusText || 'Failed to create GitLab issue');
|
||||
}
|
||||
return body;
|
||||
},
|
||||
|
||||
async issueUpdate(input: GitLabIssueUpdateInput): Promise<GitLabIssueUpdateResult> {
|
||||
const response = await runtimeFetch('/api/gitlab/issues/update', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const body = await jsonOrNull<GitLabIssueUpdateResult & { error?: string }>(response);
|
||||
if (!response.ok || !body) {
|
||||
throw new Error(body?.error || response.statusText || 'Failed to update GitLab issue');
|
||||
}
|
||||
return body;
|
||||
},
|
||||
|
||||
async mrComment(input: GitLabMrNoteInput): Promise<GitLabMrNoteResult> {
|
||||
const response = await runtimeFetch('/api/gitlab/mrs/comment', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const body = await jsonOrNull<GitLabMrNoteResult & { error?: string }>(response);
|
||||
if (!response.ok || !body) {
|
||||
throw new Error(body?.error || response.statusText || 'Failed to post GitLab merge request comment');
|
||||
}
|
||||
return body;
|
||||
},
|
||||
|
||||
async mrApprove(input: GitLabMrApproveInput): Promise<GitLabMrApproveResult> {
|
||||
const response = await runtimeFetch('/api/gitlab/mrs/approve', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const body = await jsonOrNull<GitLabMrApproveResult & { error?: string }>(response);
|
||||
if (!response.ok || !body) {
|
||||
throw new Error(body?.error || response.statusText || 'Failed to approve GitLab merge request');
|
||||
}
|
||||
return body;
|
||||
},
|
||||
|
||||
async repoBranches(namespace: string, project: string): Promise<GitLabBranchesResult> {
|
||||
const response = await runtimeFetch(
|
||||
`/api/gitlab/repo/branches?namespace=${encodeURIComponent(namespace)}&project=${encodeURIComponent(project)}`,
|
||||
{ method: 'GET', headers: { Accept: 'application/json' } }
|
||||
);
|
||||
const body = await jsonOrNull<GitLabBranchesResult & { error?: string }>(response);
|
||||
if (!response.ok || !body) {
|
||||
throw new Error(body?.error || response.statusText || 'Failed to fetch GitLab repo branches');
|
||||
}
|
||||
return {
|
||||
branches: body.branches ?? [],
|
||||
defaultBranch: body.defaultBranch ?? null,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -16,6 +16,8 @@ import { createWebToolsAPI } from './tools';
|
||||
import { createWebPushAPI } from './push';
|
||||
import { createWebGitHubAPI } from './github';
|
||||
import { createWebLinearAPI } from './linear';
|
||||
import { createWebGitLabAPI } from './gitlab';
|
||||
import { createWebGiteaAPI } from './gitea';
|
||||
import { createWebClientAuthAPI } from './clientAuth';
|
||||
|
||||
export interface WebAPIsOptions {
|
||||
@@ -47,6 +49,8 @@ export const createWebAPIs = (options: WebAPIsOptions = {}): RuntimeAPIs => {
|
||||
notifications: createWebNotificationsAPI(),
|
||||
github: createWebGitHubAPI({ urls: activeUrls }),
|
||||
linear: createWebLinearAPI(),
|
||||
gitlab: createWebGitLabAPI({ urls: activeUrls }),
|
||||
gitea: createWebGiteaAPI({ urls: activeUrls }),
|
||||
push: createWebPushAPI(),
|
||||
clientAuth: createWebClientAuthAPI(),
|
||||
tools: createWebToolsAPI(),
|
||||
|
||||
Reference in New Issue
Block a user