From b5ddb6f3a1a8ed7d93e1d1cddb34b1fb5af7f8fa Mon Sep 17 00:00:00 2001 From: bot-hermes Date: Tue, 11 Aug 2026 21:01:51 +0000 Subject: [PATCH] feat(web): add GitLab API client wrapper and types --- packages/ui/src/lib/api/types.ts | 165 ++++++++++++++++++++++++ packages/web/src/api/gitlab.test.ts | 121 ++++++++++++++++++ packages/web/src/api/gitlab.ts | 186 ++++++++++++++++++++++++++++ packages/web/src/api/index.ts | 2 + 4 files changed, 474 insertions(+) create mode 100644 packages/web/src/api/gitlab.test.ts create mode 100644 packages/web/src/api/gitlab.ts diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index 0a146327..8b898e9f 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -1136,6 +1136,170 @@ export interface GitHubAPI { repoBranches(owner: string, repo: string): Promise; } +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; + authConnect(input: { accessToken: string; baseUrl?: string }): Promise; + authActivate(accountId: string): Promise; + authDisconnect(): Promise<{ removed: boolean }>; + me(): Promise; + + issuesList(directory: string, options?: { page?: number; query?: string }): Promise; + issueGet(directory: string, number: number, options?: { namespace?: string; project?: string }): Promise; + issueComments(directory: string, number: number, options?: { namespace?: string; project?: string }): Promise; + + mrsList(directory: string, options?: { page?: number; query?: string }): Promise; + mrContext( + directory: string, + number: number, + options?: { includeDiff?: boolean; namespace?: string; project?: string } + ): Promise; + + repoBranches(namespace: string, project: string): Promise; +} + 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; diff --git a/packages/web/src/api/gitlab.test.ts b/packages/web/src/api/gitlab.test.ts new file mode 100644 index 00000000..971a5ea1 --- /dev/null +++ b/packages/web/src/api/gitlab.test.ts @@ -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'); + }); +}); diff --git a/packages/web/src/api/gitlab.ts b/packages/web/src/api/gitlab.ts new file mode 100644 index 00000000..38da4e10 --- /dev/null +++ b/packages/web/src/api/gitlab.ts @@ -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 (response: Response): Promise => { + return (await response.json().catch(() => null)) as T | null; +}; + +export const createWebGitLabAPI = ({ urls }: WebGitLabAPIOptions): GitLabAPI => ({ + async authStatus(): Promise { + const response = await runtimeFetch('/api/gitlab/auth/status', { method: 'GET', headers: { Accept: 'application/json' } }); + const payload = await jsonOrNull(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 { + 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(response); + if (!response.ok || !payload) { + throw new Error(payload?.error || response.statusText || 'Failed to connect GitLab'); + } + return payload; + }, + + async authActivate(accountId: string): Promise { + 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(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 { + const response = await runtimeFetch('/api/gitlab/me', { method: 'GET', headers: { Accept: 'application/json' } }); + const payload = await jsonOrNull(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 { + 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(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 { + 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(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 { + 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(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 { + 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(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 { + 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(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 { + const response = await runtimeFetch( + `/api/gitlab/repo/branches?namespace=${encodeURIComponent(namespace)}&project=${encodeURIComponent(project)}`, + { method: 'GET', headers: { Accept: 'application/json' } } + ); + const body = await jsonOrNull(response); + if (!response.ok || !body) { + throw new Error(body?.error || response.statusText || 'Failed to fetch GitLab repo branches'); + } + return body.branches ?? []; + }, +}); diff --git a/packages/web/src/api/index.ts b/packages/web/src/api/index.ts index 12831517..27ce1a00 100644 --- a/packages/web/src/api/index.ts +++ b/packages/web/src/api/index.ts @@ -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(),