feat(web): add GitLab API client wrapper and types
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 ?? [];
|
||||
},
|
||||
});
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user