feat(web): add GitLab API client wrapper and types

This commit is contained in:
2026-08-16 15:42:26 +00:00
parent f16a5bab6b
commit b5ddb6f3a1
4 changed files with 474 additions and 0 deletions
+165
View File
@@ -1136,6 +1136,170 @@ export interface GitHubAPI {
repoBranches(owner: string, repo: string): Promise<string[]>;
}
export type GitLabUserSummary = {
username: string;
id: number;
name?: string;
avatarUrl?: string;
webUrl?: string;
email?: string;
};
type GitLabRepoRef = {
namespace: string;
project: string;
host: string;
url: string;
baseUrl: string;
};
export type GitLabIssueSummary = {
number: number;
title: string;
url: string;
state: string;
author: GitLabUserSummary;
labels: string[];
};
export type GitLabIssue = {
number: number;
title: string;
url: string;
state: string;
body?: string;
createdAt?: string;
updatedAt?: string;
author: GitLabUserSummary;
assignees?: GitLabUserSummary[];
labels: string[];
};
export type GitLabIssueComment = {
id: number;
url: string;
body: string;
createdAt?: string;
updatedAt?: string;
author: GitLabUserSummary;
};
export type GitLabIssuesListResult = {
connected: boolean;
repo?: GitLabRepoRef | null;
issues: GitLabIssueSummary[];
page: number;
hasMore: boolean;
};
export type GitLabIssueGetResult = {
connected: boolean;
repo?: GitLabRepoRef | null;
issue?: GitLabIssue;
};
export type GitLabIssueCommentsResult = {
connected: boolean;
repo?: GitLabRepoRef | null;
comments: GitLabIssueComment[];
};
export type GitLabMergeRequestSummary = {
number: number;
title: string;
url: string;
state: string;
draft: boolean;
author: GitLabUserSummary;
sourceBranch: string;
targetBranch: string;
};
export type GitLabMergeRequest = {
number: number;
title: string;
url: string;
state: string;
draft: boolean;
body?: string;
createdAt?: string;
updatedAt?: string;
author: GitLabUserSummary;
sourceBranch: string;
targetBranch: string;
headSha?: string;
};
type GitLabMergeRequestFile = {
filename: string;
status?: string;
additions?: number;
deletions?: number;
changes?: number;
patch?: string;
};
export type GitLabMergeRequestsListResult = {
connected: boolean;
repo?: GitLabRepoRef | null;
mrs: GitLabMergeRequestSummary[];
page: number;
hasMore: boolean;
};
export type GitLabMergeRequestContextResult = {
connected: boolean;
repo?: GitLabRepoRef | null;
mr?: GitLabMergeRequest;
comments?: GitLabIssueComment[];
files?: GitLabMergeRequestFile[];
diff?: string;
};
export type GitLabBranchesResult = {
branches: string[];
};
type GitLabAuthAccount = {
id: string;
user: {
username: string;
name?: string;
avatarUrl?: string;
webUrl?: string;
};
baseUrl: string;
current: boolean;
};
export type GitLabAuthStatus = {
connected: boolean;
user?: GitLabUserSummary;
accounts: GitLabAuthAccount[];
defaultBaseUrl: string;
};
export interface GitLabAPI {
authStatus(): Promise<GitLabAuthStatus>;
authConnect(input: { accessToken: string; baseUrl?: string }): Promise<GitLabAuthStatus>;
authActivate(accountId: string): Promise<GitLabAuthStatus>;
authDisconnect(): Promise<{ removed: boolean }>;
me(): Promise<GitLabUserSummary>;
issuesList(directory: string, options?: { page?: number; query?: string }): Promise<GitLabIssuesListResult>;
issueGet(directory: string, number: number, options?: { namespace?: string; project?: string }): Promise<GitLabIssueGetResult>;
issueComments(directory: string, number: number, options?: { namespace?: string; project?: string }): Promise<GitLabIssueCommentsResult>;
mrsList(directory: string, options?: { page?: number; query?: string }): Promise<GitLabMergeRequestsListResult>;
mrContext(
directory: string,
number: number,
options?: { includeDiff?: boolean; namespace?: string; project?: string }
): Promise<GitLabMergeRequestContextResult>;
repoBranches(namespace: string, project: string): Promise<string[]>;
}
export interface RemoteClientRecord {
id: string;
label: string;
@@ -1231,6 +1395,7 @@ export interface RuntimeAPIs {
permissions: PermissionsAPI;
notifications: NotificationsAPI;
github?: GitHubAPI;
gitlab?: GitLabAPI;
push?: PushAPI;
diagnostics?: DiagnosticsAPI;
clientAuth?: ClientAuthAPI;
+121
View File
@@ -0,0 +1,121 @@
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('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');
});
});
+186
View File
@@ -0,0 +1,186 @@
import type {
GitLabAPI,
GitLabAuthStatus,
GitLabBranchesResult,
GitLabIssueCommentsResult,
GitLabIssueGetResult,
GitLabIssuesListResult,
GitLabMergeRequestContextResult,
GitLabMergeRequestsListResult,
GitLabUserSummary,
} 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 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 }): Promise<GitLabMergeRequestsListResult> {
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/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 repoBranches(namespace: string, project: string): Promise<string[]> {
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 body.branches ?? [];
},
});
+2
View File
@@ -15,6 +15,7 @@ import { createWebNotificationsAPI } from './notifications';
import { createWebToolsAPI } from './tools';
import { createWebPushAPI } from './push';
import { createWebGitHubAPI } from './github';
import { createWebGitLabAPI } from './gitlab';
import { createWebClientAuthAPI } from './clientAuth';
export interface WebAPIsOptions {
@@ -45,6 +46,7 @@ export const createWebAPIs = (options: WebAPIsOptions = {}): RuntimeAPIs => {
permissions: createWebPermissionsAPI(),
notifications: createWebNotificationsAPI(),
github: createWebGitHubAPI({ urls: activeUrls }),
gitlab: createWebGitLabAPI({ urls: activeUrls }),
push: createWebPushAPI(),
clientAuth: createWebClientAuthAPI(),
tools: createWebToolsAPI(),