From f16a5bab6b2e5fb67b93b991cbbbf4a682a4f518 Mon Sep 17 00:00:00 2001 From: bot-hermes Date: Tue, 11 Aug 2026 20:55:07 +0000 Subject: [PATCH 01/45] docs: add GitLab issues/MRs page --- packages/docs/content/docs/gitlab.mdx | 31 +++++++++++++++++++++++++++ packages/docs/sidebar.config.json | 15 +++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 packages/docs/content/docs/gitlab.mdx diff --git a/packages/docs/content/docs/gitlab.mdx b/packages/docs/content/docs/gitlab.mdx new file mode 100644 index 00000000..7fbab1b5 --- /dev/null +++ b/packages/docs/content/docs/gitlab.mdx @@ -0,0 +1,31 @@ +--- +title: GitLab Issues & MRs +description: Connect GitLab and start sessions from issues and merge requests. +--- + +# GitLab Issues & MRs + +Connect your GitLab account and OpenChamber can pull in issues and merge requests, and start a session straight from one. GitLab support is new and currently read-only — OpenChamber can't create or merge MRs for you yet. + +## Connect GitLab + +1. Open **Settings → Git**. +2. Under GitLab, choose **Connect**. +3. Paste a Personal Access Token. Create one in GitLab under **Profile → Access Tokens** — the `read_api` scope is enough for read-only workflows, and `api` is needed later for writing. +4. For a self-hosted GitLab instance, also enter the instance URL (for example `https://gitlab.example.com`). + +When it's connected, your account shows under the GitLab section. You can connect more than one account and switch between them, or disconnect at any time. + +## Start work from an issue or MR + +When you create a [worktree session](/worktrees/) with GitLab connected, you can choose **Start from GitLab issue/MR**: + +- pick an **issue** and OpenChamber names the branch after it and opens the session with the issue and its comments as the first message +- pick a **merge request** and it checks out the MR's branch; you can include the MR's diff so the agent has the full change + +This drops you straight into a session with the context already loaded. + +## Related + +- [Git & GitHub Workflows](/git/) — commit and manage branches +- [Worktree Sessions](/worktrees/) — where issue and MR sessions start diff --git a/packages/docs/sidebar.config.json b/packages/docs/sidebar.config.json index 336241f8..d40c927d 100644 --- a/packages/docs/sidebar.config.json +++ b/packages/docs/sidebar.config.json @@ -288,6 +288,21 @@ "de": "GitHub-Issues und PRs" } }, + { + "label": "GitLab Issues & MRs", + "link": "/gitlab/", + "translations": { + "uk": "Завдання та MR GitLab", + "zh-CN": "GitLab 工单与 MR", + "es": "Issues y MRs de GitLab", + "pt-BR": "Issues e MRs do GitLab", + "ko": "GitLab 이슈 및 MR", + "pl": "Zgłoszenia i MR-y GitLab", + "fr": "Issues et MR GitLab", + "ja": "GitLab Issues と MR", + "de": "GitLab-Issues und MRs" + } + }, { "label": "Magic Prompts", "link": "/magic-prompts/", From b5ddb6f3a1a8ed7d93e1d1cddb34b1fb5af7f8fa Mon Sep 17 00:00:00 2001 From: bot-hermes Date: Tue, 11 Aug 2026 21:01:51 +0000 Subject: [PATCH 02/45] 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(), From d798b3f479613afabf7254a7f4aa13dfa50a9ea0 Mon Sep 17 00:00:00 2001 From: bot-hermes Date: Tue, 11 Aug 2026 21:07:02 +0000 Subject: [PATCH 03/45] feat(web): add GitLab issues/MRs server module Add a server-side GitLab integration mirroring the GitHub module: - auth.js: PAT auth storage with multi-account support and configurable base URL (gitlab.com default, self-hosted instances supported) - client.js: raw-fetch GitLab REST v4 client with per-request timeout, ETag conditional-GET cache, own rate-limit cooldown, pagination, and single-follow redirect handling - repo.js: GitLab remote URL parser + directory resolution - routes.js: read-only /api/gitlab/* routes (auth, issues, MRs, branches) - index.js + DOCUMENTATION.md + unit tests for auth, client, repo, routes - Wire registerGitLabRoutes into feature-routes-runtime --- .../web/server/lib/gitlab/DOCUMENTATION.md | 127 ++++ packages/web/server/lib/gitlab/auth.js | 317 ++++++++ packages/web/server/lib/gitlab/auth.test.js | 161 ++++ packages/web/server/lib/gitlab/client.js | 289 ++++++++ packages/web/server/lib/gitlab/client.test.js | 251 +++++++ packages/web/server/lib/gitlab/index.js | 22 + packages/web/server/lib/gitlab/repo.js | 122 +++ packages/web/server/lib/gitlab/repo.test.js | 122 +++ packages/web/server/lib/gitlab/routes.js | 697 ++++++++++++++++++ packages/web/server/lib/gitlab/routes.test.js | 499 +++++++++++++ .../lib/opencode/feature-routes-runtime.js | 2 + 11 files changed, 2609 insertions(+) create mode 100644 packages/web/server/lib/gitlab/DOCUMENTATION.md create mode 100644 packages/web/server/lib/gitlab/auth.js create mode 100644 packages/web/server/lib/gitlab/auth.test.js create mode 100644 packages/web/server/lib/gitlab/client.js create mode 100644 packages/web/server/lib/gitlab/client.test.js create mode 100644 packages/web/server/lib/gitlab/index.js create mode 100644 packages/web/server/lib/gitlab/repo.js create mode 100644 packages/web/server/lib/gitlab/repo.test.js create mode 100644 packages/web/server/lib/gitlab/routes.js create mode 100644 packages/web/server/lib/gitlab/routes.test.js diff --git a/packages/web/server/lib/gitlab/DOCUMENTATION.md b/packages/web/server/lib/gitlab/DOCUMENTATION.md new file mode 100644 index 00000000..91dc0d08 --- /dev/null +++ b/packages/web/server/lib/gitlab/DOCUMENTATION.md @@ -0,0 +1,127 @@ +# GitLab Module Documentation + +## Purpose + +- This module owns GitLab auth (Personal Access Token), raw REST v4 client access, remote-URL repo resolution, and read-only GitLab issue / merge-request (MR) APIs for OpenChamber. +- From a user perspective, this is the layer that lets the app show GitLab issues and merge requests for a local project, including comments and per-file diffs. +- The module mirrors `packages/web/server/lib/github/` but uses a **Personal Access Token (PAT)** with a configurable base URL (gitlab.com by default, or a self-hosted instance), and talks to GitLab's REST v4 API directly via `fetch` — no new dependencies. + +## Entrypoints and structure + +- `packages/web/server/lib/gitlab/index.js`: public server entrypoint re-exports. +- `packages/web/server/lib/gitlab/routes.js`: Express route registration for `/api/gitlab/*` endpoints. +- `packages/web/server/lib/gitlab/auth.js`: PAT auth storage, multi-account support, base URL normalization. +- `packages/web/server/lib/gitlab/client.js`: raw `fetch` GitLab REST v4 client (timeout, ETag conditional GET, rate-limit cooldown, pagination, redirect handling). +- `packages/web/server/lib/gitlab/repo.js`: GitLab remote URL parsing and directory-to-repo resolution. +- `packages/web/server/lib/opencode/feature-routes-runtime.js`: API route layer that calls this module (via `registerGitLabRoutes`). +- `packages/web/src/api/gitlab.ts`: web client wrapper for GitLab endpoints. +- `packages/ui/src/lib/api/types.ts`: shared response types consumed by web, desktop, VS Code, and mobile. + +## Public exports + +### Auth (`auth.js`) + +- `getGitLabAuth()`: current auth entry. +- `getGitLabAuthAccounts()`: all configured accounts (`{ id, user, baseUrl, current }`). +- `setGitLabAuth({ accessToken, baseUrl, user })`: save or update an account (validating `user` comes from `GET /user`). +- `activateGitLabAuth(accountId)`: switch active account. +- `clearGitLabAuth()`: remove the current account. +- `normalizeBaseUrl(raw)`: add `https://` when a scheme is missing, strip trailing slash, return `null` for invalid input. +- `GITLAB_AUTH_FILE`: auth file path. +- `DEFAULT_GITLAB_BASE_URL`: `https://gitlab.com`. + +### Client (`client.js`) + +- `createGitLabClient({ token, baseUrl })`: raw-fetch REST v4 client with `request(path, { method, query, body })` plus convenience methods `user()`, `project(path)`, `issues(path, params)`, `issue(path, iid)`, `issueNotes(path, iid, params)`, `mergeRequests(path, params)`, `mergeRequest(path, iid)`, `mergeRequestDiffs(path, iid, params)`, `branches(path, params)`. +- `getGitLabClientOrNull()`: client for the current account, or `null`. +- `isGitLabRateLimited()` / `noteGitLabRateLimit(error)`: own module-level rate-limit cooldown (not shared with the GitHub module's `rate-limit.js`). + +### Repo (`repo.js`) + +- `parseGitLabRemoteUrl(raw, knownHosts?)`: parse SSH/HTTPS remote URL into `{ namespace, project, host, baseUrl, url }` (multi-segment namespaces supported; never matches `github.com`). +- `resolveGitLabRepoFromDirectory(directory, remoteName?)`: resolve a GitLab repo from a local git remote. + +## Auth storage and config + +- Auth storage: `~/.config/openchamber/gitlab-auth.json` (override with `OPENCHAMBER_DATA_DIR`). +- Writes are atomic (tmp file + rename) and file mode is `0o600`. +- Base URL resolution: caller-supplied `baseUrl` (normalized) -> `DEFAULT_GITLAB_BASE_URL`. +- Account id: `` `${host}:${username}` `` (e.g. `gitlab.com:alice`), falling back to `token:` when the username is missing. +- Auth header on every request: `PRIVATE-TOKEN: `. + +## OAuth readiness + +The stored entry shape (`accessToken`, `baseUrl`, `username`, `name`, `avatarUrl`, `webUrl`, `email`, `createdAt`, `current`) is intentionally generic. OAuth flows would slot in at two points: + +1. `routes.js` — add `POST /api/gitlab/auth/start` / `auth/complete` endpoints next to the existing `auth/connect` (mirroring the GitHub device-flow routes), exchanging the OAuth grant for an access token. +2. `setGitLabAuth` — persists whatever `accessToken` + `user` shape the OAuth callback produces; no storage changes needed. + +Nothing in the client or repo layers assumes the token came from a PAT. + +## Client behavior + +- Base URL joining: `{baseUrl}/api/v4{path}`. Project `:id` segments are URL-encoded with `encodeURIComponent` (e.g. `group/sub` -> `group%2Fsub`) and never double-encoded. +- Per-request timeout: 8000 ms via `AbortSignal.timeout`, unless the caller passes its own signal. +- ETag conditional-GET cache: keyed `token\nurl`, max 300 LRU entries; a `304` is replayed from cache as a `200`. GET only. +- Pagination: `x-page`, `x-next-page`, `x-total-pages`, and the `Link` header (`rel="next"`) are parsed into the returned `page` object (`hasMore` = a next page exists). +- Redirects: `301`/`302`/`308` with a `Location` header are followed exactly once (project moves) with `redirect: 'manual'`, preserving `PRIVATE-TOKEN` across the hop. +- Rate limits: a `429` records a module-level cooldown (honoring `Retry-After` / `RateLimit-Reset` when present) and surfaces `{ status: 429, error: 'GitLab rate limited' }`. While the cooldown is active, requests short-circuit without hitting the network. +- `request` never throws for HTTP error statuses — callers branch on `status`. + +## API integration overview + +- Issues/MRs are addressed project-scoped by **iid**. +- Issue list: `GET /projects/:id/issues?state=opened&scope=all&per_page=50&page=N&search=`. +- Issue detail: `GET /projects/:id/issues/:issue_iid`. +- Issue notes: `GET /projects/:id/issues/:issue_iid/notes?per_page=100` (system notes are skipped; each note links as `{issue_web_url}#note_{id}`). +- MR list: `GET /projects/:id/merge_requests?state=opened&scope=all&per_page=50&page=N&search=`. +- MR detail: `GET /projects/:id/merge_requests/:merge_request_iid`. +- MR diffs: `GET /projects/:id/merge_requests/:merge_request_iid/diffs?per_page=100&page=N` (paginated; the route caps at 10 pages / 3000 files). +- MR notes: `GET /projects/:id/merge_requests/:merge_request_iid/notes?per_page=100`. +- Branches: `GET /projects/:id/repository/branches?per_page=100&page=N`. +- User: `GET /user` -> `{ id, username, name, state, avatar_url, web_url, email, ... }`. + +## Route contract (`/api/gitlab/*`) + +| Method | Path | Shape | +|---|---|---| +| GET | `/api/gitlab/auth/status` | `{ connected, user?, accounts[], defaultBaseUrl }` | +| POST | `/api/gitlab/auth/connect` | body `{ accessToken, baseUrl? }` -> `{ connected, user, accounts, defaultBaseUrl }`; `400` for missing/invalid token | +| POST | `/api/gitlab/auth/activate` | body `{ accountId }` -> `{ connected, user, accounts, defaultBaseUrl }`; `404` unknown account | +| DELETE | `/api/gitlab/auth` | `{ removed }` | +| GET | `/api/gitlab/me` | `{ username, id, name, avatarUrl, webUrl, email? }`; `401` when not connected | +| GET | `/api/gitlab/issues/list` | `?directory&page&query` -> `{ connected, repo?, issues[], page, hasMore }` | +| GET | `/api/gitlab/issues/get` | `?directory&number&namespace&project` -> `{ connected, repo?, issue }` | +| GET | `/api/gitlab/issues/comments` | `?directory&number&namespace&project` -> `{ connected, repo?, comments[] }` | +| GET | `/api/gitlab/mrs/list` | `?directory&page&query` -> `{ connected, repo?, mrs[], page, hasMore }` | +| GET | `/api/gitlab/mrs/context` | `?directory&number&diff&namespace&project` -> `{ connected, repo?, mr, comments[], files[], diff? }` | +| GET | `/api/gitlab/repo/branches` | `?namespace&project` -> `{ branches[] }` | + +Conventions mirror `github/routes.js`: + +- Not authenticated -> `connected: false` (or `401` for `/me`). +- Missing/invalid params -> `400` with `{ error }`. +- Hard failures -> `4xx`/`5xx` with `{ error }`. +- A GitLab `429` -> `503 { error: 'GitLab rate limited' }`. +- Lazy-import pattern: route handlers import `./index.js` on first use, so the module never loads unless GitLab endpoints are hit. +- Composite routes run under a 15 s route-level budget on top of the client's 8 s per-request timeout. + +## Consumers + +- `packages/web/src/api/gitlab.ts` calls every `/api/gitlab/*` endpoint and maps them to the shared types. +- `packages/ui/src/lib/api/types.ts` defines the shared `GitLab*` response types used across web, desktop, VS Code, and mobile. + +## Failure handling + +- If GitLab is disconnected, read routes return `connected: false`. +- A repo that does not resolve from the local git remote yields `repo: null` with empty lists, matching the GitHub behavior. +- Invalid/expired tokens are cleared on `401`/`403` and reported as disconnected. +- Rate-limit and timeout failures surface explicit `503` responses so clients keep last-known state rather than clearing UI. + +## Notes for contributors + +- Keep the response shapes in lockstep with `GitLab*` types in `packages/ui/src/lib/api/types.ts`. +- Never log tokens. Error messages must not include the access token. +- Do not double-encode project paths; convenience methods already call `encodeURIComponent` on the `pathWithNamespace`. +- The ETag cache and rate-limit cooldown are module-level and per-instance — they are NOT shared with the GitHub module. +- To add GitLab write operations (comment, assign, merge), add the endpoint in `routes.js`, add a convenience method in `client.js`, and extend the shared types — mirror the GitHub PR write routes. diff --git a/packages/web/server/lib/gitlab/auth.js b/packages/web/server/lib/gitlab/auth.js new file mode 100644 index 00000000..e9a84408 --- /dev/null +++ b/packages/web/server/lib/gitlab/auth.js @@ -0,0 +1,317 @@ +import fs from 'fs'; +import path from 'path'; +import os from 'os'; + +const OPENCHAMBER_DATA_DIR = process.env.OPENCHAMBER_DATA_DIR + ? path.resolve(process.env.OPENCHAMBER_DATA_DIR) + : path.join(os.homedir(), '.config', 'openchamber'); + +const STORAGE_DIR = OPENCHAMBER_DATA_DIR; +const STORAGE_FILE = path.join(STORAGE_DIR, 'gitlab-auth.json'); + +export const DEFAULT_GITLAB_BASE_URL = 'https://gitlab.com'; + +function ensureStorageDir() { + if (!fs.existsSync(STORAGE_DIR)) { + fs.mkdirSync(STORAGE_DIR, { recursive: true }); + } +} + +function readJsonFile() { + ensureStorageDir(); + if (!fs.existsSync(STORAGE_FILE)) { + return null; + } + try { + const raw = fs.readFileSync(STORAGE_FILE, 'utf8'); + const trimmed = raw.trim(); + if (!trimmed) { + return null; + } + const parsed = JSON.parse(trimmed); + if (!parsed || typeof parsed !== 'object') { + return null; + } + return parsed; + } catch (error) { + console.error('Failed to read GitLab auth file:', error); + return null; + } +} + +function writeJsonFile(payload) { + ensureStorageDir(); + + // Atomic write so multiple OpenChamber instances can safely share the same file. + const tmpFile = `${STORAGE_FILE}.${process.pid}.${Date.now()}.tmp`; + fs.writeFileSync(tmpFile, JSON.stringify(payload, null, 2), 'utf8'); + try { + fs.chmodSync(tmpFile, 0o600); + } catch { + // best-effort + } + + fs.renameSync(tmpFile, STORAGE_FILE); + try { + fs.chmodSync(STORAGE_FILE, 0o600); + } catch { + // best-effort + } +} + +/** + * Normalize a user-provided GitLab base URL. Adds `https://` when no scheme is + * present, strips a trailing slash, and returns null for anything unparseable. + */ +export function normalizeBaseUrl(raw) { + if (typeof raw !== 'string') { + return null; + } + let value = raw.trim(); + if (!value) { + return null; + } + if (!/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(value)) { + value = `https://${value}`; + } + let parsed; + try { + parsed = new URL(value); + } catch { + return null; + } + if (!parsed.hostname) { + return null; + } + parsed.hash = ''; + parsed.search = ''; + parsed.pathname = parsed.pathname.replace(/\/+$/, ''); + return parsed.href.replace(/\/+$/, ''); +} + +function hostFromBaseUrl(baseUrl) { + const normalized = normalizeBaseUrl(baseUrl); + if (!normalized) { + return null; + } + try { + return new URL(normalized).hostname || null; + } catch { + return null; + } +} + +function resolveAccountId({ username, accessToken, baseUrl, accountId }) { + if (typeof accountId === 'string' && accountId.trim()) { + return accountId.trim(); + } + const host = hostFromBaseUrl(baseUrl); + if (typeof username === 'string' && username.trim()) { + return host ? `${host}:${username.trim()}` : username.trim(); + } + if (typeof accessToken === 'string' && accessToken.trim()) { + return `token:${accessToken.slice(0, 8)}`; + } + return ''; +} + +function normalizeAuthEntry(entry) { + if (!entry || typeof entry !== 'object') return null; + const accessToken = typeof entry.accessToken === 'string' ? entry.accessToken : ''; + if (!accessToken) return null; + const baseUrl = normalizeBaseUrl(entry.baseUrl) || DEFAULT_GITLAB_BASE_URL; + const username = typeof entry.username === 'string' ? entry.username : ''; + + const accountId = resolveAccountId({ + username, + accessToken, + baseUrl, + accountId: typeof entry.accountId === 'string' ? entry.accountId : '', + }); + + return { + accessToken, + baseUrl, + username: username || null, + name: typeof entry.name === 'string' ? entry.name : null, + avatarUrl: typeof entry.avatarUrl === 'string' ? entry.avatarUrl : null, + webUrl: typeof entry.webUrl === 'string' ? entry.webUrl : null, + email: typeof entry.email === 'string' ? entry.email : null, + createdAt: typeof entry.createdAt === 'number' ? entry.createdAt : null, + current: Boolean(entry.current), + accountId, + }; +} + +function normalizeAuthList(raw) { + const list = (Array.isArray(raw) ? raw : [raw]) + .map((entry) => normalizeAuthEntry(entry)) + .filter(Boolean); + + if (!list.length) { + return { list: [], changed: false }; + } + + let changed = false; + let currentFound = false; + list.forEach((entry) => { + if (entry.current && !currentFound) { + currentFound = true; + } else if (entry.current && currentFound) { + entry.current = false; + changed = true; + } + }); + + if (!currentFound && list[0]) { + list[0].current = true; + changed = true; + } + + list.forEach((entry) => { + if (!entry.accountId) { + entry.accountId = resolveAccountId(entry); + changed = true; + } + }); + + return { list, changed }; +} + +function readAuthList() { + const data = readJsonFile(); + if (!data) { + return []; + } + const { list, changed } = normalizeAuthList(data); + if (changed) { + writeJsonFile(list); + } + return list; +} + +function writeAuthList(list) { + writeJsonFile(list); +} + +export function getGitLabAuth() { + const list = readAuthList(); + if (!list.length) { + return null; + } + const current = list.find((entry) => entry.current) || list[0]; + if (!current?.accessToken) { + return null; + } + return current; +} + +export function getGitLabAuthAccounts() { + const list = readAuthList(); + return list + .filter((entry) => entry?.accountId) + .map((entry) => ({ + id: entry.accountId, + user: { + username: entry.username || null, + name: entry.name || null, + avatarUrl: entry.avatarUrl || null, + webUrl: entry.webUrl || null, + }, + baseUrl: entry.baseUrl || DEFAULT_GITLAB_BASE_URL, + current: Boolean(entry.current), + })); +} + +export function setGitLabAuth({ accessToken, baseUrl, user }) { + if (!accessToken || typeof accessToken !== 'string') { + throw new Error('accessToken is required'); + } + const normalizedBaseUrl = normalizeBaseUrl(baseUrl) || DEFAULT_GITLAB_BASE_URL; + const normalizedUser = user && typeof user === 'object' + ? { + username: typeof user.username === 'string' ? user.username : undefined, + name: typeof user.name === 'string' ? user.name : undefined, + avatarUrl: typeof user.avatar_url === 'string' ? user.avatar_url : undefined, + webUrl: typeof user.web_url === 'string' ? user.web_url : undefined, + email: typeof user.email === 'string' ? user.email : undefined, + } + : undefined; + + const username = normalizedUser?.username || ''; + const resolvedAccountId = resolveAccountId({ + username, + accessToken, + baseUrl: normalizedBaseUrl, + accountId: '', + }); + + const list = readAuthList(); + const existingIndex = list.findIndex((entry) => entry.accountId === resolvedAccountId); + const nextEntry = { + accessToken, + baseUrl: normalizedBaseUrl, + username: username || null, + name: normalizedUser?.name ?? null, + avatarUrl: normalizedUser?.avatarUrl ?? null, + webUrl: normalizedUser?.webUrl ?? null, + email: normalizedUser?.email ?? null, + createdAt: Date.now(), + current: true, + accountId: resolvedAccountId, + }; + + if (existingIndex >= 0) { + list[existingIndex] = nextEntry; + } else { + list.push(nextEntry); + } + + list.forEach((entry, index) => { + entry.current = index === (existingIndex >= 0 ? existingIndex : list.length - 1); + }); + writeAuthList(list); + return nextEntry; +} + +export function activateGitLabAuth(accountId) { + if (typeof accountId !== 'string' || !accountId.trim()) { + return false; + } + const list = readAuthList(); + const index = list.findIndex((entry) => entry.accountId === accountId.trim()); + if (index === -1) { + return false; + } + list.forEach((entry, idx) => { + entry.current = idx === index; + }); + writeAuthList(list); + return true; +} + +export function clearGitLabAuth() { + try { + const list = readAuthList(); + if (!list.length) { + return true; + } + const remaining = list.filter((entry) => !entry.current); + if (!remaining.length) { + if (fs.existsSync(STORAGE_FILE)) { + fs.unlinkSync(STORAGE_FILE); + } + return true; + } + remaining.forEach((entry, index) => { + entry.current = index === 0; + }); + writeAuthList(remaining); + return true; + } catch (error) { + console.error('Failed to clear GitLab auth file:', error); + return false; + } +} + +export const GITLAB_AUTH_FILE = STORAGE_FILE; diff --git a/packages/web/server/lib/gitlab/auth.test.js b/packages/web/server/lib/gitlab/auth.test.js new file mode 100644 index 00000000..3f6af618 --- /dev/null +++ b/packages/web/server/lib/gitlab/auth.test.js @@ -0,0 +1,161 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { afterAll, afterEach, describe, expect, test } from 'vitest'; + +const TEMP_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-gitlab-auth-')); +process.env.OPENCHAMBER_DATA_DIR = TEMP_DATA_DIR; + +const { + getGitLabAuth, + getGitLabAuthAccounts, + setGitLabAuth, + activateGitLabAuth, + clearGitLabAuth, + normalizeBaseUrl, + GITLAB_AUTH_FILE, + DEFAULT_GITLAB_BASE_URL, +} = await import('./auth.js'); + +afterAll(() => { + fs.rmSync(TEMP_DATA_DIR, { recursive: true, force: true }); +}); + +afterEach(() => { + if (fs.existsSync(GITLAB_AUTH_FILE)) { + fs.unlinkSync(GITLAB_AUTH_FILE); + } +}); + +const aliceUser = { + id: 42, + username: 'alice', + name: 'Alice Example', + state: 'active', + avatar_url: 'https://gitlab.com/uploads/-/avatar.png', + web_url: 'https://gitlab.com/alice', + email: 'alice@example.com', +}; + +describe('normalizeBaseUrl', () => { + test('adds https scheme when missing', () => { + expect(normalizeBaseUrl('gitlab.example.com')).toBe('https://gitlab.example.com'); + }); + + test('strips trailing slash', () => { + expect(normalizeBaseUrl('https://gitlab.com/')).toBe('https://gitlab.com'); + expect(normalizeBaseUrl('https://gitlab.example.com/gitlab/')).toBe('https://gitlab.example.com/gitlab'); + }); + + test('keeps an explicit scheme', () => { + expect(normalizeBaseUrl('http://localhost:8080')).toBe('http://localhost:8080'); + }); + + test('returns null for invalid input', () => { + expect(normalizeBaseUrl('')).toBeNull(); + expect(normalizeBaseUrl('not a url')).toBeNull(); + expect(normalizeBaseUrl('://bad')).toBeNull(); + expect(normalizeBaseUrl(null)).toBeNull(); + expect(normalizeBaseUrl(undefined)).toBeNull(); + }); +}); + +describe('setGitLabAuth', () => { + test('stores an account with a host-prefixed accountId', () => { + setGitLabAuth({ accessToken: 'glpat-secret', baseUrl: 'gitlab.com', user: aliceUser }); + + const auth = getGitLabAuth(); + expect(auth).not.toBeNull(); + expect(auth.accountId).toBe('gitlab.com:alice'); + expect(auth.baseUrl).toBe('https://gitlab.com'); + expect(auth.username).toBe('alice'); + expect(auth.name).toBe('Alice Example'); + expect(auth.avatarUrl).toBe('https://gitlab.com/uploads/-/avatar.png'); + expect(auth.webUrl).toBe('https://gitlab.com/alice'); + expect(auth.email).toBe('alice@example.com'); + expect(auth.current).toBe(true); + expect(auth.createdAt).toEqual(expect.any(Number)); + }); + + test('writes the auth file with 0600 permissions', () => { + setGitLabAuth({ accessToken: 'glpat-secret', baseUrl: DEFAULT_GITLAB_BASE_URL, user: aliceUser }); + const stats = fs.statSync(GITLAB_AUTH_FILE); + // 0o600 mask + expect(stats.mode & 0o777).toBe(0o600); + }); + + test('replaces the same account instead of duplicating it', () => { + setGitLabAuth({ accessToken: 'glpat-old', baseUrl: 'gitlab.com', user: aliceUser }); + setGitLabAuth({ + accessToken: 'glpat-new', + baseUrl: 'https://gitlab.com', + user: { ...aliceUser, name: 'Alice Renamed' }, + }); + + const accounts = getGitLabAuthAccounts(); + expect(accounts).toHaveLength(1); + expect(accounts[0].user.name).toBe('Alice Renamed'); + expect(getGitLabAuth().accessToken).toBe('glpat-new'); + }); + + test('falls back to a token prefix accountId when username is missing', () => { + setGitLabAuth({ accessToken: 'glpat-prefixtest', baseUrl: 'gitlab.com', user: { id: 1 } }); + const accounts = getGitLabAuthAccounts(); + expect(accounts).toHaveLength(1); + expect(accounts[0].id).toBe('token:glpat-pr'); + }); + + test('requires an access token', () => { + expect(() => setGitLabAuth({ baseUrl: 'gitlab.com', user: aliceUser })).toThrow('accessToken is required'); + }); +}); + +describe('multi-account switching', () => { + test('tracks a single current account and can switch it', () => { + setGitLabAuth({ accessToken: 'glpat-a', baseUrl: 'gitlab.com', user: aliceUser }); + setGitLabAuth({ + accessToken: 'glpat-b', + baseUrl: 'https://gitlab.example.com', + user: { ...aliceUser, username: 'bob', name: 'Bob' }, + }); + + expect(getGitLabAuth().accountId).toBe('gitlab.example.com:bob'); + + const switched = activateGitLabAuth('gitlab.com:alice'); + expect(switched).toBe(true); + expect(getGitLabAuth().accountId).toBe('gitlab.com:alice'); + expect(getGitLabAuthAccounts().find((a) => a.id === 'gitlab.example.com:bob')?.current).toBe(false); + }); + + test('activate returns false for an unknown account', () => { + setGitLabAuth({ accessToken: 'glpat-a', baseUrl: 'gitlab.com', user: aliceUser }); + expect(activateGitLabAuth('gitlab.com:nobody')).toBe(false); + expect(activateGitLabAuth('')).toBe(false); + expect(activateGitLabAuth(undefined)).toBe(false); + }); +}); + +describe('clearGitLabAuth', () => { + test('removes the current account and deletes the file when empty', () => { + setGitLabAuth({ accessToken: 'glpat-a', baseUrl: 'gitlab.com', user: aliceUser }); + const removed = clearGitLabAuth(); + expect(removed).toBe(true); + expect(getGitLabAuth()).toBeNull(); + expect(fs.existsSync(GITLAB_AUTH_FILE)).toBe(false); + }); + + test('keeps other accounts and promotes the first remaining', () => { + setGitLabAuth({ accessToken: 'glpat-a', baseUrl: 'gitlab.com', user: aliceUser }); + setGitLabAuth({ + accessToken: 'glpat-b', + baseUrl: 'https://gitlab.example.com', + user: { ...aliceUser, username: 'bob' }, + }); + clearGitLabAuth(); + + const accounts = getGitLabAuthAccounts(); + expect(accounts).toHaveLength(1); + expect(accounts[0].id).toBe('gitlab.com:alice'); + expect(accounts[0].current).toBe(true); + }); +}); diff --git a/packages/web/server/lib/gitlab/client.js b/packages/web/server/lib/gitlab/client.js new file mode 100644 index 00000000..66567fd5 --- /dev/null +++ b/packages/web/server/lib/gitlab/client.js @@ -0,0 +1,289 @@ +import { getGitLabAuth, DEFAULT_GITLAB_BASE_URL } from './auth.js'; + +// Per-request timeout for every GitLab call. GitLab REST can hang under load +// (especially self-hosted instances); bounding each request lets the caller +// fail fast and serve cached/last-known state instead of holding a socket open. +const REQUEST_TIMEOUT_MS = 8000; + +const timeoutFetch = (url, options = {}) => { + // Respect a caller-provided signal if present; otherwise attach our timeout. + if (options.signal) { + return fetch(url, options); + } + return fetch(url, { ...options, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) }); +}; + +// Conditional-request cache for GET calls: GitLab serves 304 Not Modified for +// matching If-None-Match without consuming a fresh rate-limit token, so +// polling unchanged issues/MRs stays cheap. Keyed by token+URL so different +// identities never share responses. GitLab (unlike GitHub) does not attach +// `ETag` to every endpoint, but when it does we revalidate exactly like +// github/octokit.js. +const ETAG_CACHE_MAX_ENTRIES = 300; +const etagCache = new Map(); + +const rememberEtag = (key, etag, body, headers) => { + etagCache.delete(key); + etagCache.set(key, { etag, body, headers }); + if (etagCache.size > ETAG_CACHE_MAX_ENTRIES) { + const oldest = etagCache.keys().next().value; + if (oldest !== undefined) { + etagCache.delete(oldest); + } + } +}; + +const createConditionalFetch = (token) => async (url, options = {}) => { + const method = (options.method || 'GET').toUpperCase(); + if (method !== 'GET') { + return timeoutFetch(url, options); + } + + const cacheKey = `${token}\n${url}`; + const cached = etagCache.get(cacheKey); + const headers = { ...(options.headers || {}) }; + if (cached?.etag) { + headers['if-none-match'] = cached.etag; + } + + const response = await timeoutFetch(url, { ...options, headers }); + + if (response.status === 304 && cached) { + // Touch for LRU and replay the cached success response. + rememberEtag(cacheKey, cached.etag, cached.body, cached.headers); + return new Response(cached.body, { status: 200, headers: cached.headers }); + } + + if (response.ok) { + const etag = response.headers.get('etag'); + if (etag) { + const body = await response.arrayBuffer(); + rememberEtag(cacheKey, etag, body, response.headers); + return new Response(body, { status: response.status, headers: response.headers }); + } + } + + return response; +}; + +// ---- Own rate-limit cooldown (deliberately NOT shared with github/rate-limit.js) ---- +const MAX_COOLDOWN_MS = 15 * 60 * 1000; +const DEFAULT_COOLDOWN_MS = 60 * 1000; +let rateLimitedUntil = 0; + +const headerValue = (headers, name) => { + if (!headers) return undefined; + if (typeof headers.get === 'function') return headers.get(name); + return headers[name]; +}; + +/** + * Record a cooldown after a GitLab 429. Accepts a fetch Response or any object + * carrying headers (response, `retry-after` seconds, or `RateLimit-Reset` + * Unix seconds). + */ +export function noteGitLabRateLimit(error) { + const headers = error?.headers; + let retryMs = null; + const retryAfter = headerValue(headers, 'retry-after'); + if (retryAfter !== undefined && retryAfter !== null) { + const secs = Number(retryAfter); + if (Number.isFinite(secs) && secs > 0) retryMs = secs * 1000; + } + if (retryMs === null) { + const reset = headerValue(headers, 'ratelimit-reset'); + if (reset !== undefined && reset !== null) { + const delta = Number(reset) * 1000 - Date.now(); + if (Number.isFinite(delta) && delta > 0) retryMs = delta; + } + } + if (retryMs === null) retryMs = DEFAULT_COOLDOWN_MS; + retryMs = Math.min(retryMs, MAX_COOLDOWN_MS); + const until = Date.now() + retryMs; + if (until > rateLimitedUntil) { + rateLimitedUntil = until; + console.warn(`[gitlab] rate limited — pausing GitLab calls for ~${Math.round(retryMs / 1000)}s`); + } +} + +export function isGitLabRateLimited() { + return Date.now() < rateLimitedUntil; +} + +// ---- Response helpers ---- + +const joinApiUrl = (baseUrl, path) => { + const base = String(baseUrl || DEFAULT_GITLAB_BASE_URL).replace(/\/+$/, ''); + const p = typeof path === 'string' && path ? (path.startsWith('/') ? path : `/${path}`) : ''; + return `${base}/api/v4${p}`; +}; + +const headersToObject = (headers) => { + const out = {}; + if (!headers) return out; + if (typeof headers.forEach === 'function') { + headers.forEach((value, key) => { + out[key] = value; + }); + } else if (typeof headers === 'object') { + for (const [key, value] of Object.entries(headers)) { + out[key] = value; + } + } + return out; +}; + +const parsePageInfo = (headers) => { + const get = (name) => { + const value = headerValue(headers, name); + return typeof value === 'string' ? value : ''; + }; + const pageHeader = get('x-page'); + const nextPage = get('x-next-page'); + const totalPages = get('x-total-pages'); + const linkHeader = get('link'); + const relNextMatch = linkHeader.match(/<([^>]+)>\s*;\s*rel="next"/); + const page = pageHeader ? Number(pageHeader) : null; + const next = nextPage ? Number(nextPage) : null; + const total = totalPages ? Number(totalPages) : null; + const hasMore = next != null ? next > 0 : Boolean(relNextMatch); + const parsed = { page, next, total, hasMore }; + if (relNextMatch) { + parsed.nextUrl = relNextMatch[1]; + } + return parsed; +}; + +const parseData = async (response) => { + const text = await response.text(); + if (!text) { + return null; + } + try { + return JSON.parse(text); + } catch { + return null; + } +}; + +const encodeProject = (pathWithNamespace) => encodeURIComponent(String(pathWithNamespace)); + +/** + * Create a raw-fetch GitLab REST v4 client. `request` never throws for HTTP + * error statuses — it returns `{ status, headers, data, page }` so callers can + * branch on status codes. On 429 it also sets `error: 'GitLab rate limited'` + * and records a module-level cooldown. + */ +export function createGitLabClient({ token, baseUrl }) { + const effectiveBaseUrl = normalizeBaseForClient(baseUrl); + + const request = async (path, options = {}) => { + const method = (typeof options.method === 'string' ? options.method : 'GET').toUpperCase(); + const query = options.query && typeof options.query === 'object' ? options.query : {}; + const body = options.body; + const callerSignal = options.signal; + + if (isGitLabRateLimited()) { + return { status: 429, headers: {}, data: null, page: null, error: 'GitLab rate limited' }; + } + + let url = joinApiUrl(effectiveBaseUrl, path); + const qs = new URLSearchParams(); + let hasQuery = false; + for (const [key, value] of Object.entries(query)) { + if (value === undefined || value === null || value === '') continue; + qs.set(key, String(value)); + hasQuery = true; + } + if (hasQuery) { + url += `${url.includes('?') ? '&' : '?'}${qs.toString()}`; + } + + const headers = { + 'PRIVATE-TOKEN': token, + accept: 'application/json', + }; + const fetchOptions = { + method, + headers, + redirect: 'manual', + }; + if (body !== undefined) { + headers['content-type'] = 'application/json'; + fetchOptions.body = JSON.stringify(body); + } + if (callerSignal) { + fetchOptions.signal = callerSignal; + } + + const conditionalFetch = createConditionalFetch(token); + + let response = await conditionalFetch(url, fetchOptions); + + // Follow a project-move redirect exactly once. GitLab redirects + // (301/302/308) come with a `Location` for the new project URL; a manual + // redirect keeps our PRIVATE-TOKEN header across the hop. + let redirects = 0; + while ( + (response.status === 301 || response.status === 302 || response.status === 308) + && headerValue(response.headers, 'location') + && redirects < 1 + ) { + const location = headerValue(response.headers, 'location'); + const nextUrl = new URL(location, url).toString(); + response = await conditionalFetch(nextUrl, fetchOptions); + redirects += 1; + } + + const result = { + status: response.status, + headers: headersToObject(response.headers), + data: await parseData(response), + page: parsePageInfo(response.headers), + }; + + if (response.status === 429) { + noteGitLabRateLimit(response); + result.error = 'GitLab rate limited'; + } + + return result; + }; + + return { + request, + baseUrl: effectiveBaseUrl, + user: () => request('/user'), + project: (pathWithNamespace) => request(`/projects/${encodeProject(pathWithNamespace)}`), + issues: (pathWithNamespace, params = {}) => + request(`/projects/${encodeProject(pathWithNamespace)}/issues`, { query: params }), + issue: (pathWithNamespace, iid) => + request(`/projects/${encodeProject(pathWithNamespace)}/issues/${iid}`), + issueNotes: (pathWithNamespace, iid, params = {}) => + request(`/projects/${encodeProject(pathWithNamespace)}/issues/${iid}/notes`, { query: params }), + mergeRequests: (pathWithNamespace, params = {}) => + request(`/projects/${encodeProject(pathWithNamespace)}/merge_requests`, { query: params }), + mergeRequest: (pathWithNamespace, iid) => + request(`/projects/${encodeProject(pathWithNamespace)}/merge_requests/${iid}`), + mergeRequestDiffs: (pathWithNamespace, iid, params = {}) => + request(`/projects/${encodeProject(pathWithNamespace)}/merge_requests/${iid}/diffs`, { query: params }), + branches: (pathWithNamespace, params = {}) => + request(`/projects/${encodeProject(pathWithNamespace)}/repository/branches`, { query: params }), + }; +} + +function normalizeBaseForClient(baseUrl) { + if (typeof baseUrl !== 'string' || !baseUrl.trim()) { + return DEFAULT_GITLAB_BASE_URL; + } + return baseUrl.trim().replace(/\/+$/, ''); +} + +/** Picks the current account (from auth.js) token + base URL, or null. */ +export function getGitLabClientOrNull() { + const auth = getGitLabAuth(); + if (!auth?.accessToken) { + return null; + } + return createGitLabClient({ token: auth.accessToken, baseUrl: auth.baseUrl }); +} diff --git a/packages/web/server/lib/gitlab/client.test.js b/packages/web/server/lib/gitlab/client.test.js new file mode 100644 index 00000000..e4eace7e --- /dev/null +++ b/packages/web/server/lib/gitlab/client.test.js @@ -0,0 +1,251 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { afterAll, afterEach, describe, expect, test, vi } from 'vitest'; + +// Isolate auth storage so getGitLabClientOrNull never reads a real account. +const TEMP_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-gitlab-client-')); +process.env.OPENCHAMBER_DATA_DIR = TEMP_DATA_DIR; + +afterAll(() => { + fs.rmSync(TEMP_DATA_DIR, { recursive: true, force: true }); +}); + +const { + createGitLabClient, + getGitLabClientOrNull, + isGitLabRateLimited, + noteGitLabRateLimit, +} = await import('./client.js'); + +const jsonResponse = (data, { status = 200, headers = {} } = {}) => + new Response(JSON.stringify(data), { status, headers: { 'content-type': 'application/json', ...headers } }); + +const originalFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +describe('createGitLabClient request basics', () => { + test('calls {baseUrl}/api/v4{path} and sends PRIVATE-TOKEN', async () => { + const fetchMock = vi.fn(async () => jsonResponse({ id: 42, username: 'alice' })); + globalThis.fetch = fetchMock; + + const client = createGitLabClient({ token: 'glpat-token', baseUrl: 'https://gitlab.com' }); + const result = await client.user(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, options] = fetchMock.mock.calls[0]; + expect(String(url)).toBe('https://gitlab.com/api/v4/user'); + expect(options.headers['PRIVATE-TOKEN']).toBe('glpat-token'); + expect(result).toMatchObject({ status: 200, data: { id: 42, username: 'alice' } }); + expect(result.error).toBeUndefined(); + }); + + test('joins a custom base URL without duplicating /api/v4', async () => { + const fetchMock = vi.fn(async () => jsonResponse([])); + globalThis.fetch = fetchMock; + + const client = createGitLabClient({ token: 't', baseUrl: 'https://gitlab.example.com/gitlab/' }); + await client.issues('group/sub', { state: 'opened' }); + + const [url] = fetchMock.mock.calls[0]; + expect(String(url)).toBe('https://gitlab.example.com/gitlab/api/v4/projects/group%2Fsub/issues?state=opened'); + }); + + test('encodes project path namespaces exactly once', async () => { + const fetchMock = vi.fn(async () => jsonResponse([])); + globalThis.fetch = fetchMock; + + const client = createGitLabClient({ token: 't', baseUrl: 'https://gitlab.com' }); + await client.mergeRequest('a/b/c', 5); + + const [url] = fetchMock.mock.calls[0]; + expect(String(url)).toBe('https://gitlab.com/api/v4/projects/a%2Fb%2Fc/merge_requests/5'); + expect(String(url)).not.toContain('%252F'); + }); + + test('serializes query params and omits empty ones', async () => { + const fetchMock = vi.fn(async () => jsonResponse([])); + globalThis.fetch = fetchMock; + + const client = createGitLabClient({ token: 't', baseUrl: 'https://gitlab.com' }); + await client.mergeRequests('g/p', { state: 'opened', per_page: 50, page: 2, search: '', sort: null }); + + const [url] = fetchMock.mock.calls[0]; + const query = String(url).split('?')[1]; + expect(query).toContain('state=opened'); + expect(query).toContain('per_page=50'); + expect(query).toContain('page=2'); + expect(query).not.toContain('search'); + expect(query).not.toContain('sort'); + }); + + test('POST requests send a JSON body', async () => { + const fetchMock = vi.fn(async () => jsonResponse({ ok: true }, { status: 201 })); + globalThis.fetch = fetchMock; + + const client = createGitLabClient({ token: 't', baseUrl: 'https://gitlab.com' }); + await client.request('/some/action', { method: 'POST', body: { hello: 'world' } }); + + const [, options] = fetchMock.mock.calls[0]; + expect(options.method).toBe('POST'); + expect(options.headers['content-type']).toBe('application/json'); + expect(options.body).toBe(JSON.stringify({ hello: 'world' })); + }); + + test('surfaces error statuses without throwing', async () => { + const fetchMock = vi.fn(async () => jsonResponse({ message: 'nope' }, { status: 401 })); + globalThis.fetch = fetchMock; + + const client = createGitLabClient({ token: 't', baseUrl: 'https://gitlab.com' }); + const result = await client.user(); + expect(result.status).toBe(401); + expect(result.data).toEqual({ message: 'nope' }); + }); + + test('attaches a caller signal when provided, else a timeout signal', async () => { + const fetchMock = vi.fn(async () => jsonResponse([])); + globalThis.fetch = fetchMock; + + const client = createGitLabClient({ token: 't', baseUrl: 'https://gitlab.com' }); + const controller = new AbortController(); + await client.branches('g/p', { per_page: 100 }); + await client.request('/user', { signal: controller.signal }); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock.mock.calls[0][1].signal).toEqual(expect.any(AbortSignal)); + expect(fetchMock.mock.calls[1][1].signal).toBe(controller.signal); + }); +}); + +describe('pagination', () => { + test('parses x-page/x-next-page headers into the page object', async () => { + const fetchMock = vi.fn(async () => jsonResponse([], { + headers: { 'x-page': '2', 'x-next-page': '3', 'x-total-pages': '5' }, + })); + globalThis.fetch = fetchMock; + + const client = createGitLabClient({ token: 't', baseUrl: 'https://gitlab.com' }); + const result = await client.issues('g/p', { page: 2 }); + expect(result.page).toEqual({ page: 2, next: 3, total: 5, hasMore: true }); + }); + + test('falls back to the Link rel=next header when x-next-page is absent', async () => { + const fetchMock = vi.fn(async () => jsonResponse([], { + headers: { link: '; rel="next", <...>; rel="last"' }, + })); + globalThis.fetch = fetchMock; + + const client = createGitLabClient({ token: 't', baseUrl: 'https://gitlab.com' }); + const result = await client.issues('g/p', { page: 2 }); + expect(result.page.hasMore).toBe(true); + expect(result.page.nextUrl).toBe('https://gitlab.com/api/v4/projects/g%2Fp/issues?page=3'); + }); + + test('reports hasMore=false on the last page', async () => { + const fetchMock = vi.fn(async () => jsonResponse([], { + headers: { 'x-page': '5', 'x-next-page': '', 'x-total-pages': '5' }, + })); + globalThis.fetch = fetchMock; + + const client = createGitLabClient({ token: 't', baseUrl: 'https://gitlab.com' }); + const result = await client.issues('g/p', { page: 5 }); + expect(result.page.hasMore).toBe(false); + }); +}); + +describe('redirect handling', () => { + test('follows a project-move redirect exactly once, preserving auth headers', async () => { + const movedUrl = 'https://gitlab.com/api/v4/projects/new%2Fhome/issues'; + const fetchMock = vi.fn(async (url) => { + if (String(url).includes('/projects/g%2Fp/issues')) { + return jsonResponse({}, { status: 301, headers: { location: '/api/v4/projects/new%2Fhome/issues' } }); + } + if (String(url) === movedUrl) { + return jsonResponse([{ iid: 1 }]); + } + return jsonResponse({}, { status: 404 }); + }); + globalThis.fetch = fetchMock; + + const client = createGitLabClient({ token: 'glpat-t', baseUrl: 'https://gitlab.com' }); + const result = await client.issues('g/p'); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(result.status).toBe(200); + expect(result.data).toEqual([{ iid: 1 }]); + const [, secondOptions] = fetchMock.mock.calls[1]; + expect(secondOptions.headers['PRIVATE-TOKEN']).toBe('glpat-t'); + }); +}); + +describe('etag conditional cache', () => { + test('sends if-none-match and replays a 304 as a 200 with cached body', async () => { + const fetchMock = vi.fn(async (_url, options) => { + if (options.headers['if-none-match'] === '"v1"') { + return new Response(null, { status: 304 }); + } + return jsonResponse({ ok: true }, { headers: { etag: '"v1"' } }); + }); + globalThis.fetch = fetchMock; + + const client = createGitLabClient({ token: 'glpat-t', baseUrl: 'https://gitlab.com' }); + const first = await client.user(); + expect(first.status).toBe(200); + expect(first.data).toEqual({ ok: true }); + + const second = await client.user(); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock.mock.calls[1][1].headers['if-none-match']).toBe('"v1"'); + expect(second.status).toBe(200); + expect(second.data).toEqual({ ok: true }); + }); + + test('does not cache POST responses', async () => { + const fetchMock = vi.fn(async () => jsonResponse({ ok: true })); + globalThis.fetch = fetchMock; + + const client = createGitLabClient({ token: 'glpat-t', baseUrl: 'https://gitlab.com' }); + await client.request('/thing', { method: 'POST', body: {} }); + await client.request('/thing', { method: 'POST', body: {} }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); +}); + +describe('rate limiting', () => { + // NOTE: these tests run last in this file. The rate-limit cooldown is + // module-level and has no reset export, so earlier tests must not set one. + test('429 surfaces error and records a cooldown', async () => { + const fetchMock = vi.fn(async () => jsonResponse({}, { status: 429, headers: { 'retry-after': '5' } })); + globalThis.fetch = fetchMock; + + const client = createGitLabClient({ token: 'glpat-t', baseUrl: 'https://gitlab.com' }); + const result = await client.user(); + expect(result.status).toBe(429); + expect(result.error).toBe('GitLab rate limited'); + expect(isGitLabRateLimited()).toBe(true); + }); + + test('short-circuits while the cooldown is active without calling fetch', async () => { + noteGitLabRateLimit({ headers: new Headers({ 'retry-after': '120' }) }); + const fetchMock = vi.fn(async () => jsonResponse([])); + globalThis.fetch = fetchMock; + + const client = createGitLabClient({ token: 'glpat-t', baseUrl: 'https://gitlab.com' }); + const gated = await client.issues('g/p'); + expect(gated.status).toBe(429); + expect(gated.error).toBe('GitLab rate limited'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test('parses Retry-After seconds into the cooldown', () => { + noteGitLabRateLimit({ headers: new Headers({ 'retry-after': '120' }) }); + expect(isGitLabRateLimited()).toBe(true); + }); + + test('getGitLabClientOrNull returns null without stored auth', () => { + expect(getGitLabClientOrNull()).toBeNull(); + }); +}); diff --git a/packages/web/server/lib/gitlab/index.js b/packages/web/server/lib/gitlab/index.js new file mode 100644 index 00000000..95b2deb2 --- /dev/null +++ b/packages/web/server/lib/gitlab/index.js @@ -0,0 +1,22 @@ +export { + getGitLabAuth, + getGitLabAuthAccounts, + setGitLabAuth, + activateGitLabAuth, + clearGitLabAuth, + normalizeBaseUrl, + GITLAB_AUTH_FILE, + DEFAULT_GITLAB_BASE_URL, +} from './auth.js'; + +export { + createGitLabClient, + getGitLabClientOrNull, + isGitLabRateLimited, + noteGitLabRateLimit, +} from './client.js'; + +export { + parseGitLabRemoteUrl, + resolveGitLabRepoFromDirectory, +} from './repo.js'; diff --git a/packages/web/server/lib/gitlab/repo.js b/packages/web/server/lib/gitlab/repo.js new file mode 100644 index 00000000..c83b691a --- /dev/null +++ b/packages/web/server/lib/gitlab/repo.js @@ -0,0 +1,122 @@ +import { getRemoteUrl } from '../git/index.js'; +import { getGitLabAuthAccounts, normalizeBaseUrl } from './auth.js'; + +// When no explicit host allowlist is provided, accept gitlab.com or any host +// that matches the base URL of a stored GitLab account. Never github.com. +function acceptedHosts(knownHosts) { + const hosts = new Set(); + if (knownHosts instanceof Set) { + for (const host of knownHosts) { + if (typeof host === 'string' && host.trim()) { + hosts.add(host.trim().toLowerCase()); + } + } + return hosts; + } + if (Array.isArray(knownHosts)) { + for (const host of knownHosts) { + if (typeof host === 'string' && host.trim()) { + hosts.add(host.trim().toLowerCase()); + } + } + return hosts; + } + + hosts.add('gitlab.com'); + for (const account of getGitLabAuthAccounts()) { + try { + const host = new URL(normalizeBaseUrl(account.baseUrl) || account.baseUrl).hostname.toLowerCase(); + if (host) { + hosts.add(host); + } + } catch { + // ignore malformed stored account base URLs + } + } + return hosts; +} + +/** + * Parse a GitLab remote URL into `{ namespace, project, host, baseUrl, url }`. + * + * Supports: + * - `git@HOST:NS/PROJ.git` (NS may be multi-segment, e.g. `a/b/c`) + * - `ssh://git@HOST/NS/PROJ.git` + * - `https://HOST/NS/PROJ(.git)` + * + * `knownHosts` (optional Set of hostnames) restricts which hosts are accepted. + * When omitted, `gitlab.com` and hosts from stored auth accounts are accepted. + * github.com is never accepted. + */ +export const parseGitLabRemoteUrl = (raw, knownHosts) => { + if (typeof raw !== 'string') { + return null; + } + const value = raw.trim(); + if (!value) { + return null; + } + + let host = ''; + let path = ''; + + // git@HOST:NS/PROJ.git + const scpLike = value.match(/^git@([^:]+):(.+)$/); + if (scpLike) { + host = scpLike[1].toLowerCase(); + path = scpLike[2]; + } else if (value.startsWith('ssh://') || /^https?:\/\//.test(value)) { + try { + const url = new URL(value); + host = url.hostname.toLowerCase(); + path = url.pathname.replace(/^\/+/, ''); + } catch { + return null; + } + } else { + return null; + } + + if (!host) { + return null; + } + if (host === 'github.com') { + return null; + } + if (!acceptedHosts(knownHosts).has(host)) { + return null; + } + + path = path.replace(/\/+$/, ''); + if (path.endsWith('.git')) { + path = path.slice(0, -4); + } + const segments = path.split('/').filter(Boolean); + if (segments.length < 2) { + return null; + } + const project = segments[segments.length - 1]; + const namespace = segments.slice(0, -1).join('/'); + if (!project || !namespace) { + return null; + } + + return { + namespace, + project, + host, + baseUrl: `https://${host}`, + url: `https://${host}/${namespace}/${project}`, + }; +}; + +export async function resolveGitLabRepoFromDirectory(directory, remoteName = 'origin') { + const remoteUrl = await getRemoteUrl(directory, remoteName).catch(() => null); + if (!remoteUrl) { + return { repo: null, remoteUrl: null }; + } + return { + repo: parseGitLabRemoteUrl(remoteUrl), + remoteUrl, + }; +} diff --git a/packages/web/server/lib/gitlab/repo.test.js b/packages/web/server/lib/gitlab/repo.test.js new file mode 100644 index 00000000..0695a8a3 --- /dev/null +++ b/packages/web/server/lib/gitlab/repo.test.js @@ -0,0 +1,122 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { afterAll, describe, expect, test, vi } from 'vitest'; + +const TEMP_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-gitlab-repo-')); +process.env.OPENCHAMBER_DATA_DIR = TEMP_DATA_DIR; + +vi.mock('../git/index.js', () => ({ + getRemoteUrl: vi.fn(async () => null), +})); + +const { parseGitLabRemoteUrl, resolveGitLabRepoFromDirectory } = await import('./repo.js'); +const { getRemoteUrl } = await import('../git/index.js'); +const { setGitLabAuth, clearGitLabAuth } = await import('./auth.js'); + +afterAll(() => { + fs.rmSync(TEMP_DATA_DIR, { recursive: true, force: true }); + clearGitLabAuth(); +}); + +describe('parseGitLabRemoteUrl', () => { + test('parses scp-like git@host:ns/proj.git with a single segment', () => { + expect(parseGitLabRemoteUrl('git@gitlab.com:group/project.git')).toEqual({ + namespace: 'group', + project: 'project', + host: 'gitlab.com', + baseUrl: 'https://gitlab.com', + url: 'https://gitlab.com/group/project', + }); + }); + + test('parses multi-segment namespaces', () => { + expect(parseGitLabRemoteUrl('git@gitlab.com:a/b/c/proj.git')).toMatchObject({ + namespace: 'a/b/c', + project: 'proj', + host: 'gitlab.com', + url: 'https://gitlab.com/a/b/c/proj', + }); + }); + + test('parses ssh:// URLs', () => { + expect(parseGitLabRemoteUrl('ssh://git@gitlab.com/group/sub/proj.git')).toMatchObject({ + namespace: 'group/sub', + project: 'proj', + host: 'gitlab.com', + }); + }); + + test('parses https URLs with and without .git suffix', () => { + expect(parseGitLabRemoteUrl('https://gitlab.com/group/proj.git')).toMatchObject({ + namespace: 'group', + project: 'proj', + host: 'gitlab.com', + }); + expect(parseGitLabRemoteUrl('https://gitlab.com/group/proj')).toMatchObject({ + namespace: 'group', + project: 'proj', + }); + }); + + test('accepts self-hosted hosts via knownHosts', () => { + const result = parseGitLabRemoteUrl('git@git.example.com:team/app.git', new Set(['git.example.com'])); + expect(result).toMatchObject({ namespace: 'team', project: 'app', host: 'git.example.com' }); + }); + + test('rejects hosts not in knownHosts', () => { + expect(parseGitLabRemoteUrl('git@git.example.com:team/app.git', new Set(['other.example.com']))).toBeNull(); + }); + + test('accepts hosts stored in auth accounts when knownHosts is omitted', () => { + setGitLabAuth({ + accessToken: 'glpat-account-test', + baseUrl: 'https://git.internal.example', + user: { id: 1, username: 'worker' }, + }); + const result = parseGitLabRemoteUrl('git@git.internal.example:team/app.git'); + expect(result).toMatchObject({ host: 'git.internal.example', project: 'app' }); + }); + + test('never accepts github.com', () => { + expect(parseGitLabRemoteUrl('git@github.com:owner/repo.git')).toBeNull(); + expect(parseGitLabRemoteUrl('https://github.com/owner/repo.git', new Set(['github.com']))).toBeNull(); + }); + + test('returns null for malformed input', () => { + expect(parseGitLabRemoteUrl('')).toBeNull(); + expect(parseGitLabRemoteUrl('not a remote')).toBeNull(); + expect(parseGitLabRemoteUrl('git@gitlab.com:onlyone')).toBeNull(); + expect(parseGitLabRemoteUrl(null)).toBeNull(); + expect(parseGitLabRemoteUrl(undefined)).toBeNull(); + }); +}); + +describe('resolveGitLabRepoFromDirectory', () => { + test('resolves the repo from the origin remote', async () => { + vi.mocked(getRemoteUrl).mockResolvedValue('git@gitlab.com:acme/widgets.git'); + const { repo, remoteUrl } = await resolveGitLabRepoFromDirectory('/some/project'); + expect(remoteUrl).toBe('git@gitlab.com:acme/widgets.git'); + expect(repo).toMatchObject({ namespace: 'acme', project: 'widgets', host: 'gitlab.com' }); + }); + + test('uses a custom remote name', async () => { + vi.mocked(getRemoteUrl).mockResolvedValue('https://gitlab.com/acme/widgets.git'); + await resolveGitLabRepoFromDirectory('/some/project', 'upstream'); + expect(getRemoteUrl).toHaveBeenCalledWith('/some/project', 'upstream'); + }); + + test('returns null repo when the remote is not GitLab', async () => { + vi.mocked(getRemoteUrl).mockResolvedValue('git@github.com:owner/repo.git'); + const { repo, remoteUrl } = await resolveGitLabRepoFromDirectory('/some/project'); + expect(repo).toBeNull(); + expect(remoteUrl).toBe('git@github.com:owner/repo.git'); + }); + + test('returns null when there is no remote URL', async () => { + vi.mocked(getRemoteUrl).mockResolvedValue(null); + const { repo, remoteUrl } = await resolveGitLabRepoFromDirectory('/some/project'); + expect(repo).toBeNull(); + expect(remoteUrl).toBeNull(); + }); +}); diff --git a/packages/web/server/lib/gitlab/routes.js b/packages/web/server/lib/gitlab/routes.js new file mode 100644 index 00000000..d90f679e --- /dev/null +++ b/packages/web/server/lib/gitlab/routes.js @@ -0,0 +1,697 @@ +// Route-level budget for composite GitLab calls (lists, comments, MR context). +// The client bounds each individual request at 8s; this caps the whole route +// so a slow self-hosted instance cannot hold a response (and a client socket) +// open indefinitely. The client keeps its last-known state on error. +const ROUTE_TIMEOUT_MS = 15_000; + +// MR diff pagination caps: never loop more than 10 pages / 3000 files. +const MR_DIFFS_MAX_PAGES = 10; +const MR_DIFFS_MAX_FILES = 3000; + +function withTimeout(promise, timeoutMs, label) { + let timer; + const timeout = new Promise((_resolve, reject) => { + timer = setTimeout(() => { + const error = new Error(`${label} timed out after ${timeoutMs}ms`); + error.code = 'ETIMEDOUT'; + reject(error); + }, timeoutMs); + if (typeof timer.unref === 'function') timer.unref(); + }); + return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); +} + +const asString = (value) => (typeof value === 'string' ? value.trim() : ''); + +const getRequestedProject = (req) => { + const namespace = asString(req.query?.namespace); + const project = asString(req.query?.project); + return namespace && project ? `${namespace}/${project}` : null; +}; + +const getRequiredNumber = (req) => { + const raw = typeof req.query?.number === 'string' ? req.query.number : ''; + const number = Number(raw); + return Number.isFinite(number) && number > 0 ? number : null; +}; + +const mapGitLabUser = (data) => { + if (!data || typeof data !== 'object') { + return null; + } + return { + username: typeof data.username === 'string' ? data.username : null, + id: typeof data.id === 'number' ? data.id : null, + name: typeof data.name === 'string' ? data.name : null, + avatarUrl: typeof data.avatar_url === 'string' ? data.avatar_url : null, + webUrl: typeof data.web_url === 'string' ? data.web_url : null, + email: typeof data.email === 'string' ? data.email : null, + }; +}; + +const mapAuthor = (author) => { + if (!author || typeof author !== 'object') { + return null; + } + return { + username: typeof author.username === 'string' ? author.username : null, + name: typeof author.name === 'string' ? author.name : null, + avatarUrl: typeof author.avatar_url === 'string' ? author.avatar_url : null, + id: typeof author.id === 'number' ? author.id : null, + }; +}; + +const mapIssueSummary = (item) => ({ + number: typeof item.iid === 'number' ? item.iid : Number(item.iid), + title: typeof item.title === 'string' ? item.title : '', + url: typeof item.web_url === 'string' ? item.web_url : '', + state: typeof item.state === 'string' ? item.state : 'opened', + author: mapAuthor(item.author) || {}, + labels: Array.isArray(item.labels) ? item.labels.filter((label) => typeof label === 'string') : [], +}); + +const mapMergeRequestSummary = (item) => ({ + number: typeof item.iid === 'number' ? item.iid : Number(item.iid), + title: typeof item.title === 'string' ? item.title : '', + url: typeof item.web_url === 'string' ? item.web_url : '', + state: typeof item.state === 'string' ? item.state : 'opened', + draft: Boolean(item.draft) || Boolean(item.work_in_progress), + author: mapAuthor(item.author) || {}, + sourceBranch: typeof item.source_branch === 'string' ? item.source_branch : '', + targetBranch: typeof item.target_branch === 'string' ? item.target_branch : '', +}); + +const mapComment = (note, webUrl) => ({ + id: typeof note.id === 'number' ? note.id : Number(note.id), + url: webUrl ? `${webUrl}#note_${note.id}` : '', + body: typeof note.body === 'string' ? note.body : '', + createdAt: typeof note.created_at === 'string' ? note.created_at : undefined, + updatedAt: typeof note.updated_at === 'string' ? note.updated_at : undefined, + author: mapAuthor(note.author) || {}, +}); + +const countDiffLines = (diffText) => { + if (typeof diffText !== 'string') { + return { additions: 0, deletions: 0, changes: 0 }; + } + let additions = 0; + let deletions = 0; + let inHunk = false; + for (const line of diffText.split('\n')) { + if (line.startsWith('@@')) { + inHunk = true; + continue; + } + if (!inHunk) { + continue; + } + if (line.startsWith('+++') || line.startsWith('---')) { + continue; + } + if (line.startsWith('+')) { + additions += 1; + } else if (line.startsWith('-')) { + deletions += 1; + } + } + return { additions, deletions, changes: additions + deletions }; +}; + +const mapDiffItem = (item) => { + const counts = countDiffLines(item.diff); + const status = item.new_file + ? 'added' + : (item.deleted_file ? 'deleted' : (item.renamed_file ? 'renamed' : 'modified')); + return { + filename: typeof item.new_path === 'string' ? item.new_path : (typeof item.old_path === 'string' ? item.old_path : ''), + status, + additions: counts.additions, + deletions: counts.deletions, + changes: counts.changes, + patch: typeof item.diff === 'string' ? item.diff : '', + }; +}; + +const repoRefFromProjectPath = (projectPath, baseUrl) => { + const segments = projectPath.split('/'); + const project = segments[segments.length - 1] || ''; + const namespace = segments.slice(0, -1).join('/'); + let host = null; + let normalizedBaseUrl = null; + let url = null; + if (baseUrl) { + try { + const parsed = new URL(baseUrl); + host = parsed.hostname; + normalizedBaseUrl = parsed.href.replace(/\/+$/, ''); + url = `${normalizedBaseUrl}/${projectPath}`; + } catch { + // fall back to unknown host + } + } + return { namespace, project, host, baseUrl: normalizedBaseUrl, url }; +}; + +export function registerGitLabRoutes(app, options = {}) { + let gitlabLibraries = null; + const getGitLabLibraries = async () => { + if (!gitlabLibraries) { + gitlabLibraries = await import('./index.js'); + } + return gitlabLibraries; + }; + + const getClient = async () => { + const { getGitLabClientOrNull } = await getGitLabLibraries(); + return getGitLabClientOrNull(); + }; + + // Resolve which GitLab project a request targets. A directory-local git + // remote is the primary source; `namespace`/`project` query params override + // it (needed for repos checked out from non-GitLab remotes). + const resolveProjectForRequest = async (directory, requestedProject) => { + if (requestedProject) { + return { projectPath: requestedProject, repo: null, fromDirectory: false }; + } + if (!directory) { + return { projectPath: null, repo: null, fromDirectory: false }; + } + const { resolveGitLabRepoFromDirectory } = await getGitLabLibraries(); + const { repo } = await resolveGitLabRepoFromDirectory(directory); + if (!repo) { + return { projectPath: null, repo: null, fromDirectory: false }; + } + return { projectPath: `${repo.namespace}/${repo.project}`, repo, fromDirectory: true }; + }; + + // ================= GitLab Auth APIs ================= + + app.get('/api/gitlab/auth/status', async (_req, res) => { + try { + const { getGitLabAuth, getGitLabAuthAccounts, clearGitLabAuth, DEFAULT_GITLAB_BASE_URL } = await getGitLabLibraries(); + const auth = getGitLabAuth(); + const accounts = getGitLabAuthAccounts(); + if (!auth?.accessToken) { + return res.json({ connected: false, accounts, defaultBaseUrl: DEFAULT_GITLAB_BASE_URL }); + } + + const client = await getClient(); + let user = null; + if (client) { + const resp = await client.user(); + if (resp.status === 401 || resp.status === 403) { + clearGitLabAuth(); + return res.json({ connected: false, accounts: getGitLabAuthAccounts(), defaultBaseUrl: DEFAULT_GITLAB_BASE_URL }); + } + if (resp.status === 200 && resp.data) { + user = mapGitLabUser(resp.data); + } + } + + return res.json({ + connected: true, + ...(user ? { user } : {}), + accounts, + defaultBaseUrl: DEFAULT_GITLAB_BASE_URL, + }); + } catch (error) { + console.error('Failed to get GitLab auth status:', error); + return res.status(500).json({ error: error.message || 'Failed to get GitLab auth status' }); + } + }); + + app.post('/api/gitlab/auth/connect', async (req, res) => { + try { + const accessToken = asString(req.body?.accessToken); + if (!accessToken) { + return res.status(400).json({ error: 'accessToken is required' }); + } + + const { normalizeBaseUrl, DEFAULT_GITLAB_BASE_URL, setGitLabAuth, getGitLabAuthAccounts } = await getGitLabLibraries(); + const baseUrl = normalizeBaseUrl(req.body?.baseUrl) || DEFAULT_GITLAB_BASE_URL; + + const { createGitLabClient } = await getGitLabLibraries(); + const client = createGitLabClient({ token: accessToken, baseUrl }); + const resp = await client.user(); + if (resp.status === 429) { + return res.status(503).json({ error: 'GitLab rate limited' }); + } + if (resp.status === 401 || resp.status === 403 || resp.status >= 400 || !resp.data?.username) { + return res.status(400).json({ error: 'Invalid GitLab access token' }); + } + + setGitLabAuth({ accessToken, baseUrl, user: resp.data }); + return res.json({ + connected: true, + user: mapGitLabUser(resp.data), + accounts: getGitLabAuthAccounts(), + defaultBaseUrl: DEFAULT_GITLAB_BASE_URL, + }); + } catch (error) { + console.error('Failed to connect GitLab:', error); + return res.status(500).json({ error: error.message || 'Failed to connect GitLab' }); + } + }); + + app.post('/api/gitlab/auth/activate', async (req, res) => { + try { + const accountId = asString(req.body?.accountId); + if (!accountId) { + return res.status(400).json({ error: 'accountId is required' }); + } + + const { activateGitLabAuth, getGitLabAuth, getGitLabAuthAccounts, DEFAULT_GITLAB_BASE_URL } = await getGitLabLibraries(); + const activated = activateGitLabAuth(accountId); + if (!activated) { + return res.status(404).json({ error: 'GitLab account not found' }); + } + + const auth = getGitLabAuth(); + const accounts = getGitLabAuthAccounts(); + if (!auth?.accessToken) { + return res.json({ connected: false, accounts, defaultBaseUrl: DEFAULT_GITLAB_BASE_URL }); + } + + let user = auth.username + ? { + username: auth.username, + id: null, + name: auth.name, + avatarUrl: auth.avatarUrl, + webUrl: auth.webUrl, + email: auth.email, + } + : null; + const client = await getClient(); + if (client) { + const resp = await client.user(); + if (resp.status === 200 && resp.data) { + user = mapGitLabUser(resp.data); + } + } + + return res.json({ connected: true, user, accounts, defaultBaseUrl: DEFAULT_GITLAB_BASE_URL }); + } catch (error) { + console.error('Failed to activate GitLab account:', error); + return res.status(500).json({ error: error.message || 'Failed to activate GitLab account' }); + } + }); + + app.delete('/api/gitlab/auth', async (_req, res) => { + try { + const { clearGitLabAuth } = await getGitLabLibraries(); + const removed = clearGitLabAuth(); + return res.json({ removed }); + } catch (error) { + console.error('Failed to disconnect GitLab:', error); + return res.status(500).json({ error: error.message || 'Failed to disconnect GitLab' }); + } + }); + + app.get('/api/gitlab/me', async (_req, res) => { + try { + const { clearGitLabAuth } = await getGitLabLibraries(); + const client = await getClient(); + if (!client) { + return res.status(401).json({ error: 'GitLab not connected' }); + } + const resp = await client.user(); + if (resp.status === 401 || resp.status === 403) { + clearGitLabAuth(); + return res.status(401).json({ error: 'GitLab token expired or revoked' }); + } + if (resp.status === 429) { + return res.status(503).json({ error: 'GitLab rate limited' }); + } + if (resp.status !== 200 || !resp.data) { + return res.status(500).json({ error: 'Failed to fetch GitLab user' }); + } + return res.json(mapGitLabUser(resp.data)); + } catch (error) { + console.error('Failed to fetch GitLab user:', error); + return res.status(500).json({ error: error.message || 'Failed to fetch GitLab user' }); + } + }); + + // ================= GitLab Issue APIs ================= + + app.get('/api/gitlab/issues/list', async (req, res) => { + try { + const directory = asString(req.query?.directory); + const requestedProject = getRequestedProject(req); + if (!directory && !requestedProject) { + return res.status(400).json({ error: 'directory or namespace/project is required' }); + } + const rawPage = typeof req.query?.page === 'string' ? Number(req.query.page) : 1; + const effectivePage = Number.isFinite(rawPage) && rawPage > 0 ? rawPage : 1; + const searchQuery = asString(req.query?.query); + + const client = await getClient(); + if (!client) { + return res.json({ connected: false, issues: [], page: effectivePage, hasMore: false }); + } + + const { projectPath, repo } = await resolveProjectForRequest(directory, requestedProject); + if (!projectPath) { + return res.json({ connected: true, repo: null, issues: [], page: effectivePage, hasMore: false }); + } + + const params = { state: 'opened', scope: 'all', per_page: 50, page: effectivePage }; + if (searchQuery) { + params.search = searchQuery; + } + const resp = await withTimeout(client.issues(projectPath, params), ROUTE_TIMEOUT_MS, 'gitlab issues list'); + if (resp.status === 429) { + return res.status(503).json({ error: 'GitLab rate limited' }); + } + if (resp.status !== 200) { + return res.status(502).json({ error: 'GitLab returned an error while listing issues' }); + } + + const issues = (Array.isArray(resp.data) ? resp.data : []).map(mapIssueSummary); + return res.json({ + connected: true, + repo: repo || repoRefFromProjectPath(projectPath, client.baseUrl), + issues, + page: effectivePage, + hasMore: Boolean(resp.page?.hasMore), + }); + } catch (error) { + console.error('Failed to list GitLab issues:', error); + return res.status(500).json({ error: error.message || 'Failed to list GitLab issues' }); + } + }); + + app.get('/api/gitlab/issues/get', async (req, res) => { + try { + const directory = asString(req.query?.directory); + const requestedProject = getRequestedProject(req); + const number = getRequiredNumber(req); + if (!directory && !requestedProject) { + return res.status(400).json({ error: 'directory or namespace/project is required' }); + } + if (!number) { + return res.status(400).json({ error: 'number is required' }); + } + + const client = await getClient(); + if (!client) { + return res.json({ connected: false, issue: null }); + } + + const { projectPath, repo } = await resolveProjectForRequest(directory, requestedProject); + if (!projectPath) { + return res.json({ connected: true, repo: null, issue: null }); + } + + const resp = await withTimeout(client.issue(projectPath, number), ROUTE_TIMEOUT_MS, 'gitlab issue get'); + if (resp.status === 429) { + return res.status(503).json({ error: 'GitLab rate limited' }); + } + if (resp.status === 404) { + return res.status(404).json({ error: 'Issue not found' }); + } + if (resp.status !== 200 || !resp.data) { + return res.status(502).json({ error: 'GitLab returned an error while fetching the issue' }); + } + + const item = resp.data; + const issue = { + number: typeof item.iid === 'number' ? item.iid : Number(item.iid), + title: typeof item.title === 'string' ? item.title : '', + url: typeof item.web_url === 'string' ? item.web_url : '', + state: typeof item.state === 'string' ? item.state : 'opened', + body: typeof item.description === 'string' ? item.description : '', + createdAt: typeof item.created_at === 'string' ? item.created_at : undefined, + updatedAt: typeof item.updated_at === 'string' ? item.updated_at : undefined, + author: mapAuthor(item.author) || {}, + assignees: Array.isArray(item.assignees) + ? item.assignees.map(mapAuthor).filter(Boolean) + : [], + labels: Array.isArray(item.labels) ? item.labels.filter((label) => typeof label === 'string') : [], + }; + return res.json({ connected: true, repo: repo || repoRefFromProjectPath(projectPath, client.baseUrl), issue }); + } catch (error) { + console.error('Failed to fetch GitLab issue:', error); + return res.status(500).json({ error: error.message || 'Failed to fetch GitLab issue' }); + } + }); + + app.get('/api/gitlab/issues/comments', async (req, res) => { + try { + const directory = asString(req.query?.directory); + const requestedProject = getRequestedProject(req); + const number = getRequiredNumber(req); + if (!directory && !requestedProject) { + return res.status(400).json({ error: 'directory or namespace/project is required' }); + } + if (!number) { + return res.status(400).json({ error: 'number is required' }); + } + + const client = await getClient(); + if (!client) { + return res.json({ connected: false, comments: [] }); + } + + const { projectPath, repo } = await resolveProjectForRequest(directory, requestedProject); + if (!projectPath) { + return res.json({ connected: true, repo: null, comments: [] }); + } + + // GitLab notes carry no web URL; resolve it from the issue so each note + // links as `{issue_web_url}#note_{id}`. + const issueResp = await withTimeout(client.issue(projectPath, number), ROUTE_TIMEOUT_MS, 'gitlab issue comments issue'); + if (issueResp.status === 429) { + return res.status(503).json({ error: 'GitLab rate limited' }); + } + if (issueResp.status === 404) { + return res.status(404).json({ error: 'Issue not found' }); + } + if (issueResp.status !== 200 || !issueResp.data) { + return res.status(502).json({ error: 'GitLab returned an error while fetching the issue' }); + } + const webUrl = typeof issueResp.data.web_url === 'string' ? issueResp.data.web_url : ''; + + const notesResp = await withTimeout( + client.issueNotes(projectPath, number, { per_page: 100 }), + ROUTE_TIMEOUT_MS, + 'gitlab issue comments notes', + ); + if (notesResp.status === 429) { + return res.status(503).json({ error: 'GitLab rate limited' }); + } + if (notesResp.status !== 200) { + return res.status(502).json({ error: 'GitLab returned an error while fetching issue comments' }); + } + + const comments = (Array.isArray(notesResp.data) ? notesResp.data : []) + .filter((note) => !note.system) + .map((note) => mapComment(note, webUrl)); + return res.json({ connected: true, repo: repo || repoRefFromProjectPath(projectPath, client.baseUrl), comments }); + } catch (error) { + console.error('Failed to fetch GitLab issue comments:', error); + return res.status(500).json({ error: error.message || 'Failed to fetch GitLab issue comments' }); + } + }); + + // ================= GitLab Merge Request APIs ================= + + app.get('/api/gitlab/mrs/list', async (req, res) => { + try { + const directory = asString(req.query?.directory); + const requestedProject = getRequestedProject(req); + if (!directory && !requestedProject) { + return res.status(400).json({ error: 'directory or namespace/project is required' }); + } + const rawPage = typeof req.query?.page === 'string' ? Number(req.query.page) : 1; + const effectivePage = Number.isFinite(rawPage) && rawPage > 0 ? rawPage : 1; + const searchQuery = asString(req.query?.query); + + const client = await getClient(); + if (!client) { + return res.json({ connected: false, mrs: [], page: effectivePage, hasMore: false }); + } + + const { projectPath, repo } = await resolveProjectForRequest(directory, requestedProject); + if (!projectPath) { + return res.json({ connected: true, repo: null, mrs: [], page: effectivePage, hasMore: false }); + } + + const params = { state: 'opened', scope: 'all', per_page: 50, page: effectivePage }; + if (searchQuery) { + params.search = searchQuery; + } + const resp = await withTimeout(client.mergeRequests(projectPath, params), ROUTE_TIMEOUT_MS, 'gitlab mrs list'); + if (resp.status === 429) { + return res.status(503).json({ error: 'GitLab rate limited' }); + } + if (resp.status !== 200) { + return res.status(502).json({ error: 'GitLab returned an error while listing merge requests' }); + } + + const mrs = (Array.isArray(resp.data) ? resp.data : []).map(mapMergeRequestSummary); + return res.json({ + connected: true, + repo: repo || repoRefFromProjectPath(projectPath, client.baseUrl), + mrs, + page: effectivePage, + hasMore: Boolean(resp.page?.hasMore), + }); + } catch (error) { + console.error('Failed to list GitLab merge requests:', error); + return res.status(500).json({ error: error.message || 'Failed to list GitLab merge requests' }); + } + }); + + app.get('/api/gitlab/mrs/context', async (req, res) => { + try { + const directory = asString(req.query?.directory); + const requestedProject = getRequestedProject(req); + const number = getRequiredNumber(req); + const includeDiff = req.query?.diff === '1' || req.query?.diff === 'true'; + if (!directory && !requestedProject) { + return res.status(400).json({ error: 'directory or namespace/project is required' }); + } + if (!number) { + return res.status(400).json({ error: 'number is required' }); + } + + const client = await getClient(); + if (!client) { + return res.json({ connected: false, mr: null, comments: [], files: [] }); + } + + const { projectPath, repo } = await resolveProjectForRequest(directory, requestedProject); + if (!projectPath) { + return res.json({ connected: true, repo: null, mr: null, comments: [], files: [] }); + } + + const mrResp = await withTimeout(client.mergeRequest(projectPath, number), ROUTE_TIMEOUT_MS, 'gitlab mr context'); + if (mrResp.status === 429) { + return res.status(503).json({ error: 'GitLab rate limited' }); + } + if (mrResp.status === 404) { + return res.status(404).json({ error: 'Merge request not found' }); + } + if (mrResp.status !== 200 || !mrResp.data) { + return res.status(502).json({ error: 'GitLab returned an error while fetching the merge request' }); + } + + const item = mrResp.data; + const mr = { + number: typeof item.iid === 'number' ? item.iid : Number(item.iid), + title: typeof item.title === 'string' ? item.title : '', + url: typeof item.web_url === 'string' ? item.web_url : '', + state: typeof item.state === 'string' ? item.state : 'opened', + draft: Boolean(item.draft) || Boolean(item.work_in_progress), + body: typeof item.description === 'string' ? item.description : '', + createdAt: typeof item.created_at === 'string' ? item.created_at : undefined, + updatedAt: typeof item.updated_at === 'string' ? item.updated_at : undefined, + author: mapAuthor(item.author) || {}, + sourceBranch: typeof item.source_branch === 'string' ? item.source_branch : '', + targetBranch: typeof item.target_branch === 'string' ? item.target_branch : '', + headSha: typeof item.sha === 'string' ? item.sha : (typeof item.diff_head_sha === 'string' ? item.diff_head_sha : undefined), + }; + + const notesResp = await withTimeout( + client.request(`/projects/${encodeURIComponent(projectPath)}/merge_requests/${number}/notes`, { query: { per_page: 100 } }), + ROUTE_TIMEOUT_MS, + 'gitlab mr context notes', + ); + if (notesResp.status === 429) { + return res.status(503).json({ error: 'GitLab rate limited' }); + } + const comments = (notesResp.status === 200 && Array.isArray(notesResp.data) ? notesResp.data : []) + .filter((note) => !note.system) + .map((note) => mapComment(note, mr.url)); + + // Diffs are paginated; loop pages but cap the total work. + const files = []; + for (let page = 1; page <= MR_DIFFS_MAX_PAGES; page += 1) { + const diffsResp = await client.mergeRequestDiffs(projectPath, number, { per_page: 100, page }); + if (diffsResp.status === 429) { + return res.status(503).json({ error: 'GitLab rate limited' }); + } + if (diffsResp.status !== 200 || !Array.isArray(diffsResp.data)) { + break; + } + const chunk = diffsResp.data; + for (const diffItem of chunk) { + files.push(mapDiffItem(diffItem)); + if (files.length >= MR_DIFFS_MAX_FILES) { + break; + } + } + if (files.length >= MR_DIFFS_MAX_FILES) { + break; + } + if (chunk.length < 100 || !diffsResp.page?.hasMore) { + break; + } + } + + let diff; + if (includeDiff) { + const patches = files.map((file) => file.patch || '').filter(Boolean); + diff = patches.length > 0 ? patches.join('\n') : undefined; + } + + return res.json({ + connected: true, + repo: repo || repoRefFromProjectPath(projectPath, client.baseUrl), + mr, + comments, + files, + ...(diff ? { diff } : {}), + }); + } catch (error) { + console.error('Failed to load GitLab merge request context:', error); + return res.status(500).json({ error: error.message || 'Failed to load GitLab merge request context' }); + } + }); + + // ================= GitLab Repo APIs ================= + + app.get('/api/gitlab/repo/branches', async (req, res) => { + try { + const namespace = asString(req.query?.namespace); + const project = asString(req.query?.project); + if (!namespace || !project) { + return res.status(400).json({ error: 'namespace and project are required' }); + } + + const client = await getClient(); + if (!client) { + return res.json({ branches: [] }); + } + + const branches = []; + let page = 1; + while (page <= 10) { + const resp = await client.branches(`${namespace}/${project}`, { per_page: 100, page }); + if (resp.status === 429) { + return res.status(503).json({ error: 'GitLab rate limited' }); + } + if (resp.status !== 200 || !Array.isArray(resp.data)) { + break; + } + const chunk = resp.data; + for (const branch of chunk) { + if (typeof branch?.name === 'string') { + branches.push(branch.name); + } + } + if (chunk.length < 100 || !resp.page?.hasMore) { + break; + } + page += 1; + } + + return res.json({ branches }); + } catch (error) { + console.error('Failed to fetch GitLab repo branches:', error); + return res.status(500).json({ error: error.message || 'Failed to fetch GitLab repo branches' }); + } + }); +} diff --git a/packages/web/server/lib/gitlab/routes.test.js b/packages/web/server/lib/gitlab/routes.test.js new file mode 100644 index 00000000..54b70b0c --- /dev/null +++ b/packages/web/server/lib/gitlab/routes.test.js @@ -0,0 +1,499 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { afterAll, beforeEach, describe, expect, test, vi } from 'vitest'; +import express from 'express'; +import request from 'supertest'; + +const TEMP_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-gitlab-routes-')); +process.env.OPENCHAMBER_DATA_DIR = TEMP_DATA_DIR; + +// Resolve a fake git remote so directory-based repo resolution finds a GitLab +// repo without touching the real filesystem/git. +vi.mock('../git/index.js', () => ({ + getRemoteUrl: vi.fn(async () => 'git@gitlab.com:group/sub.git'), +})); + +const { registerGitLabRoutes } = await import('./routes.js'); +const { setGitLabAuth, clearGitLabAuth, getGitLabAuth, getGitLabAuthAccounts, GITLAB_AUTH_FILE } = await import('./index.js'); + +afterAll(() => { + fs.rmSync(TEMP_DATA_DIR, { recursive: true, force: true }); +}); + +// clearGitLabAuth only drops the *current* account (multi-account model), so a +// full wipe is done by removing the auth file between tests. +const resetAuthFile = () => { + if (fs.existsSync(GITLAB_AUTH_FILE)) { + fs.unlinkSync(GITLAB_AUTH_FILE); + } +}; + +const aliceUser = { + id: 42, + username: 'alice', + name: 'Alice Example', + state: 'active', + avatar_url: 'https://gitlab.com/uploads/-/avatar.png', + web_url: 'https://gitlab.com/alice', + email: 'alice@example.com', +}; + +const jsonResponse = (data, { status = 200, headers = {} } = {}) => + new Response(JSON.stringify(data), { status, headers: { 'content-type': 'application/json', ...headers } }); + +const scriptedFetch = (handlers) => { + const fetchMock = vi.fn(async (url, options) => { + const str = String(url); + for (const handler of handlers) { + const result = handler(str, options); + if (result !== null && result !== undefined) { + return result; + } + } + return jsonResponse({ message: `unhandled request: ${str}` }, { status: 500 }); + }); + globalThis.fetch = fetchMock; + return fetchMock; +}; + +const matches = (pattern) => (url) => pattern.test(url); + +const createApp = () => { + const app = express(); + app.use(express.json()); + registerGitLabRoutes(app); + return app; +}; + +describe('GitLab auth routes', () => { + beforeEach(() => { + resetAuthFile(); + vi.restoreAllMocks(); + delete globalThis.fetch; + }); + + test('auth/status returns disconnected with the default base URL', async () => { + const app = createApp(); + const response = await request(app).get('/api/gitlab/auth/status'); + expect(response.status).toBe(200); + expect(response.body).toEqual({ + connected: false, + accounts: [], + defaultBaseUrl: 'https://gitlab.com', + }); + }); + + test('auth/connect validates the token, stores the account, and reports connected', async () => { + scriptedFetch([(url) => (matches(/\/api\/v4\/user$/)(url) ? jsonResponse(aliceUser) : null)]); + + const app = createApp(); + const response = await request(app) + .post('/api/gitlab/auth/connect') + .send({ accessToken: 'glpat-valid', baseUrl: 'https://gitlab.com' }); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + connected: true, + user: { username: 'alice', id: 42, name: 'Alice Example', avatarUrl: 'https://gitlab.com/uploads/-/avatar.png', email: 'alice@example.com' }, + defaultBaseUrl: 'https://gitlab.com', + }); + expect(response.body.accounts).toEqual([ + { id: 'gitlab.com:alice', user: { username: 'alice', name: 'Alice Example', avatarUrl: 'https://gitlab.com/uploads/-/avatar.png', webUrl: 'https://gitlab.com/alice' }, baseUrl: 'https://gitlab.com', current: true }, + ]); + expect(getGitLabAuth()?.accessToken).toBe('glpat-valid'); + }); + + test('auth/connect rejects an invalid token with 400', async () => { + scriptedFetch([(url) => (matches(/\/api\/v4\/user$/)(url) ? jsonResponse({ message: '401 Unauthorized' }, { status: 401 }) : null)]); + + const app = createApp(); + const response = await request(app) + .post('/api/gitlab/auth/connect') + .send({ accessToken: 'glpat-invalid' }); + + expect(response.status).toBe(400); + expect(response.body).toEqual({ error: 'Invalid GitLab access token' }); + expect(getGitLabAuth()).toBeNull(); + }); + + test('auth/connect requires an access token', async () => { + const app = createApp(); + const response = await request(app).post('/api/gitlab/auth/connect').send({}); + expect(response.status).toBe(400); + expect(response.body).toEqual({ error: 'accessToken is required' }); + }); + + test('auth/connect normalizes a scheme-less base URL', async () => { + const fetchMock = scriptedFetch([(url) => (matches(/\/api\/v4\/user$/)(url) ? jsonResponse(aliceUser) : null)]); + + const app = createApp(); + await request(app) + .post('/api/gitlab/auth/connect') + .send({ accessToken: 'glpat-valid', baseUrl: 'gitlab.example.com' }); + + expect(fetchMock.mock.calls[0][0]).toBe('https://gitlab.example.com/api/v4/user'); + }); + + test('auth/status reports connected with the live user', async () => { + setGitLabAuth({ accessToken: 'glpat-a', baseUrl: 'gitlab.com', user: aliceUser }); + scriptedFetch([(url) => (matches(/\/api\/v4\/user$/)(url) ? jsonResponse(aliceUser) : null)]); + + const app = createApp(); + const response = await request(app).get('/api/gitlab/auth/status'); + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + connected: true, + user: { username: 'alice', id: 42 }, + defaultBaseUrl: 'https://gitlab.com', + }); + expect(response.body.accounts).toEqual([ + { id: 'gitlab.com:alice', user: { username: 'alice', name: 'Alice Example', avatarUrl: 'https://gitlab.com/uploads/-/avatar.png', webUrl: 'https://gitlab.com/alice' }, baseUrl: 'https://gitlab.com', current: true }, + ]); + }); + + test('auth/activate returns 404 for an unknown account', async () => { + const app = createApp(); + const response = await request(app).post('/api/gitlab/auth/activate').send({ accountId: 'gitlab.com:nobody' }); + expect(response.status).toBe(404); + expect(response.body).toEqual({ error: 'GitLab account not found' }); + }); + + test('auth/activate switches the current account', async () => { + setGitLabAuth({ accessToken: 'glpat-a', baseUrl: 'gitlab.com', user: aliceUser }); + setGitLabAuth({ + accessToken: 'glpat-b', + baseUrl: 'https://gitlab.example.com', + user: { ...aliceUser, username: 'bob', name: 'Bob' }, + }); + scriptedFetch([(url) => (matches(/\/api\/v4\/user$/)(url) ? jsonResponse(aliceUser) : null)]); + + const app = createApp(); + const response = await request(app).post('/api/gitlab/auth/activate').send({ accountId: 'gitlab.com:alice' }); + expect(response.status).toBe(200); + expect(response.body.connected).toBe(true); + expect(response.body.accounts.find((a) => a.id === 'gitlab.com:alice')?.current).toBe(true); + expect(getGitLabAuth()?.accountId).toBe('gitlab.com:alice'); + }); + + test('DELETE /api/gitlab/auth clears the account', async () => { + setGitLabAuth({ accessToken: 'glpat-a', baseUrl: 'gitlab.com', user: aliceUser }); + const app = createApp(); + const response = await request(app).delete('/api/gitlab/auth'); + expect(response.status).toBe(200); + expect(response.body).toEqual({ removed: true }); + expect(getGitLabAuth()).toBeNull(); + }); + + test('me returns 401 when not connected', async () => { + const app = createApp(); + const response = await request(app).get('/api/gitlab/me'); + expect(response.status).toBe(401); + expect(response.body).toEqual({ error: 'GitLab not connected' }); + }); + + test('me returns the connected user', async () => { + setGitLabAuth({ accessToken: 'glpat-a', baseUrl: 'gitlab.com', user: aliceUser }); + scriptedFetch([(url) => (matches(/\/api\/v4\/user$/)(url) ? jsonResponse(aliceUser) : null)]); + + const app = createApp(); + const response = await request(app).get('/api/gitlab/me'); + expect(response.status).toBe(200); + expect(response.body).toEqual({ + username: 'alice', + id: 42, + name: 'Alice Example', + avatarUrl: 'https://gitlab.com/uploads/-/avatar.png', + webUrl: 'https://gitlab.com/alice', + email: 'alice@example.com', + }); + }); +}); + +describe('GitLab data routes', () => { + beforeEach(() => { + resetAuthFile(); + setGitLabAuth({ accessToken: 'glpat-a', baseUrl: 'gitlab.com', user: aliceUser }); + vi.restoreAllMocks(); + delete globalThis.fetch; + }); + + test('issues/list returns mapped issues with pagination info', async () => { + scriptedFetch([ + (url) => (matches(/\/api\/v4\/projects\/group%2Fsub\/issues\?/)(url) + ? jsonResponse( + [ + { + iid: 3, + title: 'Fix the widget', + web_url: 'https://gitlab.com/group/sub/-/issues/3', + state: 'opened', + author: { id: 42, username: 'alice', name: 'Alice Example', avatar_url: 'https://gitlab.com/alice.png' }, + labels: ['bug', 'priority:high'], + }, + ], + { headers: { 'x-page': '1', 'x-next-page': '2', 'x-total-pages': '2' } }, + ) + : null), + ]); + + const app = createApp(); + const response = await request(app).get('/api/gitlab/issues/list?directory=%2Ftmp%2Fwork&page=1'); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + connected: true, + repo: { namespace: 'group', project: 'sub', host: 'gitlab.com', url: 'https://gitlab.com/group/sub' }, + issues: [ + { + number: 3, + title: 'Fix the widget', + url: 'https://gitlab.com/group/sub/-/issues/3', + state: 'opened', + author: { username: 'alice', name: 'Alice Example', avatarUrl: 'https://gitlab.com/alice.png' }, + labels: ['bug', 'priority:high'], + }, + ], + page: 1, + hasMore: true, + }); + }); + + test('issues/list sends the search query and opened state filter', async () => { + const fetchMock = scriptedFetch([(url) => (matches(/\/issues\?/)(url) ? jsonResponse([]) : null)]); + + const app = createApp(); + await request(app).get('/api/gitlab/issues/list?directory=%2Ftmp%2Fwork&query=login'); + + const requestedUrl = String(fetchMock.mock.calls[0][0]); + expect(requestedUrl).toContain('state=opened'); + expect(requestedUrl).toContain('search=login'); + expect(requestedUrl).toContain('per_page=50'); + }); + + test('issues/list reports connected:false when not authenticated', async () => { + clearGitLabAuth(); + const app = createApp(); + const response = await request(app).get('/api/gitlab/issues/list?directory=%2Ftmp%2Fwork'); + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ connected: false, issues: [] }); + }); + + test('issues/get returns a full issue', async () => { + scriptedFetch([ + (url) => (matches(/\/issues\/7$/)(url) + ? jsonResponse({ + iid: 7, + title: 'Broken import', + web_url: 'https://gitlab.com/group/sub/-/issues/7', + state: 'opened', + description: 'It breaks at startup', + created_at: '2026-01-01T10:00:00Z', + updated_at: '2026-01-02T10:00:00Z', + author: { id: 42, username: 'alice', name: 'Alice Example', avatar_url: 'https://gitlab.com/alice.png' }, + assignees: [{ id: 43, username: 'bob', name: 'Bob', avatar_url: 'https://gitlab.com/bob.png' }], + labels: ['bug'], + }) + : null), + ]); + + const app = createApp(); + const response = await request(app).get('/api/gitlab/issues/get?directory=%2Ftmp%2Fwork&number=7'); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + connected: true, + issue: { + number: 7, + title: 'Broken import', + state: 'opened', + body: 'It breaks at startup', + createdAt: '2026-01-01T10:00:00Z', + updatedAt: '2026-01-02T10:00:00Z', + author: { username: 'alice', name: 'Alice Example' }, + assignees: [{ username: 'bob', name: 'Bob' }], + labels: ['bug'], + }, + }); + }); + + test('issues/get returns 404 for a missing issue', async () => { + scriptedFetch([(url) => (matches(/\/issues\/999$/)(url) ? jsonResponse({ message: 'Not found' }, { status: 404 }) : null)]); + + const app = createApp(); + const response = await request(app).get('/api/gitlab/issues/get?directory=%2Ftmp%2Fwork&number=999'); + expect(response.status).toBe(404); + expect(response.body).toEqual({ error: 'Issue not found' }); + }); + + test('issues/comments skips system notes and links notes to the issue URL', async () => { + scriptedFetch([ + (url) => (matches(/\/issues\/7$/)(url) + ? jsonResponse({ iid: 7, web_url: 'https://gitlab.com/group/sub/-/issues/7' }) + : null), + (url) => (matches(/\/issues\/7\/notes\?/)(url) + ? jsonResponse([ + { id: 1, body: 'system note', system: true, author: { id: 1, username: 'system' }, created_at: '2026-01-01T00:00:00Z' }, + { id: 2, body: 'Looks good to me', system: false, author: { id: 42, username: 'alice', name: 'Alice Example' }, created_at: '2026-01-01T01:00:00Z' }, + ]) + : null), + ]); + + const app = createApp(); + const response = await request(app).get('/api/gitlab/issues/comments?directory=%2Ftmp%2Fwork&number=7'); + + expect(response.status).toBe(200); + expect(response.body.comments).toEqual([ + { + id: 2, + url: 'https://gitlab.com/group/sub/-/issues/7#note_2', + body: 'Looks good to me', + createdAt: '2026-01-01T01:00:00Z', + updatedAt: undefined, + author: { username: 'alice', name: 'Alice Example', avatarUrl: null, id: 42 }, + }, + ]); + }); + + test('mrs/list returns mapped merge requests', async () => { + scriptedFetch([ + (url) => (matches(/\/merge_requests\?/)(url) + ? jsonResponse([ + { + iid: 9, + title: 'Add the API', + web_url: 'https://gitlab.com/group/sub/-/merge_requests/9', + state: 'opened', + draft: false, + work_in_progress: false, + author: { id: 42, username: 'alice', name: 'Alice Example', avatar_url: 'https://gitlab.com/alice.png' }, + source_branch: 'feat/api', + target_branch: 'main', + }, + ]) + : null), + ]); + + const app = createApp(); + const response = await request(app).get('/api/gitlab/mrs/list?directory=%2Ftmp%2Fwork'); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + connected: true, + mrs: [ + { + number: 9, + title: 'Add the API', + state: 'opened', + draft: false, + author: { username: 'alice', name: 'Alice Example' }, + sourceBranch: 'feat/api', + targetBranch: 'main', + }, + ], + page: 1, + hasMore: false, + }); + }); + + test('mrs/context returns mr, comments, files, and a concatenated diff', async () => { + scriptedFetch([ + (url) => (matches(/\/merge_requests\/9$/)(url) + ? jsonResponse({ + iid: 9, + title: 'Add the API', + web_url: 'https://gitlab.com/group/sub/-/merge_requests/9', + state: 'opened', + draft: false, + description: 'Adds the public API', + created_at: '2026-01-01T10:00:00Z', + updated_at: '2026-01-02T10:00:00Z', + author: { id: 42, username: 'alice', name: 'Alice Example', avatar_url: 'https://gitlab.com/alice.png' }, + source_branch: 'feat/api', + target_branch: 'main', + sha: 'abc123def456', + }) + : null), + (url) => (matches(/\/merge_requests\/9\/notes\?/)(url) + ? jsonResponse([ + { id: 11, body: 'LGTM', system: false, author: { id: 43, username: 'bob', name: 'Bob' }, created_at: '2026-01-02T11:00:00Z' }, + ]) + : null), + (url) => (matches(/\/merge_requests\/9\/diffs\?/)(url) + ? jsonResponse([ + { + old_path: 'src/a.ts', + new_path: 'src/a.ts', + new_file: false, + renamed_file: false, + deleted_file: false, + diff: '--- a/src/a.ts\n+++ b/src/a.ts\n@@ -1,3 +1,4 @@\n import x\n+export const added = 1\n-export const old = 2\n context line\n', + }, + { + old_path: 'src/new.ts', + new_path: 'src/new.ts', + new_file: true, + renamed_file: false, + deleted_file: false, + diff: '--- a/src/new.ts\n+++ b/src/new.ts\n@@ -0,0 +1,2 @@\n+line one\n+line two\n', + }, + ]) + : null), + ]); + + const app = createApp(); + const response = await request(app).get('/api/gitlab/mrs/context?directory=%2Ftmp%2Fwork&number=9&diff=1'); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + connected: true, + mr: { + number: 9, + title: 'Add the API', + state: 'opened', + draft: false, + body: 'Adds the public API', + sourceBranch: 'feat/api', + targetBranch: 'main', + headSha: 'abc123def456', + }, + comments: [{ id: 11, body: 'LGTM', author: { username: 'bob', name: 'Bob' } }], + files: [ + { filename: 'src/a.ts', status: 'modified', additions: 1, deletions: 1, changes: 2 }, + { filename: 'src/new.ts', status: 'added', additions: 2, deletions: 0, changes: 2 }, + ], + }); + // diff field concatenates the two patches + expect(response.body.diff).toContain('export const added = 1'); + expect(response.body.diff).toContain('line two'); + }); + + test('repo/branches returns branch names', async () => { + scriptedFetch([ + (url) => (matches(/\/repository\/branches\?/)(url) + ? jsonResponse([{ name: 'main' }, { name: 'feat/api' }]) + : null), + ]); + + const app = createApp(); + const response = await request(app).get('/api/gitlab/repo/branches?namespace=group&project=sub'); + expect(response.status).toBe(200); + expect(response.body).toEqual({ branches: ['main', 'feat/api'] }); + }); + + test('repo/branches requires namespace and project', async () => { + const app = createApp(); + const response = await request(app).get('/api/gitlab/repo/branches?namespace=group'); + expect(response.status).toBe(400); + expect(response.body).toEqual({ error: 'namespace and project are required' }); + }); + + test('data routes surface a 503 when GitLab rate limits', async () => { + scriptedFetch([(url) => (matches(/\/issues\?/)(url) ? jsonResponse({}, { status: 429, headers: { 'retry-after': '30' } }) : null)]); + + const app = createApp(); + const response = await request(app).get('/api/gitlab/issues/list?directory=%2Ftmp%2Fwork'); + expect(response.status).toBe(503); + expect(response.body).toEqual({ error: 'GitLab rate limited' }); + }); +}); diff --git a/packages/web/server/lib/opencode/feature-routes-runtime.js b/packages/web/server/lib/opencode/feature-routes-runtime.js index 90c15f1b..8c93fcd1 100644 --- a/packages/web/server/lib/opencode/feature-routes-runtime.js +++ b/packages/web/server/lib/opencode/feature-routes-runtime.js @@ -4,6 +4,7 @@ import { registerSmallModelRoutes } from '../small-model/routes.js'; import { registerWalkthroughRoutes } from '../walkthrough/routes.js'; import { registerSessionGoalRoutes } from '../session-goal/routes.js'; import { registerGitHubRoutes } from '../github/routes.js'; +import { registerGitLabRoutes } from '../gitlab/routes.js'; import { registerGitRoutes } from '../git/routes.js'; import { registerDevServerRoutes } from '../dev-servers/routes.js'; import { registerMagicPromptRoutes } from '../magic-prompts/routes.js'; @@ -297,6 +298,7 @@ export const createFeatureRoutesRuntime = (dependencies) => { registerWalkthroughRoutes(app, { getWalkthroughService }); registerSessionGoalRoutes(app); registerGitHubRoutes(app); + registerGitLabRoutes(app); registerGitRoutes(app); registerDevServerRoutes(app, { scanner: devServerScanner, getOwnPorts }); registerMagicPromptRoutes(app, { From a42eec5c9ca34417e46b6fe68b4c37ef9bb2ba4a Mon Sep 17 00:00:00 2001 From: bot-hermes Date: Tue, 11 Aug 2026 23:40:55 +0000 Subject: [PATCH 04/45] feat(ui): add GitLab connect settings --- .../sections/git-identities/GitPage.tsx | 2 + .../sections/openchamber/GitLabSettings.tsx | 293 ++++++++++++++++++ .../ui/src/lib/i18n/messages/de.settings.ts | 23 ++ .../ui/src/lib/i18n/messages/en.settings.ts | 23 ++ .../ui/src/lib/i18n/messages/es.settings.ts | 23 ++ .../ui/src/lib/i18n/messages/fr.settings.ts | 23 ++ .../ui/src/lib/i18n/messages/ja.settings.ts | 23 ++ .../ui/src/lib/i18n/messages/ko.settings.ts | 23 ++ .../ui/src/lib/i18n/messages/pl.settings.ts | 23 ++ .../src/lib/i18n/messages/pt-BR.settings.ts | 23 ++ .../ui/src/lib/i18n/messages/uk.settings.ts | 23 ++ .../src/lib/i18n/messages/zh-CN.settings.ts | 23 ++ .../src/lib/i18n/messages/zh-TW.settings.ts | 23 ++ packages/ui/src/lib/settings/search.ts | 6 + packages/ui/src/stores/useGitLabAuthStore.ts | 72 +++++ 15 files changed, 626 insertions(+) create mode 100644 packages/ui/src/components/sections/openchamber/GitLabSettings.tsx create mode 100644 packages/ui/src/stores/useGitLabAuthStore.ts diff --git a/packages/ui/src/components/sections/git-identities/GitPage.tsx b/packages/ui/src/components/sections/git-identities/GitPage.tsx index d90d1a8f..8d789a2e 100644 --- a/packages/ui/src/components/sections/git-identities/GitPage.tsx +++ b/packages/ui/src/components/sections/git-identities/GitPage.tsx @@ -20,6 +20,7 @@ import { useGitIdentitiesStore, type GitIdentityProfile, type DiscoveredGitCrede import { useShallow } from 'zustand/react/shallow'; import { GitSettings } from '@/components/sections/openchamber/GitSettings'; import { GitHubSettings } from '@/components/sections/openchamber/GitHubSettings'; +import { GitLabSettings } from '@/components/sections/openchamber/GitLabSettings'; import { GitIdentityEditorDialog } from './GitIdentityEditorDialog'; import { Icon } from "@/components/icon/Icon"; import type { IconName } from "@/components/icon/icons"; @@ -122,6 +123,7 @@ export const GitPage: React.FC = () => { showSaveStatus > + { + if (!baseUrl) return ''; + try { + return new URL(baseUrl).host; + } catch { + return baseUrl; + } +}; + +export const GitLabSettings: React.FC = () => { + const { t } = useI18n(); + const { isMobile } = useDeviceInfo(); + const runtimeGitLab = getRegisteredRuntimeAPIs()?.gitlab; + const status = useGitLabAuthStore((state) => state.status); + const isLoading = useGitLabAuthStore((state) => state.isLoading); + const hasChecked = useGitLabAuthStore((state) => state.hasChecked); + const refreshStatus = useGitLabAuthStore((state) => state.refreshStatus); + const setStatus = useGitLabAuthStore((state) => state.setStatus); + + const [isBusy, setIsBusy] = React.useState(false); + const [accessToken, setAccessToken] = React.useState(''); + const [baseUrl, setBaseUrl] = React.useState(''); + + React.useEffect(() => { + (async () => { + try { + if (!hasChecked) { + await refreshStatus(runtimeGitLab); + } + } catch (error) { + console.warn('Failed to load GitLab auth status:', error); + } + })(); + }, [hasChecked, refreshStatus, runtimeGitLab]); + + const connect = React.useCallback(async () => { + const trimmedToken = accessToken.trim(); + if (!trimmedToken) { + toast.error(t('settings.gitlab.page.errors.invalidToken')); + return; + } + const trimmedBaseUrl = baseUrl.trim() || undefined; + setIsBusy(true); + try { + const payload = runtimeGitLab + ? await runtimeGitLab.authConnect({ accessToken: trimmedToken, baseUrl: trimmedBaseUrl }) + : await (async () => { + const response = await runtimeFetch('/api/gitlab/auth/connect', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ accessToken: trimmedToken, baseUrl: trimmedBaseUrl }), + }); + const body = (await response.json().catch(() => null)) as GitLabAuthStatus | { error?: string } | null; + if (!response.ok || !body) { + throw new Error((body as { error?: string } | null)?.error || response.statusText); + } + return body as GitLabAuthStatus; + })(); + + setStatus(payload); + setAccessToken(''); + setBaseUrl(''); + toast.success(t('settings.gitlab.page.toast.connected')); + } catch (error) { + console.error('Failed to connect GitLab:', error); + toast.error(t('settings.gitlab.page.errors.failed')); + } finally { + setIsBusy(false); + } + }, [accessToken, baseUrl, runtimeGitLab, setStatus, t]); + + const disconnect = React.useCallback(async () => { + setIsBusy(true); + try { + if (runtimeGitLab) { + await runtimeGitLab.authDisconnect(); + } else { + const response = await runtimeFetch('/api/gitlab/auth', { + method: 'DELETE', + headers: { Accept: 'application/json' }, + }); + if (!response.ok) { + throw new Error(response.statusText); + } + } + toast.success(t('settings.gitlab.page.toast.disconnected')); + await refreshStatus(runtimeGitLab, { force: true }); + } catch (error) { + console.error('Failed to disconnect GitLab:', error); + toast.error(t('settings.gitlab.page.toast.disconnectFailed')); + } finally { + setIsBusy(false); + } + }, [refreshStatus, runtimeGitLab, t]); + + const activateAccount = React.useCallback(async (accountId: string) => { + if (!accountId) return; + setIsBusy(true); + try { + const payload = runtimeGitLab + ? await runtimeGitLab.authActivate(accountId) + : await (async () => { + const response = await runtimeFetch('/api/gitlab/auth/activate', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ accountId }), + }); + const body = (await response.json().catch(() => null)) as GitLabAuthStatus | { error?: string } | null; + if (!response.ok || !body) { + throw new Error((body as { error?: string } | null)?.error || response.statusText); + } + return body as GitLabAuthStatus; + })(); + + setStatus(payload); + toast.success(t('settings.gitlab.page.toast.accountSwitched')); + } catch (error) { + console.error('Failed to switch GitLab account:', error); + toast.error(t('settings.gitlab.page.toast.accountSwitchFailed')); + } finally { + setIsBusy(false); + } + }, [runtimeGitLab, setStatus, t]); + + if (isLoading) { + return null; + } + + const connected = Boolean(status?.connected); + const user = status?.user; + const accounts = status?.accounts ?? []; + const otherAccounts = accounts.filter((account) => !account.current); + const currentAccount = accounts.find((account) => account.current) ?? (accounts.length > 0 ? accounts[0] : null); + const currentBaseUrlHost = getBaseUrlHost(currentAccount?.baseUrl ?? status?.defaultBaseUrl); + + return ( + +
+ {connected ? ( +
+
+ {user?.avatarUrl ? ( + {user.username + ) : ( +
+ +
+ )} + +
+
+ {user?.name?.trim() || user?.username || 'GitLab'} +
+
+ + {t('settings.gitlab.page.connectedAs')} + {user?.username || t('settings.gitlab.page.label.unknownUser')} + + {currentBaseUrlHost} +
+
+
+ + +
+ ) : ( +
+
+ + setAccessToken(event.target.value)} + placeholder={t('settings.gitlab.page.accessToken.placeholder')} + className="h-9 max-w-[24rem]" + /> +
+
+ + setBaseUrl(event.target.value)} + placeholder={t('settings.gitlab.page.baseUrl.placeholder')} + className="h-9 max-w-[24rem]" + /> +
+
+ {t('settings.gitlab.page.status.notConnected')} + +
+
+ )} + + {otherAccounts.length > 0 && ( +
+
+ {t('settings.gitlab.page.label.otherAccounts')} +
+
+ {otherAccounts.map((account) => { + const accountUser = account.user; + return ( +
+
+ {accountUser?.avatarUrl ? ( + {accountUser.username + ) : ( +
+ +
+ )} +
+ + {accountUser?.name?.trim() || accountUser?.username || 'GitLab'} + + {accountUser?.username && ( + + {accountUser.username} + · + {getBaseUrlHost(account.baseUrl)} + + )} +
+
+ +
+ ); + })} +
+
+ )} +
+
+ ); +}; diff --git a/packages/ui/src/lib/i18n/messages/de.settings.ts b/packages/ui/src/lib/i18n/messages/de.settings.ts index 8c3ec09e..fc8d9a01 100644 --- a/packages/ui/src/lib/i18n/messages/de.settings.ts +++ b/packages/ui/src/lib/i18n/messages/de.settings.ts @@ -1629,6 +1629,29 @@ export const settingsDict = { 'settings.github.page.toast.ghCliEnabled': 'gh CLI Fallback aktiviert', 'settings.github.page.toast.ghCliDisabled': 'gh CLI Fallback deaktiviert', 'settings.github.page.toast.ghCliUpdateFailed': 'Fehler beim Aktualisieren der gh CLI Einstellung', + 'settings.gitlab.page.title': 'GitLab Personal Access Token', + 'settings.gitlab.page.description': 'Fügen Sie ein GitLab Personal Access Token ein, um eine Verbindung herzustellen. Legen Sie die Basis-URL fest, wenn Sie eine selbst gehostete GitLab-Instanz verwenden.', + 'settings.gitlab.page.tooltip.connectAccount': 'Verbinden Sie ein GitLab-Konto für Issue- und Merge-Request-Workflows in der App.', + 'settings.gitlab.page.accessToken.label': 'Personal Access Token', + 'settings.gitlab.page.accessToken.placeholder': 'Fügen Sie Ihr GitLab Personal Access Token ein', + 'settings.gitlab.page.baseUrl.label': 'Basis-URL (optional)', + 'settings.gitlab.page.baseUrl.placeholder': 'https://gitlab.com', + 'settings.gitlab.page.actions.connect': 'GitLab verbinden', + 'settings.gitlab.page.actions.disconnect': 'Trennen', + 'settings.gitlab.page.actions.switch': 'Wechseln zu', + 'settings.gitlab.page.status.notConnected': 'Nicht verbunden', + 'settings.gitlab.page.label.unknownUser': 'unbekannt', + 'settings.gitlab.page.label.otherAccounts': 'Andere Konten', + 'settings.gitlab.page.avatarAlt.withLogin': 'Avatar von {login}', + 'settings.gitlab.page.avatarAlt.fallback': 'GitLab-Avatar', + 'settings.gitlab.page.connectedAs': 'Verbunden als', + 'settings.gitlab.page.errors.invalidToken': 'Geben Sie ein gültiges GitLab Personal Access Token ein', + 'settings.gitlab.page.errors.failed': 'Verbindung zu GitLab fehlgeschlagen', + 'settings.gitlab.page.toast.connected': 'GitLab verbunden', + 'settings.gitlab.page.toast.disconnected': 'GitLab getrennt', + 'settings.gitlab.page.toast.disconnectFailed': 'Trennen von GitLab fehlgeschlagen', + 'settings.gitlab.page.toast.accountSwitched': 'GitLab-Konto gewechselt', + 'settings.gitlab.page.toast.accountSwitchFailed': 'Wechsel des GitLab-Kontos fehlgeschlagen', 'settings.notifications.page.delivery.title': 'Benachrichtigungsübermittlung', 'settings.notifications.page.delivery.enableAria': 'Benachrichtigungen aktivieren', 'settings.notifications.page.delivery.enableLabel': 'Benachrichtigungen aktivieren', diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts index 66bea849..3d854ef7 100644 --- a/packages/ui/src/lib/i18n/messages/en.settings.ts +++ b/packages/ui/src/lib/i18n/messages/en.settings.ts @@ -1695,6 +1695,29 @@ export const settingsDict = { 'settings.github.page.toast.ghCliEnabled': 'gh CLI fallback enabled', 'settings.github.page.toast.ghCliDisabled': 'gh CLI fallback disabled', 'settings.github.page.toast.ghCliUpdateFailed': 'Failed to update gh CLI setting', + 'settings.gitlab.page.title': 'GitLab Personal Access Token', + 'settings.gitlab.page.description': 'Paste a GitLab personal access token to connect. Set the base URL when using a self-hosted GitLab instance.', + 'settings.gitlab.page.tooltip.connectAccount': 'Connect a GitLab account for in-app issue and merge request workflows.', + 'settings.gitlab.page.accessToken.label': 'Personal Access Token', + 'settings.gitlab.page.accessToken.placeholder': 'Paste your GitLab personal access token', + 'settings.gitlab.page.baseUrl.label': 'Base URL (optional)', + 'settings.gitlab.page.baseUrl.placeholder': 'https://gitlab.com', + 'settings.gitlab.page.actions.connect': 'Connect GitLab', + 'settings.gitlab.page.actions.disconnect': 'Disconnect', + 'settings.gitlab.page.actions.switch': 'Switch to', + 'settings.gitlab.page.status.notConnected': 'Not Connected', + 'settings.gitlab.page.label.unknownUser': 'unknown', + 'settings.gitlab.page.label.otherAccounts': 'Other Accounts', + 'settings.gitlab.page.avatarAlt.withLogin': '{login} avatar', + 'settings.gitlab.page.avatarAlt.fallback': 'GitLab avatar', + 'settings.gitlab.page.connectedAs': 'Connected as', + 'settings.gitlab.page.errors.invalidToken': 'Enter a valid GitLab personal access token', + 'settings.gitlab.page.errors.failed': 'Failed to connect GitLab', + 'settings.gitlab.page.toast.connected': 'GitLab connected', + 'settings.gitlab.page.toast.disconnected': 'GitLab disconnected', + 'settings.gitlab.page.toast.disconnectFailed': 'Failed to disconnect GitLab', + 'settings.gitlab.page.toast.accountSwitched': 'GitLab account switched', + 'settings.gitlab.page.toast.accountSwitchFailed': 'Failed to switch GitLab account', 'settings.notifications.page.delivery.title': 'Notification Delivery', 'settings.notifications.page.delivery.enableAria': 'Enable notifications', 'settings.notifications.page.delivery.enableLabel': 'Enable Notifications', diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts index 9b4d3094..1dcd4f51 100644 --- a/packages/ui/src/lib/i18n/messages/es.settings.ts +++ b/packages/ui/src/lib/i18n/messages/es.settings.ts @@ -1672,6 +1672,29 @@ export const settingsDict = { "settings.github.page.toast.ghCliEnabled": "Respaldo de gh CLI activado", "settings.github.page.toast.ghCliDisabled": "Respaldo de gh CLI desactivado", "settings.github.page.toast.ghCliUpdateFailed": "No se pudo actualizar la configuración de gh CLI", + "settings.gitlab.page.title": "Token de acceso personal de GitLab", + "settings.gitlab.page.description": "Pega un token de acceso personal de GitLab para conectarte. Establece la URL base cuando uses una instancia de GitLab autoalojada.", + "settings.gitlab.page.tooltip.connectAccount": "Conecta una cuenta de GitLab para los flujos de trabajo de issues y merge requests en la aplicación.", + "settings.gitlab.page.accessToken.label": "Token de acceso personal", + "settings.gitlab.page.accessToken.placeholder": "Pega tu token de acceso personal de GitLab", + "settings.gitlab.page.baseUrl.label": "URL base (opcional)", + "settings.gitlab.page.baseUrl.placeholder": "https://gitlab.com", + "settings.gitlab.page.actions.connect": "Conectar GitLab", + "settings.gitlab.page.actions.disconnect": "Desconectar", + "settings.gitlab.page.actions.switch": "Cambiar a", + "settings.gitlab.page.status.notConnected": "No conectado", + "settings.gitlab.page.label.unknownUser": "desconocido", + "settings.gitlab.page.label.otherAccounts": "Otras cuentas", + "settings.gitlab.page.avatarAlt.withLogin": "Avatar de {login}", + "settings.gitlab.page.avatarAlt.fallback": "Avatar de GitLab", + "settings.gitlab.page.connectedAs": "Conectado como", + "settings.gitlab.page.errors.invalidToken": "Introduce un token de acceso personal de GitLab válido", + "settings.gitlab.page.errors.failed": "No se pudo conectar GitLab", + "settings.gitlab.page.toast.connected": "GitLab conectado", + "settings.gitlab.page.toast.disconnected": "GitLab desconectado", + "settings.gitlab.page.toast.disconnectFailed": "No se pudo desconectar GitLab", + "settings.gitlab.page.toast.accountSwitched": "Cuenta de GitLab cambiada", + "settings.gitlab.page.toast.accountSwitchFailed": "No se pudo cambiar la cuenta de GitLab", "settings.notifications.page.delivery.title": "Entrega de notificaciones", "settings.notifications.page.delivery.enableAria": "Habilitar notificaciones", "settings.notifications.page.delivery.enableLabel": "Habilitar notificaciones", diff --git a/packages/ui/src/lib/i18n/messages/fr.settings.ts b/packages/ui/src/lib/i18n/messages/fr.settings.ts index 5e065403..ddb2627c 100644 --- a/packages/ui/src/lib/i18n/messages/fr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr.settings.ts @@ -1590,6 +1590,29 @@ export const settingsDict = { 'settings.github.page.toast.ghCliEnabled': 'Solution de secours gh CLI activée', 'settings.github.page.toast.ghCliDisabled': 'Solution de secours gh CLI désactivée', 'settings.github.page.toast.ghCliUpdateFailed': 'Échec de la mise à jour du paramètre gh CLI', + 'settings.gitlab.page.title': 'Jeton d\'accès personnel GitLab', + 'settings.gitlab.page.description': 'Collez un jeton d\'accès personnel GitLab pour vous connecter. Définissez l\'URL de base si vous utilisez une instance GitLab auto-hébergée.', + 'settings.gitlab.page.tooltip.connectAccount': 'Connectez un compte GitLab pour les workflows d\'issues et de merge requests dans l\'application.', + 'settings.gitlab.page.accessToken.label': 'Jeton d\'accès personnel', + 'settings.gitlab.page.accessToken.placeholder': 'Collez votre jeton d\'accès personnel GitLab', + 'settings.gitlab.page.baseUrl.label': 'URL de base (facultatif)', + 'settings.gitlab.page.baseUrl.placeholder': 'https://gitlab.com', + 'settings.gitlab.page.actions.connect': 'Connecter GitLab', + 'settings.gitlab.page.actions.disconnect': 'Déconnecter', + 'settings.gitlab.page.actions.switch': 'Passer à', + 'settings.gitlab.page.status.notConnected': 'Non connecté', + 'settings.gitlab.page.label.unknownUser': 'inconnu', + 'settings.gitlab.page.label.otherAccounts': 'Autres comptes', + 'settings.gitlab.page.avatarAlt.withLogin': 'Avatar de {login}', + 'settings.gitlab.page.avatarAlt.fallback': 'Avatar GitLab', + 'settings.gitlab.page.connectedAs': 'Connecté en tant que', + 'settings.gitlab.page.errors.invalidToken': 'Saisissez un jeton d\'accès personnel GitLab valide', + 'settings.gitlab.page.errors.failed': 'Échec de la connexion à GitLab', + 'settings.gitlab.page.toast.connected': 'GitLab connecté', + 'settings.gitlab.page.toast.disconnected': 'GitLab déconnecté', + 'settings.gitlab.page.toast.disconnectFailed': 'Échec de la déconnexion du GitLab', + 'settings.gitlab.page.toast.accountSwitched': 'Le compte GitLab a changé', + 'settings.gitlab.page.toast.accountSwitchFailed': 'Échec du changement de compte GitLab', 'settings.notifications.page.delivery.title': 'Envoi des notifications', 'settings.notifications.page.delivery.enableAria': 'Activer les notifications', 'settings.notifications.page.delivery.enableLabel': 'Activer les notifications', diff --git a/packages/ui/src/lib/i18n/messages/ja.settings.ts b/packages/ui/src/lib/i18n/messages/ja.settings.ts index b842fe8a..7f2c59d1 100644 --- a/packages/ui/src/lib/i18n/messages/ja.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja.settings.ts @@ -1705,6 +1705,29 @@ export const settingsDict = { 'settings.github.page.toast.ghCliEnabled': 'gh CLI フォールバックを有効化しました', 'settings.github.page.toast.ghCliDisabled': 'gh CLI フォールバックを無効化しました', 'settings.github.page.toast.ghCliUpdateFailed': 'gh CLI 設定の更新に失敗しました', + 'settings.gitlab.page.title': 'GitLab パーソナルアクセストークン', + 'settings.gitlab.page.description': 'GitLab パーソナルアクセストークンを貼り付けて接続します。セルフホストの GitLab インスタンスを使用する場合はベース URL を設定してください。', + 'settings.gitlab.page.tooltip.connectAccount': 'アプリ内の Issue とマージリクエストのワークフロー用に GitLab アカウントを接続します。', + 'settings.gitlab.page.accessToken.label': 'パーソナルアクセストークン', + 'settings.gitlab.page.accessToken.placeholder': 'GitLab パーソナルアクセストークンを貼り付け', + 'settings.gitlab.page.baseUrl.label': 'ベース URL(任意)', + 'settings.gitlab.page.baseUrl.placeholder': 'https://gitlab.com', + 'settings.gitlab.page.actions.connect': 'GitLab に接続', + 'settings.gitlab.page.actions.disconnect': '切断', + 'settings.gitlab.page.actions.switch': '切り替え', + 'settings.gitlab.page.status.notConnected': '未接続', + 'settings.gitlab.page.label.unknownUser': '不明', + 'settings.gitlab.page.label.otherAccounts': 'その他のアカウント', + 'settings.gitlab.page.avatarAlt.withLogin': '{login} のアバター', + 'settings.gitlab.page.avatarAlt.fallback': 'GitLab のアバター', + 'settings.gitlab.page.connectedAs': '接続アカウント:', + 'settings.gitlab.page.errors.invalidToken': '有効な GitLab パーソナルアクセストークンを入力してください', + 'settings.gitlab.page.errors.failed': 'GitLab に接続できませんでした', + 'settings.gitlab.page.toast.connected': 'GitLab に接続しました', + 'settings.gitlab.page.toast.disconnected': 'GitLab の接続を切断しました', + 'settings.gitlab.page.toast.disconnectFailed': 'GitLab の切断に失敗しました', + 'settings.gitlab.page.toast.accountSwitched': 'GitLab アカウントを切り替えました', + 'settings.gitlab.page.toast.accountSwitchFailed': 'GitLab アカウントの切り替えに失敗しました', 'settings.notifications.page.delivery.title': '通知配信', 'settings.notifications.page.delivery.enableAria': '通知を有効化', 'settings.notifications.page.delivery.enableLabel': '通知を有効化', diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts index aae1fb17..cddd9f20 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -1672,6 +1672,29 @@ export const settingsDict = { 'settings.github.page.toast.ghCliEnabled': 'gh CLI 대체 활성화됨', 'settings.github.page.toast.ghCliDisabled': 'gh CLI 대체 비활성화됨', 'settings.github.page.toast.ghCliUpdateFailed': 'gh CLI 설정을 업데이트하지 못했습니다', + 'settings.gitlab.page.title': 'GitLab 개인 액세스 토큰', + 'settings.gitlab.page.description': '연결하려면 GitLab 개인 액세스 토큰을 붙여넣으세요. 자체 호스팅 GitLab 인스턴스를 사용하는 경우 기본 URL을 설정하세요.', + 'settings.gitlab.page.tooltip.connectAccount': '앱 내 이슈 및 병합 요청 워크플로에 GitLab 계정을 연결합니다.', + 'settings.gitlab.page.accessToken.label': '개인 액세스 토큰', + 'settings.gitlab.page.accessToken.placeholder': 'GitLab 개인 액세스 토큰을 붙여넣으세요', + 'settings.gitlab.page.baseUrl.label': '기본 URL(선택 사항)', + 'settings.gitlab.page.baseUrl.placeholder': 'https://gitlab.com', + 'settings.gitlab.page.actions.connect': 'GitLab 연결', + 'settings.gitlab.page.actions.disconnect': '연결 해제', + 'settings.gitlab.page.actions.switch': '전환', + 'settings.gitlab.page.status.notConnected': '연결되지 않음', + 'settings.gitlab.page.label.unknownUser': '알 수 없음', + 'settings.gitlab.page.label.otherAccounts': '기타 계정', + 'settings.gitlab.page.avatarAlt.withLogin': '{login} 아바타', + 'settings.gitlab.page.avatarAlt.fallback': 'GitLab 아바타', + 'settings.gitlab.page.connectedAs': '연결된 계정:', + 'settings.gitlab.page.errors.invalidToken': '유효한 GitLab 개인 액세스 토큰을 입력하세요', + 'settings.gitlab.page.errors.failed': 'GitLab에 연결하지 못했습니다', + 'settings.gitlab.page.toast.connected': 'GitLab에 연결되었습니다', + 'settings.gitlab.page.toast.disconnected': 'GitLab 연결이 해제되었습니다', + 'settings.gitlab.page.toast.disconnectFailed': 'GitLab 연결을 해제하지 못했습니다', + 'settings.gitlab.page.toast.accountSwitched': 'GitLab 계정이 전환되었습니다', + 'settings.gitlab.page.toast.accountSwitchFailed': 'GitLab 계정을 전환하지 못했습니다', 'settings.notifications.page.delivery.title': '알림', 'settings.notifications.page.delivery.enableAria': '알림 활성화', 'settings.notifications.page.delivery.enableLabel': '알림 활성화', diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts index 5fe473cb..cbc5fa6c 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -326,6 +326,29 @@ export const settingsDict = { 'settings.github.page.ghCli.actions.disable': 'Wyłącz', 'settings.github.page.ghCli.actions.enable': 'Włącz', 'settings.github.page.tooltip.connectAccount': 'Połącz konto GitHub, aby korzystać z przepływów pracy dla PR i Issue w aplikacji.', + 'settings.gitlab.page.title': 'Osobisty token dostępu GitLab', + 'settings.gitlab.page.description': 'Wklej osobisty token dostępu GitLab, aby się połączyć. Ustaw podstawowy adres URL, gdy używasz własnej instancji GitLab.', + 'settings.gitlab.page.tooltip.connectAccount': 'Połącz konto GitLab, aby korzystać z przepływów pracy dla Issues i Merge Requestów w aplikacji.', + 'settings.gitlab.page.accessToken.label': 'Osobisty token dostępu', + 'settings.gitlab.page.accessToken.placeholder': 'Wklej swój osobisty token dostępu GitLab', + 'settings.gitlab.page.baseUrl.label': 'Podstawowy URL (opcjonalnie)', + 'settings.gitlab.page.baseUrl.placeholder': 'https://gitlab.com', + 'settings.gitlab.page.actions.connect': 'Połącz GitLab', + 'settings.gitlab.page.actions.disconnect': 'Odłącz', + 'settings.gitlab.page.actions.switch': 'Przełącz na', + 'settings.gitlab.page.status.notConnected': 'Brak połączenia', + 'settings.gitlab.page.label.unknownUser': 'nieznany', + 'settings.gitlab.page.label.otherAccounts': 'Inne konta', + 'settings.gitlab.page.avatarAlt.withLogin': 'Awatar {login}', + 'settings.gitlab.page.avatarAlt.fallback': 'Awatar GitLab', + 'settings.gitlab.page.connectedAs': 'Połączono jako', + 'settings.gitlab.page.errors.invalidToken': 'Wprowadź prawidłowy osobisty token dostępu GitLab', + 'settings.gitlab.page.errors.failed': 'Nie udało się połączyć z GitLab', + 'settings.gitlab.page.toast.connected': 'Połączono z GitLab', + 'settings.gitlab.page.toast.disconnected': 'Odłączono od GitLab', + 'settings.gitlab.page.toast.disconnectFailed': 'Nie udało się odłączyć GitLab', + 'settings.gitlab.page.toast.accountSwitched': 'Konto GitLab zostało przełączone', + 'settings.gitlab.page.toast.accountSwitchFailed': 'Nie udało się przełączyć konta GitLab', 'settings.magicPrompts.page.actions.resetAllOverrides': 'Zresetuj wszystkie nadpisania', 'settings.magicPrompts.page.actions.resetToDefault': 'Zresetuj do domyślnych', 'settings.magicPrompts.page.actions.resetting': 'Resetowanie...', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts index 6a6efa84..471268b2 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -1672,6 +1672,29 @@ export const settingsDict = { "settings.github.page.toast.ghCliEnabled": "Alternativa gh CLI ativada", "settings.github.page.toast.ghCliDisabled": "Alternativa gh CLI desativada", "settings.github.page.toast.ghCliUpdateFailed": "Falha ao atualizar configuração do gh CLI", + "settings.gitlab.page.title": "Token de acesso pessoal do GitLab", + "settings.gitlab.page.description": "Cole um token de acesso pessoal do GitLab para conectar. Defina a URL base ao usar uma instância GitLab auto-hospedada.", + "settings.gitlab.page.tooltip.connectAccount": "Conecte uma conta do GitLab para fluxos de trabalho de issues e merge requests no aplicativo.", + "settings.gitlab.page.accessToken.label": "Token de acesso pessoal", + "settings.gitlab.page.accessToken.placeholder": "Cole seu token de acesso pessoal do GitLab", + "settings.gitlab.page.baseUrl.label": "URL base (opcional)", + "settings.gitlab.page.baseUrl.placeholder": "https://gitlab.com", + "settings.gitlab.page.actions.connect": "Conectar GitLab", + "settings.gitlab.page.actions.disconnect": "Desconectar", + "settings.gitlab.page.actions.switch": "Alternar para", + "settings.gitlab.page.status.notConnected": "Não conectado", + "settings.gitlab.page.label.unknownUser": "desconhecido", + "settings.gitlab.page.label.otherAccounts": "Outras contas", + "settings.gitlab.page.avatarAlt.withLogin": "Avatar de {login}", + "settings.gitlab.page.avatarAlt.fallback": "Avatar do GitLab", + "settings.gitlab.page.connectedAs": "Conectado como", + "settings.gitlab.page.errors.invalidToken": "Insira um token de acesso pessoal do GitLab válido", + "settings.gitlab.page.errors.failed": "Falha ao conectar o GitLab", + "settings.gitlab.page.toast.connected": "GitLab conectado", + "settings.gitlab.page.toast.disconnected": "GitLab desconectado", + "settings.gitlab.page.toast.disconnectFailed": "Falha ao desconectar o GitLab", + "settings.gitlab.page.toast.accountSwitched": "Conta do GitLab alterada", + "settings.gitlab.page.toast.accountSwitchFailed": "Falha ao alternar a conta do GitLab", "settings.notifications.page.delivery.title": "Entrega de notificações", "settings.notifications.page.delivery.enableAria": "Ativar notificações", "settings.notifications.page.delivery.enableLabel": "Ativar notificações", diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index 3b54c685..b5f09da6 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -1672,6 +1672,29 @@ export const settingsDict = { "settings.github.page.toast.ghCliEnabled": "Резервний варіант gh CLI увімкнено", "settings.github.page.toast.ghCliDisabled": "Резервний варіант gh CLI вимкнено", "settings.github.page.toast.ghCliUpdateFailed": "Не вдалося оновити налаштування gh CLI", + "settings.gitlab.page.title": "Персональний токен доступу GitLab", + "settings.gitlab.page.description": "Вставте персональний токен доступу GitLab, щоб підключитися. Вкажіть базову URL-адресу, якщо використовуєте самостійно розміщену інстанцію GitLab.", + "settings.gitlab.page.tooltip.connectAccount": "Підключіть обліковий запис GitLab для роботи з issues та merge requests в застосунку.", + "settings.gitlab.page.accessToken.label": "Персональний токен доступу", + "settings.gitlab.page.accessToken.placeholder": "Вставте ваш персональний токен доступу GitLab", + "settings.gitlab.page.baseUrl.label": "Базова URL-адреса (необов'язково)", + "settings.gitlab.page.baseUrl.placeholder": "https://gitlab.com", + "settings.gitlab.page.actions.connect": "Підключити GitLab", + "settings.gitlab.page.actions.disconnect": "Відключити", + "settings.gitlab.page.actions.switch": "Перемкнути на", + "settings.gitlab.page.status.notConnected": "Не підключено", + "settings.gitlab.page.label.unknownUser": "невідомо", + "settings.gitlab.page.label.otherAccounts": "Інші облікові записи", + "settings.gitlab.page.avatarAlt.withLogin": "Аватар {login}", + "settings.gitlab.page.avatarAlt.fallback": "Аватар GitLab", + "settings.gitlab.page.connectedAs": "Підключено як", + "settings.gitlab.page.errors.invalidToken": "Введіть дійсний персональний токен доступу GitLab", + "settings.gitlab.page.errors.failed": "Не вдалося підключити GitLab", + "settings.gitlab.page.toast.connected": "GitLab підключено", + "settings.gitlab.page.toast.disconnected": "GitLab відключено", + "settings.gitlab.page.toast.disconnectFailed": "Не вдалося відключити GitLab", + "settings.gitlab.page.toast.accountSwitched": "Обліковий запис GitLab перемкнено", + "settings.gitlab.page.toast.accountSwitchFailed": "Не вдалося перемкнути обліковий запис GitLab", "settings.notifications.page.delivery.title": "Доставка сповіщень", "settings.notifications.page.delivery.enableAria": "Увімкнути сповіщення", "settings.notifications.page.delivery.enableLabel": "Увімкнути сповіщення", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts index a2a459b0..e362fe0d 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -1672,6 +1672,29 @@ export const settingsDict = { 'settings.github.page.toast.ghCliEnabled': 'gh CLI 备用已启用', 'settings.github.page.toast.ghCliDisabled': 'gh CLI 备用已禁用', 'settings.github.page.toast.ghCliUpdateFailed': '更新 gh CLI 设置失败', + 'settings.gitlab.page.title': 'GitLab 个人访问令牌', + 'settings.gitlab.page.description': '粘贴 GitLab 个人访问令牌以连接。使用自托管 GitLab 实例时,请设置基础 URL。', + 'settings.gitlab.page.tooltip.connectAccount': '连接 GitLab 账户,以便在应用内使用 Issue 和合并请求工作流。', + 'settings.gitlab.page.accessToken.label': '个人访问令牌', + 'settings.gitlab.page.accessToken.placeholder': '粘贴你的 GitLab 个人访问令牌', + 'settings.gitlab.page.baseUrl.label': '基础 URL(可选)', + 'settings.gitlab.page.baseUrl.placeholder': 'https://gitlab.com', + 'settings.gitlab.page.actions.connect': '连接 GitLab', + 'settings.gitlab.page.actions.disconnect': '断开连接', + 'settings.gitlab.page.actions.switch': '切换到', + 'settings.gitlab.page.status.notConnected': '未连接', + 'settings.gitlab.page.label.unknownUser': '未知', + 'settings.gitlab.page.label.otherAccounts': '其他账户', + 'settings.gitlab.page.avatarAlt.withLogin': '{login} 的头像', + 'settings.gitlab.page.avatarAlt.fallback': 'GitLab 头像', + 'settings.gitlab.page.connectedAs': '已连接为', + 'settings.gitlab.page.errors.invalidToken': '请输入有效的 GitLab 个人访问令牌', + 'settings.gitlab.page.errors.failed': '连接 GitLab 失败', + 'settings.gitlab.page.toast.connected': 'GitLab 已连接', + 'settings.gitlab.page.toast.disconnected': 'GitLab 已断开连接', + 'settings.gitlab.page.toast.disconnectFailed': '断开 GitLab 失败', + 'settings.gitlab.page.toast.accountSwitched': 'GitLab 账户已切换', + 'settings.gitlab.page.toast.accountSwitchFailed': '切换 GitLab 账户失败', 'settings.notifications.page.delivery.title': '通知投递', 'settings.notifications.page.delivery.enableAria': '启用通知', 'settings.notifications.page.delivery.enableLabel': '启用通知', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts index b25bf8ce..16000f19 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts @@ -1579,6 +1579,29 @@ export const settingsDict = { 'settings.github.page.toast.ghCliEnabled': 'gh CLI 備用已啟用', 'settings.github.page.toast.ghCliDisabled': 'gh CLI 備用已停用', 'settings.github.page.toast.ghCliUpdateFailed': '更新 gh CLI 設定失敗', + 'settings.gitlab.page.title': 'GitLab 個人存取權杖', + 'settings.gitlab.page.description': '貼上 GitLab 個人存取權杖以連線。使用自架 GitLab 執行個體時,請設定基礎 URL。', + 'settings.gitlab.page.tooltip.connectAccount': '連線 GitLab 帳號,以在應用程式內使用 Issue 與合併請求工作流程。', + 'settings.gitlab.page.accessToken.label': '個人存取權杖', + 'settings.gitlab.page.accessToken.placeholder': '貼上你的 GitLab 個人存取權杖', + 'settings.gitlab.page.baseUrl.label': '基礎 URL(選用)', + 'settings.gitlab.page.baseUrl.placeholder': 'https://gitlab.com', + 'settings.gitlab.page.actions.connect': '連線 GitLab', + 'settings.gitlab.page.actions.disconnect': '中斷連線', + 'settings.gitlab.page.actions.switch': '切換到', + 'settings.gitlab.page.status.notConnected': '未連線', + 'settings.gitlab.page.label.unknownUser': '未知', + 'settings.gitlab.page.label.otherAccounts': '其他帳號', + 'settings.gitlab.page.avatarAlt.withLogin': '{login} 頭像', + 'settings.gitlab.page.avatarAlt.fallback': 'GitLab 頭像', + 'settings.gitlab.page.connectedAs': '已連線為', + 'settings.gitlab.page.errors.invalidToken': '請輸入有效的 GitLab 個人存取權杖', + 'settings.gitlab.page.errors.failed': '連線 GitLab 失敗', + 'settings.gitlab.page.toast.connected': 'GitLab 已連線', + 'settings.gitlab.page.toast.disconnected': 'GitLab 已中斷連線', + 'settings.gitlab.page.toast.disconnectFailed': '中斷 GitLab 失敗', + 'settings.gitlab.page.toast.accountSwitched': 'GitLab 帳號已切換', + 'settings.gitlab.page.toast.accountSwitchFailed': '切換 GitLab 帳號失敗', 'settings.notifications.page.delivery.title': '通知傳遞', 'settings.notifications.page.delivery.enableAria': '啟用通知', 'settings.notifications.page.delivery.enableLabel': '啟用通知', diff --git a/packages/ui/src/lib/settings/search.ts b/packages/ui/src/lib/settings/search.ts index 2dba4ed1..3b7a4b2c 100644 --- a/packages/ui/src/lib/settings/search.ts +++ b/packages/ui/src/lib/settings/search.ts @@ -495,6 +495,12 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [ titleKey: 'settings.github.page.actions.connect', keywords: ['github', 'account', 'oauth', 'prs', 'issues'], }, + { + id: 'git.gitlab-account', + page: 'git', + titleKey: 'settings.gitlab.page.actions.connect', + keywords: ['gitlab', 'account', 'pat', 'personal access token', 'issues', 'merge requests'], + }, { id: 'git.identities', page: 'git', diff --git a/packages/ui/src/stores/useGitLabAuthStore.ts b/packages/ui/src/stores/useGitLabAuthStore.ts new file mode 100644 index 00000000..ec96a5d9 --- /dev/null +++ b/packages/ui/src/stores/useGitLabAuthStore.ts @@ -0,0 +1,72 @@ +import { create } from 'zustand'; +import type { GitLabAuthStatus, RuntimeAPIs } from '@/lib/api/types'; +import { runtimeFetch } from '@/lib/runtime-fetch'; + +type GitLabAuthStatusWithError = GitLabAuthStatus & { error?: string }; + +type GitLabAuthStore = { + status: GitLabAuthStatusWithError | null; + isLoading: boolean; + hasChecked: boolean; + setStatus: (status: GitLabAuthStatusWithError | null) => void; + refreshStatus: ( + runtimeGitLab?: RuntimeAPIs['gitlab'], + options?: { force?: boolean } + ) => Promise; +}; + +const fetchStatus = async ( + runtimeGitLab?: RuntimeAPIs['gitlab'] +): Promise => { + if (runtimeGitLab) { + const payload = await runtimeGitLab.authStatus(); + return payload as GitLabAuthStatus; + } + + const response = await runtimeFetch('/api/gitlab/auth/status', { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + const payload = (await response.json().catch(() => null)) as GitLabAuthStatusWithError | null; + if (!response.ok || !payload) { + throw new Error(payload?.error || response.statusText || 'Failed to load GitLab status'); + } + return payload; +}; + +// In-flight dedup for refreshStatus +let _inFlightAuthRefresh: Promise | null = null; + +export const useGitLabAuthStore = create((set, get) => ({ + status: null, + isLoading: false, + hasChecked: false, + setStatus: (status) => set({ status, hasChecked: true }), + refreshStatus: async (runtimeGitLab, options) => { + const { hasChecked, status } = get(); + if (hasChecked && !options?.force) { + return status; + } + + if (_inFlightAuthRefresh) return _inFlightAuthRefresh; + + set({ isLoading: true }); + _inFlightAuthRefresh = (async () => { + try { + const payload = await fetchStatus(runtimeGitLab); + set({ status: payload, isLoading: false, hasChecked: true }); + return payload; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + set({ + status: { connected: false, accounts: [], defaultBaseUrl: '', error: message }, + isLoading: false, + hasChecked: true, + }); + return null; + } + })().finally(() => { _inFlightAuthRefresh = null; }); + + return _inFlightAuthRefresh; + }, +})); From a05056a8713ca9815627de92e341189998593614 Mon Sep 17 00:00:00 2001 From: bot-hermes Date: Wed, 12 Aug 2026 10:26:08 +0000 Subject: [PATCH 05/45] feat(ui): start worktree sessions from GitLab issues and merge requests --- .../magic-prompts/MagicPromptsPage.tsx | 16 + .../magic-prompts/MagicPromptsSidebar.tsx | 7 + .../session/GitLabIntegrationDialog.tsx | 684 ++++++++++++++++++ .../components/session/NewWorktreeDialog.tsx | 490 +++++++++++-- .../ui/src/lib/i18n/messages/de.settings.ts | 7 + packages/ui/src/lib/i18n/messages/de.ts | 28 + .../ui/src/lib/i18n/messages/en.settings.ts | 7 + packages/ui/src/lib/i18n/messages/en.ts | 28 + .../ui/src/lib/i18n/messages/es.settings.ts | 7 + packages/ui/src/lib/i18n/messages/es.ts | 28 + .../ui/src/lib/i18n/messages/fr.settings.ts | 7 + packages/ui/src/lib/i18n/messages/fr.ts | 28 + .../ui/src/lib/i18n/messages/ja.settings.ts | 7 + packages/ui/src/lib/i18n/messages/ja.ts | 28 + .../ui/src/lib/i18n/messages/ko.settings.ts | 7 + packages/ui/src/lib/i18n/messages/ko.ts | 28 + .../ui/src/lib/i18n/messages/pl.settings.ts | 7 + packages/ui/src/lib/i18n/messages/pl.ts | 28 + .../src/lib/i18n/messages/pt-BR.settings.ts | 7 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 28 + .../ui/src/lib/i18n/messages/uk.settings.ts | 7 + packages/ui/src/lib/i18n/messages/uk.ts | 28 + .../src/lib/i18n/messages/zh-CN.settings.ts | 7 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 28 + .../src/lib/i18n/messages/zh-TW.settings.ts | 7 + packages/ui/src/lib/i18n/messages/zh-TW.ts | 28 + packages/ui/src/lib/linkedIssues.test.ts | 31 + packages/ui/src/lib/linkedIssues.ts | 25 +- packages/ui/src/lib/magicPrompts.ts | 121 +++- 29 files changed, 1706 insertions(+), 53 deletions(-) create mode 100644 packages/ui/src/components/session/GitLabIntegrationDialog.tsx diff --git a/packages/ui/src/components/sections/magic-prompts/MagicPromptsPage.tsx b/packages/ui/src/components/sections/magic-prompts/MagicPromptsPage.tsx index 1b1792c6..d7615788 100644 --- a/packages/ui/src/components/sections/magic-prompts/MagicPromptsPage.tsx +++ b/packages/ui/src/components/sections/magic-prompts/MagicPromptsPage.tsx @@ -85,6 +85,22 @@ const PROMPT_PAGE_MAP: Record = { { id: 'github.pr.comment.single.instructions', titleKey: 'settings.magicPrompts.page.block.instructions' }, ], }, + 'gitlab.pr.review': { + titleKey: 'settings.magicPrompts.page.group.gitlabPrReview.title', + descriptionKey: 'settings.magicPrompts.page.group.gitlabPrReview.description', + blocks: [ + { id: 'gitlab.pr.review.visible', titleKey: 'settings.magicPrompts.page.block.visiblePrompt' }, + { id: 'gitlab.pr.review.instructions', titleKey: 'settings.magicPrompts.page.block.instructions' }, + ], + }, + 'gitlab.issue.review': { + titleKey: 'settings.magicPrompts.page.group.gitlabIssueReview.title', + descriptionKey: 'settings.magicPrompts.page.group.gitlabIssueReview.description', + blocks: [ + { id: 'gitlab.issue.review.visible', titleKey: 'settings.magicPrompts.page.block.visiblePrompt' }, + { id: 'gitlab.issue.review.instructions', titleKey: 'settings.magicPrompts.page.block.instructions' }, + ], + }, 'git.conflict.resolve': { titleKey: 'settings.magicPrompts.page.group.gitConflictResolve.title', descriptionKey: 'settings.magicPrompts.page.group.gitConflictResolve.description', diff --git a/packages/ui/src/components/sections/magic-prompts/MagicPromptsSidebar.tsx b/packages/ui/src/components/sections/magic-prompts/MagicPromptsSidebar.tsx index c25105ef..ef8389ff 100644 --- a/packages/ui/src/components/sections/magic-prompts/MagicPromptsSidebar.tsx +++ b/packages/ui/src/components/sections/magic-prompts/MagicPromptsSidebar.tsx @@ -35,6 +35,13 @@ export const MagicPromptsSidebar: React.FC = ({ onItem { id: 'github.pr.comment.single', titleKey: 'settings.magicPrompts.sidebar.item.githubSinglePrCommentReview' }, ], }, + { + groupKey: 'settings.magicPrompts.sidebar.group.gitlab', + items: [ + { id: 'gitlab.pr.review', titleKey: 'settings.magicPrompts.sidebar.item.gitlabPrReview' }, + { id: 'gitlab.issue.review', titleKey: 'settings.magicPrompts.sidebar.item.gitlabIssueReview' }, + ], + }, { groupKey: 'settings.magicPrompts.sidebar.group.planning', items: [ diff --git a/packages/ui/src/components/session/GitLabIntegrationDialog.tsx b/packages/ui/src/components/session/GitLabIntegrationDialog.tsx new file mode 100644 index 00000000..ae0a7799 --- /dev/null +++ b/packages/ui/src/components/session/GitLabIntegrationDialog.tsx @@ -0,0 +1,684 @@ +import * as React from 'react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import { cn } from '@/lib/utils'; +import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; +import { useProjectsStore } from '@/stores/useProjectsStore'; +import { useUIStore } from '@/stores/useUIStore'; +import { useGitLabAuthStore } from '@/stores/useGitLabAuthStore'; +import { validateWorktreeCreate } from '@/lib/worktrees/worktreeManager'; +import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip'; +import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; +import { Icon } from "@/components/icon/Icon"; +import { useDebouncedValue } from '@/hooks/useDebouncedValue'; +import type { + GitLabIssueSummary, + GitLabMergeRequestSummary, +} from '@/lib/api/types'; +import type { ProjectRef } from '@/lib/worktrees/worktreeManager'; +import { useI18n } from '@/lib/i18n'; + +type GitLabTab = 'issues' | 'mrs'; + +interface GitLabIntegrationDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSelect: (result: { + type: 'issue'; + number: number; + title: string; + url: string; + } | { + type: 'mr'; + number: number; + title: string; + url: string; + sourceBranch: string; + includeDiff: boolean; + } | null) => void; +} + +interface ValidationResult { + isValid: boolean; + error: string | null; +} + +export function GitLabIntegrationDialog({ + open, + onOpenChange, + onSelect, +}: GitLabIntegrationDialogProps) { + const { t } = useI18n(); + const isMobile = useUIStore((state) => state.isMobile); + const gitlab = getRegisteredRuntimeAPIs()?.gitlab; + const gitlabAuthStatus = useGitLabAuthStore((state) => state.status); + const gitlabAuthChecked = useGitLabAuthStore((state) => state.hasChecked); + const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen); + const setSettingsPage = useUIStore((state) => state.setSettingsPage); + const activeProject = useProjectsStore((state) => state.getActiveProject()); + + const projectDirectory = activeProject?.path ?? null; + const projectRef: ProjectRef | null = React.useMemo(() => { + if (projectDirectory && activeProject) { + return { id: activeProject.id, path: projectDirectory }; + } + return null; + }, [activeProject, projectDirectory]); + + // State + const [activeTab, setActiveTab] = React.useState('issues'); + const [searchQuery, setSearchQuery] = React.useState(''); + const [issues, setIssues] = React.useState([]); + const [mrs, setMrs] = React.useState([]); + const [loading, setLoading] = React.useState(false); + const [loadingMore, setLoadingMore] = React.useState(false); + const [error, setError] = React.useState(null); + const [selectedIssue, setSelectedIssue] = React.useState(null); + const [selectedMr, setSelectedMr] = React.useState(null); + const [includeDiff, setIncludeDiff] = React.useState(false); + const [validations, setValidations] = React.useState>(new Map()); + const [page, setPage] = React.useState(1); + const [hasMore, setHasMore] = React.useState(false); + + const debouncedSearchQuery = useDebouncedValue(searchQuery, 350); + + const loadData = React.useCallback(async (query?: string) => { + if (!projectDirectory || !gitlab) return; + if (gitlabAuthChecked && gitlabAuthStatus?.connected === false) return; + + setLoading(true); + setError(null); + setPage(1); + setHasMore(false); + + try { + if (activeTab === 'issues' && gitlab.issuesList) { + const result = await gitlab.issuesList(projectDirectory, { page: 1, query }); + if (result.connected === false) { + setError(t('session.gitlabIntegration.error.notConnected')); + setIssues([]); + } else { + setIssues(result.issues ?? []); + setPage(result.page ?? 1); + setHasMore(Boolean(result.hasMore)); + } + } else if (activeTab === 'mrs' && gitlab.mrsList) { + const result = await gitlab.mrsList(projectDirectory, { page: 1, query }); + if (result.connected === false) { + setError(t('session.gitlabIntegration.error.notConnected')); + setMrs([]); + } else { + setMrs(result.mrs ?? []); + setPage(result.page ?? 1); + setHasMore(Boolean(result.hasMore)); + } + } + } catch (err) { + setError(err instanceof Error ? err.message : t('session.gitlabIntegration.error.loadDataFailed')); + } finally { + setLoading(false); + } + }, [projectDirectory, gitlab, gitlabAuthChecked, gitlabAuthStatus, activeTab, t]); + + React.useEffect(() => { + if (!open || !projectDirectory) return; + if (gitlabAuthChecked && gitlabAuthStatus?.connected === false) return; + if (!gitlab) return; + if (!debouncedSearchQuery.trim()) { + void loadData(); + return; + } + + const controller = new AbortController(); + setLoading(true); + setError(null); + setPage(1); + setHasMore(false); + + const apiCall = activeTab === 'issues' && gitlab.issuesList + ? gitlab.issuesList(projectDirectory, { page: 1, query: debouncedSearchQuery.trim() }) + : activeTab === 'mrs' && gitlab.mrsList + ? gitlab.mrsList(projectDirectory, { page: 1, query: debouncedSearchQuery.trim() }) + : null; + + if (!apiCall) { + setLoading(false); + return; + } + + apiCall + .then((result) => { + if (controller.signal.aborted) return; + if ('issues' in result) { + if (result.connected === false) { + setError(t('session.gitlabIntegration.error.notConnected')); + setIssues([]); + } else { + setIssues(result.issues ?? []); + setPage(result.page ?? 1); + setHasMore(Boolean(result.hasMore)); + } + } else if ('mrs' in result) { + if (result.connected === false) { + setError(t('session.gitlabIntegration.error.notConnected')); + setMrs([]); + } else { + setMrs(result.mrs ?? []); + setPage(result.page ?? 1); + setHasMore(Boolean(result.hasMore)); + } + } + }) + .catch((err) => { + if (controller.signal.aborted) return; + setError(err instanceof Error ? err.message : t('session.gitlabIntegration.error.loadDataFailed')); + }) + .finally(() => { + if (!controller.signal.aborted) setLoading(false); + }); + + return () => controller.abort(); + }, [open, projectDirectory, gitlab, gitlabAuthChecked, gitlabAuthStatus, activeTab, debouncedSearchQuery, loadData, t]); + + const loadMore = React.useCallback(async () => { + if (!projectDirectory || !gitlab) return; + if (loading || loadingMore) return; + if (!hasMore) return; + + setLoadingMore(true); + + try { + const nextPage = page + 1; + + if (activeTab === 'issues' && gitlab.issuesList) { + const result = debouncedSearchQuery.trim() + ? await gitlab.issuesList(projectDirectory, { page: nextPage, query: debouncedSearchQuery.trim() }) + : await gitlab.issuesList(projectDirectory, { page: nextPage }); + if (result.connected !== false) { + setIssues(prev => [...prev, ...(result.issues ?? [])]); + setPage(result.page ?? nextPage); + setHasMore(Boolean(result.hasMore)); + } + } else if (activeTab === 'mrs' && gitlab.mrsList) { + const result = debouncedSearchQuery.trim() + ? await gitlab.mrsList(projectDirectory, { page: nextPage, query: debouncedSearchQuery.trim() }) + : await gitlab.mrsList(projectDirectory, { page: nextPage }); + if (result.connected !== false) { + setMrs(prev => [...prev, ...(result.mrs ?? [])]); + setPage(result.page ?? nextPage); + setHasMore(Boolean(result.hasMore)); + } + } + } catch { + // Silently fail on load more errors + } finally { + setLoadingMore(false); + } + }, [projectDirectory, gitlab, activeTab, page, hasMore, loading, loadingMore, debouncedSearchQuery]); + + // Reset state when dialog opens/closes + React.useEffect(() => { + if (!open) { + setActiveTab('issues'); + setSearchQuery(''); + setIssues([]); + setMrs([]); + setSelectedIssue(null); + setSelectedMr(null); + setIncludeDiff(false); + setError(null); + setValidations(new Map()); + setPage(1); + setHasMore(false); + return; + } + + void loadData(); + }, [open, loadData]); + + // Validate branches for worktree creation + const validateBranch = React.useCallback(async (branchName: string) => { + if (!projectRef || !branchName) return; + + // Check cache first + if (validations.has(branchName)) return; + + try { + const result = await validateWorktreeCreate(projectRef, { + mode: 'new', + branchName, + worktreeName: branchName, + }); + + const blockingError = result.errors.find((entry) => entry.code === 'branch_in_use'); + + setValidations(prev => new Map(prev).set(branchName, { + isValid: !blockingError, + error: blockingError + ? t(blockingError.code === 'branch_exists' + ? 'session.gitlabIntegration.validation.branchAlreadyExists' + : 'session.gitlabIntegration.validation.branchAlreadyCheckedOut') + : null, + })); + } catch { + setValidations(prev => new Map(prev).set(branchName, { + isValid: false, + error: t('session.gitlabIntegration.validation.failed'), + })); + } + }, [projectRef, validations, t]); + + // Validate MR branches when loaded + React.useEffect(() => { + if (!open || activeTab !== 'mrs') return; + + mrs.forEach(mr => { + if (mr.sourceBranch) { + void validateBranch(mr.sourceBranch); + } + }); + }, [open, activeTab, mrs, validateBranch]); + + // GitLab connection check + const isGitLabConnected = gitlabAuthChecked && gitlabAuthStatus?.connected === true; + + const openGitLabSettings = () => { + setSettingsPage('git'); + setSettingsDialogOpen(true); + }; + + // Handle selection + const handleSelectIssue = (issue: GitLabIssueSummary) => { + setSelectedIssue(issue); + setSelectedMr(null); + }; + + const handleSelectMr = (mr: GitLabMergeRequestSummary) => { + setSelectedMr(mr); + setSelectedIssue(null); + }; + + const handleConfirm = () => { + if (selectedIssue) { + onSelect({ + type: 'issue', + number: selectedIssue.number, + title: selectedIssue.title, + url: selectedIssue.url, + }); + } else if (selectedMr) { + onSelect({ + type: 'mr', + number: selectedMr.number, + title: selectedMr.title, + url: selectedMr.url, + sourceBranch: selectedMr.sourceBranch, + includeDiff, + }); + } + onOpenChange(false); + }; + + const handleClear = () => { + setSelectedIssue(null); + setSelectedMr(null); + setIncludeDiff(false); + }; + + // Check if selection is valid + const canConfirm = selectedIssue || (selectedMr && validations.get(selectedMr.sourceBranch)?.isValid !== false); + + // Check if MR is blocked + const isMrBlocked = (mr: GitLabMergeRequestSummary): boolean => { + if (!mr.sourceBranch) return true; + const validation = validations.get(mr.sourceBranch); + return validation?.isValid === false; + }; + + // Content for the dialog (shared between mobile and desktop) + const dialogContent = ( + <> + {!isGitLabConnected ? ( +
+ +
+

{t('session.gitlabIntegration.connect.title')}

+

+ {t('session.gitlabIntegration.connect.description')} +

+
+ +
+ ) : ( + <> + {/* Search */} +
+ + setSearchQuery(e.target.value)} + placeholder={activeTab === 'issues' + ? t('session.gitlabIntegration.search.issuesPlaceholder') + : t('session.gitlabIntegration.search.mrsPlaceholder')} + className="h-8 pl-9" + /> +
+ + {/* List Content */} +
+
+ {/* Loading */} + {loading && ( +
+ +
+ )} + + {/* Error */} + {error && ( +
+
+ + {error} +
+
+ )} + + {/* Issues List */} + {!loading && !error && activeTab === 'issues' && ( +
+ {issues.length > 0 ? ( + issues.map(issue => ( + + )) + ) : ( +
+ {t('session.gitlabIntegration.empty.noIssuesFound')} +
+ )} + + {hasMore && !loadingMore && ( +
+ +
+ )} + {loadingMore && ( +
+ +
+ )} +
+ )} + + {/* MRs List */} + {!loading && !error && activeTab === 'mrs' && ( +
+ {mrs.length > 0 ? ( + mrs.map(mr => { + const blocked = isMrBlocked(mr); + const validation = mr.sourceBranch ? validations.get(mr.sourceBranch) : undefined; + + return ( + + ); + }) + ) : ( +
+ {t('session.gitlabIntegration.empty.noMergeRequestsFound')} +
+ )} + + {hasMore && !loadingMore && ( +
+ +
+ )} + {loadingMore && ( +
+ +
+ )} +
+ )} +
+
+ + )} + + ); + + // Footer content + const footerContent = ( +
+ {/* Left side: Selected Item / Checkbox */} +
+ {/* Selected Issue/MR display - hidden on mobile (shown in header instead) */} + {!isMobile && (selectedIssue || selectedMr) && ( +
+ + + {selectedIssue + ? t('session.gitlabIntegration.selected.issueNumber', { number: selectedIssue.number }) + : t('session.gitlabIntegration.selected.mrNumber', { number: selectedMr?.number ?? '' })} + + +
+ )} + + {/* Include Diff Checkbox - only show when MR tab is active and MR is selected */} + {activeTab === 'mrs' && selectedMr && ( + + )} +
+ + {/* Right side: Buttons */} +
+ + +
+
+ ); + + return ( + <> + {isMobile ? ( + onOpenChange(false)} + footer={!isGitLabConnected ? undefined : footerContent} + renderHeader={(closeButton) => ( +
+
+

{t('session.gitlabIntegration.title')}

+ {closeButton} +
+ {/* Tabs - using SortableTabsStrip */} +
+ }, + { id: 'mrs', label: t('session.gitlabIntegration.tabs.mergeRequests'), icon: }, + ]} + activeId={activeTab} + onSelect={(id) => { + setActiveTab(id as GitLabTab); + setSearchQuery(''); + }} + variant="active-pill" + layoutMode="fit" + /> +
+ + {/* Selected Item Inline Display */} + {(selectedIssue || selectedMr) && ( +
+ + + {selectedIssue + ? t('session.gitlabIntegration.selected.issueNumber', { number: selectedIssue.number }) + : t('session.gitlabIntegration.selected.mrNumber', { number: selectedMr?.number ?? '' })} + + +
+ )} +
+ )} + > + {dialogContent} +
+ ) : ( + + + +
+ + + {t('session.gitlabIntegration.title')} + + + {/* Tabs - using SortableTabsStrip */} +
+ }, + { id: 'mrs', label: t('session.gitlabIntegration.tabs.mergeRequests'), icon: }, + ]} + activeId={activeTab} + onSelect={(id) => { + setActiveTab(id as GitLabTab); + setSearchQuery(''); + }} + variant="active-pill" + layoutMode="fit" + /> +
+
+
+ + {dialogContent} + + {/* Footer */} + + {footerContent} + +
+
+ )} + + ); +} diff --git a/packages/ui/src/components/session/NewWorktreeDialog.tsx b/packages/ui/src/components/session/NewWorktreeDialog.tsx index d9130d92..98faf7b7 100644 --- a/packages/ui/src/components/session/NewWorktreeDialog.tsx +++ b/packages/ui/src/components/session/NewWorktreeDialog.tsx @@ -27,6 +27,7 @@ import { cn } from '@/lib/utils'; import { dropdownTriggerVariants } from '@/components/ui/dropdown-trigger'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; +import { useGitLabAuthStore } from '@/stores/useGitLabAuthStore'; import { useUIStore } from '@/stores/useUIStore'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSelectionStore } from '@/sync/selection-store'; @@ -50,6 +51,7 @@ import { import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useGitBranches, useGitStore, useGitLoadingBranches } from '@/stores/useGitStore'; import { GitHubIntegrationDialog } from './GitHubIntegrationDialog'; +import { GitLabIntegrationDialog } from './GitLabIntegrationDialog'; import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip'; import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; import { Icon } from "@/components/icon/Icon"; @@ -59,6 +61,10 @@ import type { GitHubIssuesListResult, GitHubPullRequestContextResult, GitHubPullRequestSummary, + GitLabIssue, + GitLabIssueComment, + GitLabIssuesListResult, + GitLabMergeRequestContextResult, } from '@/lib/api/types'; import type { ProjectRef } from '@/lib/worktrees/worktreeManager'; import { useI18n } from '@/lib/i18n'; @@ -81,6 +87,9 @@ interface NewBranchState { linkedIssue: GitHubIssue | null; linkedPr: GitHubPullRequestSummary | null; includePrDiff: boolean; + linkedGitLabIssue: { number: number; title: string; url: string } | null; + linkedGitLabMr: { number: number; title: string; url: string; sourceBranch: string } | null; + includeGitLabMrDiff: boolean; } // State for Existing Branch mode @@ -205,16 +214,35 @@ const buildPullRequestContextText = (payload: GitHubPullRequestContextResult) => return `GitHub pull request context (JSON)\n${JSON.stringify(payload, null, 2)}`; }; +const buildGitLabIssueContextText = (args: { + repo: GitLabIssuesListResult['repo'] | undefined; + issue: GitLabIssue; + comments: GitLabIssueComment[]; +}) => { + const payload = { + repo: args.repo ?? null, + issue: args.issue, + comments: args.comments, + }; + return `GitLab issue context (JSON)\n${JSON.stringify(payload, null, 2)}`; +}; + +const buildGitLabMrContextText = (payload: GitLabMergeRequestContextResult) => { + return `GitLab merge request context (JSON)\n${JSON.stringify(payload, null, 2)}`; +}; + export function NewWorktreeDialog({ open, onOpenChange, onWorktreeCreated, }: NewWorktreeDialogProps) { const { t } = useI18n(); - const { github, git } = useRuntimeAPIs(); + const { github, git, gitlab } = useRuntimeAPIs(); const isMobile = useUIStore((state) => state.isMobile); const githubAuthStatus = useGitHubAuthStore((state) => state.status); const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked); + const gitlabAuthStatus = useGitLabAuthStore((state) => state.status); + const gitlabAuthChecked = useGitLabAuthStore((state) => state.hasChecked); const activeProject = useProjectsStore((state) => state.getActiveProject()); const projectDirectory = activeProject?.path ?? null; @@ -237,6 +265,9 @@ export function NewWorktreeDialog({ linkedIssue: null, linkedPr: null, includePrDiff: false, + linkedGitLabIssue: null, + linkedGitLabMr: null, + includeGitLabMrDiff: false, }); const [existingBranchState, setExistingBranchState] = React.useState({ @@ -286,6 +317,7 @@ export function NewWorktreeDialog({ }, [existingWorktreeNames]); const [githubDialogOpen, setGithubDialogOpen] = React.useState(false); + const [gitlabDialogOpen, setGitlabDialogOpen] = React.useState(false); // Desktop branch picker states const [existingBranchDropdownOpen, setExistingBranchDropdownOpen] = React.useState(false); @@ -477,8 +509,11 @@ export function NewWorktreeDialog({ issue: GitHubIssue | null; pr: GitHubPullRequestSummary | null; includeDiff: boolean; + gitLabIssue: { number: number; title: string; url: string } | null; + gitLabMr: { number: number; title: string; url: string; sourceBranch: string } | null; + includeGitLabMrDiff: boolean; }) => { - if (!projectDirectory || !github) { + if (!projectDirectory) { return; } @@ -497,7 +532,7 @@ export function NewWorktreeDialog({ const variant = resolveDefaultVariant(providerID, modelID); if (args.issue) { - if (!github.issueGet || !github.issueComments) { + if (!github || !github.issueGet || !github.issueComments) { return; } @@ -558,7 +593,7 @@ export function NewWorktreeDialog({ } if (args.pr) { - if (!github.prContext) { + if (!github || !github.prContext) { return; } @@ -609,8 +644,125 @@ export function NewWorktreeDialog({ toast.success(t('session.newWorktree.toast.sessionFromPr')); } + + if (args.gitLabIssue) { + if (!gitlab || !gitlab.issueGet || !gitlab.issueComments) { + return; + } + + const issueRes = await gitlab.issueGet(projectDirectory, args.gitLabIssue.number); + if (issueRes.connected === false || !issueRes.issue) { + throw new Error('Failed to load issue context'); + } + + const commentsRes = await gitlab.issueComments(projectDirectory, args.gitLabIssue.number); + if (commentsRes.connected === false) { + throw new Error('Failed to load issue comments'); + } + + const visiblePromptText = await renderMagicPrompt('gitlab.issue.review.visible', { + issue_number: String(args.gitLabIssue.number), + }); + const instructionsText = await renderMagicPrompt('gitlab.issue.review.instructions'); + const contextText = buildGitLabIssueContextText({ + repo: issueRes.repo, + issue: issueRes.issue, + comments: commentsRes.comments ?? [], + }); + + await useSessionUIStore.getState().sendMessage( + visiblePromptText, + providerID, + modelID, + agentName, + undefined, + undefined, + [ + { text: instructionsText, synthetic: true }, + { text: contextText, synthetic: true }, + ], + variant, + undefined, + { sessionId: args.sessionId }, + ); + + // Record the thread this worktree session was created for, so it stays + // visible as a context source after the opening message scrolls away. + void sessionActions.setLinkedIssue( + args.sessionId, + args.directory, + buildLinkedIssue({ + url: issueRes.issue.url, + number: issueRes.issue.number, + title: issueRes.issue.title, + kind: 'issue', + author: issueRes.issue.author + ? { login: issueRes.issue.author.username, avatarUrl: issueRes.issue.author.avatarUrl } + : null, + linkedAt: Date.now(), + }), + true, + ).catch(() => undefined); + + toast.success(t('session.newWorktree.toast.sessionFromIssue')); + return; + } + + if (args.gitLabMr) { + if (!gitlab || !gitlab.mrContext) { + return; + } + + const mrContext = await gitlab.mrContext(projectDirectory, args.gitLabMr.number, { + includeDiff: args.includeGitLabMrDiff, + }); + if (mrContext.connected === false || !mrContext.mr) { + throw new Error('Failed to load MR context'); + } + + const visiblePromptText = await renderMagicPrompt('gitlab.pr.review.visible', { + mr_number: String(args.gitLabMr.number), + }); + const instructionsText = await renderMagicPrompt('gitlab.pr.review.instructions'); + const contextText = buildGitLabMrContextText(mrContext); + + await useSessionUIStore.getState().sendMessage( + visiblePromptText, + providerID, + modelID, + agentName, + undefined, + undefined, + [ + { text: instructionsText, synthetic: true }, + { text: contextText, synthetic: true }, + ], + variant, + undefined, + { sessionId: args.sessionId }, + ); + + void sessionActions.setLinkedIssue( + args.sessionId, + args.directory, + buildLinkedIssue({ + url: mrContext.mr.url, + number: mrContext.mr.number, + title: mrContext.mr.title, + kind: 'pull', + author: mrContext.mr.author + ? { login: mrContext.mr.author.username, avatarUrl: mrContext.mr.author.avatarUrl } + : null, + linkedAt: Date.now(), + }), + true, + ).catch(() => undefined); + + toast.success(t('session.newWorktree.toast.sessionFromMr')); + } }, [ github, + gitlab, projectDirectory, resolveDefaultAgentName, resolveDefaultModelSelection, @@ -699,6 +851,9 @@ export function NewWorktreeDialog({ linkedIssue: null, linkedPr: null, includePrDiff: false, + linkedGitLabIssue: null, + linkedGitLabMr: null, + includeGitLabMrDiff: false, }); }, [open, generateUniqueSlug]); @@ -746,11 +901,13 @@ export function NewWorktreeDialog({ if (normalizedBranch && normalizedWorktree) { const linkedPr = mode === 'new-branch' ? newBranchState.linkedPr : null; const prConfig = linkedPr ? resolvePrWorktreeConfig(linkedPr, localBranches, remoteBranches) : null; + const linkedGitLabMr = mode === 'new-branch' ? newBranchState.linkedGitLabMr : null; + const gitLabMrBranch = linkedGitLabMr ? normalizeBranchName(linkedGitLabMr.sourceBranch || '') : ''; const result = await validateWorktreeCreate(projectRef, { - mode: mode === 'existing-branch' || prConfig ? 'existing' : 'new', + mode: mode === 'existing-branch' || prConfig || gitLabMrBranch ? 'existing' : 'new', branchName: normalizedBranch, worktreeName: normalizedWorktree, - existingBranch: prConfig?.existingBranch ?? (mode === 'existing-branch' ? normalizedBranch : undefined), + existingBranch: prConfig?.existingBranch ?? (gitLabMrBranch || (mode === 'existing-branch' ? normalizedBranch : undefined)), ...(prConfig?.ensureRemoteName ? { ensureRemoteName: prConfig.ensureRemoteName } : {}), ...(prConfig?.ensureRemoteUrl ? { ensureRemoteUrl: prConfig.ensureRemoteUrl } : {}), }); @@ -792,6 +949,7 @@ export function NewWorktreeDialog({ mode, newBranchState.branchName, newBranchState.linkedPr, + newBranchState.linkedGitLabMr, existingBranchState.selectedBranch, currentState.worktreeName, localBranches, @@ -860,7 +1018,10 @@ export function NewWorktreeDialog({ const linkedIssue = mode === 'new-branch' ? newBranchState.linkedIssue : null; const linkedPrState = mode === 'new-branch' ? newBranchState.linkedPr : null; const includePrDiff = mode === 'new-branch' ? newBranchState.includePrDiff : false; - const shouldCreateSession = Boolean(linkedIssue || linkedPrState); + const linkedGitLabIssue = mode === 'new-branch' ? newBranchState.linkedGitLabIssue : null; + const linkedGitLabMr = mode === 'new-branch' ? newBranchState.linkedGitLabMr : null; + const includeGitLabMrDiff = mode === 'new-branch' ? newBranchState.includeGitLabMrDiff : false; + const shouldCreateSession = Boolean(linkedIssue || linkedPrState || linkedGitLabIssue || linkedGitLabMr); const setupCommands = await getWorktreeSetupCommands(projectRef); const sourceBranch = newBranchState.sourceBranch; @@ -886,6 +1047,23 @@ export function NewWorktreeDialog({ }; } + if (linkedGitLabMr) { + const mrBranch = normalizeBranchName(linkedGitLabMr.sourceBranch || ''); + if (!mrBranch) { + throw new Error('MR source branch is missing'); + } + sourceLabel = mrBranch; + return { + preferredName: normalizedBranch || normalizedWorktree, + mode: 'existing' as const, + branchName: normalizedBranch, + worktreeName: normalizedWorktree, + existingBranch: mrBranch, + setupCommands, + returnAfterDirectoryCreated: true, + }; + } + sourceLabel = mode === 'new-branch' ? sourceBranch : ''; return { preferredName: normalizedBranch || normalizedWorktree, @@ -914,7 +1092,11 @@ export function NewWorktreeDialog({ ? `#${linkedIssue.number} ${linkedIssue.title}`.trim() : linkedPrState ? `#${linkedPrState.number} ${linkedPrState.title}`.trim() - : t('session.newWorktree.newSessionTitle'); + : linkedGitLabIssue + ? `#${linkedGitLabIssue.number} ${linkedGitLabIssue.title}`.trim() + : linkedGitLabMr + ? `!${linkedGitLabMr.number} ${linkedGitLabMr.title}`.trim() + : t('session.newWorktree.newSessionTitle'); const session = await sessionActions.createSession(sessionTitle, metadata.path, null); if (!session?.id) { @@ -963,9 +1145,16 @@ export function NewWorktreeDialog({ issue: linkedIssue, pr: linkedPrState, includeDiff: includePrDiff, + gitLabIssue: linkedGitLabIssue, + gitLabMr: linkedGitLabMr, + includeGitLabMrDiff: includeGitLabMrDiff, }).catch((error) => { - const message = error instanceof Error ? error.message : t('session.newWorktree.error.sendGitHubContextFailed'); - toast.error(t('session.newWorktree.error.sendGitHubContextFailed'), { description: message }); + const isGitLabLink = Boolean(linkedGitLabIssue || linkedGitLabMr); + const errorKey = isGitLabLink + ? 'session.newWorktree.error.sendGitLabContextFailed' + : 'session.newWorktree.error.sendGitHubContextFailed'; + const message = error instanceof Error ? error.message : t(errorKey); + toast.error(t(errorKey), { description: message }); }); } else { onWorktreeCreated?.(metadata.path); @@ -996,6 +1185,9 @@ export function NewWorktreeDialog({ linkedIssue: null, linkedPr: null, includePrDiff: false, + linkedGitLabIssue: null, + linkedGitLabMr: null, + includeGitLabMrDiff: false, branchName: '', })); return; @@ -1009,6 +1201,9 @@ export function NewWorktreeDialog({ linkedIssue: issue, linkedPr: null, includePrDiff: false, + linkedGitLabIssue: null, + linkedGitLabMr: null, + includeGitLabMrDiff: false, branchName: newBranchName, worktreeName: slugifyWorktreeName(newBranchName), isSyncingWorktreeName: true, @@ -1020,6 +1215,9 @@ export function NewWorktreeDialog({ linkedPr: pr, linkedIssue: null, includePrDiff: result.includeDiff ?? false, + linkedGitLabIssue: null, + linkedGitLabMr: null, + includeGitLabMrDiff: false, branchName: pr.head, worktreeName: slugifyWorktreeName(pr.head), isSyncingWorktreeName: true, @@ -1027,8 +1225,77 @@ export function NewWorktreeDialog({ } }; + // Handle GitLab selection + const handleGitLabSelect = (result: { + type: 'issue'; + number: number; + title: string; + url: string; + } | { + type: 'mr'; + number: number; + title: string; + url: string; + sourceBranch: string; + includeDiff: boolean; + } | null) => { + if (!result) { + setNewBranchState(prev => ({ + ...prev, + linkedGitLabIssue: null, + linkedGitLabMr: null, + includeGitLabMrDiff: false, + linkedIssue: null, + linkedPr: null, + includePrDiff: false, + branchName: '', + })); + return; + } + + if (result.type === 'issue') { + const newBranchName = `issue-${result.number}-${generateBranchSlug()}`; + setNewBranchState(prev => ({ + ...prev, + linkedGitLabIssue: { + number: result.number, + title: result.title, + url: result.url, + }, + linkedGitLabMr: null, + includeGitLabMrDiff: false, + linkedIssue: null, + linkedPr: null, + includePrDiff: false, + branchName: newBranchName, + worktreeName: slugifyWorktreeName(newBranchName), + isSyncingWorktreeName: true, + })); + } else if (result.type === 'mr') { + setNewBranchState(prev => ({ + ...prev, + linkedGitLabMr: { + number: result.number, + title: result.title, + url: result.url, + sourceBranch: result.sourceBranch, + }, + linkedGitLabIssue: null, + includeGitLabMrDiff: result.includeDiff, + linkedIssue: null, + linkedPr: null, + includePrDiff: false, + branchName: result.sourceBranch, + worktreeName: slugifyWorktreeName(result.sourceBranch), + isSyncingWorktreeName: true, + })); + } + }; + // GitHub connection check const isGitHubConnected = githubAuthChecked && githubAuthStatus?.connected === true; + // GitLab connection check + const isGitLabConnected = gitlabAuthChecked && gitlabAuthStatus?.connected === true; // Check if form is valid for submission const isFormValid = mode === 'existing-branch' @@ -1042,8 +1309,11 @@ export function NewWorktreeDialog({ ...prev, linkedIssue: null, linkedPr: null, + linkedGitLabIssue: null, + linkedGitLabMr: null, branchName: '', includePrDiff: false, + includeGitLabMrDiff: false, isSyncingWorktreeName: true, })); }; @@ -1277,16 +1547,31 @@ export function NewWorktreeDialog({ - {mode === 'new-branch' && isGitHubConnected && ( - + {mode === 'new-branch' && (isGitHubConnected || isGitLabConnected) && ( +
+ {isGitHubConnected && ( + + )} + {isGitLabConnected && ( + + )} +
)} setValidation(prev => ({ ...prev, touched: true }))} placeholder={t('session.newWorktree.branchNamePlaceholder')} - disabled={!!newBranchState.linkedPr} + disabled={!!newBranchState.linkedPr || !!newBranchState.linkedGitLabMr} className={cn( 'h-8', validation.touched && validation.branchError && 'border-destructive', - newBranchState.linkedPr && 'bg-muted text-muted-foreground' + (newBranchState.linkedPr || newBranchState.linkedGitLabMr) && 'bg-muted text-muted-foreground' )} /> {newBranchState.linkedPr && ( @@ -1317,6 +1604,14 @@ export function NewWorktreeDialog({ )} + {newBranchState.linkedGitLabMr && ( +
+ + + {t('session.newWorktree.usingMrBranch', { branch: newBranchState.linkedGitLabMr.sourceBranch })} + +
+ )} {newBranchState.linkedIssue && !newBranchState.linkedPr && (
@@ -1325,6 +1620,14 @@ export function NewWorktreeDialog({
)} + {newBranchState.linkedGitLabIssue && !newBranchState.linkedGitLabMr && ( +
+ + + {t('session.newWorktree.fromIssue', { number: newBranchState.linkedGitLabIssue.number, title: newBranchState.linkedGitLabIssue.title })} + +
+ )} )} @@ -1383,8 +1686,8 @@ export function NewWorktreeDialog({ /> - {/* Source Branch - Only for New Branch mode, hide when PR is selected */} - {mode === 'new-branch' && !newBranchState.linkedPr && ( + {/* Source Branch - Only for New Branch mode, hide when a linked PR/MR is selected */} + {mode === 'new-branch' && !newBranchState.linkedPr && !newBranchState.linkedGitLabMr && (
@@ -1746,16 +2075,31 @@ export function NewWorktreeDialog({ - {mode === 'new-branch' && isGitHubConnected && ( - + {mode === 'new-branch' && (isGitHubConnected || isGitLabConnected) && ( +
+ {isGitHubConnected && ( + + )} + {isGitLabConnected && ( + + )} +
)} setValidation(prev => ({ ...prev, touched: true }))} placeholder={t('session.newWorktree.branchNamePlaceholder')} - disabled={!!newBranchState.linkedPr} + disabled={!!newBranchState.linkedPr || !!newBranchState.linkedGitLabMr} className={cn( 'h-8', validation.touched && validation.branchError && 'border-destructive', - newBranchState.linkedPr && 'bg-muted text-muted-foreground' + (newBranchState.linkedPr || newBranchState.linkedGitLabMr) && 'bg-muted text-muted-foreground' )} /> {newBranchState.linkedPr && ( @@ -1786,6 +2132,14 @@ export function NewWorktreeDialog({ )} + {newBranchState.linkedGitLabMr && ( +
+ + + {t('session.newWorktree.usingMrBranch', { branch: newBranchState.linkedGitLabMr.sourceBranch })} + +
+ )} {newBranchState.linkedIssue && !newBranchState.linkedPr && (
@@ -1794,6 +2148,14 @@ export function NewWorktreeDialog({
)} + {newBranchState.linkedGitLabIssue && !newBranchState.linkedGitLabMr && ( +
+ + + {t('session.newWorktree.fromIssue', { number: newBranchState.linkedGitLabIssue.number, title: newBranchState.linkedGitLabIssue.title })} + +
+ )} )} @@ -1852,8 +2214,8 @@ export function NewWorktreeDialog({ /> - {/* Source Branch - Only for New Branch mode, hide when PR is selected */} - {mode === 'new-branch' && !newBranchState.linkedPr && ( + {/* Source Branch - Only for New Branch mode, hide when a linked PR/MR is selected */} + {mode === 'new-branch' && !newBranchState.linkedPr && !newBranchState.linkedGitLabMr && (
{/* Row 1: Type, number, title, actions */} - {/* Row 2: PR branch info + diff indicator */} + {/* Row 2: PR/MR branch info + diff indicator */} {newBranchState.linkedPr && (
@@ -2018,6 +2394,18 @@ export function NewWorktreeDialog({ )}
)} + {newBranchState.linkedGitLabMr && ( +
+ + {newBranchState.linkedGitLabMr.sourceBranch} + + {newBranchState.includeGitLabMrDiff && ( + + {t('session.newWorktree.includeDiffBadge')} + + )} +
+ )}
)}
@@ -2065,6 +2453,12 @@ export function NewWorktreeDialog({ onOpenChange={setGithubDialogOpen} onSelect={handleGitHubSelect} /> + + ); } diff --git a/packages/ui/src/lib/i18n/messages/de.settings.ts b/packages/ui/src/lib/i18n/messages/de.settings.ts index fc8d9a01..870e98e5 100644 --- a/packages/ui/src/lib/i18n/messages/de.settings.ts +++ b/packages/ui/src/lib/i18n/messages/de.settings.ts @@ -242,6 +242,7 @@ export const settingsDict = { 'settings.magicPrompts.sidebar.description': 'Eine Prompt-Vorlage zum Bearbeiten auswählen.', 'settings.magicPrompts.sidebar.group.git': 'Git', 'settings.magicPrompts.sidebar.group.github': 'GitHub', + 'settings.magicPrompts.sidebar.group.gitlab': 'GitLab', 'settings.magicPrompts.sidebar.group.planning': 'Planung', 'settings.magicPrompts.sidebar.group.session': 'Sitzung', 'settings.magicPrompts.sidebar.item.gitCommitGenerate': 'Commit-Generierung', @@ -253,6 +254,8 @@ export const settingsDict = { 'settings.magicPrompts.sidebar.item.githubPrFailedChecksReview': 'PR-Review fehlgeschlagener Prüfungen', 'settings.magicPrompts.sidebar.item.githubPrCommentsReview': 'PR-Kommentar-Review', 'settings.magicPrompts.sidebar.item.githubSinglePrCommentReview': 'Einzelner PR-Kommentar-Review', + 'settings.magicPrompts.sidebar.item.gitlabPrReview': 'MR-Review', + 'settings.magicPrompts.sidebar.item.gitlabIssueReview': 'Issue-Review', 'settings.magicPrompts.sidebar.item.planTodo': 'Aufgabenplanung', 'settings.magicPrompts.sidebar.item.planImprove': 'Plan verbessern', 'settings.magicPrompts.sidebar.item.planImplement': 'Plan umsetzen', @@ -2012,6 +2015,10 @@ export const settingsDict = { 'settings.magicPrompts.page.group.githubPrCommentsReview.description': 'Eingabeaufforderungen zur Analyse von PR-Kommentaren.', 'settings.magicPrompts.page.group.githubSinglePrCommentReview.title': 'Einzelner PR-Kommentar-Überprüfung', 'settings.magicPrompts.page.group.githubSinglePrCommentReview.description': 'Eingabeaufforderungen zur Analyse eines einzelnen PR-Kommentars.', + 'settings.magicPrompts.page.group.gitlabPrReview.title': 'MR-Überprüfung', + 'settings.magicPrompts.page.group.gitlabPrReview.description': 'Eingabeaufforderungen für den GitLab-MR-Überprüfungsprozess: Sichtbare Benutzernachricht + versteckte Anweisungspayload.', + 'settings.magicPrompts.page.group.gitlabIssueReview.title': 'Problem-Überprüfung', + 'settings.magicPrompts.page.group.gitlabIssueReview.description': 'Eingabeaufforderungen für den GitLab-Problem-Überprüfungsprozess: Sichtbare Benutzernachricht + versteckte Anweisungspayload.', 'settings.magicPrompts.page.group.gitConflictResolve.title': 'Merge/Rebase Konfliktlösung', 'settings.magicPrompts.page.group.gitConflictResolve.description': 'Eingabeaufforderungen beim Auflösen von Merge-/Rebase-Konflikten mit KI.', 'settings.magicPrompts.page.group.gitCherrypickConflictResolve.title': 'Cherry-pick-Konfliktlösung', diff --git a/packages/ui/src/lib/i18n/messages/de.ts b/packages/ui/src/lib/i18n/messages/de.ts index fbb8a6cb..6c24957c 100644 --- a/packages/ui/src/lib/i18n/messages/de.ts +++ b/packages/ui/src/lib/i18n/messages/de.ts @@ -1726,7 +1726,9 @@ export const dict = { 'session.newWorktree.branchNamePlaceholder': 'feature/mein-geil-feature', 'session.newWorktree.actions.change': 'Ändern', 'session.newWorktree.actions.startFromGitHubIssuePr': 'Starte von GitHub Issue/PR', + 'session.newWorktree.actions.startFromGitLabIssueMr': 'Starte von GitLab Issue/MR', 'session.newWorktree.usingPrBranch': 'Verwende PR-Branch: {branch}', + 'session.newWorktree.usingMrBranch': 'Verwende MR-Branch: {branch}', 'session.newWorktree.fromIssue': 'Von Issue #{number}: {title}', 'session.newWorktree.worktreeDirectory': 'Worktree-Verzeichnis', 'session.newWorktree.worktreeDirectoryPlaceholder': 'mein-worktree-verzeichnis', @@ -1737,6 +1739,7 @@ export const dict = { 'session.newWorktree.newBranchFromSource': 'Neuer Branch wird erstellt von {source}', 'session.newWorktree.issueNumber': 'Issue #{number}', 'session.newWorktree.prNumber': 'PR #{number}', + 'session.newWorktree.mrNumber': 'MR #{number}', 'session.newWorktree.includeDiffBadge': '+diff', 'session.newWorktree.newSessionTitle': 'Neue Sitzung', 'session.newWorktree.fromSource': 'von {source}', @@ -1749,9 +1752,11 @@ export const dict = { 'session.newWorktree.error.branchNameRequired': 'Branch-Name ist erforderlich', 'session.newWorktree.error.worktreeDirectoryRequired': 'Worktree-Verzeichnis ist erforderlich', 'session.newWorktree.error.sendGitHubContextFailed': 'Fehler beim Senden des GitHub-Kontexts', + 'session.newWorktree.error.sendGitLabContextFailed': 'Fehler beim Senden des GitLab-Kontexts', 'session.newWorktree.error.createWorktreeFailed': 'Fehler beim Erstellen des Worktrees', 'session.newWorktree.toast.sessionFromIssue': 'Sitzung aus Problem erstellt', 'session.newWorktree.toast.sessionFromPr': 'Sitzung aus Pull Request erstellt', + 'session.newWorktree.toast.sessionFromMr': 'Sitzung aus Merge Request erstellt', 'session.newWorktree.toast.worktreeCreated': 'Worktree erstellt', 'session.newWorktree.toast.worktreeCreatedDescription': '{target} - im Hintergrund initialisiert', 'session.githubIntegration.title': 'Aus GitHub auswählen', @@ -1776,6 +1781,29 @@ export const dict = { 'session.githubIntegration.validation.branchAlreadyCheckedOut': 'Branch ist bereits in einem Worktree ausgecheckt', 'session.githubIntegration.validation.branchAlreadyExists': 'Branch existiert bereits lokal', 'session.githubIntegration.validation.failed': 'Validierung fehlgeschlagen', + 'session.gitlabIntegration.title': 'Aus GitLab auswählen', + 'session.gitlabIntegration.tabs.issues': 'Probleme', + 'session.gitlabIntegration.tabs.mergeRequests': 'Merge Requests', + 'session.gitlabIntegration.connect.title': 'Mit GitLab verbinden', + 'session.gitlabIntegration.connect.description': 'Verknüpfen Sie Probleme oder Merge Requests, um Worktree-Details automatisch auszufüllen', + 'session.gitlabIntegration.connect.action': 'GitLab verbinden', + 'session.gitlabIntegration.search.issuesPlaceholder': 'GitLab-Probleme durchsuchen', + 'session.gitlabIntegration.search.mrsPlaceholder': 'GitLab-Merge-Requests durchsuchen', + 'session.gitlabIntegration.empty.noIssuesFound': 'Keine Probleme gefunden', + 'session.gitlabIntegration.empty.noMergeRequestsFound': 'Keine Merge Requests gefunden', + 'session.gitlabIntegration.actions.loadMore': 'Mehr laden', + 'session.gitlabIntegration.actions.cancel': 'Abbrechen', + 'session.gitlabIntegration.actions.select': 'Auswählen', + 'session.gitlabIntegration.selected.issueNumber': 'Problem #{number}', + 'session.gitlabIntegration.selected.mrNumber': 'MR #{number}', + 'session.gitlabIntegration.includeDiffAria': 'MR-Diff in Sitzungskontext einbeziehen', + 'session.gitlabIntegration.includeDiff': 'MR-Diff einbeziehen', + 'session.gitlabIntegration.error.notConnected': 'GitLab nicht verbunden', + 'session.gitlabIntegration.error.loadDataFailed': 'Fehler beim Laden der Daten', + 'session.gitlabIntegration.validation.branchAlreadyCheckedOut': 'Branch ist bereits in einem Worktree ausgecheckt', + 'session.gitlabIntegration.validation.branchAlreadyExists': 'Branch existiert bereits lokal', + 'session.gitlabIntegration.validation.failed': 'Validierung fehlgeschlagen', + 'session.gitlabIntegration.draftBadge': 'Entwurf', 'chat.fileAttachment.toast.attachFailed': 'Fehler beim Anhängen der Datei', 'chat.fileAttachment.toast.someFilesSkipped': 'Einige Dateien wurden übersprungen:\n{summary}', 'chat.fileAttachment.toast.vscodePickFailed': 'Fehler beim Auswählen von Dateien in VS Code', diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts index 3d854ef7..9c2acf23 100644 --- a/packages/ui/src/lib/i18n/messages/en.settings.ts +++ b/packages/ui/src/lib/i18n/messages/en.settings.ts @@ -258,6 +258,7 @@ export const settingsDict = { 'settings.magicPrompts.sidebar.description': 'Select a prompt template to edit.', 'settings.magicPrompts.sidebar.group.git': 'Git', 'settings.magicPrompts.sidebar.group.github': 'GitHub', + 'settings.magicPrompts.sidebar.group.gitlab': 'GitLab', 'settings.magicPrompts.sidebar.group.planning': 'Planning', 'settings.magicPrompts.sidebar.group.session': 'Session', 'settings.magicPrompts.sidebar.item.gitCommitGenerate': 'Commit Generation', @@ -269,6 +270,8 @@ export const settingsDict = { 'settings.magicPrompts.sidebar.item.githubPrFailedChecksReview': 'PR Failed Checks Review', 'settings.magicPrompts.sidebar.item.githubPrCommentsReview': 'PR Comments Review', 'settings.magicPrompts.sidebar.item.githubSinglePrCommentReview': 'Single PR Comment Review', + 'settings.magicPrompts.sidebar.item.gitlabPrReview': 'MR Review', + 'settings.magicPrompts.sidebar.item.gitlabIssueReview': 'Issue Review', 'settings.magicPrompts.sidebar.item.planTodo': 'Todo Planning', 'settings.magicPrompts.sidebar.item.planImprove': 'Improve Plan', 'settings.magicPrompts.sidebar.item.planImplement': 'Implement Plan', @@ -2099,6 +2102,10 @@ export const settingsDict = { 'settings.magicPrompts.page.group.githubPrCommentsReview.description': 'Prompts used for PR comments analysis.', 'settings.magicPrompts.page.group.githubSinglePrCommentReview.title': 'Single PR Comment Review', 'settings.magicPrompts.page.group.githubSinglePrCommentReview.description': 'Prompts used for single PR comment analysis.', + 'settings.magicPrompts.page.group.gitlabPrReview.title': 'MR Review', + 'settings.magicPrompts.page.group.gitlabPrReview.description': 'Prompts used for GitLab merge request review flow: visible user message + hidden instruction payload.', + 'settings.magicPrompts.page.group.gitlabIssueReview.title': 'Issue Review', + 'settings.magicPrompts.page.group.gitlabIssueReview.description': 'Prompts used for GitLab issue review flow: visible user message + hidden instruction payload.', 'settings.magicPrompts.page.group.gitConflictResolve.title': 'Merge/Rebase Conflict Resolution', 'settings.magicPrompts.page.group.gitConflictResolve.description': 'Prompts used when resolving merge/rebase conflicts with AI.', 'settings.magicPrompts.page.group.gitCherrypickConflictResolve.title': 'Cherry-pick Conflict Resolution', diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index 52e6c94d..3d19ce15 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -1883,7 +1883,9 @@ export const dict = { 'session.newWorktree.branchNamePlaceholder': 'feature/my-awesome-feature', 'session.newWorktree.actions.change': 'Change', 'session.newWorktree.actions.startFromGitHubIssuePr': 'Start from GitHub Issue/PR', + 'session.newWorktree.actions.startFromGitLabIssueMr': 'Start from GitLab Issue/MR', 'session.newWorktree.usingPrBranch': 'Using PR branch: {branch}', + 'session.newWorktree.usingMrBranch': 'Using MR branch: {branch}', 'session.newWorktree.fromIssue': 'From issue #{number}: {title}', 'session.newWorktree.worktreeDirectory': 'Worktree Directory', 'session.newWorktree.worktreeDirectoryPlaceholder': 'my-worktree-directory', @@ -1894,6 +1896,7 @@ export const dict = { 'session.newWorktree.newBranchFromSource': 'New branch will be created from {source}', 'session.newWorktree.issueNumber': 'Issue #{number}', 'session.newWorktree.prNumber': 'PR #{number}', + 'session.newWorktree.mrNumber': 'MR #{number}', 'session.newWorktree.includeDiffBadge': '+diff', 'session.newWorktree.newSessionTitle': 'New session', 'session.newWorktree.fromSource': 'from {source}', @@ -1906,9 +1909,11 @@ export const dict = { 'session.newWorktree.error.branchNameRequired': 'Branch name is required', 'session.newWorktree.error.worktreeDirectoryRequired': 'Worktree directory is required', 'session.newWorktree.error.sendGitHubContextFailed': 'Failed to send GitHub context', + 'session.newWorktree.error.sendGitLabContextFailed': 'Failed to send GitLab context', 'session.newWorktree.error.createWorktreeFailed': 'Failed to create worktree', 'session.newWorktree.toast.sessionFromIssue': 'Session created from issue', 'session.newWorktree.toast.sessionFromPr': 'Session created from PR', + 'session.newWorktree.toast.sessionFromMr': 'Session created from merge request', 'session.newWorktree.toast.worktreeCreated': 'Worktree created', 'session.newWorktree.toast.worktreeCreatedDescription': '{target} - bootstrapping in background', 'session.githubIntegration.title': 'Select from GitHub', @@ -1933,6 +1938,29 @@ export const dict = { 'session.githubIntegration.validation.branchAlreadyCheckedOut': 'Branch is already checked out in a worktree', 'session.githubIntegration.validation.branchAlreadyExists': 'Branch already exists locally', 'session.githubIntegration.validation.failed': 'Validation failed', + 'session.gitlabIntegration.title': 'Select from GitLab', + 'session.gitlabIntegration.tabs.issues': 'Issues', + 'session.gitlabIntegration.tabs.mergeRequests': 'Merge Requests', + 'session.gitlabIntegration.connect.title': 'Connect to GitLab', + 'session.gitlabIntegration.connect.description': 'Link issues or merge requests to auto-fill worktree details', + 'session.gitlabIntegration.connect.action': 'Connect GitLab', + 'session.gitlabIntegration.search.issuesPlaceholder': 'Search GitLab issues', + 'session.gitlabIntegration.search.mrsPlaceholder': 'Search GitLab merge requests', + 'session.gitlabIntegration.empty.noIssuesFound': 'No issues found', + 'session.gitlabIntegration.empty.noMergeRequestsFound': 'No merge requests found', + 'session.gitlabIntegration.actions.loadMore': 'Load more', + 'session.gitlabIntegration.actions.cancel': 'Cancel', + 'session.gitlabIntegration.actions.select': 'Select', + 'session.gitlabIntegration.selected.issueNumber': 'Issue #{number}', + 'session.gitlabIntegration.selected.mrNumber': 'MR #{number}', + 'session.gitlabIntegration.includeDiffAria': 'Include MR diff in session context', + 'session.gitlabIntegration.includeDiff': 'Include MR diff', + 'session.gitlabIntegration.error.notConnected': 'GitLab not connected', + 'session.gitlabIntegration.error.loadDataFailed': 'Failed to load data', + 'session.gitlabIntegration.validation.branchAlreadyCheckedOut': 'Branch is already checked out in a worktree', + 'session.gitlabIntegration.validation.branchAlreadyExists': 'Branch already exists locally', + 'session.gitlabIntegration.validation.failed': 'Validation failed', + 'session.gitlabIntegration.draftBadge': 'Draft', 'chat.fileAttachment.toast.attachFailed': 'Failed to attach file', 'chat.fileAttachment.toast.someFilesSkipped': 'Some files were skipped:\n{summary}', 'chat.fileAttachment.toast.vscodePickFailed': 'Failed to pick files in VS Code', diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts index 1dcd4f51..178bef04 100644 --- a/packages/ui/src/lib/i18n/messages/es.settings.ts +++ b/packages/ui/src/lib/i18n/messages/es.settings.ts @@ -226,6 +226,7 @@ export const settingsDict = { "settings.magicPrompts.sidebar.description": "Selecciona una plantilla de prompt para editar.", "settings.magicPrompts.sidebar.group.git": "Git", "settings.magicPrompts.sidebar.group.github": "GitHub", + "settings.magicPrompts.sidebar.group.gitlab": "GitLab", "settings.magicPrompts.sidebar.group.planning": "Planificación", "settings.magicPrompts.sidebar.group.session": "Sesión", "settings.magicPrompts.sidebar.item.gitCommitGenerate": "Generación de commit", @@ -237,6 +238,8 @@ export const settingsDict = { "settings.magicPrompts.sidebar.item.githubPrFailedChecksReview": "Revisión de PR con comprobaciones fallidas", "settings.magicPrompts.sidebar.item.githubPrCommentsReview": "Revisión de comentarios de PR", "settings.magicPrompts.sidebar.item.githubSinglePrCommentReview": "Revisión de comentario único de PR", + "settings.magicPrompts.sidebar.item.gitlabPrReview": "Revisión de MR", + "settings.magicPrompts.sidebar.item.gitlabIssueReview": "Revisión de issue", "settings.magicPrompts.sidebar.item.planTodo": "Planificar Todo", "settings.magicPrompts.sidebar.item.planImprove": "Mejorar plan", "settings.magicPrompts.sidebar.item.planImplement": "Implementar plan", @@ -2076,6 +2079,10 @@ export const settingsDict = { "settings.magicPrompts.page.group.githubPrCommentsReview.description": "Prompts usados para el análisis de comentarios de un PR.", "settings.magicPrompts.page.group.githubSinglePrCommentReview.title": "Revisión de comentario único de PR", "settings.magicPrompts.page.group.githubSinglePrCommentReview.description": "Prompts usados para el análisis de un comentario único de un PR.", + "settings.magicPrompts.page.group.gitlabPrReview.title": "Revisión de MR", + "settings.magicPrompts.page.group.gitlabPrReview.description": "Prompts usados para el flujo de revisión de MR de GitLab: mensaje visible del usuario + carga de instrucciones ocultas.", + "settings.magicPrompts.page.group.gitlabIssueReview.title": "Revisión de issue", + "settings.magicPrompts.page.group.gitlabIssueReview.description": "Prompts usados para el flujo de revisión de issue de GitLab: mensaje visible del usuario + carga de instrucciones ocultas.", "settings.magicPrompts.page.group.gitConflictResolve.title": "Resolución de conflictos de merge/rebase", "settings.magicPrompts.page.group.gitConflictResolve.description": "Prompts usados al resolver conflictos de merge/rebase con IA.", "settings.magicPrompts.page.group.gitCherrypickConflictResolve.title": "Resolución de conflictos de cherry-pick", diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 52e3b35f..47d7299e 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -1861,7 +1861,9 @@ export const dict: Record = { "session.newWorktree.branchNamePlaceholder": "feature/my-awesome-feature", "session.newWorktree.actions.change": "Cambiar", "session.newWorktree.actions.startFromGitHubIssuePr": "Iniciar desde Issue/PR de GitHub", + "session.newWorktree.actions.startFromGitLabIssueMr": "Iniciar desde Issue/MR de GitLab", "session.newWorktree.usingPrBranch": "Usando rama de la PR: {branch}", + "session.newWorktree.usingMrBranch": "Usando rama del MR: {branch}", "session.newWorktree.fromIssue": "De issue #{number}: {title}", "session.newWorktree.worktreeDirectory": "Directorio del worktree", "session.newWorktree.worktreeDirectoryPlaceholder": "my-worktree-directory", @@ -1872,6 +1874,7 @@ export const dict: Record = { "session.newWorktree.newBranchFromSource": "Se creará una nueva rama desde {source}", "session.newWorktree.issueNumber": "Issue #{number}", "session.newWorktree.prNumber": "PR #{number}", + "session.newWorktree.mrNumber": "MR #{number}", "session.newWorktree.includeDiffBadge": "+diff", "session.newWorktree.newSessionTitle": "Nueva sesión", "session.newWorktree.fromSource": "desde {source}", @@ -1884,9 +1887,11 @@ export const dict: Record = { "session.newWorktree.error.branchNameRequired": "Se requiere el nombre de la rama", "session.newWorktree.error.worktreeDirectoryRequired": "Se requiere el directorio del worktree", "session.newWorktree.error.sendGitHubContextFailed": "No se pudo enviar el contexto de GitHub", + "session.newWorktree.error.sendGitLabContextFailed": "No se pudo enviar el contexto de GitLab", "session.newWorktree.error.createWorktreeFailed": "No se pudo crear el worktree", "session.newWorktree.toast.sessionFromIssue": "Sesión creada desde issue", "session.newWorktree.toast.sessionFromPr": "Sesión creada desde PR", + "session.newWorktree.toast.sessionFromMr": "Sesión creada desde merge request", "session.newWorktree.toast.worktreeCreated": "Worktree creado", "session.newWorktree.toast.worktreeCreatedDescription": "{target} - configurando en segundo plano", "session.githubIntegration.title": "Seleccionar desde GitHub", @@ -1911,6 +1916,29 @@ export const dict: Record = { "session.githubIntegration.validation.branchAlreadyCheckedOut": "La rama ya está en uso en un worktree", "session.githubIntegration.validation.branchAlreadyExists": "La rama ya existe localmente", "session.githubIntegration.validation.failed": "No se pudo validar", + "session.gitlabIntegration.title": "Seleccionar desde GitLab", + "session.gitlabIntegration.tabs.issues": "Issues", + "session.gitlabIntegration.tabs.mergeRequests": "Merge Requests", + "session.gitlabIntegration.connect.title": "Conectar a GitLab", + "session.gitlabIntegration.connect.description": "Vincula issues o merge requests para rellenar automáticamente los detalles del worktree", + "session.gitlabIntegration.connect.action": "Conectar a GitLab", + "session.gitlabIntegration.search.issuesPlaceholder": "Buscar issues de GitLab", + "session.gitlabIntegration.search.mrsPlaceholder": "Buscar merge requests de GitLab", + "session.gitlabIntegration.empty.noIssuesFound": "No se encontraron issues", + "session.gitlabIntegration.empty.noMergeRequestsFound": "No se encontraron merge requests", + "session.gitlabIntegration.actions.loadMore": "Cargar más", + "session.gitlabIntegration.actions.cancel": "Cancelar", + "session.gitlabIntegration.actions.select": "Seleccionar", + "session.gitlabIntegration.selected.issueNumber": "Issue #{number}", + "session.gitlabIntegration.selected.mrNumber": "MR #{number}", + "session.gitlabIntegration.includeDiffAria": "Incluir diff del MR en el contexto de la sesión", + "session.gitlabIntegration.includeDiff": "Incluir diff del MR", + "session.gitlabIntegration.error.notConnected": "GitLab no está conectado", + "session.gitlabIntegration.error.loadDataFailed": "No se pudieron cargar los datos", + "session.gitlabIntegration.validation.branchAlreadyCheckedOut": "La rama ya está en uso en un worktree", + "session.gitlabIntegration.validation.branchAlreadyExists": "La rama ya existe localmente", + "session.gitlabIntegration.validation.failed": "No se pudo validar", + "session.gitlabIntegration.draftBadge": "Borrador", "chat.fileAttachment.toast.attachFailed": "No se pudo adjuntar el archivo", "chat.fileAttachment.toast.someFilesSkipped": "Algunos archivos se omitieron:\n{summary}", "chat.fileAttachment.toast.vscodePickFailed": "No se pudieron seleccionar archivos en VS Code", diff --git a/packages/ui/src/lib/i18n/messages/fr.settings.ts b/packages/ui/src/lib/i18n/messages/fr.settings.ts index ddb2627c..9ce575b6 100644 --- a/packages/ui/src/lib/i18n/messages/fr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr.settings.ts @@ -249,6 +249,7 @@ export const settingsDict = { 'settings.magicPrompts.sidebar.description': 'Sélectionnez un modèle de prompt à modifier.', 'settings.magicPrompts.sidebar.group.git': 'Git', 'settings.magicPrompts.sidebar.group.github': 'GitHub', + 'settings.magicPrompts.sidebar.group.gitlab': 'GitLab', 'settings.magicPrompts.sidebar.group.planning': 'Planification', 'settings.magicPrompts.sidebar.group.session': 'Session', 'settings.magicPrompts.sidebar.item.gitCommitGenerate': 'Génération de commit', @@ -260,6 +261,8 @@ export const settingsDict = { 'settings.magicPrompts.sidebar.item.githubPrFailedChecksReview': 'Examen des checks PR en échec', 'settings.magicPrompts.sidebar.item.githubPrCommentsReview': 'Examen des commentaires PR', 'settings.magicPrompts.sidebar.item.githubSinglePrCommentReview': 'Examen des commentaires PR uniques', + 'settings.magicPrompts.sidebar.item.gitlabPrReview': 'Revue de MR', + 'settings.magicPrompts.sidebar.item.gitlabIssueReview': 'Examen du problème', 'settings.magicPrompts.sidebar.item.planTodo': 'Planification des tâches', 'settings.magicPrompts.sidebar.item.planImprove': 'Améliorer le plan', 'settings.magicPrompts.sidebar.item.planImplement': 'Plan de mise en œuvre', @@ -1978,6 +1981,10 @@ export const settingsDict = { 'settings.magicPrompts.page.group.githubPrCommentsReview.description': 'Prompts utilisés pour l’analyse des commentaires PR.', 'settings.magicPrompts.page.group.githubSinglePrCommentReview.title': 'Examen des commentaires PR uniques', 'settings.magicPrompts.page.group.githubSinglePrCommentReview.description': 'Prompts utilisés pour l’analyse d’un seul commentaire PR.', + 'settings.magicPrompts.page.group.gitlabPrReview.title': 'Revue de MR', + 'settings.magicPrompts.page.group.gitlabPrReview.description': 'Prompts utilisés pour le flux de revue des MR GitLab : message utilisateur visible + charge utile d\'instruction cachée.', + 'settings.magicPrompts.page.group.gitlabIssueReview.title': 'Examen du problème', + 'settings.magicPrompts.page.group.gitlabIssueReview.description': 'Prompts utilisés pour le flux d\'examen des issues GitLab : message utilisateur visible + charge utile d\'instruction masquée.', 'settings.magicPrompts.page.group.gitConflictResolve.title': 'Résolution des conflits de fusion/rebase', 'settings.magicPrompts.page.group.gitConflictResolve.description': 'Prompts utilisés lors de la résolution des conflits de fusion/rebase avec l\'IA.', 'settings.magicPrompts.page.group.gitCherrypickConflictResolve.title': 'Résolution des conflits par sélection', diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 9aed071b..1911a1f3 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -1641,7 +1641,9 @@ export const dict = { 'session.newWorktree.branchNamePlaceholder': 'fonctionnalité/ma-fonctionnalité-géniale', 'session.newWorktree.actions.change': 'Changement', 'session.newWorktree.actions.startFromGitHubIssuePr': 'À partir de GitHub Issue/PR', + 'session.newWorktree.actions.startFromGitLabIssueMr': 'À partir de GitLab Issue/MR', 'session.newWorktree.usingPrBranch': 'Utilisation de la branche PR : {branch}', + 'session.newWorktree.usingMrBranch': 'Utilisation de la branche MR : {branch}', 'session.newWorktree.fromIssue': 'Extrait du numéro {number} : {title}', 'session.newWorktree.worktreeDirectory': 'Répertoire du worktree', 'session.newWorktree.worktreeDirectoryPlaceholder': 'mon-répertoire-worktree', @@ -1652,6 +1654,7 @@ export const dict = { 'session.newWorktree.newBranchFromSource': 'Une nouvelle branche sera créée à partir de {source}', 'session.newWorktree.issueNumber': 'Numéro {number}', 'session.newWorktree.prNumber': 'PR #{number}', + 'session.newWorktree.mrNumber': 'MR #{number}', 'session.newWorktree.includeDiffBadge': '+diff', 'session.newWorktree.newSessionTitle': 'Nouvelle session', 'session.newWorktree.fromSource': 'de {source}', @@ -1664,9 +1667,11 @@ export const dict = { 'session.newWorktree.error.branchNameRequired': 'Le nom de la branche est requis', 'session.newWorktree.error.worktreeDirectoryRequired': 'Le répertoire du worktree est requis', 'session.newWorktree.error.sendGitHubContextFailed': 'Échec de l\'envoi du contexte GitHub', + 'session.newWorktree.error.sendGitLabContextFailed': 'Échec de l\'envoi du contexte GitLab', 'session.newWorktree.error.createWorktreeFailed': 'Échec de la création du worktree', 'session.newWorktree.toast.sessionFromIssue': 'Session créée à partir du problème', 'session.newWorktree.toast.sessionFromPr': 'Session créée à partir de PR', + 'session.newWorktree.toast.sessionFromMr': 'Session créée à partir de la merge request', 'session.newWorktree.toast.worktreeCreated': 'Worktree créé', 'session.newWorktree.toast.worktreeCreatedDescription': '{target} - démarrage en arrière-plan', 'session.githubIntegration.title': 'Sélectionnez parmi GitHub', @@ -1691,6 +1696,29 @@ export const dict = { 'session.githubIntegration.validation.branchAlreadyCheckedOut': 'La branche est déjà checkout dans un worktree', 'session.githubIntegration.validation.branchAlreadyExists': 'Une branche existe déjà localement', 'session.githubIntegration.validation.failed': 'Échec de la validation', + 'session.gitlabIntegration.title': 'Sélectionnez parmi GitLab', + 'session.gitlabIntegration.tabs.issues': 'Problèmes', + 'session.gitlabIntegration.tabs.mergeRequests': 'Merge Requests', + 'session.gitlabIntegration.connect.title': 'Connectez-vous à GitLab', + 'session.gitlabIntegration.connect.description': 'Lier des issues ou des merge requests pour remplir automatiquement les détails du worktree', + 'session.gitlabIntegration.connect.action': 'Connectez GitLab', + 'session.gitlabIntegration.search.issuesPlaceholder': 'Recherchez des problèmes GitLab', + 'session.gitlabIntegration.search.mrsPlaceholder': 'Recherchez les merge requests GitLab', + 'session.gitlabIntegration.empty.noIssuesFound': 'Aucun problème trouvé', + 'session.gitlabIntegration.empty.noMergeRequestsFound': 'Aucune merge request trouvée', + 'session.gitlabIntegration.actions.loadMore': 'Charger plus', + 'session.gitlabIntegration.actions.cancel': 'Annuler', + 'session.gitlabIntegration.actions.select': 'Sélectionner', + 'session.gitlabIntegration.selected.issueNumber': 'Numéro {number}', + 'session.gitlabIntegration.selected.mrNumber': 'MR #{number}', + 'session.gitlabIntegration.includeDiffAria': 'Inclure la différence MR dans le contexte de la session', + 'session.gitlabIntegration.includeDiff': 'Inclure la différence MR', + 'session.gitlabIntegration.error.notConnected': 'GitLab non connecté', + 'session.gitlabIntegration.error.loadDataFailed': 'Échec du chargement des données', + 'session.gitlabIntegration.validation.branchAlreadyCheckedOut': 'La branche est déjà checkout dans un worktree', + 'session.gitlabIntegration.validation.branchAlreadyExists': 'Une branche existe déjà localement', + 'session.gitlabIntegration.validation.failed': 'Échec de la validation', + 'session.gitlabIntegration.draftBadge': 'Brouillon', 'chat.fileAttachment.toast.attachFailed': 'Impossible de joindre le fichier', 'chat.fileAttachment.toast.someFilesSkipped': 'Certains fichiers ont été ignorés :\n{summary}', 'chat.fileAttachment.toast.vscodePickFailed': 'Échec de la sélection des fichiers dans VS Code', diff --git a/packages/ui/src/lib/i18n/messages/ja.settings.ts b/packages/ui/src/lib/i18n/messages/ja.settings.ts index 7f2c59d1..1c18df4c 100644 --- a/packages/ui/src/lib/i18n/messages/ja.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja.settings.ts @@ -259,6 +259,7 @@ export const settingsDict = { 'settings.magicPrompts.sidebar.description': '編集する Prompt Template を選択してください。', 'settings.magicPrompts.sidebar.group.git': 'Git', 'settings.magicPrompts.sidebar.group.github': 'GitHub', + 'settings.magicPrompts.sidebar.group.gitlab': 'GitLab', 'settings.magicPrompts.sidebar.group.planning': '計画', 'settings.magicPrompts.sidebar.group.session': 'セッション', 'settings.magicPrompts.sidebar.item.gitCommitGenerate': 'Commit 生成', @@ -270,6 +271,8 @@ export const settingsDict = { 'settings.magicPrompts.sidebar.item.githubPrFailedChecksReview': 'PR 失敗チェックレビュー', 'settings.magicPrompts.sidebar.item.githubPrCommentsReview': 'PR コメントレビュー', 'settings.magicPrompts.sidebar.item.githubSinglePrCommentReview': '単一 PR コメントレビュー', + 'settings.magicPrompts.sidebar.item.gitlabPrReview': 'MR レビュー', + 'settings.magicPrompts.sidebar.item.gitlabIssueReview': 'Issue レビュー', 'settings.magicPrompts.sidebar.item.planTodo': 'Todo 計画', 'settings.magicPrompts.sidebar.item.planImprove': '計画の改善', 'settings.magicPrompts.sidebar.item.planImplement': '計画の実装', @@ -2109,6 +2112,10 @@ export const settingsDict = { 'settings.magicPrompts.page.group.githubPrCommentsReview.description': 'PR コメント分析に使用するプロンプト。', 'settings.magicPrompts.page.group.githubSinglePrCommentReview.title': '単一 PR コメントレビュー', 'settings.magicPrompts.page.group.githubSinglePrCommentReview.description': '単一 PR コメント分析に使用するプロンプト。', + 'settings.magicPrompts.page.group.gitlabPrReview.title': 'MR レビュー', + 'settings.magicPrompts.page.group.gitlabPrReview.description': 'GitLab MR レビューフローに使用するプロンプト: 表示ユーザーメッセージ + 非表示指示ペイロード。', + 'settings.magicPrompts.page.group.gitlabIssueReview.title': 'Issue レビュー', + 'settings.magicPrompts.page.group.gitlabIssueReview.description': 'GitLab Issue レビューフローに使用するプロンプト: 表示ユーザーメッセージ + 非表示指示ペイロード。', 'settings.magicPrompts.page.group.gitConflictResolve.title': 'Merge/Rebase 競合解決', 'settings.magicPrompts.page.group.gitConflictResolve.description': 'AI で Merge/Rebase 競合を解決する際に使用するプロンプト。', 'settings.magicPrompts.page.group.gitCherrypickConflictResolve.title': 'Cherry-pick 競合解決', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index 1d4959f7..250e63af 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -1879,7 +1879,9 @@ export const dict: Record = { 'session.newWorktree.branchNamePlaceholder': 'feature/my-awesome-feature', 'session.newWorktree.actions.change': '変更', 'session.newWorktree.actions.startFromGitHubIssuePr': 'GitHub Issue/PRから開始', + 'session.newWorktree.actions.startFromGitLabIssueMr': 'GitLab Issue/MRから開始', 'session.newWorktree.usingPrBranch': 'PRブランチを使用: {branch}', + 'session.newWorktree.usingMrBranch': 'MRブランチを使用: {branch}', 'session.newWorktree.fromIssue': 'Issue #{number}: {title}から', 'session.newWorktree.worktreeDirectory': 'ワークツリーディレクトリ', 'session.newWorktree.worktreeDirectoryPlaceholder': 'my-worktree-directory', @@ -1890,6 +1892,7 @@ export const dict: Record = { 'session.newWorktree.newBranchFromSource': '{source}から新しいブランチが作成されます', 'session.newWorktree.issueNumber': 'Issue #{number}', 'session.newWorktree.prNumber': 'PR #{number}', + 'session.newWorktree.mrNumber': 'MR #{number}', 'session.newWorktree.includeDiffBadge': '+diff', 'session.newWorktree.newSessionTitle': '新しいセッション', 'session.newWorktree.fromSource': 'from {source}', @@ -1902,9 +1905,11 @@ export const dict: Record = { 'session.newWorktree.error.branchNameRequired': 'ブランチ名が必要です', 'session.newWorktree.error.worktreeDirectoryRequired': 'ワークツリーディレクトリが必要です', 'session.newWorktree.error.sendGitHubContextFailed': 'GitHubコンテキストの送信に失敗しました', + 'session.newWorktree.error.sendGitLabContextFailed': 'GitLabコンテキストの送信に失敗しました', 'session.newWorktree.error.createWorktreeFailed': 'ワークツリーの作成に失敗しました', 'session.newWorktree.toast.sessionFromIssue': 'Issueからセッションを作成しました', 'session.newWorktree.toast.sessionFromPr': 'PRからセッションを作成しました', + 'session.newWorktree.toast.sessionFromMr': 'マージリクエストからセッションを作成しました', 'session.newWorktree.toast.worktreeCreated': 'ワークツリーを作成しました', 'session.newWorktree.toast.worktreeCreatedDescription': '{target} - バックグラウンドでブートストラップ中', 'session.githubIntegration.title': 'GitHubから選択', @@ -1929,6 +1934,29 @@ export const dict: Record = { 'session.githubIntegration.validation.branchAlreadyCheckedOut': 'ブランチはすでにワークツリーでチェックアウトされています', 'session.githubIntegration.validation.branchAlreadyExists': 'ブランチはすでにローカルに存在します', 'session.githubIntegration.validation.failed': '検証に失敗しました', + 'session.gitlabIntegration.title': 'GitLabから選択', + 'session.gitlabIntegration.tabs.issues': 'Issue', + 'session.gitlabIntegration.tabs.mergeRequests': 'マージリクエスト', + 'session.gitlabIntegration.connect.title': 'GitLabに接続', + 'session.gitlabIntegration.connect.description': 'Issueまたはマージリクエストをリンクしてワークツリー詳細を自動入力', + 'session.gitlabIntegration.connect.action': 'GitLabに接続', + 'session.gitlabIntegration.search.issuesPlaceholder': 'GitLabのIssueを検索', + 'session.gitlabIntegration.search.mrsPlaceholder': 'GitLabのマージリクエストを検索', + 'session.gitlabIntegration.empty.noIssuesFound': 'Issueが見つかりません', + 'session.gitlabIntegration.empty.noMergeRequestsFound': 'マージリクエストが見つかりません', + 'session.gitlabIntegration.actions.loadMore': 'さらに読み込む', + 'session.gitlabIntegration.actions.cancel': 'キャンセル', + 'session.gitlabIntegration.actions.select': '選択', + 'session.gitlabIntegration.selected.issueNumber': 'Issue #{number}', + 'session.gitlabIntegration.selected.mrNumber': 'MR #{number}', + 'session.gitlabIntegration.includeDiffAria': 'セッションコンテキストにMR差分を含める', + 'session.gitlabIntegration.includeDiff': 'MR差分を含める', + 'session.gitlabIntegration.error.notConnected': 'GitLabに接続されていません', + 'session.gitlabIntegration.error.loadDataFailed': 'データの読み込みに失敗しました', + 'session.gitlabIntegration.validation.branchAlreadyCheckedOut': 'ブランチはすでにワークツリーでチェックアウトされています', + 'session.gitlabIntegration.validation.branchAlreadyExists': 'ブランチはすでにローカルに存在します', + 'session.gitlabIntegration.validation.failed': '検証に失敗しました', + 'session.gitlabIntegration.draftBadge': '下書き', 'chat.fileAttachment.toast.attachFailed': 'ファイルの添付に失敗しました', 'chat.fileAttachment.toast.someFilesSkipped': '一部のファイルがスキップされました:\n{summary}', 'chat.fileAttachment.toast.vscodePickFailed': 'VS Codeでのファイル選択に失敗しました', diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts index cddd9f20..15d5cb8b 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -226,6 +226,7 @@ export const settingsDict = { 'settings.magicPrompts.sidebar.description': '편집할 프롬프트 템플릿을 선택하세요.', 'settings.magicPrompts.sidebar.group.git': 'Git', 'settings.magicPrompts.sidebar.group.github': 'GitHub', + 'settings.magicPrompts.sidebar.group.gitlab': 'GitLab', 'settings.magicPrompts.sidebar.group.planning': '계획', 'settings.magicPrompts.sidebar.group.session': '세션', 'settings.magicPrompts.sidebar.item.gitCommitGenerate': 'Commit 생성', @@ -237,6 +238,8 @@ export const settingsDict = { 'settings.magicPrompts.sidebar.item.githubPrFailedChecksReview': 'PR 실패 체크 리뷰', 'settings.magicPrompts.sidebar.item.githubPrCommentsReview': 'PR 댓글 리뷰', 'settings.magicPrompts.sidebar.item.githubSinglePrCommentReview': '단일 PR 댓글 리뷰', + 'settings.magicPrompts.sidebar.item.gitlabPrReview': 'MR 리뷰', + 'settings.magicPrompts.sidebar.item.gitlabIssueReview': '이슈 리뷰', 'settings.magicPrompts.sidebar.item.planTodo': '할 일 계획', 'settings.magicPrompts.sidebar.item.planImprove': '계획 개선', 'settings.magicPrompts.sidebar.item.planImplement': '계획 구현', @@ -2076,6 +2079,10 @@ export const settingsDict = { 'settings.magicPrompts.page.group.githubPrCommentsReview.description': 'PR 댓글 분석에 사용되는 프롬프트입니다.', 'settings.magicPrompts.page.group.githubSinglePrCommentReview.title': '단일 PR 댓글 리뷰', 'settings.magicPrompts.page.group.githubSinglePrCommentReview.description': '단일 PR 댓글 분석에 사용되는 프롬프트입니다.', + 'settings.magicPrompts.page.group.gitlabPrReview.title': 'MR 리뷰', + 'settings.magicPrompts.page.group.gitlabPrReview.description': 'GitLab MR 리뷰 flow에 사용하는 프롬프트입니다: 표시 사용자 메시지 + 숨겨진 지침 payload.', + 'settings.magicPrompts.page.group.gitlabIssueReview.title': '이슈 리뷰', + 'settings.magicPrompts.page.group.gitlabIssueReview.description': 'GitLab issue 리뷰 flow에 사용하는 프롬프트입니다: 표시 사용자 메시지 + 숨겨진 지침 payload.', 'settings.magicPrompts.page.group.gitConflictResolve.title': 'Merge/Rebase 충돌 해결', 'settings.magicPrompts.page.group.gitConflictResolve.description': 'AI로 merge/rebase 충돌을 해결할 때 사용하는 프롬프트입니다.', 'settings.magicPrompts.page.group.gitCherrypickConflictResolve.title': 'Cherry-pick 충돌 해결', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 52449bc8..96f95770 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -1885,7 +1885,9 @@ export const dict: Record = { 'session.newWorktree.branchNamePlaceholder': 'feature/my-awesome-feature', 'session.newWorktree.actions.change': '변경', 'session.newWorktree.actions.startFromGitHubIssuePr': 'GitHub 이슈/PR에서 시작', + 'session.newWorktree.actions.startFromGitLabIssueMr': 'GitLab 이슈/MR에서 시작', 'session.newWorktree.usingPrBranch': 'PR 브랜치 사용 중: {branch}', + 'session.newWorktree.usingMrBranch': 'MR 브랜치 사용 중: {branch}', 'session.newWorktree.fromIssue': '이슈 #{number}에서: {title}', 'session.newWorktree.worktreeDirectory': '워크트리 디렉터리', 'session.newWorktree.worktreeDirectoryPlaceholder': 'my-워크트리-디렉터리', @@ -1896,6 +1898,7 @@ export const dict: Record = { 'session.newWorktree.newBranchFromSource': '{source}에서 새 브랜치가 생성됩니다', 'session.newWorktree.issueNumber': '이슈 #{number}', 'session.newWorktree.prNumber': 'PR #{number}', + 'session.newWorktree.mrNumber': 'MR #{number}', 'session.newWorktree.includeDiffBadge': '+diff', 'session.newWorktree.newSessionTitle': '새 세션', 'session.newWorktree.fromSource': '{source}에서', @@ -1908,9 +1911,11 @@ export const dict: Record = { 'session.newWorktree.error.branchNameRequired': '브랜치 이름은 필수입니다', 'session.newWorktree.error.worktreeDirectoryRequired': '워크트리 디렉터리 필수', 'session.newWorktree.error.sendGitHubContextFailed': 'GitHub 컨텍스트 전송에 실패했습니다', + 'session.newWorktree.error.sendGitLabContextFailed': 'GitLab 컨텍스트 전송에 실패했습니다', 'session.newWorktree.error.createWorktreeFailed': '워크트리 생성에 실패했습니다', 'session.newWorktree.toast.sessionFromIssue': '이슈에서 세션을 생성했습니다', 'session.newWorktree.toast.sessionFromPr': 'PR에서 세션을 생성했습니다', + 'session.newWorktree.toast.sessionFromMr': '머지 리퀘스트에서 세션을 생성했습니다', 'session.newWorktree.toast.worktreeCreated': '워크트리를 생성했습니다', 'session.newWorktree.toast.worktreeCreatedDescription': '{target} - 백그라운드에서 초기 설정 중', 'session.githubIntegration.title': 'GitHub에서 선택', @@ -1935,6 +1940,29 @@ export const dict: Record = { 'session.githubIntegration.validation.branchAlreadyCheckedOut': '브랜치가 이미 워크트리에 체크아웃되어 있습니다', 'session.githubIntegration.validation.branchAlreadyExists': '브랜치가 이미 로컬에 있습니다', 'session.githubIntegration.validation.failed': '유효성 검사에 실패했습니다', + 'session.gitlabIntegration.title': 'GitLab에서 선택', + 'session.gitlabIntegration.tabs.issues': '이슈', + 'session.gitlabIntegration.tabs.mergeRequests': '머지 리퀘스트', + 'session.gitlabIntegration.connect.title': 'GitLab에 연결', + 'session.gitlabIntegration.connect.description': '이슈 또는 머지 리퀘스트를 연결해 워크트리 정보를 자동으로 채웁니다', + 'session.gitlabIntegration.connect.action': 'GitLab 연결', + 'session.gitlabIntegration.search.issuesPlaceholder': 'GitLab 이슈 검색', + 'session.gitlabIntegration.search.mrsPlaceholder': 'GitLab 머지 리퀘스트 검색', + 'session.gitlabIntegration.empty.noIssuesFound': '이슈 없음', + 'session.gitlabIntegration.empty.noMergeRequestsFound': '머지 리퀘스트가 없습니다', + 'session.gitlabIntegration.actions.loadMore': '더 불러오기', + 'session.gitlabIntegration.actions.cancel': '취소', + 'session.gitlabIntegration.actions.select': '선택', + 'session.gitlabIntegration.selected.issueNumber': '이슈 #{number}', + 'session.gitlabIntegration.selected.mrNumber': 'MR #{number}', + 'session.gitlabIntegration.includeDiffAria': 'MR 변경사항을 세션 컨텍스트에 포함', + 'session.gitlabIntegration.includeDiff': 'MR 변경사항 포함', + 'session.gitlabIntegration.error.notConnected': 'GitLab에 연결되어 있지 않습니다', + 'session.gitlabIntegration.error.loadDataFailed': '데이터를 불러오지 못했습니다', + 'session.gitlabIntegration.validation.branchAlreadyCheckedOut': '브랜치가 이미 워크트리에 체크아웃되어 있습니다', + 'session.gitlabIntegration.validation.branchAlreadyExists': '브랜치가 이미 로컬에 있습니다', + 'session.gitlabIntegration.validation.failed': '유효성 검사에 실패했습니다', + 'session.gitlabIntegration.draftBadge': '초안', 'chat.fileAttachment.toast.attachFailed': '첨부 파일 실패', 'chat.fileAttachment.toast.someFilesSkipped': '일부 파일을 건너뛰었습니다:\n{summary}', 'chat.fileAttachment.toast.vscodePickFailed': 'VS Code에서 파일 선택에 실패했습니다', diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts index cbc5fa6c..5732d552 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -372,6 +372,10 @@ export const settingsDict = { 'settings.magicPrompts.page.group.githubPrReview.description': 'Prompty używane w przepływie przeglądu PR: widoczna wiadomość użytkownika + ukryty ładunek instrukcji.', 'settings.magicPrompts.page.group.githubPrReview.title': 'Przegląd PR', 'settings.magicPrompts.page.group.githubSinglePrCommentReview.description': 'Prompty używane do analizy pojedynczego komentarza w PR.', + 'settings.magicPrompts.page.group.gitlabPrReview.title': 'Przegląd MR', + 'settings.magicPrompts.page.group.gitlabPrReview.description': 'Prompty używane w przepływie przeglądu MR GitLab: widoczna wiadomość użytkownika + ukryty ładunek instrukcji.', + 'settings.magicPrompts.page.group.gitlabIssueReview.title': 'Przegląd Issue', + 'settings.magicPrompts.page.group.gitlabIssueReview.description': 'Prompty używane w przepływie przeglądu Issue GitLab: widoczna wiadomość użytkownika + ukryty ładunek instrukcji.', 'settings.magicPrompts.page.group.githubSinglePrCommentReview.title': 'Przegląd pojedynczego komentarza PR', 'settings.magicPrompts.page.group.planImplement.description': 'Ukryty prompt używany podczas przesyłania zapisanego planu do przepływu implementacji.', 'settings.magicPrompts.page.group.planImplement.title': 'Implementuj Plan', @@ -413,6 +417,7 @@ export const settingsDict = { 'settings.magicPrompts.sidebar.description': 'Wybierz szablon promptu, aby go edytować.', 'settings.magicPrompts.sidebar.group.git': 'Git', 'settings.magicPrompts.sidebar.group.github': 'GitHub', + 'settings.magicPrompts.sidebar.group.gitlab': 'GitLab', 'settings.magicPrompts.sidebar.group.planning': 'Planowanie', 'settings.magicPrompts.sidebar.group.session': 'Sesja', 'settings.magicPrompts.sidebar.item.gitCherrypickConflictResolve': 'Rozwiązywanie konfliktów Cherry-pick', @@ -424,6 +429,8 @@ export const settingsDict = { 'settings.magicPrompts.sidebar.item.githubPrFailedChecksReview': 'Przegląd nieudanych sprawdzeń PR', 'settings.magicPrompts.sidebar.item.githubPrReview': 'Przegląd PR', 'settings.magicPrompts.sidebar.item.githubSinglePrCommentReview': 'Przegląd pojedynczego komentarza PR', + 'settings.magicPrompts.sidebar.item.gitlabPrReview': 'Przegląd MR', + 'settings.magicPrompts.sidebar.item.gitlabIssueReview': 'Przegląd Issue', 'settings.magicPrompts.sidebar.item.planImplement': 'Implementuj Plan', 'settings.magicPrompts.sidebar.item.planImprove': 'Ulepsz Plan', 'settings.magicPrompts.sidebar.item.planTodo': 'Planowanie Todo', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index b903014d..97ce96f5 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -682,6 +682,29 @@ export const dict: Record = { 'session.githubIntegration.validation.branchAlreadyCheckedOut': 'Gałąź jest już wyewidencjonowana w drzewie pracy', 'session.githubIntegration.validation.branchAlreadyExists': 'Gałąź już istnieje lokalnie', 'session.githubIntegration.validation.failed': 'Walidacja nie powiodła się', + 'session.gitlabIntegration.title': 'Wybierz z GitLab', + 'session.gitlabIntegration.tabs.issues': 'Zagadnienia', + 'session.gitlabIntegration.tabs.mergeRequests': 'Merge requesty', + 'session.gitlabIntegration.connect.title': 'Połącz z GitLab', + 'session.gitlabIntegration.connect.description': 'Połącz zagadnienia lub merge requesty aby auto-wypełnić szczegóły drzewa pracy', + 'session.gitlabIntegration.connect.action': 'Połącz GitLab', + 'session.gitlabIntegration.search.issuesPlaceholder': 'Szukaj zagadnień GitLab', + 'session.gitlabIntegration.search.mrsPlaceholder': 'Szukaj merge requestów GitLab', + 'session.gitlabIntegration.empty.noIssuesFound': 'Nie znaleziono zagadnień', + 'session.gitlabIntegration.empty.noMergeRequestsFound': 'Nie znaleziono merge requestów', + 'session.gitlabIntegration.actions.loadMore': 'Załaduj więcej', + 'session.gitlabIntegration.actions.cancel': 'Anuluj', + 'session.gitlabIntegration.actions.select': 'Wybierz', + 'session.gitlabIntegration.selected.issueNumber': 'Zagadnienie #{number}', + 'session.gitlabIntegration.selected.mrNumber': 'MR #{number}', + 'session.gitlabIntegration.includeDiffAria': 'Dołącz diff MR do kontekstu sesji', + 'session.gitlabIntegration.includeDiff': 'Dołącz diff MR', + 'session.gitlabIntegration.error.notConnected': 'GitLab nie jest połączony', + 'session.gitlabIntegration.error.loadDataFailed': 'Nie udało się załadować danych', + 'session.gitlabIntegration.validation.branchAlreadyCheckedOut': 'Gałąź jest już wyewidencjonowana w drzewie pracy', + 'session.gitlabIntegration.validation.branchAlreadyExists': 'Gałąź już istnieje lokalnie', + 'session.gitlabIntegration.validation.failed': 'Walidacja nie powiodła się', + 'session.gitlabIntegration.draftBadge': 'Szkic', 'chat.fileAttachment.toast.attachFailed': 'Nie udało się dołączyć pliku', 'chat.fileAttachment.toast.someFilesSkipped': 'Niektóre pliki zostały pominięte:\n{summary}', 'chat.fileAttachment.toast.vscodePickFailed': 'Nie udało się wybrać plików w VS Code', @@ -2701,6 +2724,7 @@ export const dict: Record = { 'session.newWorktree.actions.creating': 'Tworzenie...', 'session.newWorktree.actions.reset': 'Resetuj', 'session.newWorktree.actions.startFromGitHubIssuePr': 'Rozpocznij ze zgłoszenia/PR GitHub', + 'session.newWorktree.actions.startFromGitLabIssueMr': 'Rozpocznij z GitLab zagadnienia/MR', 'session.newWorktree.branchName': 'Nazwa gałęzi', 'session.newWorktree.branchNamePlaceholder': 'feature/my-awesome-feature', 'session.newWorktree.chooseBranch': 'Wybierz gałąź...', @@ -2709,6 +2733,7 @@ export const dict: Record = { 'session.newWorktree.error.noActiveProject': 'Brak aktywnego projektu', 'session.newWorktree.error.noModelSelected': 'Nie wybrano modelu', 'session.newWorktree.error.sendGitHubContextFailed': 'Nie udało się wysłać kontekstu GitHub', + 'session.newWorktree.error.sendGitLabContextFailed': 'Nie udało się wysłać kontekstu GitLab', 'session.newWorktree.error.worktreeDirectoryRequired': 'Katalog drzewa pracy jest wymagany', 'session.newWorktree.fetchBranches': 'Pobierz gałęzie', 'session.newWorktree.fromIssue': 'Ze zgłoszenia #{number}: {title}', @@ -2727,6 +2752,7 @@ export const dict: Record = { 'session.newWorktree.otherLocalBranches': 'Pozostałe lokalne gałęzie', 'session.newWorktree.otherRemoteBranches': 'Pozostałe zdalne gałęzie', 'session.newWorktree.prNumber': 'PR #{number}', + 'session.newWorktree.mrNumber': 'MR #{number}', 'session.newWorktree.remoteBranches': 'Zdalne gałęzie', 'session.newWorktree.resetToMatchBranchName': 'Zresetuj do nazwy zgodnej z gałęzią', 'session.newWorktree.searchBranches': 'Szukaj gałęzi...', @@ -2737,9 +2763,11 @@ export const dict: Record = { 'session.newWorktree.title': 'Nowe drzewo pracy', 'session.newWorktree.toast.sessionFromIssue': 'Sesja utworzona ze zgłoszenia', 'session.newWorktree.toast.sessionFromPr': 'Sesja utworzona z PR', + 'session.newWorktree.toast.sessionFromMr': 'Sesja utworzona z merge requesta', 'session.newWorktree.toast.worktreeCreated': 'Drzewo pracy utworzone', 'session.newWorktree.toast.worktreeCreatedDescription': '{target} — inicjalizacja w tle', 'session.newWorktree.usingPrBranch': 'Używana gałąź PR: {branch}', + 'session.newWorktree.usingMrBranch': 'Używana gałąź MR: {branch}', 'session.newWorktree.worktreeDirectory': 'Katalog drzewa pracy', 'session.newWorktree.worktreeDirectoryPlaceholder': 'my-worktree-directory', 'sessionAuth.actions.addPasskey': 'Add passkey', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts index 471268b2..4cf1df7a 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -226,6 +226,7 @@ export const settingsDict = { "settings.magicPrompts.sidebar.description": "Selecione um modelo de prompt para editar.", "settings.magicPrompts.sidebar.group.git": "Git", "settings.magicPrompts.sidebar.group.github": "GitHub", + "settings.magicPrompts.sidebar.group.gitlab": "GitLab", "settings.magicPrompts.sidebar.group.planning": "Planejamento", "settings.magicPrompts.sidebar.group.session": "Sessão", "settings.magicPrompts.sidebar.item.gitCommitGenerate": "Geração de commit", @@ -237,6 +238,8 @@ export const settingsDict = { "settings.magicPrompts.sidebar.item.githubPrFailedChecksReview": "Revisão de PR com verificações com falha", "settings.magicPrompts.sidebar.item.githubPrCommentsReview": "Revisão de comentários de PR", "settings.magicPrompts.sidebar.item.githubSinglePrCommentReview": "Revisão de comentário único de PR", + "settings.magicPrompts.sidebar.item.gitlabPrReview": "Revisão de MR", + "settings.magicPrompts.sidebar.item.gitlabIssueReview": "Revisão de issue", "settings.magicPrompts.sidebar.item.planTodo": "Planejar Todo", "settings.magicPrompts.sidebar.item.planImprove": "Melhorar plano", "settings.magicPrompts.sidebar.item.planImplement": "Implementar plano", @@ -2076,6 +2079,10 @@ export const settingsDict = { "settings.magicPrompts.page.group.githubPrCommentsReview.description": "Prompts usados para a análise de comentários de um PR.", "settings.magicPrompts.page.group.githubSinglePrCommentReview.title": "Revisão de comentário único de PR", "settings.magicPrompts.page.group.githubSinglePrCommentReview.description": "Prompts usados para a análise de um único comentário em um PR.", + "settings.magicPrompts.page.group.gitlabPrReview.title": "Revisão de MR", + "settings.magicPrompts.page.group.gitlabPrReview.description": "Prompts usados para o fluxo de revisão de MR do GitLab: mensagem visível do usuário + carga de instruções ocultas.", + "settings.magicPrompts.page.group.gitlabIssueReview.title": "Revisão de issue", + "settings.magicPrompts.page.group.gitlabIssueReview.description": "Prompts usados para o fluxo de revisão de issue do GitLab: mensagem visível do usuário + carga de instruções ocultas.", "settings.magicPrompts.page.group.gitConflictResolve.title": "Resolução de conflitos de merge/rebase", "settings.magicPrompts.page.group.gitConflictResolve.description": "Prompts usados ao resolver conflitos de merge/rebase com IA.", "settings.magicPrompts.page.group.gitCherrypickConflictResolve.title": "Resolução de conflitos de cherry-pick", diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 500cc98b..deaf986f 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -1861,7 +1861,9 @@ export const dict: Record = { "session.newWorktree.branchNamePlaceholder": "feature/my-awesome-feature", "session.newWorktree.actions.change": "Alterar", "session.newWorktree.actions.startFromGitHubIssuePr": "Iniciar de Issue/PR de GitHub", + "session.newWorktree.actions.startFromGitLabIssueMr": "Iniciar de Issue/MR de GitLab", "session.newWorktree.usingPrBranch": "Usando branch da PR: {branch}", + "session.newWorktree.usingMrBranch": "Usando branch do MR: {branch}", "session.newWorktree.fromIssue": "Da issue #{number}: {title}", "session.newWorktree.worktreeDirectory": "Diretório do worktree", "session.newWorktree.worktreeDirectoryPlaceholder": "my-worktree-directory", @@ -1872,6 +1874,7 @@ export const dict: Record = { "session.newWorktree.newBranchFromSource": "Uma nova branch será criada a partir de {source}", "session.newWorktree.issueNumber": "Issue #{number}", "session.newWorktree.prNumber": "PR #{number}", + "session.newWorktree.mrNumber": "MR #{number}", "session.newWorktree.includeDiffBadge": "+diff", "session.newWorktree.newSessionTitle": "Nova sessão", "session.newWorktree.fromSource": "de {source}", @@ -1884,9 +1887,11 @@ export const dict: Record = { "session.newWorktree.error.branchNameRequired": "É necessário informar o nome da branch", "session.newWorktree.error.worktreeDirectoryRequired": "É necessário o diretório do worktree", "session.newWorktree.error.sendGitHubContextFailed": "Não foi possível enviar o contexto de GitHub", + "session.newWorktree.error.sendGitLabContextFailed": "Não foi possível enviar o contexto de GitLab", "session.newWorktree.error.createWorktreeFailed": "Não foi possível criar o worktree", "session.newWorktree.toast.sessionFromIssue": "Sessão criada a partir da issue", "session.newWorktree.toast.sessionFromPr": "Sessão criada a partir da PR", + "session.newWorktree.toast.sessionFromMr": "Sessão criada a partir do merge request", "session.newWorktree.toast.worktreeCreated": "Worktree criado", "session.newWorktree.toast.worktreeCreatedDescription": "{target} - configurando em segundo plano", "session.githubIntegration.title": "Selecionar de GitHub", @@ -1911,6 +1916,29 @@ export const dict: Record = { "session.githubIntegration.validation.branchAlreadyCheckedOut": "A branch já está em uso em um worktree", "session.githubIntegration.validation.branchAlreadyExists": "A branch já existe localmente", "session.githubIntegration.validation.failed": "Não foi possível validar", + "session.gitlabIntegration.title": "Selecionar de GitLab", + "session.gitlabIntegration.tabs.issues": "Issues", + "session.gitlabIntegration.tabs.mergeRequests": "Merge Requests", + "session.gitlabIntegration.connect.title": "Conectar a GitLab", + "session.gitlabIntegration.connect.description": "Vincule issues ou merge requests para preencher automaticamente os detalhes do worktree", + "session.gitlabIntegration.connect.action": "Conectar a GitLab", + "session.gitlabIntegration.search.issuesPlaceholder": "Pesquisar issues do GitLab", + "session.gitlabIntegration.search.mrsPlaceholder": "Pesquisar merge requests do GitLab", + "session.gitlabIntegration.empty.noIssuesFound": "Nenhuma issue encontrada", + "session.gitlabIntegration.empty.noMergeRequestsFound": "Nenhum merge request encontrado", + "session.gitlabIntegration.actions.loadMore": "Carregar mais", + "session.gitlabIntegration.actions.cancel": "Cancelar", + "session.gitlabIntegration.actions.select": "Selecionar", + "session.gitlabIntegration.selected.issueNumber": "Issue #{number}", + "session.gitlabIntegration.selected.mrNumber": "MR #{number}", + "session.gitlabIntegration.includeDiffAria": "Incluir diff do MR no contexto da sessão", + "session.gitlabIntegration.includeDiff": "Incluir diff do MR", + "session.gitlabIntegration.error.notConnected": "GitLab não está conectado", + "session.gitlabIntegration.error.loadDataFailed": "Não foi possível carregar os dados", + "session.gitlabIntegration.validation.branchAlreadyCheckedOut": "A branch já está em uso em um worktree", + "session.gitlabIntegration.validation.branchAlreadyExists": "A branch já existe localmente", + "session.gitlabIntegration.validation.failed": "Não foi possível validar", + "session.gitlabIntegration.draftBadge": "Rascunho", "chat.fileAttachment.toast.attachFailed": "Não foi possível anexar o arquivo", "chat.fileAttachment.toast.someFilesSkipped": "Alguns arquivos foram omitidos:\n{summary}", "chat.fileAttachment.toast.vscodePickFailed": "Não foi possível selecionar arquivos em VS Code", diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index b5f09da6..12b9c97d 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -226,6 +226,7 @@ export const settingsDict = { "settings.magicPrompts.sidebar.description": "Виберіть шаблон промпта для редагування.", "settings.magicPrompts.sidebar.group.git": "Git", "settings.magicPrompts.sidebar.group.github": "GitHub", + "settings.magicPrompts.sidebar.group.gitlab": "GitLab", "settings.magicPrompts.sidebar.group.planning": "Планування", "settings.magicPrompts.sidebar.group.session": "Сесія", "settings.magicPrompts.sidebar.item.gitCommitGenerate": "Генерація комітів", @@ -237,6 +238,8 @@ export const settingsDict = { "settings.magicPrompts.sidebar.item.githubPrFailedChecksReview": "Перевірка перевірок PR", "settings.magicPrompts.sidebar.item.githubPrCommentsReview": "Огляд коментарів PR", "settings.magicPrompts.sidebar.item.githubSinglePrCommentReview": "Огляд єдиного PR коментаря", + "settings.magicPrompts.sidebar.item.gitlabPrReview": "MR огляд", + "settings.magicPrompts.sidebar.item.gitlabIssueReview": "Огляд issue", "settings.magicPrompts.sidebar.item.planTodo": "Планування Todo", "settings.magicPrompts.sidebar.item.planImprove": "Поліпшити план", "settings.magicPrompts.sidebar.item.planImplement": "Реалізувати план", @@ -2076,6 +2079,10 @@ export const settingsDict = { "settings.magicPrompts.page.group.githubPrCommentsReview.description": "Промпти, які використовуються для аналізу PR коментарів.", "settings.magicPrompts.page.group.githubSinglePrCommentReview.title": "Огляд окремого коментаря PR", "settings.magicPrompts.page.group.githubSinglePrCommentReview.description": "Промпти, які використовуються для аналізу окремих коментарів PR.", + "settings.magicPrompts.page.group.gitlabPrReview.title": "Огляд MR", + "settings.magicPrompts.page.group.gitlabPrReview.description": "Промпти, які використовуються для GitLab MR-огляду: видиме повідомлення користувача + приховані інструкції.", + "settings.magicPrompts.page.group.gitlabIssueReview.title": "Огляд issue", + "settings.magicPrompts.page.group.gitlabIssueReview.description": "Промпти, які використовуються для GitLab issue-огляду: видиме повідомлення користувача + приховані інструкції.", "settings.magicPrompts.page.group.gitConflictResolve.title": "Вирішення конфліктів злиття/перебазування", "settings.magicPrompts.page.group.gitConflictResolve.description": "Промпти, які використовуються під час вирішення конфліктів злиття/перебазування з AI.", "settings.magicPrompts.page.group.gitCherrypickConflictResolve.title": "Вирішення конфліктів", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index fa1496f5..88b9e7cf 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -1861,7 +1861,9 @@ export const dict: Record = { "session.newWorktree.branchNamePlaceholder": "feature/my-awesome-feature", "session.newWorktree.actions.change": "Змінити", "session.newWorktree.actions.startFromGitHubIssuePr": "Почати з GitHub issue/PR", + "session.newWorktree.actions.startFromGitLabIssueMr": "Почати з GitLab issue/MR", "session.newWorktree.usingPrBranch": "Використовується PR-гілка: {branch}", + "session.newWorktree.usingMrBranch": "Використовується MR-гілка: {branch}", "session.newWorktree.fromIssue": "З issue №{number}: {title}", "session.newWorktree.worktreeDirectory": "Каталог worktree", "session.newWorktree.worktreeDirectoryPlaceholder": "my-worktree-directory", @@ -1872,6 +1874,7 @@ export const dict: Record = { "session.newWorktree.newBranchFromSource": "Нову гілку буде створено з {source}", "session.newWorktree.issueNumber": "issue №{number}", "session.newWorktree.prNumber": "PR #{number}", + "session.newWorktree.mrNumber": "MR #{number}", "session.newWorktree.includeDiffBadge": "+diff", "session.newWorktree.newSessionTitle": "Нова сесія", "session.newWorktree.fromSource": "з {source}", @@ -1884,9 +1887,11 @@ export const dict: Record = { "session.newWorktree.error.branchNameRequired": "Необхідно вказати назву гілки", "session.newWorktree.error.worktreeDirectoryRequired": "Потрібен каталог worktree", "session.newWorktree.error.sendGitHubContextFailed": "Не вдалося надіслати контекст GitHub", + "session.newWorktree.error.sendGitLabContextFailed": "Не вдалося надіслати контекст GitLab", "session.newWorktree.error.createWorktreeFailed": "Не вдалося створити worktree", "session.newWorktree.toast.sessionFromIssue": "Сесію створено з issue", "session.newWorktree.toast.sessionFromPr": "Сесію створено з PR", + "session.newWorktree.toast.sessionFromMr": "Сесію створено з merge request", "session.newWorktree.toast.worktreeCreated": "Створено worktree", "session.newWorktree.toast.worktreeCreatedDescription": "{target} - завантаження у фоні", "session.githubIntegration.title": "Виберіть із GitHub", @@ -1911,6 +1916,29 @@ export const dict: Record = { "session.githubIntegration.validation.branchAlreadyCheckedOut": "Гілку вже відкрито в worktree", "session.githubIntegration.validation.branchAlreadyExists": "Гілка вже існує локально", "session.githubIntegration.validation.failed": "Помилка перевірки", + "session.gitlabIntegration.title": "Виберіть із GitLab", + "session.gitlabIntegration.tabs.issues": "Issue", + "session.gitlabIntegration.tabs.mergeRequests": "Merge Request", + "session.gitlabIntegration.connect.title": "Підключитися до GitLab", + "session.gitlabIntegration.connect.description": "Пов’яжіть issue або merge request, щоб автоматично заповнити деталі worktree", + "session.gitlabIntegration.connect.action": "Підключити GitLab", + "session.gitlabIntegration.search.issuesPlaceholder": "Шукати issue в GitLab", + "session.gitlabIntegration.search.mrsPlaceholder": "Шукати merge request у GitLab", + "session.gitlabIntegration.empty.noIssuesFound": "Issue не знайдено", + "session.gitlabIntegration.empty.noMergeRequestsFound": "Merge request не знайдено", + "session.gitlabIntegration.actions.loadMore": "Завантажити ще", + "session.gitlabIntegration.actions.cancel": "Скасувати", + "session.gitlabIntegration.actions.select": "Вибрати", + "session.gitlabIntegration.selected.issueNumber": "issue №{number}", + "session.gitlabIntegration.selected.mrNumber": "MR #{number}", + "session.gitlabIntegration.includeDiffAria": "Додати diff MR у контекст сесії", + "session.gitlabIntegration.includeDiff": "Додати diff MR", + "session.gitlabIntegration.error.notConnected": "GitLab не підключено", + "session.gitlabIntegration.error.loadDataFailed": "Не вдалося завантажити дані", + "session.gitlabIntegration.validation.branchAlreadyCheckedOut": "Гілку вже відкрито в worktree", + "session.gitlabIntegration.validation.branchAlreadyExists": "Гілка вже існує локально", + "session.gitlabIntegration.validation.failed": "Помилка перевірки", + "session.gitlabIntegration.draftBadge": "Чернетка", "chat.fileAttachment.toast.attachFailed": "Не вдалося прикріпити файл", "chat.fileAttachment.toast.someFilesSkipped": "Деякі файли були пропущені:\n{summary}", "chat.fileAttachment.toast.vscodePickFailed": "Не вдалося вибрати файли в VS Code", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts index e362fe0d..efe2082e 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -226,6 +226,7 @@ export const settingsDict = { 'settings.magicPrompts.sidebar.description': '选择要编辑的提示词模板。', 'settings.magicPrompts.sidebar.group.git': 'Git', 'settings.magicPrompts.sidebar.group.github': 'GitHub', + 'settings.magicPrompts.sidebar.group.gitlab': 'GitLab', 'settings.magicPrompts.sidebar.group.planning': '规划', 'settings.magicPrompts.sidebar.group.session': '会话', 'settings.magicPrompts.sidebar.item.gitCommitGenerate': '提交生成', @@ -237,6 +238,8 @@ export const settingsDict = { 'settings.magicPrompts.sidebar.item.githubPrFailedChecksReview': 'PR 失败检查审查', 'settings.magicPrompts.sidebar.item.githubPrCommentsReview': 'PR 评论审查', 'settings.magicPrompts.sidebar.item.githubSinglePrCommentReview': '单条 PR 评论审查', + 'settings.magicPrompts.sidebar.item.gitlabPrReview': 'MR 审查', + 'settings.magicPrompts.sidebar.item.gitlabIssueReview': 'Issue 审查', 'settings.magicPrompts.sidebar.item.planTodo': '待办规划', 'settings.magicPrompts.sidebar.item.planImprove': '改进计划', 'settings.magicPrompts.sidebar.item.planImplement': '执行计划', @@ -2076,6 +2079,10 @@ export const settingsDict = { 'settings.magicPrompts.page.group.githubPrCommentsReview.description': '用于 PR 评论分析的提示词。', 'settings.magicPrompts.page.group.githubSinglePrCommentReview.title': '单条 PR 评论审查', 'settings.magicPrompts.page.group.githubSinglePrCommentReview.description': '用于单条 PR 评论分析的提示词。', + 'settings.magicPrompts.page.group.gitlabPrReview.title': 'MR 审查', + 'settings.magicPrompts.page.group.gitlabPrReview.description': '用于 GitLab MR 审查流程的提示词:可见用户消息 + 隐藏指令负载。', + 'settings.magicPrompts.page.group.gitlabIssueReview.title': 'Issue 审查', + 'settings.magicPrompts.page.group.gitlabIssueReview.description': '用于 GitLab Issue 审查流程的提示词:可见用户消息 + 隐藏指令负载。', 'settings.magicPrompts.page.group.gitConflictResolve.title': '合并/Rebase 冲突解决', 'settings.magicPrompts.page.group.gitConflictResolve.description': '用于 AI 解决合并/Rebase 冲突的提示词。', 'settings.magicPrompts.page.group.gitCherrypickConflictResolve.title': 'Cherry-pick 冲突解决', diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 2b670e97..06a404e9 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -1849,7 +1849,9 @@ export const dict: Record = { 'session.newWorktree.branchNamePlaceholder': 'feature/my-awesome-feature', 'session.newWorktree.actions.change': '更改', 'session.newWorktree.actions.startFromGitHubIssuePr': '从 GitHub Issue/PR 开始', + 'session.newWorktree.actions.startFromGitLabIssueMr': '从 GitLab Issue/MR 开始', 'session.newWorktree.usingPrBranch': '使用 PR 分支:{branch}', + 'session.newWorktree.usingMrBranch': '使用 MR 分支:{branch}', 'session.newWorktree.fromIssue': '来自 Issue #{number}:{title}', 'session.newWorktree.worktreeDirectory': '工作树目录', 'session.newWorktree.worktreeDirectoryPlaceholder': 'my-worktree-directory', @@ -1860,6 +1862,7 @@ export const dict: Record = { 'session.newWorktree.newBranchFromSource': '新分支将从 {source} 创建', 'session.newWorktree.issueNumber': 'Issue #{number}', 'session.newWorktree.prNumber': 'PR #{number}', + 'session.newWorktree.mrNumber': 'MR #{number}', 'session.newWorktree.includeDiffBadge': '+差异', 'session.newWorktree.newSessionTitle': '新会话', 'session.newWorktree.fromSource': '来自 {source}', @@ -1872,9 +1875,11 @@ export const dict: Record = { 'session.newWorktree.error.branchNameRequired': '分支名是必填项', 'session.newWorktree.error.worktreeDirectoryRequired': '工作树目录为必填项', 'session.newWorktree.error.sendGitHubContextFailed': '发送 GitHub 上下文失败', + 'session.newWorktree.error.sendGitLabContextFailed': '发送 GitLab 上下文失败', 'session.newWorktree.error.createWorktreeFailed': '创建工作树失败', 'session.newWorktree.toast.sessionFromIssue': '已从 Issue 创建会话', 'session.newWorktree.toast.sessionFromPr': '已从 PR 创建会话', + 'session.newWorktree.toast.sessionFromMr': '已从 Merge Request 创建会话', 'session.newWorktree.toast.worktreeCreated': '工作树已创建', 'session.newWorktree.toast.worktreeCreatedDescription': '{target} - 正在后台初始化', 'session.githubIntegration.title': '从 GitHub 选择', @@ -1899,6 +1904,29 @@ export const dict: Record = { 'session.githubIntegration.validation.branchAlreadyCheckedOut': '该分支已在某个工作树中检出', 'session.githubIntegration.validation.branchAlreadyExists': '该分支已在本地存在', 'session.githubIntegration.validation.failed': '校验失败', + 'session.gitlabIntegration.title': '从 GitLab 选择', + 'session.gitlabIntegration.tabs.issues': 'Issues', + 'session.gitlabIntegration.tabs.mergeRequests': 'Merge Requests', + 'session.gitlabIntegration.connect.title': '连接到 GitLab', + 'session.gitlabIntegration.connect.description': '关联 Issue 或 Merge Request 以自动填充工作树详情', + 'session.gitlabIntegration.connect.action': '连接 GitLab', + 'session.gitlabIntegration.search.issuesPlaceholder': '搜索 GitLab Issue', + 'session.gitlabIntegration.search.mrsPlaceholder': '搜索 GitLab Merge Request', + 'session.gitlabIntegration.empty.noIssuesFound': '未找到 Issue', + 'session.gitlabIntegration.empty.noMergeRequestsFound': '未找到 Merge Request', + 'session.gitlabIntegration.actions.loadMore': '加载更多', + 'session.gitlabIntegration.actions.cancel': '取消', + 'session.gitlabIntegration.actions.select': '选择', + 'session.gitlabIntegration.selected.issueNumber': 'Issue #{number}', + 'session.gitlabIntegration.selected.mrNumber': 'MR #{number}', + 'session.gitlabIntegration.includeDiffAria': '在会话上下文中包含 MR 差异', + 'session.gitlabIntegration.includeDiff': '包含 MR 差异', + 'session.gitlabIntegration.error.notConnected': 'GitLab 未连接', + 'session.gitlabIntegration.error.loadDataFailed': '加载数据失败', + 'session.gitlabIntegration.validation.branchAlreadyCheckedOut': '该分支已在某个工作树中检出', + 'session.gitlabIntegration.validation.branchAlreadyExists': '该分支已在本地存在', + 'session.gitlabIntegration.validation.failed': '校验失败', + 'session.gitlabIntegration.draftBadge': '草稿', 'chat.fileAttachment.toast.attachFailed': '附加文件失败', 'chat.fileAttachment.toast.someFilesSkipped': '以下文件被跳过:\n{summary}', 'chat.fileAttachment.toast.vscodePickFailed': '在 VS Code 中选择文件失败', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts index 16000f19..9d2e6aa9 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts @@ -223,6 +223,7 @@ export const settingsDict = { 'settings.magicPrompts.sidebar.description': '選擇要編輯的提示詞模板。', 'settings.magicPrompts.sidebar.group.git': 'Git', 'settings.magicPrompts.sidebar.group.github': 'GitHub', + 'settings.magicPrompts.sidebar.group.gitlab': 'GitLab', 'settings.magicPrompts.sidebar.group.planning': '規劃', 'settings.magicPrompts.sidebar.group.session': '工作階段', 'settings.magicPrompts.sidebar.item.gitCommitGenerate': '提交生成', @@ -234,6 +235,8 @@ export const settingsDict = { 'settings.magicPrompts.sidebar.item.githubPrFailedChecksReview': 'PR 失敗檢查審查', 'settings.magicPrompts.sidebar.item.githubPrCommentsReview': 'PR 評論審查', 'settings.magicPrompts.sidebar.item.githubSinglePrCommentReview': '單則 PR 評論審查', + 'settings.magicPrompts.sidebar.item.gitlabPrReview': 'MR 審查', + 'settings.magicPrompts.sidebar.item.gitlabIssueReview': 'Issue 審查', 'settings.magicPrompts.sidebar.item.planTodo': '待辦規劃', 'settings.magicPrompts.sidebar.item.planImprove': '改進計畫', 'settings.magicPrompts.sidebar.item.planImplement': '執行計畫', @@ -1983,6 +1986,10 @@ export const settingsDict = { 'settings.magicPrompts.page.group.githubPrCommentsReview.description': '用於 PR 評論分析的提示詞。', 'settings.magicPrompts.page.group.githubSinglePrCommentReview.title': '單則 PR 評論審查', 'settings.magicPrompts.page.group.githubSinglePrCommentReview.description': '用於單則 PR 評論分析的提示詞。', + 'settings.magicPrompts.page.group.gitlabPrReview.title': 'MR 審查', + 'settings.magicPrompts.page.group.gitlabPrReview.description': '用於 GitLab MR 審查流程的提示詞:可見使用者訊息 + 隱藏指令負載。', + 'settings.magicPrompts.page.group.gitlabIssueReview.title': 'Issue 審查', + 'settings.magicPrompts.page.group.gitlabIssueReview.description': '用於 GitLab Issue 審查流程的提示詞:可見使用者訊息 + 隱藏指令負載。', 'settings.magicPrompts.page.group.gitConflictResolve.title': 'Merge/Rebase 衝突解決', 'settings.magicPrompts.page.group.gitConflictResolve.description': '用於 AI 解決 Merge/Rebase 衝突的提示詞。', 'settings.magicPrompts.page.group.gitCherrypickConflictResolve.title': 'Cherry-pick 衝突解決', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 313d59ed..921bacf1 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -1853,7 +1853,9 @@ export const dict: Record = { 'session.newWorktree.branchNamePlaceholder': 'feature/my-awesome-feature', 'session.newWorktree.actions.change': '變更', 'session.newWorktree.actions.startFromGitHubIssuePr': '從 GitHub Issue/PR 開始', + 'session.newWorktree.actions.startFromGitLabIssueMr': '從 GitLab Issue/MR 開始', 'session.newWorktree.usingPrBranch': '使用 PR 分支:{branch}', + 'session.newWorktree.usingMrBranch': '使用 MR 分支:{branch}', 'session.newWorktree.fromIssue': '來自 Issue #{number}:{title}', 'session.newWorktree.worktreeDirectory': 'Worktree 目錄', 'session.newWorktree.worktreeDirectoryPlaceholder': 'my-worktree-directory', @@ -1864,6 +1866,7 @@ export const dict: Record = { 'session.newWorktree.newBranchFromSource': '新分支將從 {source} 建立', 'session.newWorktree.issueNumber': 'Issue #{number}', 'session.newWorktree.prNumber': 'PR #{number}', + 'session.newWorktree.mrNumber': 'MR #{number}', 'session.newWorktree.includeDiffBadge': '+diff', 'session.newWorktree.newSessionTitle': '新會話', 'session.newWorktree.fromSource': '來自 {source}', @@ -1876,9 +1879,11 @@ export const dict: Record = { 'session.newWorktree.error.branchNameRequired': '分支名稱是必填項', 'session.newWorktree.error.worktreeDirectoryRequired': 'Worktree 目錄是必填項', 'session.newWorktree.error.sendGitHubContextFailed': '傳送 GitHub 上下文失敗', + 'session.newWorktree.error.sendGitLabContextFailed': '傳送 GitLab 上下文失敗', 'session.newWorktree.error.createWorktreeFailed': '建立 Worktree 失敗', 'session.newWorktree.toast.sessionFromIssue': '已從 Issue 建立會話', 'session.newWorktree.toast.sessionFromPr': '已從 PR 建立會話', + 'session.newWorktree.toast.sessionFromMr': '已從 Merge Request 建立會話', 'session.newWorktree.toast.worktreeCreated': 'Worktree 已建立', 'session.newWorktree.toast.worktreeCreatedDescription': '{target} - 正在背景初始化', 'session.githubIntegration.title': '從 GitHub 選擇', @@ -1903,6 +1908,29 @@ export const dict: Record = { 'session.githubIntegration.validation.branchAlreadyCheckedOut': '該分支已在某個 worktree 中簽出', 'session.githubIntegration.validation.branchAlreadyExists': '該分支已在本地存在', 'session.githubIntegration.validation.failed': '驗證失敗', + 'session.gitlabIntegration.title': '從 GitLab 選擇', + 'session.gitlabIntegration.tabs.issues': 'Issues', + 'session.gitlabIntegration.tabs.mergeRequests': 'Merge Requests', + 'session.gitlabIntegration.connect.title': '連接到 GitLab', + 'session.gitlabIntegration.connect.description': '關聯 Issue 或 Merge Request 以自動填入 worktree 詳情', + 'session.gitlabIntegration.connect.action': '連接 GitLab', + 'session.gitlabIntegration.search.issuesPlaceholder': '搜尋 GitLab Issue', + 'session.gitlabIntegration.search.mrsPlaceholder': '搜尋 GitLab Merge Request', + 'session.gitlabIntegration.empty.noIssuesFound': '找不到 Issue', + 'session.gitlabIntegration.empty.noMergeRequestsFound': '找不到 Merge Request', + 'session.gitlabIntegration.actions.loadMore': '載入更多', + 'session.gitlabIntegration.actions.cancel': '取消', + 'session.gitlabIntegration.actions.select': '選擇', + 'session.gitlabIntegration.selected.issueNumber': 'Issue #{number}', + 'session.gitlabIntegration.selected.mrNumber': 'MR #{number}', + 'session.gitlabIntegration.includeDiffAria': '在會話上下文中包含 MR diff', + 'session.gitlabIntegration.includeDiff': '包含 MR diff', + 'session.gitlabIntegration.error.notConnected': 'GitLab 未連線', + 'session.gitlabIntegration.error.loadDataFailed': '載入資料失敗', + 'session.gitlabIntegration.validation.branchAlreadyCheckedOut': '該分支已在某個 worktree 中簽出', + 'session.gitlabIntegration.validation.branchAlreadyExists': '該分支已在本地存在', + 'session.gitlabIntegration.validation.failed': '驗證失敗', + 'session.gitlabIntegration.draftBadge': '草稿', 'chat.fileAttachment.toast.attachFailed': '附加檔案失敗', 'chat.fileAttachment.toast.someFilesSkipped': '以下檔案被跳過:\n{summary}', 'chat.fileAttachment.toast.vscodePickFailed': '在 VS Code 中選擇檔案失敗', diff --git a/packages/ui/src/lib/linkedIssues.test.ts b/packages/ui/src/lib/linkedIssues.test.ts index 34281475..071c736e 100644 --- a/packages/ui/src/lib/linkedIssues.test.ts +++ b/packages/ui/src/lib/linkedIssues.test.ts @@ -51,6 +51,37 @@ describe('buildLinkedIssue', () => { expect(built.kind).toBe('pull'); }); + test('parses GitLab issue urls with nested namespaces on any host', () => { + const built = buildLinkedIssue({ + url: 'https://gitlab.example.com/a/b/project/-/issues/5', + number: 5, + title: 'Nested issue', + kind: 'issue', + linkedAt: 5, + }); + expect(built.id).toBe('a/b/project#5'); + }); + + test('parses GitLab merge request urls, including legacy non-/- paths', () => { + const modern = buildLinkedIssue({ + url: 'https://gitlab.com/owner/repo/-/merge_requests/7', + number: 7, + title: 'Modern MR', + kind: 'pull', + linkedAt: 5, + }); + expect(modern.id).toBe('owner/repo#7'); + + const legacy = buildLinkedIssue({ + url: 'https://gitlab.example.com/owner/repo/merge_requests/9', + number: 9, + title: 'Legacy MR', + kind: 'pull', + linkedAt: 5, + }); + expect(legacy.id).toBe('owner/repo#9'); + }); + test('falls back to a url-based id for an unparseable url', () => { const built = buildLinkedIssue({ url: 'https://ghe.internal/x', diff --git a/packages/ui/src/lib/linkedIssues.ts b/packages/ui/src/lib/linkedIssues.ts index da61ba31..2e6a87d7 100644 --- a/packages/ui/src/lib/linkedIssues.ts +++ b/packages/ui/src/lib/linkedIssues.ts @@ -54,6 +54,26 @@ export const buildLinkedIssueId = (owner: string, repo: string, number: number): * separately. A URL that does not parse falls back to itself, which is still * unique per thread — the id only has to identify an entry, not be pretty. */ +const GITHUB_URL_RE = /github\.com\/([^/]+)\/([^/]+)\//; +// GitLab puts the project path (possibly nested namespaces, e.g. a/b/project) +// before the `/-/issues|/merge_requests/` segment on any host. The legacy +// non-`/-/` issue/merge-request URLs are accepted too. +const GITLAB_URL_RE = /^https?:\/\/[^/]+\/(.+?)\/(?:-\/)?(?:issues|merge_requests)\/\d+/; + +const buildStableIssueId = (url: string, number: number): string => { + const githubMatch = GITHUB_URL_RE.exec(url); + if (githubMatch) { + return buildLinkedIssueId(githubMatch[1], githubMatch[2], number); + } + + const gitlabMatch = GITLAB_URL_RE.exec(url); + if (gitlabMatch) { + return `${gitlabMatch[1]}#${number}`; + } + + return `${url}#${number}`; +}; + export const buildLinkedIssue = (input: { url: string; number: number; @@ -62,10 +82,7 @@ export const buildLinkedIssue = (input: { author?: { login?: string; avatarUrl?: string } | null; linkedAt: number; }): LinkedIssue => { - const match = /github\.com\/([^/]+)\/([^/]+)\//.exec(input.url); - const id = match - ? buildLinkedIssueId(match[1], match[2], input.number) - : `${input.url}#${input.number}`; + const id = buildStableIssueId(input.url, input.number); return { id, diff --git a/packages/ui/src/lib/magicPrompts.ts b/packages/ui/src/lib/magicPrompts.ts index 07a31208..c8f99a3d 100644 --- a/packages/ui/src/lib/magicPrompts.ts +++ b/packages/ui/src/lib/magicPrompts.ts @@ -19,6 +19,10 @@ export type MagicPromptId = | 'github.pr.comments.review.instructions' | 'github.pr.comment.single.visible' | 'github.pr.comment.single.instructions' + | 'gitlab.pr.review.visible' + | 'gitlab.pr.review.instructions' + | 'gitlab.issue.review.visible' + | 'gitlab.issue.review.instructions' | 'plan.todo.visible' | 'plan.todo.instructions' | 'plan.improve.visible' @@ -56,7 +60,7 @@ export interface MagicPromptDefinition { id: MagicPromptId; title: string; description: string; - group: 'Git' | 'GitHub' | 'Planning' | 'Session'; + group: 'Git' | 'GitHub' | 'GitLab' | 'Planning' | 'Session'; template: string; placeholders?: Array<{ key: string; description: string }>; } @@ -311,6 +315,121 @@ Do not implement changes until I confirm; end with: "Next actions: <1 sentence>" - Identify exact code areas likely impacted. - Before proposing a plan: if the reviewer's intent is ambiguous or the required change depends on a tradeoff only I can decide, ask me focused clarifying questions in batches of at most 3 and wait for answers. Do not speculate. - Once intent is clear, propose a minimal implementation plan and verification steps.`, + }, + { + id: 'gitlab.pr.review.visible', + title: 'MR Review Visible Prompt', + group: 'GitLab', + description: 'Visible user message when creating merge request review requests from GitLab context.', + placeholders: [ + { key: 'mr_number', description: 'Merge request number.' }, + ], + template: 'Review this merge request !{{mr_number}} using the provided MR context', + }, + { + id: 'gitlab.pr.review.instructions', + title: 'MR Review Instructions', + group: 'GitLab', + description: 'Hidden instructions attached when generating a GitLab merge request review response.', + template: `You are drafting a merge request review comment that will be posted back to the MR author. You are not the implementer; do not propose to write code or run commands. + +Before drafting: +- Read the MR title and body first to anchor on the author's intent. Evaluate whether the implementation matches that intent — missing pieces, incorrect behavior vs intent, scope creep. +- The MR diff is the source of truth for what changed; the repo on disk may not yet reflect those changes. Read the diff carefully. Use the repo only as ancillary context (imports, call sites, existing patterns, nearby code) when you need to verify a specific claim — not to discover the changes themselves. +- No speculation: every reported issue must be grounded in the diff plus ancillary repo evidence you actually read. If a claim cannot be verified, drop it — do not hedge or guess. +- Clarifying question: if the MR's intent itself is unreadable (title/body give no "why", diff is ambiguous on purpose), ask me one focused question about intent and stop. Do not open a discovery loop — this is a review, not a planning session. + +High-signal bar — only report issues that meet all of: +- Objective and verifiable from the diff plus ancillary repo evidence. +- Introduced by this MR (not pre-existing). +- Material: bugs that will cause incorrect runtime behavior, security/privacy risks, correctness edge cases, backwards-compat breakage, missing implementations across modules/targets, boundary violations, OR a clear CLAUDE.md / AGENTS.md violation where you can quote the exact rule. + +Do NOT report: +- Pre-existing issues unrelated to the diff. +- Pedantic nitpicks a senior engineer would not flag. +- Issues a linter would catch. +- Subjective style preferences not explicitly required by CLAUDE.md / AGENTS.md. +- "Might" / "could" / "potential" concerns without concrete evidence. +- Rules mentioned in CLAUDE.md / AGENTS.md but explicitly silenced in the code (e.g., via an ignore comment or documented exception). +- Missing tests / coverage gaps unless CLAUDE.md / AGENTS.md explicitly requires them for the changed area. + +Validation pass: before writing the final comment, re-check each candidate issue against the diff + ancillary repo evidence. Drop anything you are not certain about. False positives waste the author's time. + +Output rules: +- Produce a single review comment addressed to the MR author, using the exact format below. +- No emojis. No code snippets. No fenced blocks. Short inline code identifiers are fine. +- Reference evidence with file paths and line ranges (e.g., path/to/file.ts:120-138) derived from the diff. Use "approx" only as a last resort when the diff does not expose exact lines. +- One bullet per unique issue; do not duplicate an issue across sections. +- Keep the whole comment under ~300 words. + +Format exactly: +<1-2 sentence summary of intent and top-level verdict> + +Must-fix: +- - - - Action: +Nice-to-have: +- - - - Action: + +If nothing clears the high-signal bar, write: +Must-fix: +- None +Nice-to-have: +- None`, + }, + { + id: 'gitlab.issue.review.visible', + title: 'Issue Review Visible Prompt', + group: 'GitLab', + description: 'Visible user message when creating issue review requests from GitLab context.', + placeholders: [ + { key: 'issue_number', description: 'Issue number.' }, + ], + template: 'Review this issue #{{issue_number}} using the provided issue context', + }, + { + id: 'gitlab.issue.review.instructions', + title: 'Issue Review Instructions', + group: 'GitLab', + description: 'Hidden instructions attached when generating a GitLab issue review response.', + template: `Review this GitLab issue using the provided issue context. + +Process: +- First classify the issue type (bug / feature request / question/support / refactor / ops) and state it as: Type: . +- Gather any needed repository context (code, config, docs) to validate assumptions. +- After gathering, if anything is still unclear or cannot be verified, do not speculate — state what's missing and ask targeted questions. + +Mode selection by type: +- Bug / Question/Support / Ops: deliver the response directly using the matching template below. Do not bombard me with questions for straightforward diagnosis; use "Missing info" / "Repro/diagnostics needed" fields instead. +- Feature request / Refactor with substantive unknowns: this is effectively a planning session. Do not emit the Feature template on the first turn. Instead, ask me focused clarifying questions in batches of at most 3, one topic at a time (scope, constraints, tradeoffs, UX, etc.), wait for answers, drop questions that became irrelevant, and repeat until you have no more substantive questions. Only then emit the Feature template. + +Output rules: +- Compact output; pick ONE template below and omit the others. +- No emojis. No code snippets. No fenced blocks. +- Short inline code identifiers allowed. +- Reference evidence with file paths and line ranges when applicable; if exact lines are not available, cite the file and say "approx" + why. +- Keep the entire response under ~300 words (applies to the final template output, not to clarifying-question turns). + +Templates (choose one): +Bug: +- Summary (1-2 sentences) +- Likely cause (max 2) +- Repro/diagnostics needed (max 3) +- Fix approach (max 4 steps) +- Verification (max 3) + +Feature: +- Summary (1-2 sentences) +- Requirements (max 4) +- Unknowns/questions (max 4) +- Proposed plan (max 5 steps) +- Verification (max 3) + +Question/Support: +- Summary (1-2 sentences) +- Answer/guidance (max 6 lines) +- Missing info (max 4) + +Do not implement changes until I confirm; end with: "Next actions: <1 sentence>".`, }, { id: 'git.conflict.resolve.visible', From e83bd2bc080126a70036b17e9d5e89ff996772dd Mon Sep 17 00:00:00 2001 From: bot-hermes Date: Thu, 13 Aug 2026 14:23:24 +0000 Subject: [PATCH 06/45] fix(ui): refresh GitLab auth status on worktree dialog mount --- .../ui/src/components/session/NewWorktreeDialog.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/ui/src/components/session/NewWorktreeDialog.tsx b/packages/ui/src/components/session/NewWorktreeDialog.tsx index 98faf7b7..1a4cbfee 100644 --- a/packages/ui/src/components/session/NewWorktreeDialog.tsx +++ b/packages/ui/src/components/session/NewWorktreeDialog.tsx @@ -243,6 +243,7 @@ export function NewWorktreeDialog({ const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked); const gitlabAuthStatus = useGitLabAuthStore((state) => state.status); const gitlabAuthChecked = useGitLabAuthStore((state) => state.hasChecked); + const refreshGitLabAuth = useGitLabAuthStore((state) => state.refreshStatus); const activeProject = useProjectsStore((state) => state.getActiveProject()); const projectDirectory = activeProject?.path ?? null; @@ -318,6 +319,15 @@ export function NewWorktreeDialog({ const [githubDialogOpen, setGithubDialogOpen] = React.useState(false); const [gitlabDialogOpen, setGitlabDialogOpen] = React.useState(false); + + // Populate the GitLab auth status on mount so the "Start from GitLab issue/MR" + // action is available without first visiting Settings. refreshStatus dedupes + // when already checked and falls back to runtimeFetch when the runtime API + // is unavailable. + React.useEffect(() => { + void refreshGitLabAuth(gitlab); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); // Desktop branch picker states const [existingBranchDropdownOpen, setExistingBranchDropdownOpen] = React.useState(false); From 6868a583591042e41d655ae687fa9064528dda5d Mon Sep 17 00:00:00 2001 From: bot-hermes Date: Thu, 13 Aug 2026 14:40:01 +0000 Subject: [PATCH 07/45] fix(ui): hide GitHub surfaces in non-GitHub repositories --- .../ui/ComposerAttachmentControls.tsx | 41 +++--- .../chat/composer/ui/ComposerFooter.tsx | 5 + .../chat/composer/ui/MobilePillComposer.tsx | 4 + .../ui/src/components/layout/ContextPanel.tsx | 4 +- .../components/session/NewWorktreeDialog.tsx | 20 ++- .../views/walkthrough/WalkthroughView.tsx | 5 +- packages/ui/src/lib/gitProvider.test.ts | 45 +++++++ packages/ui/src/lib/gitProvider.ts | 124 ++++++++++++++++++ 8 files changed, 224 insertions(+), 24 deletions(-) create mode 100644 packages/ui/src/lib/gitProvider.test.ts create mode 100644 packages/ui/src/lib/gitProvider.ts diff --git a/packages/ui/src/components/chat/composer/ui/ComposerAttachmentControls.tsx b/packages/ui/src/components/chat/composer/ui/ComposerAttachmentControls.tsx index 85788b5b..2387ccb9 100644 --- a/packages/ui/src/components/chat/composer/ui/ComposerAttachmentControls.tsx +++ b/packages/ui/src/components/chat/composer/ui/ComposerAttachmentControls.tsx @@ -18,6 +18,7 @@ import { } from '@/components/ui/dropdown-menu'; import { useI18n } from '@/lib/i18n'; import { cn } from '@/lib/utils'; +import type { GitProvider } from '@/lib/gitProvider'; type ComposerAttachmentControlsProps = { isVSCode: boolean; @@ -26,6 +27,8 @@ type ComposerAttachmentControlsProps = { handlePickLocalFiles: () => void; openIssuePicker: () => void; openPrPicker: () => void; + /** Only shows the GitHub issue/PR attach actions when the repo is GitHub. */ + gitProvider?: GitProvider | null; onOpenSettings?: () => void; onMenuOpenChange?: (open: boolean) => void; /** Mobile: open the attachment bottom sheet instead of the dropdown menu. */ @@ -41,6 +44,7 @@ export const ComposerAttachmentControls = React.memo(function ComposerAttachment handlePickLocalFiles, openIssuePicker, openPrPicker, + gitProvider, onOpenSettings, } = props; @@ -98,22 +102,26 @@ export const ComposerAttachmentControls = React.memo(function ComposerAttachment {t('chat.chatInput.actions.attachFiles')} - { - requestAnimationFrame(openIssuePicker); - }} - > - - {t('chat.chatInput.actions.linkGithubIssue')} - - { - requestAnimationFrame(openPrPicker); - }} - > - - {t('chat.chatInput.actions.linkGithubPr')} - + {gitProvider === 'github' ? ( + <> + { + requestAnimationFrame(openIssuePicker); + }} + > + + {t('chat.chatInput.actions.linkGithubIssue')} + + { + requestAnimationFrame(openPrPicker); + }} + > + + {t('chat.chatInput.actions.linkGithubPr')} + + + ) : null} )} @@ -136,6 +144,7 @@ export const ComposerAttachmentControls = React.memo(function ComposerAttachment prev.isVSCode === next.isVSCode && prev.footerIconButtonClass === next.footerIconButtonClass && prev.iconSizeClass === next.iconSizeClass + && prev.gitProvider === next.gitProvider && prev.onOpenSettings === next.onOpenSettings && prev.onMenuOpenChange === next.onMenuOpenChange && prev.onOpenMobileSheet === next.onOpenMobileSheet diff --git a/packages/ui/src/components/chat/composer/ui/ComposerFooter.tsx b/packages/ui/src/components/chat/composer/ui/ComposerFooter.tsx index cc52fdc9..e633475d 100644 --- a/packages/ui/src/components/chat/composer/ui/ComposerFooter.tsx +++ b/packages/ui/src/components/chat/composer/ui/ComposerFooter.tsx @@ -18,6 +18,7 @@ import { ComposerDictation } from '@/components/dictation/ComposerDictation'; import { Icon } from '@/components/icon/Icon'; import { useI18n } from '@/lib/i18n'; import { cn } from '@/lib/utils'; +import { useGitProvider } from '@/lib/gitProvider'; import { ModelControls } from '../../ModelControls'; import { ComposerActionButtons } from './ComposerActionButtons'; import { ComposerAttachmentControls } from './ComposerAttachmentControls'; @@ -106,6 +107,8 @@ export function ComposerFooter(props: ComposerFooterProps) { onDictationContentHeightChange, } = props; + const gitProvider = useGitProvider(directory); + return (
@@ -199,6 +203,7 @@ export function ComposerFooter(props: ComposerFooterProps) { handlePickLocalFiles={onPickLocalFiles} openIssuePicker={onOpenIssuePicker} openPrPicker={onOpenPrPicker} + gitProvider={gitProvider} onOpenSettings={onOpenSettings} />
diff --git a/packages/ui/src/components/chat/composer/ui/ComposerAttachmentControls.tsx b/packages/ui/src/components/chat/composer/ui/ComposerAttachmentControls.tsx index 2387ccb9..df4a070a 100644 --- a/packages/ui/src/components/chat/composer/ui/ComposerAttachmentControls.tsx +++ b/packages/ui/src/components/chat/composer/ui/ComposerAttachmentControls.tsx @@ -27,7 +27,7 @@ type ComposerAttachmentControlsProps = { handlePickLocalFiles: () => void; openIssuePicker: () => void; openPrPicker: () => void; - /** Only shows the GitHub issue/PR attach actions when the repo is GitHub. */ + /** Shows the GitHub issue/PR or GitLab issue/MR attach actions based on the repo provider. */ gitProvider?: GitProvider | null; onOpenSettings?: () => void; onMenuOpenChange?: (open: boolean) => void; @@ -121,6 +121,25 @@ export const ComposerAttachmentControls = React.memo(function ComposerAttachment {t('chat.chatInput.actions.linkGithubPr')} + ) : gitProvider === 'gitlab' ? ( + <> + { + requestAnimationFrame(openIssuePicker); + }} + > + + {t('chat.chatInput.actions.linkGitlabIssue')} + + { + requestAnimationFrame(openPrPicker); + }} + > + + {t('chat.chatInput.actions.linkGitlabMr')} + + ) : null} diff --git a/packages/ui/src/components/session/GitLabIssuePickerDialog.tsx b/packages/ui/src/components/session/GitLabIssuePickerDialog.tsx new file mode 100644 index 00000000..3fd2253e --- /dev/null +++ b/packages/ui/src/components/session/GitLabIssuePickerDialog.tsx @@ -0,0 +1,733 @@ +import React from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; +import { toast } from '@/components/ui'; +import { Icon } from "@/components/icon/Icon"; +import { cn } from '@/lib/utils'; +import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; +import { useProjectsStore } from '@/stores/useProjectsStore'; +import { useDirectoryStore } from '@/stores/useDirectoryStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useSelectionStore } from '@/sync/selection-store'; +import * as sessionActions from '@/sync/session-actions'; +import { buildLinkedIssue } from '@/lib/linkedIssues'; +import { useConfigStore } from '@/stores/useConfigStore'; +import { useUIStore } from '@/stores/useUIStore'; +import { useGitLabAuthStore } from '@/stores/useGitLabAuthStore'; +import { renderMagicPrompt } from '@/lib/magicPrompts'; +import { parseModelIdentifier } from '@/lib/modelIdentifier'; +import { useDeviceInfo } from '@/lib/device'; +import { createWorktreeSessionForNewBranch } from '@/lib/worktreeSessionCreator'; +import { generateBranchSlug } from '@/lib/git/branchNameGenerator'; +import { useDebouncedValue } from '@/hooks/useDebouncedValue'; +import type { GitLabIssue, GitLabIssueComment, GitLabIssuesListResult, GitLabIssueSummary } from '@/lib/api/types'; +import { useI18n } from '@/lib/i18n'; + +const parseIssueNumber = (value: string): number | null => { + const trimmed = value.trim(); + if (!trimmed) return null; + + const urlMatch = trimmed.match(/\/issues\/(\d+)(?:\b|\/|$)/i); + if (urlMatch) { + const parsed = Number(urlMatch[1]); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; + } + + const hashMatch = trimmed.match(/^#?(\d+)$/); + if (hashMatch) { + const parsed = Number(hashMatch[1]); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; + } + + return null; +}; + +const buildIssueContextText = (args: { + repo: GitLabIssuesListResult['repo'] | undefined; + issue: GitLabIssue; + comments: GitLabIssueComment[]; +}) => { + const payload = { + repo: args.repo ?? null, + issue: args.issue, + comments: args.comments, + }; + return `GitLab issue context (JSON)\n${JSON.stringify(payload, null, 2)}`; +}; + +export function GitLabIssuePickerDialog({ + open, + onOpenChange, + mode = 'createSession', + onSelect, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + mode?: 'createSession' | 'select'; + onSelect?: (issue: { number: number; title: string; url: string; contextText: string; author?: { login: string; avatarUrl?: string } }) => void; +}) { + const { t } = useI18n(); + const { gitlab } = useRuntimeAPIs(); + const gitlabAuthStatus = useGitLabAuthStore((state) => state.status); + const gitlabAuthChecked = useGitLabAuthStore((state) => state.hasChecked); + const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen); + const setSettingsPage = useUIStore((state) => state.setSettingsPage); + const isMobile = useUIStore((state) => state.isMobile); + const { isTablet } = useDeviceInfo(); + const alwaysShowActions = isMobile || isTablet; + const activeProject = useProjectsStore((state) => state.getActiveProject()); + const currentDirectory = useDirectoryStore((state) => state.currentDirectory); + + const projectDirectory = React.useMemo(() => { + return activeProject?.path?.trim() || currentDirectory?.trim() || null; + }, [activeProject?.path, currentDirectory]); + + const [query, setQuery] = React.useState(''); + const [createInWorktree, setCreateInWorktree] = React.useState(false); + const [result, setResult] = React.useState(null); + const [issues, setIssues] = React.useState([]); + const [page, setPage] = React.useState(1); + const [hasMore, setHasMore] = React.useState(false); + const [startingIssueNumber, setStartingIssueNumber] = React.useState(null); + const [isLoading, setIsLoading] = React.useState(false); + const [isLoadingMore, setIsLoadingMore] = React.useState(false); + const [error, setError] = React.useState(null); + + const directNumber = React.useMemo(() => parseIssueNumber(query), [query]); + const debouncedQuery = useDebouncedValue(query, 350); + const isTextSearch = debouncedQuery.trim().length > 0 && !directNumber; + + const refresh = React.useCallback(async () => { + if (!projectDirectory) { + setResult(null); + setError(t('session.gitlabIssuePicker.error.noActiveProject')); + return; + } + if (gitlabAuthChecked && gitlabAuthStatus?.connected === false) { + setResult({ connected: false, issues: [], page: 1, hasMore: false }); + setIssues([]); + setHasMore(false); + setPage(1); + setError(null); + return; + } + if (!gitlab?.issuesList) { + setResult(null); + setError(t('session.gitlabIssuePicker.error.runtimeUnavailable')); + return; + } + + setIsLoading(true); + setError(null); + try { + const next = await gitlab.issuesList(projectDirectory, { page: 1 }); + setResult(next); + setIssues(next.issues ?? []); + setPage(next.page ?? 1); + setHasMore(Boolean(next.hasMore)); + if (next.connected === false) { + setError(null); + } + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setIsLoading(false); + } + }, [gitlab, gitlabAuthChecked, gitlabAuthStatus, projectDirectory, t]); + + React.useEffect(() => { + if (!open || !projectDirectory) return; + if (gitlabAuthChecked && gitlabAuthStatus?.connected === false) return; + if (!gitlab?.issuesList) return; + if (!debouncedQuery.trim() || directNumber) { + void refresh(); + return; + } + + const controller = new AbortController(); + setIsLoading(true); + setError(null); + + gitlab.issuesList(projectDirectory, { page: 1, query: debouncedQuery.trim() }) + .then((next) => { + if (controller.signal.aborted) return; + setResult(next); + setIssues(next.issues ?? []); + setPage(next.page ?? 1); + setHasMore(Boolean(next.hasMore)); + }) + .catch((e) => { + if (controller.signal.aborted) return; + setError(e instanceof Error ? e.message : String(e)); + }) + .finally(() => { + if (!controller.signal.aborted) setIsLoading(false); + }); + + return () => controller.abort(); + }, [open, projectDirectory, gitlab, gitlabAuthChecked, gitlabAuthStatus, debouncedQuery, directNumber, refresh, t]); + + const loadMore = React.useCallback(async () => { + if (!projectDirectory) return; + if (!gitlab?.issuesList) return; + if (isLoadingMore || isLoading) return; + if (!hasMore) return; + + setIsLoadingMore(true); + try { + const nextPage = page + 1; + const next = isTextSearch + ? await gitlab.issuesList(projectDirectory, { page: nextPage, query: debouncedQuery.trim() }) + : await gitlab.issuesList(projectDirectory, { page: nextPage }); + setResult(next); + setIssues((prev) => [...prev, ...(next.issues ?? [])]); + setPage(next.page ?? nextPage); + setHasMore(Boolean(next.hasMore)); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + toast.error(t('session.gitlabIssuePicker.toast.loadMoreFailed'), { description: message }); + } finally { + setIsLoadingMore(false); + } + }, [gitlab, hasMore, isLoading, isLoadingMore, isTextSearch, debouncedQuery, page, projectDirectory, t]); + + React.useEffect(() => { + if (!open) { + setQuery(''); + setCreateInWorktree(false); + setStartingIssueNumber(null); + setError(null); + setResult(null); + setIssues([]); + setPage(1); + setHasMore(false); + setIsLoading(false); + return; + } + void refresh(); + }, [open, refresh]); + + React.useEffect(() => { + if (!open) return; + if (gitlabAuthChecked && gitlabAuthStatus?.connected === false) { + setResult({ connected: false, issues: [], page: 1, hasMore: false }); + setIssues([]); + setHasMore(false); + setPage(1); + setError(null); + } + }, [gitlabAuthChecked, gitlabAuthStatus, open]); + + const connected = gitlabAuthChecked ? result?.connected !== false : true; + const repoUrl = result?.repo?.url ?? null; + + const openGitLabSettings = React.useCallback(() => { + setSettingsPage('git'); + setSettingsDialogOpen(true); + }, [setSettingsDialogOpen, setSettingsPage]); + + const resolveDefaultAgentName = React.useCallback((): string | undefined => { + const configState = useConfigStore.getState(); + const visibleAgents = configState.getVisibleAgents(); + + if (configState.settingsDefaultAgent) { + const settingsAgent = visibleAgents.find((a) => a.name === configState.settingsDefaultAgent); + if (settingsAgent) { + return settingsAgent.name; + } + } + + return ( + visibleAgents.find((agent) => agent.name === 'build')?.name || + visibleAgents[0]?.name + ); + }, []); + + const resolveDefaultModelSelection = React.useCallback((): { providerID: string; modelID: string } | null => { + const configState = useConfigStore.getState(); + const settingsDefaultModel = configState.settingsDefaultModel; + if (!settingsDefaultModel) { + return null; + } + + const parsed = parseModelIdentifier(settingsDefaultModel); + if (!parsed) { + return null; + } + const { providerId: providerID, modelId: modelID } = parsed; + + const modelMetadata = configState.getModelMetadata(providerID, modelID); + if (!modelMetadata) { + return null; + } + + return { providerID, modelID }; + }, []); + + const resolveDefaultVariant = React.useCallback((providerID: string, modelID: string): string | undefined => { + const configState = useConfigStore.getState(); + const settingsDefaultVariant = configState.settingsDefaultVariant; + const currentVariant = configState.currentProviderId === providerID && configState.currentModelId === modelID + ? configState.currentVariant + : undefined; + + const provider = configState.providers.find((p) => p.id === providerID); + const model = provider?.models.find((m: Record) => (m as { id?: string }).id === modelID) as + | { variants?: Record } + | undefined; + const variants = model?.variants; + if (!variants) { + return settingsDefaultVariant || currentVariant || undefined; + } + if (settingsDefaultVariant && Object.prototype.hasOwnProperty.call(variants, settingsDefaultVariant)) { + return settingsDefaultVariant; + } + if (currentVariant && Object.prototype.hasOwnProperty.call(variants, currentVariant)) { + return currentVariant; + } + return undefined; + }, []); + + const startSession = React.useCallback(async (issueNumber: number) => { + if (mode === 'select') { + // In select mode, fetch full issue details and return via onSelect + if (!projectDirectory) { + toast.error(t('session.gitlabIssuePicker.error.noActiveProject')); + return; + } + if (!gitlab?.issueGet || !gitlab?.issueComments) { + toast.error(t('session.gitlabIssuePicker.error.runtimeUnavailable')); + return; + } + if (startingIssueNumber) return; + setStartingIssueNumber(issueNumber); + try { + const issueRes = await gitlab.issueGet(projectDirectory, issueNumber); + if (issueRes.connected === false) { + toast.error(t('session.gitlabIssuePicker.error.notConnected')); + return; + } + if (!issueRes.repo) { + toast.error(t('session.gitlabIssuePicker.error.repoNotResolvable'), { + description: t('session.gitlabIssuePicker.error.repoMustBeGitlab'), + }); + return; + } + const issue = issueRes.issue; + if (!issue) { + toast.error(t('session.gitlabIssuePicker.error.issueNotFound')); + return; + } + + const commentsRes = await gitlab.issueComments(projectDirectory, issueNumber); + if (commentsRes.connected === false) { + toast.error(t('session.gitlabIssuePicker.error.notConnected')); + return; + } + const comments = commentsRes.comments ?? []; + + // Build full context text like in createSession mode + const contextText = buildIssueContextText({ repo: issueRes.repo, issue, comments }); + + if (onSelect) { + onSelect({ + number: issue.number, + title: issue.title, + url: issue.url, + contextText, + author: issue.author ? { + login: issue.author.username, + avatarUrl: issue.author.avatarUrl, + } : undefined, + }); + } + onOpenChange(false); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + toast.error(t('session.gitlabIssuePicker.toast.loadIssueDetailsFailed'), { description: message }); + } finally { + setStartingIssueNumber(null); + } + return; + } + + if (!projectDirectory) { + toast.error(t('session.gitlabIssuePicker.error.noActiveProject')); + return; + } + if (!gitlab?.issueGet || !gitlab?.issueComments) { + toast.error(t('session.gitlabIssuePicker.error.runtimeUnavailable')); + return; + } + if (startingIssueNumber) return; + setStartingIssueNumber(issueNumber); + try { + const issueRes = await gitlab.issueGet(projectDirectory, issueNumber); + if (issueRes.connected === false) { + toast.error(t('session.gitlabIssuePicker.error.notConnected')); + return; + } + if (!issueRes.repo) { + toast.error(t('session.gitlabIssuePicker.error.repoNotResolvable'), { + description: t('session.gitlabIssuePicker.error.repoMustBeGitlab'), + }); + return; + } + const issue = issueRes.issue; + if (!issue) { + toast.error(t('session.gitlabIssuePicker.error.issueNotFound')); + return; + } + + const commentsRes = await gitlab.issueComments(projectDirectory, issueNumber); + if (commentsRes.connected === false) { + toast.error(t('session.gitlabIssuePicker.error.notConnected')); + return; + } + const comments = commentsRes.comments ?? []; + + const sessionTitle = `#${issue.number} ${issue.title}`.trim(); + + const { sessionId, sessionDirectory } = await (async () => { + if (createInWorktree) { + const preferred = `issue-${issue.number}-${generateBranchSlug()}`; + const created = await createWorktreeSessionForNewBranch( + projectDirectory, + preferred, + undefined, + { returnAfterDirectoryCreated: true } + ); + if (!created?.id) { + throw new Error('Failed to create worktree session'); + } + return { sessionId: created.id, sessionDirectory: created.path }; + } + + const session = await sessionActions.createSession(sessionTitle, projectDirectory, null); + if (!session?.id) { + throw new Error('Failed to create session'); + } + return { sessionId: session.id, sessionDirectory: session.directory ?? projectDirectory }; + })(); + + // Ensure worktree-based sessions also get the issue title. + void sessionActions.updateSessionTitle(sessionId, sessionTitle).catch(() => undefined); + + try { + useSessionUIStore.getState().initializeNewOpenChamberSession(sessionId, useConfigStore.getState().agents); + } catch { + // ignore + } + + // Close modal immediately after session exists (don't wait for message send). + onOpenChange(false); + + const configState = useConfigStore.getState(); + const lastUsedProvider = useSelectionStore.getState().lastUsedProvider; + + const defaultModel = resolveDefaultModelSelection(); + const providerID = defaultModel?.providerID || configState.currentProviderId || lastUsedProvider?.providerID; + const modelID = defaultModel?.modelID || configState.currentModelId || lastUsedProvider?.modelID; + const agentName = resolveDefaultAgentName() || configState.currentAgentName || undefined; + if (!providerID || !modelID) { + toast.error(t('session.gitlabIssuePicker.error.noModelSelected')); + return; + } + + const variant = resolveDefaultVariant(providerID, modelID); + + const visiblePromptText = await renderMagicPrompt('gitlab.issue.review.visible', { + issue_number: String(issue.number), + }); + const instructionsText = await renderMagicPrompt('gitlab.issue.review.instructions'); + const contextText = buildIssueContextText({ repo: issueRes.repo, issue, comments }); + + // Record the thread this session was created for, so it stays visible as + // a context source once the opening message has scrolled away. A + // snapshot, never re-fetched; a failed write must not fail the flow. + void sessionActions.setLinkedIssue( + sessionId, + sessionDirectory, + buildLinkedIssue({ + url: issue.url, + number: issue.number, + title: issue.title, + kind: 'issue', + author: issue.author ? { + login: issue.author.username, + avatarUrl: issue.author.avatarUrl, + } : undefined, + linkedAt: Date.now(), + }), + true, + ).catch(() => undefined); + + void useSessionUIStore.getState().sendMessage( + visiblePromptText, + providerID, + modelID, + agentName, + undefined, + undefined, + [ + { text: instructionsText, synthetic: true }, + { text: contextText, synthetic: true }, + ], + variant, + undefined, + { sessionId }, + ).catch((e) => { + const message = e instanceof Error ? e.message : String(e); + toast.error(t('session.gitlabIssuePicker.toast.sendContextFailed'), { + description: message, + }); + }); + + toast.success(t('session.gitlabIssuePicker.toast.sessionCreated')); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + toast.error(t('session.gitlabIssuePicker.toast.startSessionFailed'), { description: message }); + } finally { + setStartingIssueNumber(null); + } + }, [createInWorktree, gitlab, mode, onOpenChange, onSelect, projectDirectory, resolveDefaultAgentName, resolveDefaultModelSelection, resolveDefaultVariant, startingIssueNumber, t]); + + const title = mode === 'select' ? t('session.gitlabIssuePicker.title.select') : t('session.gitlabIssuePicker.title.createSession'); + const description = mode === 'select' + ? t('session.gitlabIssuePicker.description.select') + : t('session.gitlabIssuePicker.description.createSession'); + + const content = ( + <> +
+ + setQuery(e.target.value)} + className="pl-9 w-full" + /> +
+ +
+ {!projectDirectory ? ( +
{t('session.gitlabIssuePicker.empty.noActiveProject')}
+ ) : null} + + {!gitlab ? ( +
{t('session.gitlabIssuePicker.empty.runtimeUnavailable')}
+ ) : null} + + {isLoading ? ( +
+ + {t('session.gitlabIssuePicker.loading.issues')} +
+ ) : null} + + {connected === false ? ( +
+
{t('session.gitlabIssuePicker.empty.notConnected')}
+
+ +
+
+ ) : null} + + {error ? ( +
{error}
+ ) : null} + + {directNumber && projectDirectory && gitlab && connected ? ( +
void startSession(directNumber)} + > + # +

+ {t('session.gitlabIssuePicker.actions.useIssue', { number: directNumber })} +

+
+ {startingIssueNumber === directNumber ? ( + + ) : null} +
+
+ ) : null} + + {issues.length === 0 && !isLoading && connected && gitlab && projectDirectory ? ( +
{debouncedQuery.trim() ? t('session.gitlabIssuePicker.empty.noIssuesFound') : t('session.gitlabIssuePicker.empty.noOpenIssuesFound')}
+ ) : null} + + {issues.map((issue) => ( +
+ ))} + + {hasMore && connected && projectDirectory && gitlab ? ( +
+ +
+ ) : null} +
+ + {mode !== 'select' && ( +
+

{t('session.gitlabIssuePicker.actions.sectionTitle')}

+
+
setCreateInWorktree((v) => !v)} + onKeyDown={(e) => { + if (e.key === ' ' || e.key === 'Enter') { + e.preventDefault(); + setCreateInWorktree((v) => !v); + } + }} + > + + {t('session.gitlabIssuePicker.actions.createInWorktree')} + (issue-<number>-<slug>) +
+
+
+ {repoUrl ? ( + + ) : null} + +
+
+
+ )} + + ); + + if (isMobile) { + return ( + onOpenChange(false)} + renderHeader={(closeButton) => ( +
+
+

{title}

+ {closeButton} +
+

{description}

+
+ )} + > + {content} +
+ ); + } + + return ( + + + + + + {title} + + + {description} + + + + {content} + + + ); +} diff --git a/packages/ui/src/components/session/GitLabMrPickerDialog.tsx b/packages/ui/src/components/session/GitLabMrPickerDialog.tsx new file mode 100644 index 00000000..335deef6 --- /dev/null +++ b/packages/ui/src/components/session/GitLabMrPickerDialog.tsx @@ -0,0 +1,477 @@ +import React from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; +import { toast } from '@/components/ui'; +import { Icon } from "@/components/icon/Icon"; +import { cn } from '@/lib/utils'; +import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; +import { useProjectsStore } from '@/stores/useProjectsStore'; +import { useUIStore } from '@/stores/useUIStore'; +import { useGitLabAuthStore } from '@/stores/useGitLabAuthStore'; +import { renderMagicPrompt } from '@/lib/magicPrompts'; +import { useDeviceInfo } from '@/lib/device'; +import { useDebouncedValue } from '@/hooks/useDebouncedValue'; +import type { GitLabMergeRequestContextResult, GitLabMergeRequestSummary, GitLabMergeRequestsListResult } from '@/lib/api/types'; +import { useI18n } from '@/lib/i18n'; + +const parsePrNumber = (value: string): number | null => { + const trimmed = value.trim(); + if (!trimmed) return null; + + const urlMatch = trimmed.match(/\/merge_requests\/(\d+)(?:\b|\/|$)/i); + if (urlMatch) { + const parsed = Number(urlMatch[1]); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; + } + + const shortMatch = trimmed.match(/^!?(\d+)$/); + if (shortMatch) { + const parsed = Number(shortMatch[1]); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; + } + + return null; +}; + +const buildMergeRequestContextText = (payload: GitLabMergeRequestContextResult) => { + return `GitLab merge request context (JSON)\n${JSON.stringify(payload, null, 2)}`; +}; + +export function GitLabMrPickerDialog({ + open, + onOpenChange, + onSelect, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + onSelect?: (mr: { + number: number; + title: string; + url: string; + head: string; + base: string; + includeDiff: boolean; + instructionsText: string; + contextText: string; + author?: { login: string; avatarUrl?: string }; + }) => void; +}) { + const { t } = useI18n(); + const { gitlab } = useRuntimeAPIs(); + const gitlabAuthStatus = useGitLabAuthStore((state) => state.status); + const gitlabAuthChecked = useGitLabAuthStore((state) => state.hasChecked); + const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen); + const setSettingsPage = useUIStore((state) => state.setSettingsPage); + const isMobile = useUIStore((state) => state.isMobile); + const { isTablet } = useDeviceInfo(); + const alwaysShowActions = isMobile || isTablet; + const activeProject = useProjectsStore((state) => state.getActiveProject()); + + const projectDirectory = activeProject?.path ?? null; + + const [query, setQuery] = React.useState(''); + const [includeDiff, setIncludeDiff] = React.useState(false); + const [result, setResult] = React.useState(null); + const [mrs, setMrs] = React.useState([]); + const [page, setPage] = React.useState(1); + const [hasMore, setHasMore] = React.useState(false); + const [loadingMrNumber, setLoadingMrNumber] = React.useState(null); + const [isLoading, setIsLoading] = React.useState(false); + const [isLoadingMore, setIsLoadingMore] = React.useState(false); + const [error, setError] = React.useState(null); + + const directNumber = React.useMemo(() => parsePrNumber(query), [query]); + const debouncedQuery = useDebouncedValue(query, 350); + const isTextSearch = debouncedQuery.trim().length > 0 && !directNumber; + + const refresh = React.useCallback(async () => { + if (!projectDirectory) { + setResult(null); + setError(t('session.gitlabMrPicker.error.noActiveProject')); + return; + } + if (gitlabAuthChecked && gitlabAuthStatus?.connected === false) { + setResult({ connected: false, mrs: [], page: 1, hasMore: false }); + setMrs([]); + setHasMore(false); + setPage(1); + setError(null); + return; + } + if (!gitlab?.mrsList) { + setResult(null); + setError(t('session.gitlabMrPicker.error.runtimeUnavailable')); + return; + } + + setIsLoading(true); + setError(null); + try { + const next = await gitlab.mrsList(projectDirectory, { page: 1 }); + setResult(next); + setMrs(next.mrs ?? []); + setPage(next.page ?? 1); + setHasMore(Boolean(next.hasMore)); + if (next.connected === false) { + setError(null); + } + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setIsLoading(false); + } + }, [gitlab, gitlabAuthChecked, gitlabAuthStatus, projectDirectory, t]); + + React.useEffect(() => { + if (!open || !projectDirectory) return; + if (gitlabAuthChecked && gitlabAuthStatus?.connected === false) return; + if (!gitlab?.mrsList) return; + if (!debouncedQuery.trim() || directNumber) { + void refresh(); + return; + } + + const controller = new AbortController(); + setIsLoading(true); + setError(null); + + gitlab.mrsList(projectDirectory, { page: 1, query: debouncedQuery.trim() }) + .then((next) => { + if (controller.signal.aborted) return; + setResult(next); + setMrs(next.mrs ?? []); + setPage(next.page ?? 1); + setHasMore(Boolean(next.hasMore)); + }) + .catch((e) => { + if (controller.signal.aborted) return; + setError(e instanceof Error ? e.message : String(e)); + }) + .finally(() => { + if (!controller.signal.aborted) setIsLoading(false); + }); + + return () => controller.abort(); + }, [open, projectDirectory, gitlab, gitlabAuthChecked, gitlabAuthStatus, debouncedQuery, directNumber, refresh, t]); + + const loadMore = React.useCallback(async () => { + if (!projectDirectory) return; + if (!gitlab?.mrsList) return; + if (isLoadingMore || isLoading) return; + if (!hasMore) return; + + setIsLoadingMore(true); + try { + const nextPage = page + 1; + const next = isTextSearch + ? await gitlab.mrsList(projectDirectory, { page: nextPage, query: debouncedQuery.trim() }) + : await gitlab.mrsList(projectDirectory, { page: nextPage }); + setResult(next); + setMrs((prev) => [...prev, ...(next.mrs ?? [])]); + setPage(next.page ?? nextPage); + setHasMore(Boolean(next.hasMore)); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + toast.error(t('session.gitlabMrPicker.toast.loadMoreFailed'), { description: message }); + } finally { + setIsLoadingMore(false); + } + }, [gitlab, hasMore, isLoading, isLoadingMore, isTextSearch, debouncedQuery, page, projectDirectory, t]); + + React.useEffect(() => { + if (!open) { + setQuery(''); + setIncludeDiff(false); + setLoadingMrNumber(null); + setError(null); + setResult(null); + setMrs([]); + setPage(1); + setHasMore(false); + setIsLoading(false); + return; + } + void refresh(); + }, [open, refresh]); + + React.useEffect(() => { + if (!open) return; + if (gitlabAuthChecked && gitlabAuthStatus?.connected === false) { + setResult({ connected: false, mrs: [], page: 1, hasMore: false }); + setMrs([]); + setHasMore(false); + setPage(1); + setError(null); + } + }, [gitlabAuthChecked, gitlabAuthStatus, open]); + + const connected = gitlabAuthChecked ? result?.connected !== false : true; + + const openGitLabSettings = React.useCallback(() => { + setSettingsPage('git'); + setSettingsDialogOpen(true); + }, [setSettingsDialogOpen, setSettingsPage]); + + const attachMr = React.useCallback(async (mrNumber: number) => { + if (!projectDirectory) { + toast.error(t('session.gitlabMrPicker.error.noActiveProject')); + return; + } + if (!gitlab?.mrContext) { + toast.error(t('session.gitlabMrPicker.error.runtimeUnavailable')); + return; + } + if (loadingMrNumber) return; + + setLoadingMrNumber(mrNumber); + try { + const context = await gitlab.mrContext(projectDirectory, mrNumber, { + includeDiff, + }); + + if (context.connected === false) { + toast.error(t('session.gitlabMrPicker.error.notConnected')); + return; + } + + if (!context.mr) { + toast.error(t('session.gitlabMrPicker.error.mrNotFound')); + return; + } + + if (!context.repo) { + toast.error(t('session.gitlabMrPicker.error.repoNotResolvable'), { + description: t('session.gitlabMrPicker.error.repoMustBeGitlab'), + }); + return; + } + + if (onSelect) { + const instructionsText = await renderMagicPrompt('gitlab.pr.review.instructions'); + onSelect({ + number: context.mr.number, + title: context.mr.title, + url: context.mr.url, + head: context.mr.sourceBranch, + base: context.mr.targetBranch, + includeDiff, + instructionsText, + contextText: buildMergeRequestContextText(context), + author: context.mr.author + ? { + login: context.mr.author.username, + avatarUrl: context.mr.author.avatarUrl, + } + : undefined, + }); + } + onOpenChange(false); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + toast.error(t('session.gitlabMrPicker.toast.loadDetailsFailed'), { description: message }); + } finally { + setLoadingMrNumber(null); + } + }, [gitlab, includeDiff, loadingMrNumber, onOpenChange, onSelect, projectDirectory, t]); + + const title = t('session.gitlabMrPicker.title'); + const description = t('session.gitlabMrPicker.description'); + + const content = ( + <> +
+
+ + setQuery(e.target.value)} + className="pl-9 w-full" + /> +
+ +
+ +
+ {!projectDirectory ? ( +
{t('session.gitlabMrPicker.empty.noActiveProject')}
+ ) : null} + + {!gitlab ? ( +
{t('session.gitlabMrPicker.empty.runtimeUnavailable')}
+ ) : null} + + {isLoading ? ( +
+ + {t('session.gitlabMrPicker.loading.mergeRequests')} +
+ ) : null} + + {connected === false ? ( +
+
{t('session.gitlabMrPicker.empty.notConnected')}
+
+ +
+
+ ) : null} + + {error ? ( +
{error}
+ ) : null} + + {directNumber && projectDirectory && gitlab && connected ? ( +
void attachMr(directNumber)} + > + ! +

+ {t('session.gitlabMrPicker.actions.useMergeRequest', { number: directNumber })} +

+
+ {loadingMrNumber === directNumber ? ( + + ) : null} +
+
+ ) : null} + + {mrs.length === 0 && !isLoading && connected && gitlab && projectDirectory ? ( +
{debouncedQuery.trim() ? t('session.gitlabMrPicker.empty.noMergeRequestsFound') : t('session.gitlabMrPicker.empty.noOpenMergeRequestsFound')}
+ ) : null} + + {mrs.map((mr) => ( +
void attachMr(mr.number)} + > +
+

+ !{mr.number} + {mr.title} +

+

{mr.sourceBranch} → {mr.targetBranch}

+
+ + +
+ ))} + + {hasMore && connected && projectDirectory && gitlab ? ( +
+ +
+ ) : null} +
+ + ); + + if (isMobile) { + return ( + onOpenChange(false)} + renderHeader={(closeButton) => ( +
+
+

{title}

+ {closeButton} +
+

{description}

+
+ )} + > + {content} +
+ ); + } + + return ( + + + + + + {title} + + + {description} + + + + {content} + + + ); +} diff --git a/packages/ui/src/lib/i18n/messages/de.ts b/packages/ui/src/lib/i18n/messages/de.ts index 6c24957c..5fdae5eb 100644 --- a/packages/ui/src/lib/i18n/messages/de.ts +++ b/packages/ui/src/lib/i18n/messages/de.ts @@ -1683,6 +1683,39 @@ export const dict = { 'session.githubIssuePicker.actions.createInWorktree': 'In Worktree erstellen', 'session.githubIssuePicker.actions.openRepo': 'Repository öffnen', 'session.githubIssuePicker.actions.refresh': 'Aktualisieren', + 'session.gitlabIssuePicker.error.noActiveProject': 'Kein aktives Projekt', + 'session.gitlabIssuePicker.error.runtimeUnavailable': 'GitLab-Laufzeit-API nicht verfügbar', + 'session.gitlabIssuePicker.error.notConnected': 'GitLab nicht verbunden', + 'session.gitlabIssuePicker.error.repoNotResolvable': 'Repository kann nicht aufgelöst werden', + 'session.gitlabIssuePicker.error.repoMustBeGitlab': 'Das origin-Remote muss eine GitLab-URL sein', + 'session.gitlabIssuePicker.error.issueNotFound': 'Problem nicht gefunden', + 'session.gitlabIssuePicker.error.noModelSelected': 'Kein Modell ausgewählt', + 'session.gitlabIssuePicker.toast.loadMoreFailed': 'Fehler beim Laden weiterer Probleme', + 'session.gitlabIssuePicker.toast.loadIssueDetailsFailed': 'Fehler beim Laden der Problemdetails', + 'session.gitlabIssuePicker.toast.sendContextFailed': 'Fehler beim Senden des Problem-Kontexts', + 'session.gitlabIssuePicker.toast.sessionCreated': 'Sitzung aus Problem erstellt', + 'session.gitlabIssuePicker.toast.startSessionFailed': 'Fehler beim Starten der Sitzung', + 'session.gitlabIssuePicker.title.select': 'GitLab-Problem verknüpfen', + 'session.gitlabIssuePicker.title.createSession': 'Neue Sitzung aus GitLab-Problem', + 'session.gitlabIssuePicker.description.select': 'Wählen Sie ein Problem, um es mit dieser Sitzung zu verknüpfen.', + 'session.gitlabIssuePicker.description.createSession': 'Erstellt eine neue Sitzung mit verstecktem Problem-Kontext (Titel/Inhalt/Labels/Kommentare).', + 'session.gitlabIssuePicker.searchPlaceholder': 'Suche nach Titel oder fügen Sie eine Problem-URL ein', + 'session.gitlabIssuePicker.empty.noActiveProject': 'Kein aktives Projekt ausgewählt.', + 'session.gitlabIssuePicker.empty.runtimeUnavailable': 'GitLab-Laufzeit-API nicht verfügbar.', + 'session.gitlabIssuePicker.empty.notConnected': 'GitLab nicht verbunden. Verbinden Sie Ihr GitLab-Konto in den Einstellungen.', + 'session.gitlabIssuePicker.empty.noIssuesFound': 'Keine Probleme gefunden', + 'session.gitlabIssuePicker.empty.noOpenIssuesFound': 'Keine offenen Probleme gefunden', + 'session.gitlabIssuePicker.loading.issues': 'Probleme werden geladen...', + 'session.gitlabIssuePicker.loading.more': 'Wird geladen...', + 'session.gitlabIssuePicker.actions.openSettings': 'Einstellungen öffnen', + 'session.gitlabIssuePicker.actions.useIssue': 'Problem #{number} verwenden', + 'session.gitlabIssuePicker.actions.openInGitLabAria': 'In GitLab öffnen', + 'session.gitlabIssuePicker.actions.loadMore': 'Mehr laden', + 'session.gitlabIssuePicker.actions.sectionTitle': 'Aktionen', + 'session.gitlabIssuePicker.actions.toggleWorktreeAria': 'Worktree umschalten', + 'session.gitlabIssuePicker.actions.createInWorktree': 'In Worktree erstellen', + 'session.gitlabIssuePicker.actions.openRepo': 'Repository öffnen', + 'session.gitlabIssuePicker.actions.refresh': 'Aktualisieren', 'session.githubPrPicker.error.noActiveProject': 'Kein aktives Projekt', 'session.githubPrPicker.error.runtimeUnavailable': 'GitHub-Laufzeit-API nicht verfügbar', 'session.githubPrPicker.error.notConnected': 'GitHub nicht verbunden', @@ -1707,6 +1740,30 @@ export const dict = { 'session.githubPrPicker.actions.usePullRequest': 'Pull Request #{number} verwenden', 'session.githubPrPicker.actions.openInGitHubAria': 'In GitHub öffnen', 'session.githubPrPicker.actions.loadMore': 'Mehr laden', + 'session.gitlabMrPicker.error.noActiveProject': 'Kein aktives Projekt', + 'session.gitlabMrPicker.error.runtimeUnavailable': 'GitLab-Laufzeit-API nicht verfügbar', + 'session.gitlabMrPicker.error.notConnected': 'GitLab nicht verbunden', + 'session.gitlabMrPicker.error.mrNotFound': 'Merge Request nicht gefunden', + 'session.gitlabMrPicker.error.repoNotResolvable': 'Repository kann nicht aufgelöst werden', + 'session.gitlabMrPicker.error.repoMustBeGitlab': 'Das origin-Remote muss eine GitLab-URL sein', + 'session.gitlabMrPicker.toast.loadMoreFailed': 'Fehler beim Laden weiterer Merge Requests', + 'session.gitlabMrPicker.toast.loadDetailsFailed': 'Fehler beim Laden der Merge-Request-Details', + 'session.gitlabMrPicker.title': 'GitLab-Merge Request verknüpfen', + 'session.gitlabMrPicker.description': 'Wählen Sie einen Merge Request aus, um den Überprüfungs-Kontext zu dieser Nachricht hinzuzufügen.', + 'session.gitlabMrPicker.searchPlaceholder': 'Suche nach Titel oder fügen Sie eine Merge-Request-URL ein', + 'session.gitlabMrPicker.includeDiffAria': 'MR-Diff in angehängten Kontext einfügen', + 'session.gitlabMrPicker.includeDiff': 'MR-Diff einfügen', + 'session.gitlabMrPicker.empty.noActiveProject': 'Kein aktives Projekt ausgewählt.', + 'session.gitlabMrPicker.empty.runtimeUnavailable': 'GitLab-Laufzeit-API nicht verfügbar.', + 'session.gitlabMrPicker.empty.notConnected': 'GitLab nicht verbunden. Verbinden Sie Ihr GitLab-Konto in den Einstellungen.', + 'session.gitlabMrPicker.empty.noMergeRequestsFound': 'Keine Merge Requests gefunden', + 'session.gitlabMrPicker.empty.noOpenMergeRequestsFound': 'Keine offenen Merge Requests gefunden', + 'session.gitlabMrPicker.loading.mergeRequests': 'Merge Requests werden geladen...', + 'session.gitlabMrPicker.loading.more': 'Wird geladen...', + 'session.gitlabMrPicker.actions.openSettings': 'Einstellungen öffnen', + 'session.gitlabMrPicker.actions.useMergeRequest': 'Merge Request !{number} verwenden', + 'session.gitlabMrPicker.actions.openInGitLabAria': 'In GitLab öffnen', + 'session.gitlabMrPicker.actions.loadMore': 'Mehr laden', 'session.newWorktree.title': 'Neuer Worktree', 'session.newWorktree.mode.newBranch': 'Neue Branch', 'session.newWorktree.mode.existingBranch': 'Vorhandener Branch', @@ -2010,6 +2067,8 @@ export const dict = { 'chat.chatInput.actions.addAttachment': 'Anhang hinzufügen', 'chat.chatInput.actions.linkGithubIssue': 'GitHub-Issue verknüpfen', 'chat.chatInput.actions.linkGithubPr': 'GitHub-PR verknüpfen', + 'chat.chatInput.actions.linkGitlabIssue': 'GitLab-Issue verknüpfen', + 'chat.chatInput.actions.linkGitlabMr': 'GitLab-MR verknüpfen', 'chat.chatInput.actions.modelAgentSettings': 'Modell- und Agenteneinstellungen', 'chat.chatInput.actions.sendMessageAria': 'Nachricht senden', 'chat.chatInput.actions.queueMessageAria': 'Nachricht in die Warteschlange stellen', diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index 3d19ce15..666113b2 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -1840,6 +1840,39 @@ export const dict = { 'session.githubIssuePicker.actions.createInWorktree': 'Create in worktree', 'session.githubIssuePicker.actions.openRepo': 'Open Repo', 'session.githubIssuePicker.actions.refresh': 'Refresh', + 'session.gitlabIssuePicker.error.noActiveProject': 'No active project', + 'session.gitlabIssuePicker.error.runtimeUnavailable': 'GitLab runtime API unavailable', + 'session.gitlabIssuePicker.error.notConnected': 'GitLab not connected', + 'session.gitlabIssuePicker.error.repoNotResolvable': 'Repo not resolvable', + 'session.gitlabIssuePicker.error.repoMustBeGitlab': 'origin remote must be a GitLab URL', + 'session.gitlabIssuePicker.error.issueNotFound': 'Issue not found', + 'session.gitlabIssuePicker.error.noModelSelected': 'No model selected', + 'session.gitlabIssuePicker.toast.loadMoreFailed': 'Failed to load more issues', + 'session.gitlabIssuePicker.toast.loadIssueDetailsFailed': 'Failed to load issue details', + 'session.gitlabIssuePicker.toast.sendContextFailed': 'Failed to send issue context', + 'session.gitlabIssuePicker.toast.sessionCreated': 'Session created from issue', + 'session.gitlabIssuePicker.toast.startSessionFailed': 'Failed to start session', + 'session.gitlabIssuePicker.title.select': 'Link GitLab Issue', + 'session.gitlabIssuePicker.title.createSession': 'New Session From GitLab Issue', + 'session.gitlabIssuePicker.description.select': 'Select an issue to link to this session.', + 'session.gitlabIssuePicker.description.createSession': 'Seeds a new session with hidden issue context (title/body/labels/comments).', + 'session.gitlabIssuePicker.searchPlaceholder': 'Search by title or paste an issue URL', + 'session.gitlabIssuePicker.empty.noActiveProject': 'No active project selected.', + 'session.gitlabIssuePicker.empty.runtimeUnavailable': 'GitLab runtime API unavailable.', + 'session.gitlabIssuePicker.empty.notConnected': 'GitLab not connected. Connect your GitLab account in settings.', + 'session.gitlabIssuePicker.empty.noIssuesFound': 'No issues found', + 'session.gitlabIssuePicker.empty.noOpenIssuesFound': 'No open issues found', + 'session.gitlabIssuePicker.loading.issues': 'Loading issues...', + 'session.gitlabIssuePicker.loading.more': 'Loading...', + 'session.gitlabIssuePicker.actions.openSettings': 'Open settings', + 'session.gitlabIssuePicker.actions.useIssue': 'Use issue #{number}', + 'session.gitlabIssuePicker.actions.openInGitLabAria': 'Open in GitLab', + 'session.gitlabIssuePicker.actions.loadMore': 'Load more', + 'session.gitlabIssuePicker.actions.sectionTitle': 'Actions', + 'session.gitlabIssuePicker.actions.toggleWorktreeAria': 'Toggle worktree', + 'session.gitlabIssuePicker.actions.createInWorktree': 'Create in worktree', + 'session.gitlabIssuePicker.actions.openRepo': 'Open Repo', + 'session.gitlabIssuePicker.actions.refresh': 'Refresh', 'session.githubPrPicker.error.noActiveProject': 'No active project', 'session.githubPrPicker.error.runtimeUnavailable': 'GitHub runtime API unavailable', 'session.githubPrPicker.error.notConnected': 'GitHub not connected', @@ -1864,6 +1897,30 @@ export const dict = { 'session.githubPrPicker.actions.usePullRequest': 'Use pull request #{number}', 'session.githubPrPicker.actions.openInGitHubAria': 'Open in GitHub', 'session.githubPrPicker.actions.loadMore': 'Load more', + 'session.gitlabMrPicker.error.noActiveProject': 'No active project', + 'session.gitlabMrPicker.error.runtimeUnavailable': 'GitLab runtime API unavailable', + 'session.gitlabMrPicker.error.notConnected': 'GitLab not connected', + 'session.gitlabMrPicker.error.mrNotFound': 'Merge request not found', + 'session.gitlabMrPicker.error.repoNotResolvable': 'Repo not resolvable', + 'session.gitlabMrPicker.error.repoMustBeGitlab': 'origin remote must be a GitLab URL', + 'session.gitlabMrPicker.toast.loadMoreFailed': 'Failed to load more merge requests', + 'session.gitlabMrPicker.toast.loadDetailsFailed': 'Failed to load merge request details', + 'session.gitlabMrPicker.title': 'Link GitLab Merge Request', + 'session.gitlabMrPicker.description': 'Select a merge request to attach review context to this message.', + 'session.gitlabMrPicker.searchPlaceholder': 'Search by title or paste a merge request URL', + 'session.gitlabMrPicker.includeDiffAria': 'Include MR diff in attached context', + 'session.gitlabMrPicker.includeDiff': 'Include MR diff', + 'session.gitlabMrPicker.empty.noActiveProject': 'No active project selected.', + 'session.gitlabMrPicker.empty.runtimeUnavailable': 'GitLab runtime API unavailable.', + 'session.gitlabMrPicker.empty.notConnected': 'GitLab not connected. Connect your GitLab account in settings.', + 'session.gitlabMrPicker.empty.noMergeRequestsFound': 'No merge requests found', + 'session.gitlabMrPicker.empty.noOpenMergeRequestsFound': 'No open merge requests found', + 'session.gitlabMrPicker.loading.mergeRequests': 'Loading merge requests...', + 'session.gitlabMrPicker.loading.more': 'Loading...', + 'session.gitlabMrPicker.actions.openSettings': 'Open settings', + 'session.gitlabMrPicker.actions.useMergeRequest': 'Use merge request !{number}', + 'session.gitlabMrPicker.actions.openInGitLabAria': 'Open in GitLab', + 'session.gitlabMrPicker.actions.loadMore': 'Load more', 'session.newWorktree.title': 'New Worktree', 'session.newWorktree.mode.newBranch': 'New Branch', 'session.newWorktree.mode.existingBranch': 'Existing Branch', @@ -2182,6 +2239,8 @@ export const dict = { 'chat.chatInput.actions.addAttachment': 'Add attachment', 'chat.chatInput.actions.linkGithubIssue': 'Link GitHub Issue', 'chat.chatInput.actions.linkGithubPr': 'Link GitHub PR', + 'chat.chatInput.actions.linkGitlabIssue': 'Link GitLab Issue', + 'chat.chatInput.actions.linkGitlabMr': 'Link GitLab MR', 'chat.chatInput.actions.modelAgentSettings': 'Model and agent settings', 'chat.chatInput.actions.sendMessageAria': 'Send message', 'chat.chatInput.actions.queueMessageAria': 'Queue message', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 47d7299e..51424df8 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -1818,6 +1818,39 @@ export const dict: Record = { "session.githubIssuePicker.actions.createInWorktree": "Crear en worktree", "session.githubIssuePicker.actions.openRepo": "Abrir repositorio", "session.githubIssuePicker.actions.refresh": "Actualizar", + "session.gitlabIssuePicker.error.noActiveProject": "No hay ningún proyecto activo", + "session.gitlabIssuePicker.error.runtimeUnavailable": "API de runtime de GitLab no disponible", + "session.gitlabIssuePicker.error.notConnected": "GitLab no está conectado", + "session.gitlabIssuePicker.error.repoNotResolvable": "No se pudo resolver el repositorio", + "session.gitlabIssuePicker.error.repoMustBeGitlab": "El remoto origin debe ser una URL de GitLab", + "session.gitlabIssuePicker.error.issueNotFound": "No se encontró el issue", + "session.gitlabIssuePicker.error.noModelSelected": "No hay ningún modelo seleccionado", + "session.gitlabIssuePicker.toast.loadMoreFailed": "No se pudo cargar más issues", + "session.gitlabIssuePicker.toast.loadIssueDetailsFailed": "No se pudieron cargar los detalles del issue", + "session.gitlabIssuePicker.toast.sendContextFailed": "No se pudo enviar el contexto del issue", + "session.gitlabIssuePicker.toast.sessionCreated": "Sesión creada desde el issue", + "session.gitlabIssuePicker.toast.startSessionFailed": "No se pudo iniciar la sesión", + "session.gitlabIssuePicker.title.select": "Vincular issue de GitLab", + "session.gitlabIssuePicker.title.createSession": "Nueva sesión desde issue de GitLab", + "session.gitlabIssuePicker.description.select": "Selecciona un issue para vincularlo a esta sesión.", + "session.gitlabIssuePicker.description.createSession": "Inicia una nueva sesión con contexto oculto del issue (título/cuerpo/etiquetas/comentarios).", + "session.gitlabIssuePicker.searchPlaceholder": "Buscar por título o pega la URL del issue", + "session.gitlabIssuePicker.empty.noActiveProject": "No hay ningún proyecto activo seleccionado.", + "session.gitlabIssuePicker.empty.runtimeUnavailable": "API de runtime de GitLab no disponible.", + "session.gitlabIssuePicker.empty.notConnected": "GitLab no está conectado. Conecta tu cuenta de GitLab en configuración.", + "session.gitlabIssuePicker.empty.noIssuesFound": "No se encontraron issues", + "session.gitlabIssuePicker.empty.noOpenIssuesFound": "No se encontraron issues abiertos", + "session.gitlabIssuePicker.loading.issues": "Cargando issues...", + "session.gitlabIssuePicker.loading.more": "Cargando...", + "session.gitlabIssuePicker.actions.openSettings": "Abrir configuración", + "session.gitlabIssuePicker.actions.useIssue": "Usar issue #{number}", + "session.gitlabIssuePicker.actions.openInGitLabAria": "Abrir en GitLab", + "session.gitlabIssuePicker.actions.loadMore": "Cargar más", + "session.gitlabIssuePicker.actions.sectionTitle": "Acciones", + "session.gitlabIssuePicker.actions.toggleWorktreeAria": "Activar worktree", + "session.gitlabIssuePicker.actions.createInWorktree": "Crear en worktree", + "session.gitlabIssuePicker.actions.openRepo": "Abrir repositorio", + "session.gitlabIssuePicker.actions.refresh": "Actualizar", "session.githubPrPicker.error.noActiveProject": "No hay ningún proyecto activo", "session.githubPrPicker.error.runtimeUnavailable": "API de runtime de GitHub no disponible", "session.githubPrPicker.error.notConnected": "GitHub no está conectado", @@ -1842,6 +1875,30 @@ export const dict: Record = { "session.githubPrPicker.actions.usePullRequest": "Usar PR #{number}", "session.githubPrPicker.actions.openInGitHubAria": "Abrir en GitHub", "session.githubPrPicker.actions.loadMore": "Cargar más", + "session.gitlabMrPicker.error.noActiveProject": "No hay ningún proyecto activo", + "session.gitlabMrPicker.error.runtimeUnavailable": "API de runtime de GitLab no disponible", + "session.gitlabMrPicker.error.notConnected": "GitLab no está conectado", + "session.gitlabMrPicker.error.mrNotFound": "No se encontró el MR", + "session.gitlabMrPicker.error.repoNotResolvable": "No se pudo resolver el repositorio", + "session.gitlabMrPicker.error.repoMustBeGitlab": "El remoto origin debe ser una URL de GitLab", + "session.gitlabMrPicker.toast.loadMoreFailed": "No se pudieron cargar más MR", + "session.gitlabMrPicker.toast.loadDetailsFailed": "No se pudieron cargar los detalles del MR", + "session.gitlabMrPicker.title": "Vincular MR de GitLab", + "session.gitlabMrPicker.description": "Selecciona un MR para adjuntar contexto de revisión a este mensaje.", + "session.gitlabMrPicker.searchPlaceholder": "Buscar por título o pega la URL del MR", + "session.gitlabMrPicker.includeDiffAria": "Incluir diff del MR en el contexto adjunto", + "session.gitlabMrPicker.includeDiff": "Incluir diff del MR", + "session.gitlabMrPicker.empty.noActiveProject": "No hay ningún proyecto activo seleccionado.", + "session.gitlabMrPicker.empty.runtimeUnavailable": "API de runtime de GitLab no disponible.", + "session.gitlabMrPicker.empty.notConnected": "GitLab no está conectado. Conecta tu cuenta de GitLab en configuración.", + "session.gitlabMrPicker.empty.noMergeRequestsFound": "No se encontraron MR", + "session.gitlabMrPicker.empty.noOpenMergeRequestsFound": "No se encontraron MR abiertos", + "session.gitlabMrPicker.loading.mergeRequests": "Cargando MR...", + "session.gitlabMrPicker.loading.more": "Cargando...", + "session.gitlabMrPicker.actions.openSettings": "Abrir configuración", + "session.gitlabMrPicker.actions.useMergeRequest": "Usar MR !{number}", + "session.gitlabMrPicker.actions.openInGitLabAria": "Abrir en GitLab", + "session.gitlabMrPicker.actions.loadMore": "Cargar más", "session.newWorktree.title": "Nuevo worktree", "session.newWorktree.mode.newBranch": "Nueva rama", "session.newWorktree.mode.existingBranch": "Rama existente", @@ -2160,6 +2217,8 @@ export const dict: Record = { "chat.chatInput.actions.addAttachment": "Añadir adjunto", "chat.chatInput.actions.linkGithubIssue": "Vincular issue de GitHub", "chat.chatInput.actions.linkGithubPr": "Vincular PR de GitHub", + "chat.chatInput.actions.linkGitlabIssue": "Vincular issue de GitLab", + "chat.chatInput.actions.linkGitlabMr": "Vincular MR de GitLab", "chat.chatInput.actions.modelAgentSettings": "Configuración del modelo y agente", "chat.chatInput.actions.sendMessageAria": "Enviar mensaje", "chat.chatInput.actions.queueMessageAria": "Poner mensaje en cola", diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 1911a1f3..4bc24344 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -1598,6 +1598,39 @@ export const dict = { 'session.githubIssuePicker.actions.createInWorktree': 'Créer dans le worktree', 'session.githubIssuePicker.actions.openRepo': 'Ouvrir le dépôt', 'session.githubIssuePicker.actions.refresh': 'Rafraîchir', + 'session.gitlabIssuePicker.error.noActiveProject': 'Aucun projet actif', + 'session.gitlabIssuePicker.error.runtimeUnavailable': 'Exécution GitLab API indisponible', + 'session.gitlabIssuePicker.error.notConnected': 'GitLab non connecté', + 'session.gitlabIssuePicker.error.repoNotResolvable': 'Repo non résoluble', + 'session.gitlabIssuePicker.error.repoMustBeGitlab': 'Le dépôt distant origin doit pointer vers une URL GitLab', + 'session.gitlabIssuePicker.error.issueNotFound': 'Problème introuvable', + 'session.gitlabIssuePicker.error.noModelSelected': 'Aucun modèle sélectionné', + 'session.gitlabIssuePicker.toast.loadMoreFailed': 'Échec du chargement d\'autres problèmes', + 'session.gitlabIssuePicker.toast.loadIssueDetailsFailed': 'Échec du chargement des détails du problème', + 'session.gitlabIssuePicker.toast.sendContextFailed': 'Échec de l\'envoi du contexte du problème', + 'session.gitlabIssuePicker.toast.sessionCreated': 'Session créée à partir du problème', + 'session.gitlabIssuePicker.toast.startSessionFailed': 'Échec du démarrage de la session', + 'session.gitlabIssuePicker.title.select': 'Lien vers le problème GitLab', + 'session.gitlabIssuePicker.title.createSession': 'Nouvelle session du problème GitLab', + 'session.gitlabIssuePicker.description.select': 'Sélectionnez un problème à lier à cette session.', + 'session.gitlabIssuePicker.description.createSession': 'Lance une nouvelle session avec un contexte de problème masqué (titre/corps/étiquettes/commentaires).', + 'session.gitlabIssuePicker.searchPlaceholder': 'Recherchez par titre ou collez l\'URL du problème', + 'session.gitlabIssuePicker.empty.noActiveProject': 'Aucun projet actif sélectionné.', + 'session.gitlabIssuePicker.empty.runtimeUnavailable': 'L\'environnement d\'exécution GitLab API n\'est pas disponible.', + 'session.gitlabIssuePicker.empty.notConnected': 'GitLab non connecté. Connectez votre compte GitLab dans les paramètres.', + 'session.gitlabIssuePicker.empty.noIssuesFound': 'Aucun problème trouvé', + 'session.gitlabIssuePicker.empty.noOpenIssuesFound': 'Aucun problème ouvert trouvé', + 'session.gitlabIssuePicker.loading.issues': 'Chargement des problèmes...', + 'session.gitlabIssuePicker.loading.more': 'Chargement...', + 'session.gitlabIssuePicker.actions.openSettings': 'Ouvrir les paramètres', + 'session.gitlabIssuePicker.actions.useIssue': 'Utilisez le problème #{number}', + 'session.gitlabIssuePicker.actions.openInGitLabAria': 'Ouvrir dans GitLab', + 'session.gitlabIssuePicker.actions.loadMore': 'Charger plus', + 'session.gitlabIssuePicker.actions.sectionTitle': 'Actions', + 'session.gitlabIssuePicker.actions.toggleWorktreeAria': 'Basculer le worktree', + 'session.gitlabIssuePicker.actions.createInWorktree': 'Créer dans le worktree', + 'session.gitlabIssuePicker.actions.openRepo': 'Ouvrir le dépôt', + 'session.gitlabIssuePicker.actions.refresh': 'Rafraîchir', 'session.githubPrPicker.error.noActiveProject': 'Aucun projet actif', 'session.githubPrPicker.error.runtimeUnavailable': 'Exécution GitHub API indisponible', 'session.githubPrPicker.error.notConnected': 'GitHub non connecté', @@ -1622,6 +1655,30 @@ export const dict = { 'session.githubPrPicker.actions.usePullRequest': 'Utiliser la demande d\'extraction #{number}', 'session.githubPrPicker.actions.openInGitHubAria': 'Ouvrir dans GitHub', 'session.githubPrPicker.actions.loadMore': 'Charger plus', + 'session.gitlabMrPicker.error.noActiveProject': 'Aucun projet actif', + 'session.gitlabMrPicker.error.runtimeUnavailable': 'Exécution GitLab API indisponible', + 'session.gitlabMrPicker.error.notConnected': 'GitLab non connecté', + 'session.gitlabMrPicker.error.mrNotFound': 'Demande de fusion introuvable', + 'session.gitlabMrPicker.error.repoNotResolvable': 'Repo non résoluble', + 'session.gitlabMrPicker.error.repoMustBeGitlab': 'Le dépôt distant origin doit pointer vers une URL GitLab', + 'session.gitlabMrPicker.toast.loadMoreFailed': 'Échec du chargement d\'autres demandes de fusion', + 'session.gitlabMrPicker.toast.loadDetailsFailed': 'Échec du chargement des détails de la demande de fusion', + 'session.gitlabMrPicker.title': 'Lier le MR GitLab', + 'session.gitlabMrPicker.description': 'Sélectionnez un MR pour joindre un contexte de revue à ce message.', + 'session.gitlabMrPicker.searchPlaceholder': 'Recherchez par titre ou collez l\'URL du MR', + 'session.gitlabMrPicker.includeDiffAria': 'Inclure le diff du MR dans le contexte ci-joint', + 'session.gitlabMrPicker.includeDiff': 'Inclure le diff du MR', + 'session.gitlabMrPicker.empty.noActiveProject': 'Aucun projet actif sélectionné.', + 'session.gitlabMrPicker.empty.runtimeUnavailable': 'L\'environnement d\'exécution GitLab API n\'est pas disponible.', + 'session.gitlabMrPicker.empty.notConnected': 'GitLab non connecté. Connectez votre compte GitLab dans les paramètres.', + 'session.gitlabMrPicker.empty.noMergeRequestsFound': 'Aucune demande de fusion trouvée', + 'session.gitlabMrPicker.empty.noOpenMergeRequestsFound': 'Aucune demande de fusion ouverte trouvée', + 'session.gitlabMrPicker.loading.mergeRequests': 'Chargement des demandes de fusion...', + 'session.gitlabMrPicker.loading.more': 'Chargement...', + 'session.gitlabMrPicker.actions.openSettings': 'Ouvrir les paramètres', + 'session.gitlabMrPicker.actions.useMergeRequest': 'Utiliser le MR !{number}', + 'session.gitlabMrPicker.actions.openInGitLabAria': 'Ouvrir dans GitLab', + 'session.gitlabMrPicker.actions.loadMore': 'Charger plus', 'session.newWorktree.title': 'Nouveau worktree', 'session.newWorktree.mode.newBranch': 'Nouvelle branche', 'session.newWorktree.mode.existingBranch': 'Branche existante', @@ -1903,6 +1960,8 @@ export const dict = { 'chat.chatInput.actions.addAttachment': 'Ajouter une pièce jointe', 'chat.chatInput.actions.linkGithubIssue': 'Lien vers le problème GitHub', 'chat.chatInput.actions.linkGithubPr': 'Lien GitHub PR', + 'chat.chatInput.actions.linkGitlabIssue': 'Lien vers le problème GitLab', + 'chat.chatInput.actions.linkGitlabMr': 'Lien GitLab MR', 'chat.chatInput.actions.modelAgentSettings': 'Paramètres du modèle et de l\'agent', 'chat.chatInput.actions.sendMessageAria': 'Envoyer un message', 'chat.chatInput.actions.queueMessageAria': 'Message de file d\'attente', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index 250e63af..bae492c6 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -1836,6 +1836,39 @@ export const dict: Record = { 'session.githubIssuePicker.actions.createInWorktree': 'ワークツリーで作成', 'session.githubIssuePicker.actions.openRepo': 'リポジトリを開く', 'session.githubIssuePicker.actions.refresh': '更新', + 'session.gitlabIssuePicker.error.noActiveProject': 'アクティブなプロジェクトがありません', + 'session.gitlabIssuePicker.error.runtimeUnavailable': 'GitLabランタイムAPIは利用できません', + 'session.gitlabIssuePicker.error.notConnected': 'GitLabに接続されていません', + 'session.gitlabIssuePicker.error.repoNotResolvable': 'リポジトリを解決できません', + 'session.gitlabIssuePicker.error.repoMustBeGitlab': 'originリモートはGitLab URLである必要があります', + 'session.gitlabIssuePicker.error.issueNotFound': 'Issueが見つかりません', + 'session.gitlabIssuePicker.error.noModelSelected': 'モデルが選択されていません', + 'session.gitlabIssuePicker.toast.loadMoreFailed': 'さらにIssueを読み込めませんでした', + 'session.gitlabIssuePicker.toast.loadIssueDetailsFailed': 'Issueの詳細の読み込みに失敗しました', + 'session.gitlabIssuePicker.toast.sendContextFailed': 'Issueコンテキストの送信に失敗しました', + 'session.gitlabIssuePicker.toast.sessionCreated': 'Issueからセッションを作成しました', + 'session.gitlabIssuePicker.toast.startSessionFailed': 'セッションの開始に失敗しました', + 'session.gitlabIssuePicker.title.select': 'GitLab Issueをリンク', + 'session.gitlabIssuePicker.title.createSession': 'GitLab Issueから新しいセッション', + 'session.gitlabIssuePicker.description.select': 'このセッションにリンクするIssueを選択してください。', + 'session.gitlabIssuePicker.description.createSession': '非表示のIssueコンテキスト(タイトル/本文/ラベル/コメント)を含む新しいセッションを作成します。', + 'session.gitlabIssuePicker.searchPlaceholder': 'タイトルで検索するか、Issue URLを貼り付けてください', + 'session.gitlabIssuePicker.empty.noActiveProject': 'アクティブなプロジェクトが選択されていません。', + 'session.gitlabIssuePicker.empty.runtimeUnavailable': 'GitLabランタイムAPIは利用できません。', + 'session.gitlabIssuePicker.empty.notConnected': 'GitLabに接続されていません。設定でGitLabアカウントを接続してください。', + 'session.gitlabIssuePicker.empty.noIssuesFound': 'Issueが見つかりません', + 'session.gitlabIssuePicker.empty.noOpenIssuesFound': 'オープンなIssueが見つかりません', + 'session.gitlabIssuePicker.loading.issues': 'Issueを読み込み中...', + 'session.gitlabIssuePicker.loading.more': '読み込み中...', + 'session.gitlabIssuePicker.actions.openSettings': '設定を開く', + 'session.gitlabIssuePicker.actions.useIssue': 'Issue #{number}を使用', + 'session.gitlabIssuePicker.actions.openInGitLabAria': 'GitLabで開く', + 'session.gitlabIssuePicker.actions.loadMore': 'さらに読み込む', + 'session.gitlabIssuePicker.actions.sectionTitle': 'アクション', + 'session.gitlabIssuePicker.actions.toggleWorktreeAria': 'ワークツリーの切り替え', + 'session.gitlabIssuePicker.actions.createInWorktree': 'ワークツリーで作成', + 'session.gitlabIssuePicker.actions.openRepo': 'リポジトリを開く', + 'session.gitlabIssuePicker.actions.refresh': '更新', 'session.githubPrPicker.error.noActiveProject': 'アクティブなプロジェクトがありません', 'session.githubPrPicker.error.runtimeUnavailable': 'GitHubランタイムAPIは利用できません', 'session.githubPrPicker.error.notConnected': 'GitHubに接続されていません', @@ -1860,6 +1893,30 @@ export const dict: Record = { 'session.githubPrPicker.actions.usePullRequest': 'プルリクエスト #{number}を使用', 'session.githubPrPicker.actions.openInGitHubAria': 'GitHubで開く', 'session.githubPrPicker.actions.loadMore': 'さらに読み込む', + 'session.gitlabMrPicker.error.noActiveProject': 'アクティブなプロジェクトがありません', + 'session.gitlabMrPicker.error.runtimeUnavailable': 'GitLabランタイムAPIは利用できません', + 'session.gitlabMrPicker.error.notConnected': 'GitLabに接続されていません', + 'session.gitlabMrPicker.error.mrNotFound': 'マージリクエストが見つかりません', + 'session.gitlabMrPicker.error.repoNotResolvable': 'リポジトリを解決できません', + 'session.gitlabMrPicker.error.repoMustBeGitlab': 'originリモートはGitLab URLである必要があります', + 'session.gitlabMrPicker.toast.loadMoreFailed': 'さらにマージリクエストを読み込めませんでした', + 'session.gitlabMrPicker.toast.loadDetailsFailed': 'マージリクエストの詳細の読み込みに失敗しました', + 'session.gitlabMrPicker.title': 'GitLabマージリクエストをリンク', + 'session.gitlabMrPicker.description': 'このメッセージにレビューコンテキストを添付するマージリクエストを選択してください。', + 'session.gitlabMrPicker.searchPlaceholder': 'タイトルで検索するか、マージリクエストURLを貼り付けてください', + 'session.gitlabMrPicker.includeDiffAria': '添付コンテキストにMR差分を含める', + 'session.gitlabMrPicker.includeDiff': 'MR差分を含める', + 'session.gitlabMrPicker.empty.noActiveProject': 'アクティブなプロジェクトが選択されていません。', + 'session.gitlabMrPicker.empty.runtimeUnavailable': 'GitLabランタイムAPIは利用できません。', + 'session.gitlabMrPicker.empty.notConnected': 'GitLabに接続されていません。設定でGitLabアカウントを接続してください。', + 'session.gitlabMrPicker.empty.noMergeRequestsFound': 'マージリクエストが見つかりません', + 'session.gitlabMrPicker.empty.noOpenMergeRequestsFound': 'オープンなマージリクエストが見つかりません', + 'session.gitlabMrPicker.loading.mergeRequests': 'マージリクエストを読み込み中...', + 'session.gitlabMrPicker.loading.more': '読み込み中...', + 'session.gitlabMrPicker.actions.openSettings': '設定を開く', + 'session.gitlabMrPicker.actions.useMergeRequest': 'マージリクエスト !{number}を使用', + 'session.gitlabMrPicker.actions.openInGitLabAria': 'GitLabで開く', + 'session.gitlabMrPicker.actions.loadMore': 'さらに読み込む', 'session.newWorktree.title': '新しいワークツリー', 'session.newWorktree.mode.newBranch': '新しいブランチ', 'session.newWorktree.mode.existingBranch': '既存のブランチ', @@ -2178,6 +2235,8 @@ export const dict: Record = { 'chat.chatInput.actions.addAttachment': '添付ファイルを追加', 'chat.chatInput.actions.linkGithubIssue': 'GitHub Issueをリンク', 'chat.chatInput.actions.linkGithubPr': 'GitHub PRをリンク', + 'chat.chatInput.actions.linkGitlabIssue': 'GitLab Issueをリンク', + 'chat.chatInput.actions.linkGitlabMr': 'GitLab MRをリンク', 'chat.chatInput.actions.modelAgentSettings': 'モデルとエージェント設定', 'chat.chatInput.actions.sendMessageAria': 'メッセージを送信', 'chat.chatInput.actions.queueMessageAria': 'メッセージをキュー', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 96f95770..dbf204b8 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -1842,6 +1842,39 @@ export const dict: Record = { 'session.githubIssuePicker.actions.createInWorktree': '워크트리에서 생성', 'session.githubIssuePicker.actions.openRepo': '레포지토리 열기', 'session.githubIssuePicker.actions.refresh': '새로고침', + 'session.gitlabIssuePicker.error.noActiveProject': '활성 프로젝트가 없습니다', + 'session.gitlabIssuePicker.error.runtimeUnavailable': 'GitLab 런타임 API를 사용할 수 없습니다', + 'session.gitlabIssuePicker.error.notConnected': 'GitLab에 연결되어 있지 않습니다', + 'session.gitlabIssuePicker.error.repoNotResolvable': '저장소를 확인할 수 없습니다', + 'session.gitlabIssuePicker.error.repoMustBeGitlab': 'origin 리모트는 GitLab URL이어야 합니다', + 'session.gitlabIssuePicker.error.issueNotFound': '이슈를 찾을 수 없습니다', + 'session.gitlabIssuePicker.error.noModelSelected': '선택된 모델이 없습니다', + 'session.gitlabIssuePicker.toast.loadMoreFailed': '이슈를 더 불러오지 못했습니다', + 'session.gitlabIssuePicker.toast.loadIssueDetailsFailed': '이슈 상세 정보를 불러오지 못했습니다', + 'session.gitlabIssuePicker.toast.sendContextFailed': '이슈 컨텍스트 전송에 실패했습니다', + 'session.gitlabIssuePicker.toast.sessionCreated': '이슈에서 세션을 생성했습니다', + 'session.gitlabIssuePicker.toast.startSessionFailed': '세션 시작에 실패했습니다', + 'session.gitlabIssuePicker.title.select': 'GitLab 이슈 연결', + 'session.gitlabIssuePicker.title.createSession': 'GitLab 이슈로 새 세션 만들기', + 'session.gitlabIssuePicker.description.select': '이 세션에 연결할 이슈를 선택하세요.', + 'session.gitlabIssuePicker.description.createSession': '제목, 본문, 라벨, 댓글을 숨겨진 이슈 컨텍스트로 새 세션에 추가합니다.', + 'session.gitlabIssuePicker.searchPlaceholder': '제목으로 검색하거나 이슈 URL을 붙여넣으세요', + 'session.gitlabIssuePicker.empty.noActiveProject': '선택된 활성 프로젝트가 없습니다', + 'session.gitlabIssuePicker.empty.runtimeUnavailable': 'GitLab 런타임 API를 사용할 수 없습니다.', + 'session.gitlabIssuePicker.empty.notConnected': 'GitLab에 연결되지 않았습니다. 설정에서 GitLab 계정을 연결하세요.', + 'session.gitlabIssuePicker.empty.noIssuesFound': '이슈 없음', + 'session.gitlabIssuePicker.empty.noOpenIssuesFound': '열린 이슈가 없습니다', + 'session.gitlabIssuePicker.loading.issues': '이슈 로드 중…', + 'session.gitlabIssuePicker.loading.more': '로드 중…', + 'session.gitlabIssuePicker.actions.openSettings': '설정 열기', + 'session.gitlabIssuePicker.actions.useIssue': '이슈 #{number} 사용', + 'session.gitlabIssuePicker.actions.openInGitLabAria': 'GitLab에서 열기', + 'session.gitlabIssuePicker.actions.loadMore': '더 불러오기', + 'session.gitlabIssuePicker.actions.sectionTitle': '작업', + 'session.gitlabIssuePicker.actions.toggleWorktreeAria': '토글 워크트리', + 'session.gitlabIssuePicker.actions.createInWorktree': '워크트리에서 생성', + 'session.gitlabIssuePicker.actions.openRepo': '레포지토리 열기', + 'session.gitlabIssuePicker.actions.refresh': '새로고침', 'session.githubPrPicker.error.noActiveProject': '활성 프로젝트가 없습니다', 'session.githubPrPicker.error.runtimeUnavailable': 'GitHub 런타임 API를 사용할 수 없습니다', 'session.githubPrPicker.error.notConnected': 'GitHub에 연결되어 있지 않습니다', @@ -1866,6 +1899,30 @@ export const dict: Record = { 'session.githubPrPicker.actions.usePullRequest': 'PR #{number} 사용', 'session.githubPrPicker.actions.openInGitHubAria': 'GitHub에서 열기', 'session.githubPrPicker.actions.loadMore': '더 불러오기', + 'session.gitlabMrPicker.error.noActiveProject': '활성 프로젝트가 없습니다', + 'session.gitlabMrPicker.error.runtimeUnavailable': 'GitLab 런타임 API를 사용할 수 없습니다', + 'session.gitlabMrPicker.error.notConnected': 'GitLab에 연결되어 있지 않습니다', + 'session.gitlabMrPicker.error.mrNotFound': 'MR을 찾을 수 없습니다', + 'session.gitlabMrPicker.error.repoNotResolvable': '저장소를 확인할 수 없습니다', + 'session.gitlabMrPicker.error.repoMustBeGitlab': 'origin 리모트는 GitLab URL이어야 합니다', + 'session.gitlabMrPicker.toast.loadMoreFailed': 'MR을 더 불러오지 못했습니다', + 'session.gitlabMrPicker.toast.loadDetailsFailed': 'MR 상세 정보를 불러오지 못했습니다', + 'session.gitlabMrPicker.title': 'GitLab MR 연결', + 'session.gitlabMrPicker.description': '이 메시지에 리뷰 컨텍스트로 첨부할 MR을 선택하세요.', + 'session.gitlabMrPicker.searchPlaceholder': '제목으로 검색하거나 MR URL을 붙여넣으세요', + 'session.gitlabMrPicker.includeDiffAria': 'MR 변경사항을 첨부 컨텍스트에 포함', + 'session.gitlabMrPicker.includeDiff': 'MR 변경사항 포함', + 'session.gitlabMrPicker.empty.noActiveProject': '선택된 활성 프로젝트가 없습니다', + 'session.gitlabMrPicker.empty.runtimeUnavailable': 'GitLab 런타임 API를 사용할 수 없습니다.', + 'session.gitlabMrPicker.empty.notConnected': 'GitLab에 연결되지 않았습니다. 설정에서 GitLab 계정을 연결하세요.', + 'session.gitlabMrPicker.empty.noMergeRequestsFound': 'MR이 없습니다', + 'session.gitlabMrPicker.empty.noOpenMergeRequestsFound': '열린 MR이 없습니다', + 'session.gitlabMrPicker.loading.mergeRequests': 'MR 불러오는 중…', + 'session.gitlabMrPicker.loading.more': '로드 중…', + 'session.gitlabMrPicker.actions.openSettings': '설정 열기', + 'session.gitlabMrPicker.actions.useMergeRequest': 'MR !{number} 사용', + 'session.gitlabMrPicker.actions.openInGitLabAria': 'GitLab에서 열기', + 'session.gitlabMrPicker.actions.loadMore': '더 불러오기', 'session.newWorktree.title': '새 워크트리', 'session.newWorktree.mode.newBranch': '새 브랜치', 'session.newWorktree.mode.existingBranch': '기존 브랜치', @@ -2182,6 +2239,8 @@ export const dict: Record = { 'chat.chatInput.actions.addAttachment': '첨부 파일 추가', 'chat.chatInput.actions.linkGithubIssue': 'GitHub 이슈 연결', 'chat.chatInput.actions.linkGithubPr': 'GitHub PR 연결', + 'chat.chatInput.actions.linkGitlabIssue': 'GitLab 이슈 연결', + 'chat.chatInput.actions.linkGitlabMr': 'GitLab MR 연결', 'chat.chatInput.actions.modelAgentSettings': '모델 및 에이전트 설정', 'chat.chatInput.actions.sendMessageAria': '보내기 메시지', 'chat.chatInput.actions.queueMessageAria': '메시지 대기열에 추가', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 97ce96f5..24c1b104 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -1202,6 +1202,8 @@ export const dict: Record = { 'chat.chatInput.actions.commands': 'Commands', 'chat.chatInput.actions.linkGithubIssue': 'Link GitHub Issue', 'chat.chatInput.actions.linkGithubPr': 'Link GitHub PR', + 'chat.chatInput.actions.linkGitlabIssue': 'Połącz zgłoszenie GitLab', + 'chat.chatInput.actions.linkGitlabMr': 'Połącz merge request GitLab', 'chat.chatInput.actions.modelAgentSettings': 'Model and agent settings', 'chat.chatInput.actions.queueMessageAria': 'Queue message', 'chat.chatInput.actions.sendMessageAria': 'Send message', @@ -2694,6 +2696,39 @@ export const dict: Record = { 'session.githubIssuePicker.toast.sendContextFailed': 'Nie udało się wysłać kontekstu zgłoszenia', 'session.githubIssuePicker.toast.sessionCreated': 'Sesja utworzona ze zgłoszenia', 'session.githubIssuePicker.toast.startSessionFailed': 'Nie udało się uruchomić sesji', + 'session.gitlabIssuePicker.actions.createInWorktree': 'Utwórz w drzewie pracy', + 'session.gitlabIssuePicker.actions.loadMore': 'Załaduj więcej', + 'session.gitlabIssuePicker.actions.openInGitLabAria': 'Otwórz w GitLab', + 'session.gitlabIssuePicker.actions.openRepo': 'Otwórz repozytorium', + 'session.gitlabIssuePicker.actions.openSettings': 'Otwórz ustawienia', + 'session.gitlabIssuePicker.actions.refresh': 'Odśwież', + 'session.gitlabIssuePicker.actions.sectionTitle': 'Akcje', + 'session.gitlabIssuePicker.actions.toggleWorktreeAria': 'Przełącz drzewo pracy', + 'session.gitlabIssuePicker.actions.useIssue': 'Użyj zgłoszenia #{number}', + 'session.gitlabIssuePicker.description.createSession': 'Tworzy nową sesję z ukrytym kontekstem zgłoszenia (tytuł/treść/etykiety/komentarze).', + 'session.gitlabIssuePicker.description.select': 'Wybierz zgłoszenie, które chcesz powiązać z tą sesją.', + 'session.gitlabIssuePicker.empty.noActiveProject': 'Nie wybrano aktywnego projektu.', + 'session.gitlabIssuePicker.empty.noIssuesFound': 'Nie znaleziono zgłoszeń', + 'session.gitlabIssuePicker.empty.noOpenIssuesFound': 'Nie znaleziono otwartych zgłoszeń', + 'session.gitlabIssuePicker.empty.notConnected': 'GitLab nie jest połączony. Połącz konto GitLab w ustawieniach.', + 'session.gitlabIssuePicker.empty.runtimeUnavailable': 'API środowiska GitLab jest niedostępne.', + 'session.gitlabIssuePicker.error.issueNotFound': 'Nie znaleziono zgłoszenia', + 'session.gitlabIssuePicker.error.noActiveProject': 'Brak aktywnego projektu', + 'session.gitlabIssuePicker.error.noModelSelected': 'Nie wybrano modelu', + 'session.gitlabIssuePicker.error.notConnected': 'GitLab nie jest połączony', + 'session.gitlabIssuePicker.error.repoMustBeGitlab': 'remote origin musi być adresem URL GitLab', + 'session.gitlabIssuePicker.error.repoNotResolvable': 'Nie udało się rozpoznać repozytorium', + 'session.gitlabIssuePicker.error.runtimeUnavailable': 'API środowiska GitLab jest niedostępne', + 'session.gitlabIssuePicker.loading.issues': 'Ładowanie zgłoszeń...', + 'session.gitlabIssuePicker.loading.more': 'Ładowanie...', + 'session.gitlabIssuePicker.searchPlaceholder': 'Szukaj po tytule lub wklej adres URL zgłoszenia', + 'session.gitlabIssuePicker.title.createSession': 'Nowa sesja ze zgłoszenia GitLab', + 'session.gitlabIssuePicker.title.select': 'Połącz zgłoszenie GitLab', + 'session.gitlabIssuePicker.toast.loadIssueDetailsFailed': 'Nie udało się załadować szczegółów zgłoszenia', + 'session.gitlabIssuePicker.toast.loadMoreFailed': 'Nie udało się załadować kolejnych zgłoszeń', + 'session.gitlabIssuePicker.toast.sendContextFailed': 'Nie udało się wysłać kontekstu zgłoszenia', + 'session.gitlabIssuePicker.toast.sessionCreated': 'Sesja utworzona ze zgłoszenia', + 'session.gitlabIssuePicker.toast.startSessionFailed': 'Nie udało się uruchomić sesji', 'session.githubPrPicker.actions.loadMore': 'Załaduj więcej', 'session.githubPrPicker.actions.openInGitHubAria': 'Otwórz w GitHub', 'session.githubPrPicker.actions.openSettings': 'Otwórz ustawienia', @@ -2718,6 +2753,30 @@ export const dict: Record = { 'session.githubPrPicker.title': 'Połącz pull request GitHub', 'session.githubPrPicker.toast.loadDetailsFailed': 'Nie udało się załadować szczegółów pull requesta', 'session.githubPrPicker.toast.loadMoreFailed': 'Nie udało się załadować kolejnych pull requestów', + 'session.gitlabMrPicker.actions.loadMore': 'Załaduj więcej', + 'session.gitlabMrPicker.actions.openInGitLabAria': 'Otwórz w GitLab', + 'session.gitlabMrPicker.actions.openSettings': 'Otwórz ustawienia', + 'session.gitlabMrPicker.actions.useMergeRequest': 'Użyj merge requesta !{number}', + 'session.gitlabMrPicker.description': 'Wybierz merge request, aby dołączyć kontekst recenzji do tej wiadomości.', + 'session.gitlabMrPicker.empty.noActiveProject': 'Nie wybrano aktywnego projektu.', + 'session.gitlabMrPicker.empty.noMergeRequestsFound': 'Nie znaleziono merge requestów', + 'session.gitlabMrPicker.empty.noOpenMergeRequestsFound': 'Nie znaleziono otwartych merge requestów', + 'session.gitlabMrPicker.empty.notConnected': 'GitLab nie jest połączony. Połącz konto GitLab w ustawieniach.', + 'session.gitlabMrPicker.empty.runtimeUnavailable': 'API środowiska GitLab jest niedostępne.', + 'session.gitlabMrPicker.error.mrNotFound': 'Nie znaleziono merge requesta', + 'session.gitlabMrPicker.error.noActiveProject': 'Brak aktywnego projektu', + 'session.gitlabMrPicker.error.notConnected': 'GitLab nie jest połączony', + 'session.gitlabMrPicker.error.repoMustBeGitlab': 'remote origin musi być adresem URL GitLab', + 'session.gitlabMrPicker.error.repoNotResolvable': 'Nie udało się rozpoznać repozytorium', + 'session.gitlabMrPicker.error.runtimeUnavailable': 'API środowiska GitLab jest niedostępne', + 'session.gitlabMrPicker.includeDiff': 'Dołącz diff MR', + 'session.gitlabMrPicker.includeDiffAria': 'Dołącz diff MR do załączonego kontekstu', + 'session.gitlabMrPicker.loading.mergeRequests': 'Ładowanie merge requestów...', + 'session.gitlabMrPicker.loading.more': 'Ładowanie...', + 'session.gitlabMrPicker.searchPlaceholder': 'Szukaj po tytule lub wklej adres URL merge requesta', + 'session.gitlabMrPicker.title': 'Połącz merge request GitLab', + 'session.gitlabMrPicker.toast.loadDetailsFailed': 'Nie udało się załadować szczegółów merge requesta', + 'session.gitlabMrPicker.toast.loadMoreFailed': 'Nie udało się załadować kolejnych merge requestów', 'session.newWorktree.actions.cancel': 'Anuluj', 'session.newWorktree.actions.change': 'Zmień', 'session.newWorktree.actions.createWorktree': 'Utwórz drzewo pracy', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index deaf986f..fc6092f4 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -1818,6 +1818,39 @@ export const dict: Record = { "session.githubIssuePicker.actions.createInWorktree": "Criar em worktree", "session.githubIssuePicker.actions.openRepo": "Abrir repositório", "session.githubIssuePicker.actions.refresh": "Atualizar", + "session.gitlabIssuePicker.error.noActiveProject": "Não há nenhum projeto ativo", + "session.gitlabIssuePicker.error.runtimeUnavailable": "API de runtime de GitLab não disponível", + "session.gitlabIssuePicker.error.notConnected": "GitLab não está conectado", + "session.gitlabIssuePicker.error.repoNotResolvable": "Não foi possível resolver o repositório", + "session.gitlabIssuePicker.error.repoMustBeGitlab": "O remoto origin deve ser uma URL de GitLab", + "session.gitlabIssuePicker.error.issueNotFound": "Issue não encontrada", + "session.gitlabIssuePicker.error.noModelSelected": "Não há nenhum modelo selecionado", + "session.gitlabIssuePicker.toast.loadMoreFailed": "Não foi possível carregar mais issues", + "session.gitlabIssuePicker.toast.loadIssueDetailsFailed": "Não foi possível carregar os detalhes da issue", + "session.gitlabIssuePicker.toast.sendContextFailed": "Não foi possível enviar o contexto da issue", + "session.gitlabIssuePicker.toast.sessionCreated": "Sessão criada a partir da issue", + "session.gitlabIssuePicker.toast.startSessionFailed": "Não foi possível iniciar a sessão", + "session.gitlabIssuePicker.title.select": "Vincular issue de GitLab", + "session.gitlabIssuePicker.title.createSession": "Nova sessão de issue de GitLab", + "session.gitlabIssuePicker.description.select": "Selecione uma issue para vinculá-la a esta sessão.", + "session.gitlabIssuePicker.description.createSession": "Inicie uma nova sessão com contexto oculto do issue (título/corpo/etiquetas/comentários).", + "session.gitlabIssuePicker.searchPlaceholder": "Pesquisar por título ou cole a URL da issue", + "session.gitlabIssuePicker.empty.noActiveProject": "Não há nenhum projeto ativo selecionado.", + "session.gitlabIssuePicker.empty.runtimeUnavailable": "API de runtime de GitLab não disponível.", + "session.gitlabIssuePicker.empty.notConnected": "GitLab não está conectado. Conecte sua conta do GitLab nas configurações.", + "session.gitlabIssuePicker.empty.noIssuesFound": "Nenhuma issue encontrada", + "session.gitlabIssuePicker.empty.noOpenIssuesFound": "Nenhuma issue aberta encontrada", + "session.gitlabIssuePicker.loading.issues": "Carregando issues...", + "session.gitlabIssuePicker.loading.more": "Carregando...", + "session.gitlabIssuePicker.actions.openSettings": "Abrir configurações", + "session.gitlabIssuePicker.actions.useIssue": "Usar issue #{number}", + "session.gitlabIssuePicker.actions.openInGitLabAria": "Abrir em GitLab", + "session.gitlabIssuePicker.actions.loadMore": "Carregar mais", + "session.gitlabIssuePicker.actions.sectionTitle": "Ações", + "session.gitlabIssuePicker.actions.toggleWorktreeAria": "Ativar worktree", + "session.gitlabIssuePicker.actions.createInWorktree": "Criar em worktree", + "session.gitlabIssuePicker.actions.openRepo": "Abrir repositório", + "session.gitlabIssuePicker.actions.refresh": "Atualizar", "session.githubPrPicker.error.noActiveProject": "Não há nenhum projeto ativo", "session.githubPrPicker.error.runtimeUnavailable": "API de runtime de GitHub não disponível", "session.githubPrPicker.error.notConnected": "GitHub não está conectado", @@ -1842,6 +1875,30 @@ export const dict: Record = { "session.githubPrPicker.actions.usePullRequest": "Usar PR #{number}", "session.githubPrPicker.actions.openInGitHubAria": "Abrir em GitHub", "session.githubPrPicker.actions.loadMore": "Carregar mais", + "session.gitlabMrPicker.error.noActiveProject": "Não há nenhum projeto ativo", + "session.gitlabMrPicker.error.runtimeUnavailable": "API de runtime de GitLab não disponível", + "session.gitlabMrPicker.error.notConnected": "GitLab não está conectado", + "session.gitlabMrPicker.error.mrNotFound": "MR não encontrado", + "session.gitlabMrPicker.error.repoNotResolvable": "Não foi possível resolver o repositório", + "session.gitlabMrPicker.error.repoMustBeGitlab": "O remoto origin deve ser uma URL de GitLab", + "session.gitlabMrPicker.toast.loadMoreFailed": "Não foi possível carregar mais MRs", + "session.gitlabMrPicker.toast.loadDetailsFailed": "Não foi possível carregar os detalhes do MR", + "session.gitlabMrPicker.title": "Vincular MR de GitLab", + "session.gitlabMrPicker.description": "Selecione um MR para anexar contexto de revisão a esta mensagem.", + "session.gitlabMrPicker.searchPlaceholder": "Pesquisar por título ou cole a URL do MR", + "session.gitlabMrPicker.includeDiffAria": "Incluir diff do MR no contexto adjunto", + "session.gitlabMrPicker.includeDiff": "Incluir diff do MR", + "session.gitlabMrPicker.empty.noActiveProject": "Não há nenhum projeto ativo selecionado.", + "session.gitlabMrPicker.empty.runtimeUnavailable": "API de runtime de GitLab não disponível.", + "session.gitlabMrPicker.empty.notConnected": "GitLab não está conectado. Conecte sua conta do GitLab nas configurações.", + "session.gitlabMrPicker.empty.noMergeRequestsFound": "Nenhum MR encontrado", + "session.gitlabMrPicker.empty.noOpenMergeRequestsFound": "Nenhum MR aberto encontrado", + "session.gitlabMrPicker.loading.mergeRequests": "Carregando MRs...", + "session.gitlabMrPicker.loading.more": "Carregando...", + "session.gitlabMrPicker.actions.openSettings": "Abrir configurações", + "session.gitlabMrPicker.actions.useMergeRequest": "Usar MR !{number}", + "session.gitlabMrPicker.actions.openInGitLabAria": "Abrir em GitLab", + "session.gitlabMrPicker.actions.loadMore": "Carregar mais", "session.newWorktree.title": "Novo worktree", "session.newWorktree.mode.newBranch": "Nova branch", "session.newWorktree.mode.existingBranch": "Branch existente", @@ -2160,6 +2217,8 @@ export const dict: Record = { "chat.chatInput.actions.addAttachment": "Adicionar adjunto", "chat.chatInput.actions.linkGithubIssue": "Vincular issue de GitHub", "chat.chatInput.actions.linkGithubPr": "Vincular PR de GitHub", + "chat.chatInput.actions.linkGitlabIssue": "Vincular issue de GitLab", + "chat.chatInput.actions.linkGitlabMr": "Vincular MR de GitLab", "chat.chatInput.actions.modelAgentSettings": "Configurações de modelo e agente", "chat.chatInput.actions.sendMessageAria": "Enviar mensagem", "chat.chatInput.actions.queueMessageAria": "Colocar mensagem na fila", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 88b9e7cf..b2d77d6b 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -1818,6 +1818,39 @@ export const dict: Record = { "session.githubIssuePicker.actions.createInWorktree": "Створити в worktree", "session.githubIssuePicker.actions.openRepo": "Відкрити репозиторій", "session.githubIssuePicker.actions.refresh": "Оновити", + "session.gitlabIssuePicker.error.noActiveProject": "Немає активного проєкту", + "session.gitlabIssuePicker.error.runtimeUnavailable": "GitLab API недоступний", + "session.gitlabIssuePicker.error.notConnected": "GitLab не підключено", + "session.gitlabIssuePicker.error.repoNotResolvable": "Не вдалося визначити репозиторій", + "session.gitlabIssuePicker.error.repoMustBeGitlab": "remote origin має бути GitLab URL", + "session.gitlabIssuePicker.error.issueNotFound": "Issue не знайдено", + "session.gitlabIssuePicker.error.noModelSelected": "Модель не вибрано", + "session.gitlabIssuePicker.toast.loadMoreFailed": "Не вдалося завантажити додаткові issue", + "session.gitlabIssuePicker.toast.loadIssueDetailsFailed": "Не вдалося завантажити деталі issue", + "session.gitlabIssuePicker.toast.sendContextFailed": "Не вдалося надіслати контекст issue", + "session.gitlabIssuePicker.toast.sessionCreated": "Сесію створено з issue", + "session.gitlabIssuePicker.toast.startSessionFailed": "Не вдалося почати сесію", + "session.gitlabIssuePicker.title.select": "Пов’язати GitLab issue", + "session.gitlabIssuePicker.title.createSession": "Нова сесія з GitLab issue", + "session.gitlabIssuePicker.description.select": "Виберіть issue, щоб пов’язати її з цією сесією.", + "session.gitlabIssuePicker.description.createSession": "Запускає нову сесію із прихованим контекстом issue: заголовком, текстом, мітками й коментарями.", + "session.gitlabIssuePicker.searchPlaceholder": "Пошук за назвою або вставте URL issue", + "session.gitlabIssuePicker.empty.noActiveProject": "Не вибрано жодного активного проєкту.", + "session.gitlabIssuePicker.empty.runtimeUnavailable": "GitLab API недоступний.", + "session.gitlabIssuePicker.empty.notConnected": "GitLab не підключено. Підключіть обліковий запис GitLab у налаштуваннях.", + "session.gitlabIssuePicker.empty.noIssuesFound": "Issue не знайдено", + "session.gitlabIssuePicker.empty.noOpenIssuesFound": "Відкритих issue не знайдено", + "session.gitlabIssuePicker.loading.issues": "Завантаження issue...", + "session.gitlabIssuePicker.loading.more": "Завантаження...", + "session.gitlabIssuePicker.actions.openSettings": "Відкрити налаштування", + "session.gitlabIssuePicker.actions.useIssue": "Використовувати issue №{number}", + "session.gitlabIssuePicker.actions.openInGitLabAria": "Відкрити в GitLab", + "session.gitlabIssuePicker.actions.loadMore": "Завантажити ще", + "session.gitlabIssuePicker.actions.sectionTitle": "Дії", + "session.gitlabIssuePicker.actions.toggleWorktreeAria": "Перемкнути worktree", + "session.gitlabIssuePicker.actions.createInWorktree": "Створити в worktree", + "session.gitlabIssuePicker.actions.openRepo": "Відкрити репозиторій", + "session.gitlabIssuePicker.actions.refresh": "Оновити", "session.githubPrPicker.error.noActiveProject": "Немає активного проєкту", "session.githubPrPicker.error.runtimeUnavailable": "GitHub API недоступний", "session.githubPrPicker.error.notConnected": "GitHub не підключено", @@ -1842,6 +1875,30 @@ export const dict: Record = { "session.githubPrPicker.actions.usePullRequest": "Використовувати PR #{number}", "session.githubPrPicker.actions.openInGitHubAria": "Відкрити в GitHub", "session.githubPrPicker.actions.loadMore": "Завантажити ще", + "session.gitlabMrPicker.error.noActiveProject": "Немає активного проєкту", + "session.gitlabMrPicker.error.runtimeUnavailable": "GitLab API недоступний", + "session.gitlabMrPicker.error.notConnected": "GitLab не підключено", + "session.gitlabMrPicker.error.mrNotFound": "MR не знайдено", + "session.gitlabMrPicker.error.repoNotResolvable": "Не вдалося визначити репозиторій", + "session.gitlabMrPicker.error.repoMustBeGitlab": "remote origin має бути GitLab URL", + "session.gitlabMrPicker.toast.loadMoreFailed": "Не вдалося завантажити додаткові MR", + "session.gitlabMrPicker.toast.loadDetailsFailed": "Не вдалося завантажити деталі MR", + "session.gitlabMrPicker.title": "Пов’язати GitLab MR", + "session.gitlabMrPicker.description": "Виберіть MR, щоб додати контекст рев’ю до цього повідомлення.", + "session.gitlabMrPicker.searchPlaceholder": "Пошук за назвою або вставте URL MR", + "session.gitlabMrPicker.includeDiffAria": "Додати diff MR у вкладений контекст", + "session.gitlabMrPicker.includeDiff": "Додати diff MR", + "session.gitlabMrPicker.empty.noActiveProject": "Не вибрано жодного активного проєкту.", + "session.gitlabMrPicker.empty.runtimeUnavailable": "GitLab API недоступний.", + "session.gitlabMrPicker.empty.notConnected": "GitLab не підключено. Підключіть обліковий запис GitLab у налаштуваннях.", + "session.gitlabMrPicker.empty.noMergeRequestsFound": "MR не знайдено", + "session.gitlabMrPicker.empty.noOpenMergeRequestsFound": "Не знайдено відкритих MR", + "session.gitlabMrPicker.loading.mergeRequests": "Завантаження MR...", + "session.gitlabMrPicker.loading.more": "Завантаження...", + "session.gitlabMrPicker.actions.openSettings": "Відкрити налаштування", + "session.gitlabMrPicker.actions.useMergeRequest": "Використовувати MR !{number}", + "session.gitlabMrPicker.actions.openInGitLabAria": "Відкрити в GitLab", + "session.gitlabMrPicker.actions.loadMore": "Завантажити ще", "session.newWorktree.title": "Нове worktree", "session.newWorktree.mode.newBranch": "Нова гілка", "session.newWorktree.mode.existingBranch": "Наявна гілка", @@ -2160,6 +2217,8 @@ export const dict: Record = { "chat.chatInput.actions.addAttachment": "Додати вкладення", "chat.chatInput.actions.linkGithubIssue": "Пов’язати GitHub issue", "chat.chatInput.actions.linkGithubPr": "Пов’язати GitHub PR", + "chat.chatInput.actions.linkGitlabIssue": "Пов’язати GitLab issue", + "chat.chatInput.actions.linkGitlabMr": "Пов’язати GitLab MR", "chat.chatInput.actions.modelAgentSettings": "Параметри моделі та агента", "chat.chatInput.actions.sendMessageAria": "Надіслати повідомлення", "chat.chatInput.actions.queueMessageAria": "Поставити повідомлення в чергу", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 06a404e9..892f2f60 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -1806,6 +1806,39 @@ export const dict: Record = { 'session.githubIssuePicker.actions.createInWorktree': '在工作树中创建', 'session.githubIssuePicker.actions.openRepo': '打开仓库', 'session.githubIssuePicker.actions.refresh': '刷新', + 'session.gitlabIssuePicker.error.noActiveProject': '没有活动项目', + 'session.gitlabIssuePicker.error.runtimeUnavailable': 'GitLab 运行时 API 不可用', + 'session.gitlabIssuePicker.error.notConnected': 'GitLab 未连接', + 'session.gitlabIssuePicker.error.repoNotResolvable': '无法解析仓库', + 'session.gitlabIssuePicker.error.repoMustBeGitlab': 'origin 远程仓库必须是 GitLab URL', + 'session.gitlabIssuePicker.error.issueNotFound': '未找到 Issue', + 'session.gitlabIssuePicker.error.noModelSelected': '未选择模型', + 'session.gitlabIssuePicker.toast.loadMoreFailed': '加载更多 Issue 失败', + 'session.gitlabIssuePicker.toast.loadIssueDetailsFailed': '加载 Issue 详情失败', + 'session.gitlabIssuePicker.toast.sendContextFailed': '发送 Issue 上下文失败', + 'session.gitlabIssuePicker.toast.sessionCreated': '已从 Issue 创建会话', + 'session.gitlabIssuePicker.toast.startSessionFailed': '启动会话失败', + 'session.gitlabIssuePicker.title.select': '关联 GitLab Issue', + 'session.gitlabIssuePicker.title.createSession': '从 GitLab Issue 新建会话', + 'session.gitlabIssuePicker.description.select': '选择一个 Issue 关联到当前会话。', + 'session.gitlabIssuePicker.description.createSession': '使用隐藏的 Issue 上下文(标题/正文/标签/评论)初始化新会话。', + 'session.gitlabIssuePicker.searchPlaceholder': '按标题搜索,或粘贴 Issue URL', + 'session.gitlabIssuePicker.empty.noActiveProject': '未选择活动项目。', + 'session.gitlabIssuePicker.empty.runtimeUnavailable': 'GitLab 运行时 API 不可用。', + 'session.gitlabIssuePicker.empty.notConnected': 'GitLab 未连接。请在设置中连接 GitLab 账号。', + 'session.gitlabIssuePicker.empty.noIssuesFound': '未找到 Issue', + 'session.gitlabIssuePicker.empty.noOpenIssuesFound': '没有未关闭的 Issue', + 'session.gitlabIssuePicker.loading.issues': '正在加载 Issue...', + 'session.gitlabIssuePicker.loading.more': '加载中...', + 'session.gitlabIssuePicker.actions.openSettings': '打开设置', + 'session.gitlabIssuePicker.actions.useIssue': '使用 Issue #{number}', + 'session.gitlabIssuePicker.actions.openInGitLabAria': '在 GitLab 中打开', + 'session.gitlabIssuePicker.actions.loadMore': '加载更多', + 'session.gitlabIssuePicker.actions.sectionTitle': '操作', + 'session.gitlabIssuePicker.actions.toggleWorktreeAria': '切换工作树', + 'session.gitlabIssuePicker.actions.createInWorktree': '在工作树中创建', + 'session.gitlabIssuePicker.actions.openRepo': '打开仓库', + 'session.gitlabIssuePicker.actions.refresh': '刷新', 'session.githubPrPicker.error.noActiveProject': '没有活动项目', 'session.githubPrPicker.error.runtimeUnavailable': 'GitHub 运行时 API 不可用', 'session.githubPrPicker.error.notConnected': 'GitHub 未连接', @@ -1830,6 +1863,30 @@ export const dict: Record = { 'session.githubPrPicker.actions.usePullRequest': '使用 Pull Request #{number}', 'session.githubPrPicker.actions.openInGitHubAria': '在 GitHub 中打开', 'session.githubPrPicker.actions.loadMore': '加载更多', + 'session.gitlabMrPicker.error.noActiveProject': '没有活动项目', + 'session.gitlabMrPicker.error.runtimeUnavailable': 'GitLab 运行时 API 不可用', + 'session.gitlabMrPicker.error.notConnected': 'GitLab 未连接', + 'session.gitlabMrPicker.error.mrNotFound': '未找到 Merge Request', + 'session.gitlabMrPicker.error.repoNotResolvable': '无法解析仓库', + 'session.gitlabMrPicker.error.repoMustBeGitlab': 'origin 远程仓库必须是 GitLab URL', + 'session.gitlabMrPicker.toast.loadMoreFailed': '加载更多 Merge Request 失败', + 'session.gitlabMrPicker.toast.loadDetailsFailed': '加载 Merge Request 详情失败', + 'session.gitlabMrPicker.title': '关联 GitLab Merge Request', + 'session.gitlabMrPicker.description': '选择一个 Merge Request,将审查上下文附加到此消息。', + 'session.gitlabMrPicker.searchPlaceholder': '按标题搜索,或粘贴 Merge Request URL', + 'session.gitlabMrPicker.includeDiffAria': '在附加上下文中包含 MR 差异', + 'session.gitlabMrPicker.includeDiff': '包含 MR 差异', + 'session.gitlabMrPicker.empty.noActiveProject': '未选择活动项目。', + 'session.gitlabMrPicker.empty.runtimeUnavailable': 'GitLab 运行时 API 不可用。', + 'session.gitlabMrPicker.empty.notConnected': 'GitLab 未连接。请在设置中连接 GitLab 账号。', + 'session.gitlabMrPicker.empty.noMergeRequestsFound': '未找到 Merge Request', + 'session.gitlabMrPicker.empty.noOpenMergeRequestsFound': '没有未关闭的 Merge Request', + 'session.gitlabMrPicker.loading.mergeRequests': '正在加载 Merge Request...', + 'session.gitlabMrPicker.loading.more': '加载中...', + 'session.gitlabMrPicker.actions.openSettings': '打开设置', + 'session.gitlabMrPicker.actions.useMergeRequest': '使用 Merge Request !{number}', + 'session.gitlabMrPicker.actions.openInGitLabAria': '在 GitLab 中打开', + 'session.gitlabMrPicker.actions.loadMore': '加载更多', 'session.newWorktree.title': '新建工作树', 'session.newWorktree.mode.newBranch': '新分支', 'session.newWorktree.mode.existingBranch': '现有分支', @@ -2148,6 +2205,8 @@ export const dict: Record = { 'chat.chatInput.actions.addAttachment': '添加附件', 'chat.chatInput.actions.linkGithubIssue': '关联 GitHub Issue', 'chat.chatInput.actions.linkGithubPr': '关联 GitHub PR', + 'chat.chatInput.actions.linkGitlabIssue': '关联 GitLab Issue', + 'chat.chatInput.actions.linkGitlabMr': '关联 GitLab MR', 'chat.chatInput.actions.modelAgentSettings': '模型与智能体设置', 'chat.chatInput.actions.sendMessageAria': '发送消息', 'chat.chatInput.actions.queueMessageAria': '将消息加入队列', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 921bacf1..43ad93fa 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -1810,6 +1810,39 @@ export const dict: Record = { 'session.githubIssuePicker.actions.createInWorktree': '在 worktree 中建立', 'session.githubIssuePicker.actions.openRepo': '開啟儲存庫', 'session.githubIssuePicker.actions.refresh': '重新整理', + 'session.gitlabIssuePicker.error.noActiveProject': '沒有活動專案', + 'session.gitlabIssuePicker.error.runtimeUnavailable': 'GitLab 執行時 API 無法使用', + 'session.gitlabIssuePicker.error.notConnected': 'GitLab 未連線', + 'session.gitlabIssuePicker.error.repoNotResolvable': '無法解析儲存庫', + 'session.gitlabIssuePicker.error.repoMustBeGitlab': 'origin 遠端儲存庫必須是 GitLab URL', + 'session.gitlabIssuePicker.error.issueNotFound': '找不到 Issue', + 'session.gitlabIssuePicker.error.noModelSelected': '未選擇模型', + 'session.gitlabIssuePicker.toast.loadMoreFailed': '載入更多 Issue 失敗', + 'session.gitlabIssuePicker.toast.loadIssueDetailsFailed': '載入 Issue 詳情失敗', + 'session.gitlabIssuePicker.toast.sendContextFailed': '傳送 Issue 上下文失敗', + 'session.gitlabIssuePicker.toast.sessionCreated': '已從 Issue 建立會話', + 'session.gitlabIssuePicker.toast.startSessionFailed': '啟動會話失敗', + 'session.gitlabIssuePicker.title.select': '關聯 GitLab Issue', + 'session.gitlabIssuePicker.title.createSession': '從 GitLab Issue 新增會話', + 'session.gitlabIssuePicker.description.select': '選擇一個 Issue 關聯到目前會話。', + 'session.gitlabIssuePicker.description.createSession': '使用隱藏的 Issue 上下文(標題/內文/標籤/留言)初始化新會話。', + 'session.gitlabIssuePicker.searchPlaceholder': '按標題搜尋,或貼上 Issue URL', + 'session.gitlabIssuePicker.empty.noActiveProject': '未選擇活動專案。', + 'session.gitlabIssuePicker.empty.runtimeUnavailable': 'GitLab 執行時 API 無法使用。', + 'session.gitlabIssuePicker.empty.notConnected': 'GitLab 未連線。請在設定中連接 GitLab 帳號。', + 'session.gitlabIssuePicker.empty.noIssuesFound': '找不到 Issue', + 'session.gitlabIssuePicker.empty.noOpenIssuesFound': '沒有未關閉的 Issue', + 'session.gitlabIssuePicker.loading.issues': '正在載入 Issue...', + 'session.gitlabIssuePicker.loading.more': '載入中...', + 'session.gitlabIssuePicker.actions.openSettings': '開啟設定', + 'session.gitlabIssuePicker.actions.useIssue': '使用 Issue #{number}', + 'session.gitlabIssuePicker.actions.openInGitLabAria': '在 GitLab 中開啟', + 'session.gitlabIssuePicker.actions.loadMore': '載入更多', + 'session.gitlabIssuePicker.actions.sectionTitle': '操作', + 'session.gitlabIssuePicker.actions.toggleWorktreeAria': '切換 worktree', + 'session.gitlabIssuePicker.actions.createInWorktree': '在 worktree 中建立', + 'session.gitlabIssuePicker.actions.openRepo': '開啟儲存庫', + 'session.gitlabIssuePicker.actions.refresh': '重新整理', 'session.githubPrPicker.error.noActiveProject': '沒有活動專案', 'session.githubPrPicker.error.runtimeUnavailable': 'GitHub 執行時 API 無法使用', 'session.githubPrPicker.error.notConnected': 'GitHub 未連線', @@ -1834,6 +1867,30 @@ export const dict: Record = { 'session.githubPrPicker.actions.usePullRequest': '使用 Pull Request #{number}', 'session.githubPrPicker.actions.openInGitHubAria': '在 GitHub 中開啟', 'session.githubPrPicker.actions.loadMore': '載入更多', + 'session.gitlabMrPicker.error.noActiveProject': '沒有活動專案', + 'session.gitlabMrPicker.error.runtimeUnavailable': 'GitLab 執行時 API 無法使用', + 'session.gitlabMrPicker.error.notConnected': 'GitLab 未連線', + 'session.gitlabMrPicker.error.mrNotFound': '找不到 Merge Request', + 'session.gitlabMrPicker.error.repoNotResolvable': '無法解析儲存庫', + 'session.gitlabMrPicker.error.repoMustBeGitlab': 'origin 遠端儲存庫必須是 GitLab URL', + 'session.gitlabMrPicker.toast.loadMoreFailed': '載入更多 Merge Request 失敗', + 'session.gitlabMrPicker.toast.loadDetailsFailed': '載入 Merge Request 詳情失敗', + 'session.gitlabMrPicker.title': '關聯 GitLab Merge Request', + 'session.gitlabMrPicker.description': '選擇一個 Merge Request,將審查上下文附加到此訊息。', + 'session.gitlabMrPicker.searchPlaceholder': '按標題搜尋,或貼上 Merge Request URL', + 'session.gitlabMrPicker.includeDiffAria': '在附加上下文中包含 MR diff', + 'session.gitlabMrPicker.includeDiff': '包含 MR diff', + 'session.gitlabMrPicker.empty.noActiveProject': '未選擇活動專案。', + 'session.gitlabMrPicker.empty.runtimeUnavailable': 'GitLab 執行時 API 無法使用。', + 'session.gitlabMrPicker.empty.notConnected': 'GitLab 未連線。請在設定中連接 GitLab 帳號。', + 'session.gitlabMrPicker.empty.noMergeRequestsFound': '找不到 Merge Request', + 'session.gitlabMrPicker.empty.noOpenMergeRequestsFound': '沒有未關閉的 Merge Request', + 'session.gitlabMrPicker.loading.mergeRequests': '正在載入 Merge Request...', + 'session.gitlabMrPicker.loading.more': '載入中...', + 'session.gitlabMrPicker.actions.openSettings': '開啟設定', + 'session.gitlabMrPicker.actions.useMergeRequest': '使用 Merge Request !{number}', + 'session.gitlabMrPicker.actions.openInGitLabAria': '在 GitLab 中開啟', + 'session.gitlabMrPicker.actions.loadMore': '載入更多', 'session.newWorktree.title': '新增 Worktree', 'session.newWorktree.mode.newBranch': '新分支', 'session.newWorktree.mode.existingBranch': '現有分支', @@ -2152,6 +2209,8 @@ export const dict: Record = { 'chat.chatInput.actions.addAttachment': '加入附件', 'chat.chatInput.actions.linkGithubIssue': '關聯 GitHub Issue', 'chat.chatInput.actions.linkGithubPr': '關聯 GitHub PR', + 'chat.chatInput.actions.linkGitlabIssue': '關聯 GitLab Issue', + 'chat.chatInput.actions.linkGitlabMr': '關聯 GitLab MR', 'chat.chatInput.actions.modelAgentSettings': '模型與 Agent 設定', 'chat.chatInput.actions.sendMessageAria': '傳送訊息', 'chat.chatInput.actions.queueMessageAria': '將訊息加入佇列', From 2c4ba78ae53d27c9e0a4ddfa8d3c0443cd0b59d5 Mon Sep 17 00:00:00 2001 From: bot-hermes Date: Thu, 13 Aug 2026 16:55:46 +0000 Subject: [PATCH 10/45] feat(ui): GitLab merge request view in the context panel --- .../ui/src/components/layout/ContextPanel.tsx | 3 +- .../ui/src/components/views/GitLabMrView.tsx | 489 ++++++++++++++++++ packages/ui/src/lib/i18n/messages/de.ts | 19 + packages/ui/src/lib/i18n/messages/en.ts | 19 + packages/ui/src/lib/i18n/messages/es.ts | 19 + packages/ui/src/lib/i18n/messages/fr.ts | 19 + packages/ui/src/lib/i18n/messages/ja.ts | 19 + packages/ui/src/lib/i18n/messages/ko.ts | 19 + packages/ui/src/lib/i18n/messages/pl.ts | 19 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 19 + packages/ui/src/lib/i18n/messages/uk.ts | 19 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 19 + packages/ui/src/lib/i18n/messages/zh-TW.ts | 19 + 13 files changed, 700 insertions(+), 1 deletion(-) create mode 100644 packages/ui/src/components/views/GitLabMrView.tsx diff --git a/packages/ui/src/components/layout/ContextPanel.tsx b/packages/ui/src/components/layout/ContextPanel.tsx index dcd9240a..f5d2676f 100644 --- a/packages/ui/src/components/layout/ContextPanel.tsx +++ b/packages/ui/src/components/layout/ContextPanel.tsx @@ -5,6 +5,7 @@ import { DiffViewIcon } from '@/components/icons/DiffIcon'; import { Button } from '@/components/ui/button'; import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip'; import { PullRequestView } from '@/components/views/PullRequestView'; +import { GitLabMrView } from '@/components/views/GitLabMrView'; import { TerminalView } from '@/components/views/TerminalView'; import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery'; @@ -938,7 +939,7 @@ export const ContextPanel: React.FC = () => { : activeTab?.mode === 'git' ? : activeTab?.mode === 'pr' - ? (gitProvider === 'github' ? : null) + ? (gitProvider === 'github' ? : gitProvider === 'gitlab' ? : null) : activeTab?.mode === 'notes' ? : activeTab?.mode === 'plan' diff --git a/packages/ui/src/components/views/GitLabMrView.tsx b/packages/ui/src/components/views/GitLabMrView.tsx new file mode 100644 index 00000000..c81d74fb --- /dev/null +++ b/packages/ui/src/components/views/GitLabMrView.tsx @@ -0,0 +1,489 @@ +import React from 'react'; +import { useShallow } from 'zustand/react/shallow'; +import { Icon } from '@/components/icon/Icon'; +import { Button } from '@/components/ui/button'; +import { ScrollShadow } from '@/components/ui/ScrollShadow'; +import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; +import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer'; +import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; +import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; +import { useGitStatus, useGitStore } from '@/stores/useGitStore'; +import { useGitLabAuthStore } from '@/stores/useGitLabAuthStore'; +import { useUIStore } from '@/stores/useUIStore'; +import { openExternalUrl } from '@/lib/url'; +import { formatDateTimeForPreference } from '@/lib/timeFormat'; +import type { GitLabMergeRequestContextResult, GitLabMergeRequestSummary } from '@/lib/api/types'; +import { useI18n } from '@/lib/i18n'; + +const mrStateColor = (state: string): string => { + switch (state) { + case 'merged': + return 'var(--pr-merged)'; + case 'closed': + return 'var(--pr-closed)'; + default: + return 'var(--pr-open)'; + } +}; + +const mrAuthorLabel = (mr: GitLabMergeRequestSummary): string => + mr.author?.name?.trim() || mr.author?.username || ''; + +const draftBadgeClass = + 'inline-flex items-center rounded border border-border/60 bg-surface-elevated px-1.5 py-px typography-micro text-foreground'; + +/** + * Read-only GitLab merge request surface for the context panel. Resolves the + * same repository context GitView uses (effective directory + current branch + * from the shared git stores) and renders the branch's merge request plus the + * repository's open merge requests. v1 is intentionally read-only: no create, + * update, or merge actions. + */ +export const GitLabMrView: React.FC = () => { + const { t } = useI18n(); + const { git, gitlab } = useRuntimeAPIs(); + const currentDirectory = useEffectiveDirectory(); + const status = useGitStatus(currentDirectory ?? null); + const { ensureAll } = useGitStore(useShallow((state) => ({ ensureAll: state.ensureAll }))); + + const gitlabAuthStatus = useGitLabAuthStore((state) => state.status); + const gitlabAuthChecked = useGitLabAuthStore((state) => state.hasChecked); + const refreshGitLabStatus = useGitLabAuthStore((state) => state.refreshStatus); + + const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen); + const setSettingsPage = useUIStore((state) => state.setSettingsPage); + const timeFormatPreference = useUIStore((state) => state.timeFormatPreference); + + React.useEffect(() => { + if (!currentDirectory || !git) { + return; + } + void ensureAll(currentDirectory, git); + }, [currentDirectory, ensureAll, git]); + + // Settle the connection state exactly once; the store dedupes in-flight + // refreshes so remounts never pile up status requests. + React.useEffect(() => { + if (gitlabAuthChecked) { + return; + } + void refreshGitLabStatus(gitlab); + }, [gitlab, gitlabAuthChecked, refreshGitLabStatus]); + + const currentBranch = status?.current ?? null; + const connected = gitlabAuthChecked ? gitlabAuthStatus?.connected === true : null; + + const openGitLabSettings = React.useCallback(() => { + setSettingsPage('git'); + setSettingsDialogOpen(true); + }, [setSettingsDialogOpen, setSettingsPage]); + + // ---- Current-branch merge request -------------------------------------- + + const [branchMr, setBranchMr] = React.useState(null); + const [branchMrLoading, setBranchMrLoading] = React.useState(false); + const [branchMrError, setBranchMrError] = React.useState(null); + const [retryToken, setRetryToken] = React.useState(0); + + const retry = React.useCallback(() => setRetryToken((value) => value + 1), []); + + React.useEffect(() => { + if (!currentDirectory || !currentBranch || !connected || !gitlab?.mrsList) { + return; + } + let cancelled = false; + setBranchMrLoading(true); + setBranchMrError(null); + void gitlab + .mrsList(currentDirectory, { sourceBranch: currentBranch }) + .then((result) => { + if (cancelled) { + return; + } + const candidates = result.mrs ?? []; + // Prefer the open MR for the branch; fall back to a merged one so a + // just-merged branch still shows its request instead of nothing. + const matching = + candidates.find((mr) => mr.state === 'opened') + ?? candidates.find((mr) => mr.state === 'merged') + ?? null; + setBranchMr(matching); + }) + .catch((error) => { + if (!cancelled) { + setBranchMrError(error instanceof Error ? error.message : String(error)); + } + }) + .finally(() => { + if (!cancelled) { + setBranchMrLoading(false); + } + }); + return () => { + cancelled = true; + }; + }, [connected, currentBranch, currentDirectory, gitlab, retryToken]); + + // ---- Open merge requests in this repository ---------------------------- + + const [openMrs, setOpenMrs] = React.useState([]); + const [listPage, setListPage] = React.useState(1); + const [listHasMore, setListHasMore] = React.useState(false); + const [listLoading, setListLoading] = React.useState(false); + const [listLoadingMore, setListLoadingMore] = React.useState(false); + const [listError, setListError] = React.useState(null); + + React.useEffect(() => { + if (!currentDirectory || !connected || !gitlab?.mrsList) { + return; + } + let cancelled = false; + setListLoading(true); + setListError(null); + void gitlab + .mrsList(currentDirectory, { page: 1 }) + .then((result) => { + if (cancelled) { + return; + } + setOpenMrs(result.mrs ?? []); + setListPage(result.page ?? 1); + setListHasMore(Boolean(result.hasMore)); + }) + .catch((error) => { + if (!cancelled) { + setListError(error instanceof Error ? error.message : String(error)); + } + }) + .finally(() => { + if (!cancelled) { + setListLoading(false); + } + }); + return () => { + cancelled = true; + }; + }, [connected, currentDirectory, gitlab, retryToken]); + + const loadMore = React.useCallback(async () => { + if (!currentDirectory || !connected || !gitlab?.mrsList) { + return; + } + if (listLoadingMore || listLoading || !listHasMore) { + return; + } + setListLoadingMore(true); + try { + const next = await gitlab.mrsList(currentDirectory, { page: listPage + 1 }); + setOpenMrs((previous) => [...previous, ...(next.mrs ?? [])]); + setListPage(next.page ?? listPage + 1); + setListHasMore(Boolean(next.hasMore)); + } catch (error) { + setListError(error instanceof Error ? error.message : String(error)); + } finally { + setListLoadingMore(false); + } + }, [connected, currentDirectory, gitlab, listHasMore, listLoading, listLoadingMore, listPage]); + + // ---- Inline MR context (current-branch MR only) ------------------------ + + const [contextOpen, setContextOpen] = React.useState(false); + const [contextResult, setContextResult] = React.useState(null); + const [contextLoading, setContextLoading] = React.useState(false); + const [contextError, setContextError] = React.useState(null); + + // A different branch MR invalidates any previously loaded context. + React.useEffect(() => { + setContextOpen(false); + setContextResult(null); + setContextError(null); + }, [branchMr?.number]); + + const toggleContext = React.useCallback(async (mr: GitLabMergeRequestSummary) => { + if (!currentDirectory || !gitlab?.mrContext) { + return; + } + if (contextOpen) { + setContextOpen(false); + setContextResult(null); + setContextError(null); + return; + } + setContextOpen(true); + setContextLoading(true); + setContextError(null); + try { + const result = await gitlab.mrContext(currentDirectory, mr.number, { includeDiff: false }); + if (result.connected === false) { + setContextError(t('contextPanel.gitlabMr.error.notConnected')); + } else { + setContextResult(result); + } + } catch (error) { + setContextError(error instanceof Error ? error.message : String(error)); + } finally { + setContextLoading(false); + } + }, [contextOpen, currentDirectory, gitlab, t]); + + const formatTimestamp = React.useCallback((value?: string) => { + if (!value) { + return ''; + } + const timestamp = Date.parse(value); + if (!Number.isFinite(timestamp)) { + return value; + } + return formatDateTimeForPreference(timestamp, timeFormatPreference, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }); + }, [timeFormatPreference]); + + // ---- Render ------------------------------------------------------------ + + if (!currentDirectory) { + return ( +
+ +
{t('contextPanel.gitlabMr.title')}
+
{t('contextPanel.gitlabMr.empty.noActiveProject')}
+
+ ); + } + + if (connected === null) { + return ( +
+ +
{t('contextPanel.gitlabMr.loading')}
+
+ ); + } + + if (connected === false) { + return ( +
+ +
{t('contextPanel.gitlabMr.error.notConnected')}
+ +
+ ); + } + + const branchMrStateLabel = branchMr + ? branchMr.state === 'merged' + ? t('contextPanel.gitlabMr.state.merged') + : branchMr.state === 'closed' + ? t('contextPanel.gitlabMr.state.closed') + : t('contextPanel.gitlabMr.state.opened') + : ''; + const branchMrAuthor = branchMr ? mrAuthorLabel(branchMr) : ''; + const mrComments = contextResult?.comments ?? []; + + return ( + +
+
+
{t('contextPanel.gitlabMr.title')}
+
{t('contextPanel.gitlabMr.listSectionTitle')}
+
+ + {/* Current-branch merge request */} +
+

{t('contextPanel.gitlabMr.branchSectionTitle')}

+ + {branchMrLoading ? ( +
+ + {t('contextPanel.gitlabMr.loading')} +
+ ) : branchMrError ? ( +
+
{t('contextPanel.gitlabMr.error.loadFailed')}
+
{branchMrError}
+ +
+ ) : branchMr ? ( +
+
+
+ !{branchMr.number} {branchMr.title} +
+
+ {branchMr.draft ? ( + {t('contextPanel.gitlabMr.draft')} + ) : null} + + + {branchMrStateLabel} + + {branchMr.sourceBranch} → {branchMr.targetBranch} +
+ {branchMrAuthor ? ( +
{branchMrAuthor}
+ ) : null} +
+ +
+ + +
+ + {contextOpen ? ( +
+ {contextLoading ? ( +
+ + {t('contextPanel.gitlabMr.loading')} +
+ ) : contextError ? ( +
{contextError}
+ ) : ( + <> +
+
{t('gitView.pr.field.description')}
+ {contextResult?.mr?.body?.trim() ? ( + + ) : ( +
{t('gitView.pr.noDescription')}
+ )} +
+
+
{t('gitView.pr.segment.comments')}
+ {mrComments.length > 0 ? ( + mrComments.map((comment) => ( +
+
+ + {comment.author?.name?.trim() || comment.author?.username || ''} + + {comment.createdAt ? ( + {formatTimestamp(comment.createdAt)} + ) : null} +
+ +
+ )) + ) : ( +
{t('gitView.pr.comments.empty')}
+ )} +
+ + )} +
+ ) : null} +
+ ) : ( +
{t('contextPanel.gitlabMr.noMrForBranch')}
+ )} +
+ + {/* Open merge requests in this repository */} +
+

{t('contextPanel.gitlabMr.openMrTitle')}

+ + {listLoading ? ( +
+ + {t('contextPanel.gitlabMr.loading')} +
+ ) : listError ? ( +
+
{t('contextPanel.gitlabMr.error.loadFailed')}
+
{listError}
+ +
+ ) : openMrs.length === 0 ? ( +
{t('contextPanel.gitlabMr.openMrEmpty')}
+ ) : ( +
+ {openMrs.map((mr) => ( +
void openExternalUrl(mr.url)} + > +
+

+ !{mr.number} + {mr.title} +

+

{mr.sourceBranch} → {mr.targetBranch}

+
+ {mr.draft ? ( + {t('contextPanel.gitlabMr.draft')} + ) : null} + event.stopPropagation()} + aria-label={t('contextPanel.gitlabMr.openInGitLab')} + className="hidden size-5 flex-shrink-0 items-center justify-center text-muted-foreground transition-colors hover:text-foreground group-hover:flex" + > + + +
+ ))} + + {listHasMore ? ( +
+ +
+ ) : null} +
+ )} +
+
+
+ ); +}; diff --git a/packages/ui/src/lib/i18n/messages/de.ts b/packages/ui/src/lib/i18n/messages/de.ts index 5fdae5eb..401031a3 100644 --- a/packages/ui/src/lib/i18n/messages/de.ts +++ b/packages/ui/src/lib/i18n/messages/de.ts @@ -2980,6 +2980,25 @@ export const dict = { 'contextRail.aria.rail': 'Kontextleiste', 'contextPanel.editorEmpty.title': 'Kein Kontext ausgewählt', 'contextPanel.editorEmpty.description': 'Wählen Sie etwas aus der Seitenleiste aus, um Kontext anzuzeigen.', + 'contextPanel.gitlabMr.title': 'Merge-Requests', + 'contextPanel.gitlabMr.branchSectionTitle': 'Aktueller Zweig', + 'contextPanel.gitlabMr.openMrTitle': 'Offene Merge-Requests', + 'contextPanel.gitlabMr.listSectionTitle': 'In diesem Repository', + 'contextPanel.gitlabMr.openMrEmpty': 'Keine offenen Merge-Requests', + 'contextPanel.gitlabMr.noMrForBranch': 'Keine Merge-Request für diesen Zweig', + 'contextPanel.gitlabMr.loadContext': 'Kontext laden', + 'contextPanel.gitlabMr.hideContext': 'Kontext ausblenden', + 'contextPanel.gitlabMr.openInGitLab': 'In GitLab öffnen', + 'contextPanel.gitlabMr.draft': 'Entwurf', + 'contextPanel.gitlabMr.state.opened': 'Offen', + 'contextPanel.gitlabMr.state.merged': 'Zusammengeführt', + 'contextPanel.gitlabMr.state.closed': 'Geschlossen', + 'contextPanel.gitlabMr.loadMore': 'Mehr laden', + 'contextPanel.gitlabMr.loading': 'Laden...', + 'contextPanel.gitlabMr.error.loadFailed': 'Merge-Requests konnten nicht geladen werden', + 'contextPanel.gitlabMr.error.notConnected': 'GitLab ist nicht verbunden', + 'contextPanel.gitlabMr.empty.noActiveProject': 'Kein aktives Projekt', + 'contextPanel.gitlabMr.actions.openSettings': 'Einstellungen öffnen', 'contextRail.surface.editor.description': 'Bearbeitungskontext', 'contextRail.surface.git.description': 'Git-Kontext', 'contextRail.surface.git.changesCountAriaSingle': '{label}, {count} geänderte Datei', diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index 666113b2..a7c4d82a 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -1118,6 +1118,25 @@ export const dict = { 'contextRail.aria.rail': 'Panel surfaces', 'contextPanel.editorEmpty.title': 'No file open', 'contextPanel.editorEmpty.description': 'Pick a file from the tree to start editing.', + 'contextPanel.gitlabMr.title': 'Merge requests', + 'contextPanel.gitlabMr.branchSectionTitle': 'Current branch', + 'contextPanel.gitlabMr.openMrTitle': 'Open merge requests', + 'contextPanel.gitlabMr.listSectionTitle': 'In this repository', + 'contextPanel.gitlabMr.openMrEmpty': 'No open merge requests', + 'contextPanel.gitlabMr.noMrForBranch': 'No merge request for this branch', + 'contextPanel.gitlabMr.loadContext': 'Load context', + 'contextPanel.gitlabMr.hideContext': 'Hide context', + 'contextPanel.gitlabMr.openInGitLab': 'Open in GitLab', + 'contextPanel.gitlabMr.draft': 'Draft', + 'contextPanel.gitlabMr.state.opened': 'Open', + 'contextPanel.gitlabMr.state.merged': 'Merged', + 'contextPanel.gitlabMr.state.closed': 'Closed', + 'contextPanel.gitlabMr.loadMore': 'Load more', + 'contextPanel.gitlabMr.loading': 'Loading...', + 'contextPanel.gitlabMr.error.loadFailed': 'Failed to load merge requests', + 'contextPanel.gitlabMr.error.notConnected': 'GitLab is not connected', + 'contextPanel.gitlabMr.empty.noActiveProject': 'No active project', + 'contextPanel.gitlabMr.actions.openSettings': 'Open settings', 'contextRail.surface.editor.description': 'Edit project files', 'contextRail.surface.git.description': 'Commits, branches, and pull requests', 'contextRail.surface.git.changesCountAriaSingle': '{label}, {count} changed file', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 51424df8..414469bf 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -1119,6 +1119,25 @@ export const dict: Record = { "contextRail.aria.rail": "Superficies del panel", "contextPanel.editorEmpty.title": "Ningún archivo abierto", "contextPanel.editorEmpty.description": "Elige un archivo del árbol para empezar a editar.", + "contextPanel.gitlabMr.title": "Solicitudes de fusión", + "contextPanel.gitlabMr.branchSectionTitle": "Rama actual", + "contextPanel.gitlabMr.openMrTitle": "Solicitudes de fusión abiertas", + "contextPanel.gitlabMr.listSectionTitle": "En este repositorio", + "contextPanel.gitlabMr.openMrEmpty": "No hay solicitudes de fusión abiertas", + "contextPanel.gitlabMr.noMrForBranch": "No hay solicitud de fusión para esta rama", + "contextPanel.gitlabMr.loadContext": "Cargar contexto", + "contextPanel.gitlabMr.hideContext": "Ocultar contexto", + "contextPanel.gitlabMr.openInGitLab": "Abrir en GitLab", + "contextPanel.gitlabMr.draft": "Borrador", + "contextPanel.gitlabMr.state.opened": "Abierta", + "contextPanel.gitlabMr.state.merged": "Fusionada", + "contextPanel.gitlabMr.state.closed": "Cerrada", + "contextPanel.gitlabMr.loadMore": "Cargar más", + "contextPanel.gitlabMr.loading": "Cargando...", + "contextPanel.gitlabMr.error.loadFailed": "No se pudieron cargar las solicitudes de fusión", + "contextPanel.gitlabMr.error.notConnected": "GitLab no está conectado", + "contextPanel.gitlabMr.empty.noActiveProject": "Sin proyecto activo", + "contextPanel.gitlabMr.actions.openSettings": "Abrir ajustes", "contextRail.surface.editor.description": "Editar archivos del proyecto", "contextRail.surface.git.description": "Commits, ramas y pull requests", "contextRail.surface.git.changesCountAriaSingle": "{label}, {count} archivo modificado", diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 4bc24344..10964f38 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -938,6 +938,25 @@ export const dict = { 'contextRail.aria.rail': 'Surfaces du panneau', 'contextPanel.editorEmpty.title': 'Aucun fichier ouvert', 'contextPanel.editorEmpty.description': 'Choisissez un fichier dans l’arborescence pour commencer.', + 'contextPanel.gitlabMr.title': 'Demandes de fusion', + 'contextPanel.gitlabMr.branchSectionTitle': 'Branche actuelle', + 'contextPanel.gitlabMr.openMrTitle': 'Demandes de fusion ouvertes', + 'contextPanel.gitlabMr.listSectionTitle': 'Dans ce dépôt', + 'contextPanel.gitlabMr.openMrEmpty': 'Aucune demande de fusion ouverte', + 'contextPanel.gitlabMr.noMrForBranch': 'Aucune demande de fusion pour cette branche', + 'contextPanel.gitlabMr.loadContext': 'Charger le contexte', + 'contextPanel.gitlabMr.hideContext': 'Masquer le contexte', + 'contextPanel.gitlabMr.openInGitLab': 'Ouvrir dans GitLab', + 'contextPanel.gitlabMr.draft': 'Brouillon', + 'contextPanel.gitlabMr.state.opened': 'Ouverte', + 'contextPanel.gitlabMr.state.merged': 'Fusionnée', + 'contextPanel.gitlabMr.state.closed': 'Fermée', + 'contextPanel.gitlabMr.loadMore': 'Charger plus', + 'contextPanel.gitlabMr.loading': 'Chargement...', + 'contextPanel.gitlabMr.error.loadFailed': 'Échec du chargement des demandes de fusion', + 'contextPanel.gitlabMr.error.notConnected': 'GitLab n\'est pas connecté', + 'contextPanel.gitlabMr.empty.noActiveProject': 'Aucun projet actif', + 'contextPanel.gitlabMr.actions.openSettings': 'Ouvrir les paramètres', 'contextRail.surface.editor.description': 'Modifier les fichiers du projet', 'contextRail.surface.git.description': 'Commits, branches et pull requests', 'contextRail.surface.git.changesCountAriaSingle': '{label}, {count} fichier modifié', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index bae492c6..cf857617 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -1115,6 +1115,25 @@ export const dict: Record = { 'contextRail.aria.rail': 'パネルサーフェス', 'contextPanel.editorEmpty.title': 'ファイルが開かれていません', 'contextPanel.editorEmpty.description': 'ツリーからファイルを選んで編集を始めましょう。', + 'contextPanel.gitlabMr.title': 'マージリクエスト', + 'contextPanel.gitlabMr.branchSectionTitle': '現在のブランチ', + 'contextPanel.gitlabMr.openMrTitle': '開いているマージリクエスト', + 'contextPanel.gitlabMr.listSectionTitle': 'このリポジトリ内', + 'contextPanel.gitlabMr.openMrEmpty': '開いているマージリクエストはありません', + 'contextPanel.gitlabMr.noMrForBranch': 'このブランチのマージリクエストはありません', + 'contextPanel.gitlabMr.loadContext': 'コンテキストを読み込む', + 'contextPanel.gitlabMr.hideContext': 'コンテキストを隠す', + 'contextPanel.gitlabMr.openInGitLab': 'GitLab で開く', + 'contextPanel.gitlabMr.draft': 'ドラフト', + 'contextPanel.gitlabMr.state.opened': 'オープン', + 'contextPanel.gitlabMr.state.merged': 'マージ済み', + 'contextPanel.gitlabMr.state.closed': 'クローズ', + 'contextPanel.gitlabMr.loadMore': 'さらに読み込む', + 'contextPanel.gitlabMr.loading': '読み込み中...', + 'contextPanel.gitlabMr.error.loadFailed': 'マージリクエストを読み込めませんでした', + 'contextPanel.gitlabMr.error.notConnected': 'GitLab に接続されていません', + 'contextPanel.gitlabMr.empty.noActiveProject': 'アクティブなプロジェクトがありません', + 'contextPanel.gitlabMr.actions.openSettings': '設定を開く', 'contextRail.surface.editor.description': 'プロジェクトのファイルを編集', 'contextRail.surface.git.description': 'コミット・ブランチ・プルリクエスト', 'contextRail.surface.git.changesCountAriaSingle': '{label}、変更ファイル{count}件', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index dbf204b8..ca1aef58 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -1119,6 +1119,25 @@ export const dict: Record = { 'contextRail.aria.rail': '패널 서피스', 'contextPanel.editorEmpty.title': '열린 파일 없음', 'contextPanel.editorEmpty.description': '트리에서 파일을 선택해 편집을 시작하세요.', + 'contextPanel.gitlabMr.title': '병합 요청', + 'contextPanel.gitlabMr.branchSectionTitle': '현재 브랜치', + 'contextPanel.gitlabMr.openMrTitle': '열린 병합 요청', + 'contextPanel.gitlabMr.listSectionTitle': '이 저장소', + 'contextPanel.gitlabMr.openMrEmpty': '열린 병합 요청이 없습니다', + 'contextPanel.gitlabMr.noMrForBranch': '이 브랜치에 대한 병합 요청이 없습니다', + 'contextPanel.gitlabMr.loadContext': '컨텍스트 불러오기', + 'contextPanel.gitlabMr.hideContext': '컨텍스트 숨기기', + 'contextPanel.gitlabMr.openInGitLab': 'GitLab에서 열기', + 'contextPanel.gitlabMr.draft': '초안', + 'contextPanel.gitlabMr.state.opened': '열림', + 'contextPanel.gitlabMr.state.merged': '병합됨', + 'contextPanel.gitlabMr.state.closed': '닫힘', + 'contextPanel.gitlabMr.loadMore': '더 불러오기', + 'contextPanel.gitlabMr.loading': '불러오는 중...', + 'contextPanel.gitlabMr.error.loadFailed': '병합 요청을 불러오지 못했습니다', + 'contextPanel.gitlabMr.error.notConnected': 'GitLab에 연결되지 않았습니다', + 'contextPanel.gitlabMr.empty.noActiveProject': '활성 프로젝트가 없습니다', + 'contextPanel.gitlabMr.actions.openSettings': '설정 열기', 'contextRail.surface.editor.description': '프로젝트 파일 편집', 'contextRail.surface.git.description': '커밋, 브랜치, 풀 리퀘스트', 'contextRail.surface.git.changesCountAriaSingle': '{label}, 변경된 파일 {count}개', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 24c1b104..8d887778 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -1456,6 +1456,25 @@ export const dict: Record = { 'contextRail.aria.rail': 'Powierzchnie panelu', 'contextPanel.editorEmpty.title': 'Brak otwartego pliku', 'contextPanel.editorEmpty.description': 'Wybierz plik z drzewa, aby rozpocząć edycję.', + 'contextPanel.gitlabMr.title': 'Żądania scalenia', + 'contextPanel.gitlabMr.branchSectionTitle': 'Bieżąca gałąź', + 'contextPanel.gitlabMr.openMrTitle': 'Otwarte żądania scalenia', + 'contextPanel.gitlabMr.listSectionTitle': 'W tym repozytorium', + 'contextPanel.gitlabMr.openMrEmpty': 'Brak otwartych żądań scalenia', + 'contextPanel.gitlabMr.noMrForBranch': 'Brak żądania scalenia dla tej gałęzi', + 'contextPanel.gitlabMr.loadContext': 'Wczytaj kontekst', + 'contextPanel.gitlabMr.hideContext': 'Ukryj kontekst', + 'contextPanel.gitlabMr.openInGitLab': 'Otwórz w GitLab', + 'contextPanel.gitlabMr.draft': 'Wersja robocza', + 'contextPanel.gitlabMr.state.opened': 'Otwarte', + 'contextPanel.gitlabMr.state.merged': 'Scalone', + 'contextPanel.gitlabMr.state.closed': 'Zamknięte', + 'contextPanel.gitlabMr.loadMore': 'Wczytaj więcej', + 'contextPanel.gitlabMr.loading': 'Wczytywanie...', + 'contextPanel.gitlabMr.error.loadFailed': 'Nie udało się wczytać żądań scalenia', + 'contextPanel.gitlabMr.error.notConnected': 'GitLab nie jest połączony', + 'contextPanel.gitlabMr.empty.noActiveProject': 'Brak aktywnego projektu', + 'contextPanel.gitlabMr.actions.openSettings': 'Otwórz ustawienia', 'contextRail.surface.editor.description': 'Edytuj pliki projektu', 'contextRail.surface.git.description': 'Commity, gałęzie i pull requesty', 'contextRail.surface.git.changesCountAriaSingle': '{label}, {count} zmieniony plik', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index fc6092f4..03569a30 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -1119,6 +1119,25 @@ export const dict: Record = { "contextRail.aria.rail": "Superfícies do painel", "contextPanel.editorEmpty.title": "Nenhum arquivo aberto", "contextPanel.editorEmpty.description": "Escolha um arquivo na árvore para começar a editar.", + "contextPanel.gitlabMr.title": "Solicitações de merge", + "contextPanel.gitlabMr.branchSectionTitle": "Branch atual", + "contextPanel.gitlabMr.openMrTitle": "Solicitações de merge abertas", + "contextPanel.gitlabMr.listSectionTitle": "Neste repositório", + "contextPanel.gitlabMr.openMrEmpty": "Nenhuma solicitação de merge aberta", + "contextPanel.gitlabMr.noMrForBranch": "Nenhuma solicitação de merge para esta branch", + "contextPanel.gitlabMr.loadContext": "Carregar contexto", + "contextPanel.gitlabMr.hideContext": "Ocultar contexto", + "contextPanel.gitlabMr.openInGitLab": "Abrir no GitLab", + "contextPanel.gitlabMr.draft": "Rascunho", + "contextPanel.gitlabMr.state.opened": "Aberta", + "contextPanel.gitlabMr.state.merged": "Mesclada", + "contextPanel.gitlabMr.state.closed": "Fechada", + "contextPanel.gitlabMr.loadMore": "Carregar mais", + "contextPanel.gitlabMr.loading": "Carregando...", + "contextPanel.gitlabMr.error.loadFailed": "Falha ao carregar solicitações de merge", + "contextPanel.gitlabMr.error.notConnected": "GitLab não está conectado", + "contextPanel.gitlabMr.empty.noActiveProject": "Nenhum projeto ativo", + "contextPanel.gitlabMr.actions.openSettings": "Abrir configurações", "contextRail.surface.editor.description": "Editar arquivos do projeto", "contextRail.surface.git.description": "Commits, branches e pull requests", "contextRail.surface.git.changesCountAriaSingle": "{label}, {count} arquivo modificado", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index b2d77d6b..ae396daf 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -1119,6 +1119,25 @@ export const dict: Record = { "contextRail.aria.rail": "Поверхні панелі", "contextPanel.editorEmpty.title": "Файл не відкрито", "contextPanel.editorEmpty.description": "Виберіть файл у дереві, щоб почати редагування.", + "contextPanel.gitlabMr.title": "Запити на злиття", + "contextPanel.gitlabMr.branchSectionTitle": "Поточна гілка", + "contextPanel.gitlabMr.openMrTitle": "Відкриті запити на злиття", + "contextPanel.gitlabMr.listSectionTitle": "У цьому репозиторії", + "contextPanel.gitlabMr.openMrEmpty": "Немає відкритих запитів на злиття", + "contextPanel.gitlabMr.noMrForBranch": "Немає запиту на злиття для цієї гілки", + "contextPanel.gitlabMr.loadContext": "Завантажити контекст", + "contextPanel.gitlabMr.hideContext": "Приховати контекст", + "contextPanel.gitlabMr.openInGitLab": "Відкрити в GitLab", + "contextPanel.gitlabMr.draft": "Чернетка", + "contextPanel.gitlabMr.state.opened": "Відкритий", + "contextPanel.gitlabMr.state.merged": "Злитий", + "contextPanel.gitlabMr.state.closed": "Закритий", + "contextPanel.gitlabMr.loadMore": "Завантажити ще", + "contextPanel.gitlabMr.loading": "Завантаження...", + "contextPanel.gitlabMr.error.loadFailed": "Не вдалося завантажити запити на злиття", + "contextPanel.gitlabMr.error.notConnected": "GitLab не підключено", + "contextPanel.gitlabMr.empty.noActiveProject": "Немає активного проєкту", + "contextPanel.gitlabMr.actions.openSettings": "Відкрити налаштування", "contextRail.surface.editor.description": "Редагування файлів проєкту", "contextRail.surface.git.description": "Коміти, гілки та pull request-и", "contextRail.surface.git.changesCountAriaSingle": "{label}, {count} змінений файл", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 892f2f60..c96e3db0 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -1119,6 +1119,25 @@ export const dict: Record = { 'contextRail.aria.rail': '面板界面', 'contextPanel.editorEmpty.title': '未打开文件', 'contextPanel.editorEmpty.description': '从文件树中选择一个文件开始编辑。', + 'contextPanel.gitlabMr.title': '合并请求', + 'contextPanel.gitlabMr.branchSectionTitle': '当前分支', + 'contextPanel.gitlabMr.openMrTitle': '打开的合并请求', + 'contextPanel.gitlabMr.listSectionTitle': '在此仓库中', + 'contextPanel.gitlabMr.openMrEmpty': '没有打开的合并请求', + 'contextPanel.gitlabMr.noMrForBranch': '此分支没有合并请求', + 'contextPanel.gitlabMr.loadContext': '加载上下文', + 'contextPanel.gitlabMr.hideContext': '隐藏上下文', + 'contextPanel.gitlabMr.openInGitLab': '在 GitLab 中打开', + 'contextPanel.gitlabMr.draft': '草稿', + 'contextPanel.gitlabMr.state.opened': '已打开', + 'contextPanel.gitlabMr.state.merged': '已合并', + 'contextPanel.gitlabMr.state.closed': '已关闭', + 'contextPanel.gitlabMr.loadMore': '加载更多', + 'contextPanel.gitlabMr.loading': '加载中...', + 'contextPanel.gitlabMr.error.loadFailed': '加载合并请求失败', + 'contextPanel.gitlabMr.error.notConnected': 'GitLab 未连接', + 'contextPanel.gitlabMr.empty.noActiveProject': '没有活动的项目', + 'contextPanel.gitlabMr.actions.openSettings': '打开设置', 'contextRail.surface.editor.description': '编辑项目文件', 'contextRail.surface.git.description': '提交、分支和拉取请求', 'contextRail.surface.git.changesCountAriaSingle': '{label},{count} 个更改的文件', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 43ad93fa..7860b7c1 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -1131,6 +1131,25 @@ export const dict: Record = { 'contextRail.aria.rail': '面板介面', 'contextPanel.editorEmpty.title': '未開啟檔案', 'contextPanel.editorEmpty.description': '從檔案樹選擇檔案開始編輯。', + 'contextPanel.gitlabMr.title': '合併請求', + 'contextPanel.gitlabMr.branchSectionTitle': '目前分支', + 'contextPanel.gitlabMr.openMrTitle': '已開啟的合併請求', + 'contextPanel.gitlabMr.listSectionTitle': '在此存放庫中', + 'contextPanel.gitlabMr.openMrEmpty': '沒有已開啟的合併請求', + 'contextPanel.gitlabMr.noMrForBranch': '此分支沒有合併請求', + 'contextPanel.gitlabMr.loadContext': '載入內容', + 'contextPanel.gitlabMr.hideContext': '隱藏內容', + 'contextPanel.gitlabMr.openInGitLab': '在 GitLab 中開啟', + 'contextPanel.gitlabMr.draft': '草稿', + 'contextPanel.gitlabMr.state.opened': '已開啟', + 'contextPanel.gitlabMr.state.merged': '已合併', + 'contextPanel.gitlabMr.state.closed': '已關閉', + 'contextPanel.gitlabMr.loadMore': '載入更多', + 'contextPanel.gitlabMr.loading': '載入中...', + 'contextPanel.gitlabMr.error.loadFailed': '載入合併請求失敗', + 'contextPanel.gitlabMr.error.notConnected': 'GitLab 未連線', + 'contextPanel.gitlabMr.empty.noActiveProject': '沒有使用中的專案', + 'contextPanel.gitlabMr.actions.openSettings': '開啟設定', 'contextRail.surface.editor.description': '編輯專案檔案', 'contextRail.surface.git.description': '提交、分支與拉取請求', 'contextRail.surface.git.changesCountAriaSingle': '{label},{count} 個變更的檔案', From 89db3251681fdd07762f08e716ca2752114ee61c Mon Sep 17 00:00:00 2001 From: bot-hermes Date: Thu, 13 Aug 2026 17:15:22 +0000 Subject: [PATCH 11/45] feat(ui): show GitLab merge request status in walkthrough, git view and work status --- .../work-status/WorkStatusPrimaryGroup.tsx | 38 +++++- packages/ui/src/components/views/GitView.tsx | 6 + .../ui/src/components/views/git/GitHeader.tsx | 40 ++++++ .../views/walkthrough/WalkthroughView.tsx | 22 +++- packages/ui/src/lib/gitlabMrStatus.test.ts | 119 ++++++++++++++++++ packages/ui/src/lib/gitlabMrStatus.ts | 110 ++++++++++++++++ packages/ui/src/lib/i18n/messages/de.ts | 4 + packages/ui/src/lib/i18n/messages/en.ts | 4 + packages/ui/src/lib/i18n/messages/es.ts | 4 + packages/ui/src/lib/i18n/messages/fr.ts | 4 + packages/ui/src/lib/i18n/messages/ja.ts | 4 + packages/ui/src/lib/i18n/messages/ko.ts | 4 + packages/ui/src/lib/i18n/messages/pl.ts | 4 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 4 + packages/ui/src/lib/i18n/messages/uk.ts | 4 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 4 + packages/ui/src/lib/i18n/messages/zh-TW.ts | 4 + 17 files changed, 373 insertions(+), 6 deletions(-) create mode 100644 packages/ui/src/lib/gitlabMrStatus.test.ts create mode 100644 packages/ui/src/lib/gitlabMrStatus.ts diff --git a/packages/ui/src/components/chat/work-status/WorkStatusPrimaryGroup.tsx b/packages/ui/src/components/chat/work-status/WorkStatusPrimaryGroup.tsx index a5f12e15..2099da54 100644 --- a/packages/ui/src/components/chat/work-status/WorkStatusPrimaryGroup.tsx +++ b/packages/ui/src/components/chat/work-status/WorkStatusPrimaryGroup.tsx @@ -4,6 +4,8 @@ import { useGitStore } from '@/stores/useGitStore'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { runBackgroundNetworkTask } from '@/lib/background-network'; import { getGitHubPrStatusKey, usePrVisualSummary } from '@/stores/useGitHubPrStatusStore'; +import { useGitLabMrForBranch } from '@/lib/gitlabMrStatus'; +import { useGitProvider } from '@/lib/gitProvider'; import { useSession, useSessionMessages } from '@/sync/sync-context'; import { useConfigStore } from '@/stores/useConfigStore'; import { useUIStore } from '@/stores/useUIStore'; @@ -113,6 +115,12 @@ export const WorkStatusPrimaryGroup: React.FC = ({ sessionId, directory, ); const prSummary = usePrVisualSummary(prKey); + // GitLab merge requests ride the same shared TTL cache as the git view and + // the walkthrough, so every surface that reports the branch's request stays + // consistent without extra requests. + const gitProvider = useGitProvider(directory); + const { mr: gitLabMr } = useGitLabMrForBranch(directory, branch); + // `getCurrentModel` is an imperative getter: its reference never changes, so // calling it in render subscribes to nothing. Subscribe to the selected model // ids and recompute the limits from those. @@ -201,7 +209,17 @@ export const WorkStatusPrimaryGroup: React.FC = ({ sessionId, directory, const cost = typeof session?.cost === 'number' && session.cost > 0 ? session.cost : null; const hasSession = showSession && (usagePercent !== null || cost !== null || Boolean(goalRow)); - const hasRepository = showRepository && Boolean(branch || changed || prSummary || attentionLabel); + const hasGitLabMr = gitProvider === 'gitlab' && gitLabMr !== null; + const gitLabMrVisualState = gitLabMr + ? gitLabMr.state === 'merged' + ? 'merged' + : gitLabMr.state === 'closed' + ? 'closed' + : gitLabMr.draft + ? 'draft' + : 'open' + : null; + const hasRepository = showRepository && Boolean(branch || changed || prSummary || attentionLabel || hasGitLabMr); useReportWorkStatusPresence('session-repository', hasSession || hasRepository); @@ -284,6 +302,24 @@ export const WorkStatusPrimaryGroup: React.FC = ({ sessionId, directory, /> ) : null} + {hasGitLabMr && gitLabMr ? ( + openSurface('pr') : undefined} + ariaLabel={t('chat.workStatus.action.openMr')} + iconColor={`var(--pr-${gitLabMrVisualState})`} + label={gitLabMr.title || t('chat.workStatus.mr.untitled')} + value={( + + {gitLabMr.draft ? t('chat.workStatus.pr.draft') : `!${gitLabMr.number}`} + + )} + /> + ) : null} + {prSummary ? ( <> = ({ isActive }) => { const openContextSurface = useUIStore((state) => state.openContextSurface); const prStatusBranch = status?.current ?? null; + const { mr: gitLabMr } = useGitLabMrForBranch(currentDirectory, prStatusBranch); const prChipStatus = useGitHubPrStatusStore((state) => { if (!currentDirectory || !prStatusBranch) { return null; @@ -2361,6 +2363,10 @@ export const GitView: React.FC = ({ isActive }) => { onOpenPullRequest={ currentDirectory ? () => openContextSurface(currentDirectory, 'pr') : undefined } + gitLabMr={gitLabMr} + onOpenGitLabMr={ + currentDirectory ? () => openContextSurface(currentDirectory, 'pr') : undefined + } /> {/* In-progress operation banner */} diff --git a/packages/ui/src/components/views/git/GitHeader.tsx b/packages/ui/src/components/views/git/GitHeader.tsx index c96a8a59..28f2ff07 100644 --- a/packages/ui/src/components/views/git/GitHeader.tsx +++ b/packages/ui/src/components/views/git/GitHeader.tsx @@ -19,6 +19,7 @@ import type { GitRemoteComparison, GitHubPullRequest, GitHubChecksSummary, + GitLabMergeRequestSummary, } from '@/lib/api/types'; import { useI18n } from '@/lib/i18n'; @@ -51,6 +52,8 @@ interface GitHeaderProps { pullRequest?: GitHubPullRequest | null; prChecks?: GitHubChecksSummary | null; onOpenPullRequest?: () => void; + gitLabMr?: GitLabMergeRequestSummary | null; + onOpenGitLabMr?: () => void; } const IDENTITY_ICON_MAP: Record = { @@ -258,6 +261,8 @@ export const GitHeader: React.FC = ({ pullRequest, prChecks, onOpenPullRequest, + gitLabMr, + onOpenGitLabMr, }) => { const { t } = useI18n(); if (!status) { @@ -371,6 +376,40 @@ export const GitHeader: React.FC = ({ ) : null; + // GitLab merge request chip, mirroring the GitHub PR chip above. GitLab + // states are surfaced with the same PR state palette so merged/closed/open + // read identically across providers. + const gitLabMrVisualState = gitLabMr + ? gitLabMr.state === 'merged' + ? 'merged' + : gitLabMr.state === 'closed' + ? 'closed' + : gitLabMr.draft + ? 'draft' + : 'open' + : null; + + const gitLabMrChip = gitLabMr && onOpenGitLabMr ? ( + + + + + {t('gitView.header.openMergeRequest')} + + ) : null; + const syncButtons = ( = ({
{prChip ?
{prChip}
: null} + {gitLabMrChip ?
{gitLabMrChip}
: null}
{upstreamStatusPill ? (
{upstreamStatusPill}
diff --git a/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx b/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx index 2882af7e..4d356b8b 100644 --- a/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx +++ b/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx @@ -14,6 +14,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip import { useI18n, type Locale } from '@/lib/i18n'; import { openExternalUrl } from '@/lib/url'; import { useGitProvider } from '@/lib/gitProvider'; +import { useGitLabMrForBranch } from '@/lib/gitlabMrStatus'; import { buildWalkthroughView } from '@/lib/walkthrough/model'; import type { WalkthroughSource, WalkthroughWorkingTreeScope } from '@/lib/walkthrough/types'; import { ModelSelector } from '@/components/sections/agents/ModelSelector'; @@ -210,6 +211,7 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => { const setPrStatusParams = useGitHubPrStatusStore((state) => state.setParams); const refreshPrStatusTargets = useGitHubPrStatusStore((state) => state.refreshTargets); const gitProvider = useGitProvider(directory); + const gitLabMr = useGitLabMrForBranch(directory, currentBranch); useEffect(() => { if (!directory || !currentBranch || !githubAuthChecked || !githubConnected || gitProvider !== 'github') return; @@ -250,12 +252,18 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => { [requestedSource, scope] ); - // Offer whichever pull request we know about: the one already selected, or - // the one this branch has. + // Offer whichever pull request or merge request we know about: the one + // already selected, or the one this branch has. GitLab repos get their MR + // number from the branch lookup; everything else falls back to the GitHub PR + // status store, which the polling effect above only fills for GitHub repos. const prSource = useMemo | null>(() => { if (source.kind === 'pr') return source; + if (gitProvider === 'gitlab') { + const number = gitLabMr.mr?.number; + return number ? { kind: 'pr', number } : null; + } return branchPrNumber ? { kind: 'pr', number: branchPrNumber } : null; - }, [branchPrNumber, source]); + }, [branchPrNumber, gitLabMr.mr, gitProvider, source]); const selectWorkingTree = useCallback( (value: WalkthroughWorkingTreeScope) => { @@ -341,7 +349,9 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => { const sourceLabel = source.kind === 'branch' ? t('walkthrough.scope.branch') : source.kind === 'pr' - ? t('walkthrough.scope.pullRequest', { number: source.number }) + ? gitProvider === 'gitlab' + ? t('walkthrough.scope.mergeRequest', { number: source.number }) + : t('walkthrough.scope.pullRequest', { number: source.number }) : scope === 'all' ? t('walkthrough.scope.all') : scope === 'staged' @@ -547,7 +557,9 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => { )} {prSource && ( - {t('walkthrough.scope.pullRequest', { number: prSource.number })} + {gitProvider === 'gitlab' + ? t('walkthrough.scope.mergeRequest', { number: prSource.number }) + : t('walkthrough.scope.pullRequest', { number: prSource.number })} )} diff --git a/packages/ui/src/lib/gitlabMrStatus.test.ts b/packages/ui/src/lib/gitlabMrStatus.test.ts new file mode 100644 index 00000000..5ab492ce --- /dev/null +++ b/packages/ui/src/lib/gitlabMrStatus.test.ts @@ -0,0 +1,119 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'; +import type { GitLabMergeRequestSummary } from '@/lib/api/types'; + +const mrsListCalls: Array<{ directory: string; options?: { sourceBranch?: string } }> = []; +let mrsListResult: GitLabMergeRequestSummary[] = []; +let mrsListFailure: Error | null = null; +let registryHasGitlab = true; + +mock.module('@/contexts/runtimeAPIRegistry', () => ({ + getRegisteredRuntimeAPIs: () => { + if (!registryHasGitlab) return null; + return { + gitlab: { + mrsList: (directory: string, options?: { sourceBranch?: string }) => { + mrsListCalls.push({ directory, options }); + if (mrsListFailure) { + return Promise.reject(mrsListFailure); + } + const all = mrsListResult; + const filtered = options?.sourceBranch + ? all.filter((item) => item.sourceBranch === options.sourceBranch) + : all; + return Promise.resolve({ mrs: filtered }); + }, + }, + }; + }, +})); + +const { resolveGitLabMrForBranch } = await import('./gitlabMrStatus'); + +const mr = (number: number, state: string, sourceBranch: string): GitLabMergeRequestSummary => ({ + number, + title: `MR ${number}`, + url: `https://gitlab.example/${number}`, + state, + draft: false, + author: { id: 1, username: 'user', name: 'User' }, + sourceBranch, + targetBranch: 'main', +}); + +describe('resolveGitLabMrForBranch', () => { + beforeEach(() => { + mrsListCalls.length = 0; + mrsListResult = []; + mrsListFailure = null; + registryHasGitlab = true; + }); + + afterEach(() => { + mrsListCalls.length = 0; + mrsListResult = []; + mrsListFailure = null; + registryHasGitlab = true; + }); + + test('prefers the opened MR over a merged one for the branch', async () => { + mrsListResult = [mr(3, 'merged', 'feat/a'), mr(7, 'opened', 'feat/a')]; + + const result = await resolveGitLabMrForBranch('/repo', 'feat/a'); + + expect(result?.number).toBe(7); + expect(mrsListCalls).toEqual([ + { directory: '/repo', options: { sourceBranch: 'feat/a' } }, + ]); + }); + + test('falls back to a merged MR when no opened one exists', async () => { + mrsListResult = [mr(5, 'merged', 'feat/b'), mr(9, 'closed', 'feat/b')]; + + const result = await resolveGitLabMrForBranch('/repo', 'feat/b'); + + expect(result?.number).toBe(5); + }); + + test('returns null when no MR matches the branch', async () => { + mrsListResult = [mr(5, 'merged', 'feat/a')]; + + const result = await resolveGitLabMrForBranch('/repo', 'feat/c'); + + expect(result).toBeNull(); + expect(mrsListCalls).toHaveLength(1); + }); + + test('returns null without calling the API when the runtime has no GitLab client', async () => { + registryHasGitlab = false; + + const result = await resolveGitLabMrForBranch('/repo', 'feat/a'); + + expect(result).toBeNull(); + expect(mrsListCalls).toHaveLength(0); + }); + + test('returns null and caches when the request fails', async () => { + mrsListFailure = new Error('boom'); + + const first = await resolveGitLabMrForBranch('/repo', 'fail-branch'); + expect(first).toBeNull(); + + mrsListFailure = null; + mrsListResult = [mr(1, 'opened', 'fail-branch')]; + // Same directory+branch within TTL must not re-request. + const second = await resolveGitLabMrForBranch('/repo', 'fail-branch'); + expect(second).toBeNull(); + expect(mrsListCalls).toHaveLength(1); + }); + + test('serves the second call from cache within the TTL window', async () => { + mrsListResult = [mr(7, 'opened', 'cache-branch')]; + + const first = await resolveGitLabMrForBranch('/repo', 'cache-branch'); + const second = await resolveGitLabMrForBranch('/repo', 'cache-branch'); + + expect(first?.number).toBe(7); + expect(second?.number).toBe(7); + expect(mrsListCalls).toHaveLength(1); + }); +}); diff --git a/packages/ui/src/lib/gitlabMrStatus.ts b/packages/ui/src/lib/gitlabMrStatus.ts new file mode 100644 index 00000000..8046019d --- /dev/null +++ b/packages/ui/src/lib/gitlabMrStatus.ts @@ -0,0 +1,110 @@ +import { useState, useEffect } from 'react'; +import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; +import type { GitLabMergeRequestSummary } from '@/lib/api/types'; + +// Branch MR lookups are cheap to re-request but visible to the user on every +// mount of the surfaces that display them (walkthrough header, git view, work +// status). A shared TTL cache keeps those surfaces consistent with each other +// and stops repeated GitLab calls while a branch is in view. +const CACHE_TTL_MS = 90_000; +const mrCache = new Map(); + +const cacheKeyFor = (directory: string, branch: string): string => `${directory}\n${branch}`; + +const readCachedMr = (directory: string, branch: string): GitLabMergeRequestSummary | null | undefined => { + const entry = mrCache.get(cacheKeyFor(directory, branch)); + return entry?.mr; +}; + +/** + * Resolve the merge request targeting `branch` in `directory`, preferring the + * opened request and falling back to a merged one so a just-merged branch still + * surfaces its request instead of nothing. + * + * Returns null when the runtime has no GitLab API, the request fails, or no MR + * matches — callers only use the result to show or hide an additive chip, so a + * null answer simply means "nothing to show". Results are cached per + * directory+branch for CACHE_TTL_MS, including the null case. + */ +export const resolveGitLabMrForBranch = async ( + directory: string, + branch: string, +): Promise => { + const gitlab = getRegisteredRuntimeAPIs()?.gitlab; + if (!gitlab?.mrsList || !directory || !branch) { + return null; + } + + const key = cacheKeyFor(directory, branch); + const cached = mrCache.get(key); + if (cached && Date.now() - cached.at < CACHE_TTL_MS) { + return cached.mr; + } + + let mr: GitLabMergeRequestSummary | null = null; + try { + const result = await gitlab.mrsList(directory, { sourceBranch: branch }); + const candidates = result.mrs ?? []; + mr = candidates.find((item) => item.state === 'opened') + ?? candidates.find((item) => item.state === 'merged') + ?? null; + } catch { + mr = null; + } + + mrCache.set(key, { at: Date.now(), mr }); + return mr; +}; + +/** + * Subscribe to the branch's merge request. Reads the TTL cache synchronously + * for the initial render so an already-resolved MR never flashes away while a + * refresh runs; a cache miss shows a loading state instead of a stale result + * from another branch. + */ +export const useGitLabMrForBranch = ( + directory: string | null | undefined, + branch: string | null | undefined, +): { mr: GitLabMergeRequestSummary | null; isLoading: boolean } => { + const [mr, setMr] = useState(() => + directory && branch ? (readCachedMr(directory, branch) ?? null) : null + ); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + if (!directory || !branch) { + setMr(null); + setIsLoading(false); + return; + } + + let mounted = true; + const cached = readCachedMr(directory, branch); + const cacheEntry = mrCache.get(cacheKeyFor(directory, branch)); + const fresh = cacheEntry !== undefined && Date.now() - cacheEntry.at < CACHE_TTL_MS; + + if (fresh) { + setMr(cached ?? null); + setIsLoading(false); + return; + } + + // A stale entry stays on screen while it refreshes; a missing one shows + // the loading state rather than a result from a previous branch. + setMr(cached ?? null); + setIsLoading(true); + + void resolveGitLabMrForBranch(directory, branch).then((resolved) => { + if (mounted) { + setMr(resolved); + setIsLoading(false); + } + }); + + return () => { + mounted = false; + }; + }, [directory, branch]); + + return { mr, isLoading }; +}; diff --git a/packages/ui/src/lib/i18n/messages/de.ts b/packages/ui/src/lib/i18n/messages/de.ts index 401031a3..14fd30d1 100644 --- a/packages/ui/src/lib/i18n/messages/de.ts +++ b/packages/ui/src/lib/i18n/messages/de.ts @@ -2968,6 +2968,7 @@ export const dict = { 'sessions.sidebar.session.status.movingToWorktree': 'Sitzung wird in einen neuen Worktree verschoben', 'gitView.header.updateBranch': 'Branch aktualisieren', 'gitView.header.openPullRequest': 'Pull Request öffnen', + 'gitView.header.openMergeRequest': 'Merge Request öffnen', 'gitView.history.refresh': 'Verlauf aktualisieren', 'gitView.operation.inProgressTitleManyConflicts': 'Operation {operation} läuft: {count} Konflikte', 'gitView.operation.inProgressTitleOneConflict': 'Operation {operation} läuft: {count} Konflikt', @@ -3022,6 +3023,7 @@ export const dict = { 'walkthrough.missing.languageAndModel': 'Noch kein Walkthrough auf dieser Sprache von diesem Modell vorhanden — zeige den zuletzt hier erstellten.', 'walkthrough.language.selectorAria': 'Sprache auswählen', 'walkthrough.scope.pullRequest': 'PR #{number}', + 'walkthrough.scope.mergeRequest': 'MR !{number}', 'walkthrough.action.generate': 'Generieren', 'walkthrough.action.regenerate': 'Erneut generieren', 'walkthrough.action.cancel': 'Abbrechen', @@ -3109,6 +3111,7 @@ export const dict = { 'chat.workStatus.git.changedFileSingle': '{count} Datei geändert', 'chat.workStatus.git.changedFilePlural': '{count} Dateien geändert', 'chat.workStatus.pr.untitled': 'Pull Request ohne Titel', + 'chat.workStatus.mr.untitled': 'Merge Request ohne Titel', 'chat.workStatus.pr.draft': 'Entwurf', 'chat.workStatus.pr.checks': 'Prüfungen', 'chat.workStatus.pr.checksFailed': '{count} fehlgeschlagen', @@ -3140,6 +3143,7 @@ export const dict = { 'chat.workStatus.action.openChanges': 'Änderungen öffnen', 'chat.workStatus.action.openGit': 'Git-Panel öffnen', 'chat.workStatus.action.openPr': 'Pull Request öffnen', + 'chat.workStatus.action.openMr': 'Merge Request öffnen', 'chat.workStatus.action.openSubagent': '{name} öffnen', 'chat.workStatus.section.usage': 'Nutzung', 'chat.workStatus.goal.open': 'Ziel verwalten', diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index a7c4d82a..85f8a435 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -770,6 +770,7 @@ export const dict = { 'gitView.header.repositoryViews': 'Repository views', 'gitView.header.updateBranch': 'Update branch', 'gitView.header.openPullRequest': 'Open pull request', + 'gitView.header.openMergeRequest': 'Open merge request', 'gitView.header.removeRemoteAria': 'Remove remote {name}', 'gitView.header.removeRemoteTitle': 'Remove remote {name}', 'gitView.header.upstreamSynced': 'synced', @@ -1160,6 +1161,7 @@ export const dict = { 'walkthrough.missing.languageAndModel': 'No walkthrough in this language from this model yet — showing the last one generated here.', 'walkthrough.language.selectorAria': 'Select the walkthrough language', 'walkthrough.scope.pullRequest': 'PR #{number}', + 'walkthrough.scope.mergeRequest': 'MR !{number}', 'walkthrough.action.generate': 'Generate walkthrough', 'walkthrough.action.regenerate': 'Regenerate', 'walkthrough.action.cancel': 'Cancel', @@ -3111,6 +3113,7 @@ export const dict = { 'chat.workStatus.git.changedFileSingle': '{count} file changed', 'chat.workStatus.git.changedFilePlural': '{count} files changed', 'chat.workStatus.pr.untitled': 'Untitled pull request', + 'chat.workStatus.mr.untitled': 'Untitled merge request', 'chat.workStatus.pr.draft': 'Draft', 'chat.workStatus.pr.checks': 'Checks', 'chat.workStatus.pr.checksFailed': '{count} failed', @@ -3142,6 +3145,7 @@ export const dict = { 'chat.workStatus.action.openChanges': 'Open changes', 'chat.workStatus.action.openGit': 'Open Git panel', 'chat.workStatus.action.openPr': 'Open pull request', + 'chat.workStatus.action.openMr': 'Open merge request', 'chat.workStatus.action.openSubagent': 'Open {name}', 'chat.workStatus.section.usage': 'Usage', 'chat.workStatus.goal.open': 'Manage goal', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 414469bf..28958594 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -771,6 +771,7 @@ export const dict: Record = { "gitView.header.repositoryViews": "Vistas del repositorio", "gitView.header.updateBranch": "Actualizar rama", "gitView.header.openPullRequest": "Abrir pull request", + "gitView.header.openMergeRequest": "Abrir solicitud de fusión", "gitView.header.removeRemoteAria": "Eliminar remoto", "gitView.header.removeRemoteTitle": "Eliminar remoto", "gitView.header.upstreamSynced": "sincronizado", @@ -1161,6 +1162,7 @@ export const dict: Record = { "walkthrough.missing.languageAndModel": "Aún no hay un recorrido en este idioma con este modelo: se muestra el último generado aquí.", "walkthrough.language.selectorAria": "Elegir el idioma del recorrido", "walkthrough.scope.pullRequest": "PR n.º {number}", + "walkthrough.scope.mergeRequest": "MR !{number}", "walkthrough.action.generate": "Generar recorrido", "walkthrough.action.regenerate": "Regenerar", "walkthrough.action.cancel": "Cancelar", @@ -3112,6 +3114,7 @@ export const dict: Record = { 'chat.workStatus.git.changedFileSingle': '{count} archivo modificado', 'chat.workStatus.git.changedFilePlural': '{count} archivos modificados', 'chat.workStatus.pr.untitled': 'Pull request sin título', + 'chat.workStatus.mr.untitled': 'Solicitud de fusión sin título', 'chat.workStatus.pr.draft': 'Borrador', 'chat.workStatus.pr.checks': 'Comprobaciones', 'chat.workStatus.pr.checksFailed': '{count} fallaron', @@ -3143,6 +3146,7 @@ export const dict: Record = { 'chat.workStatus.action.openChanges': 'Abrir cambios', 'chat.workStatus.action.openGit': 'Abrir panel de Git', 'chat.workStatus.action.openPr': 'Abrir pull request', + 'chat.workStatus.action.openMr': 'Abrir solicitud de fusión', 'chat.workStatus.action.openSubagent': 'Abrir {name}', 'chat.workStatus.section.usage': 'Uso', 'chat.workStatus.goal.open': 'Gestionar objetivo', diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 10964f38..43241bff 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -594,6 +594,7 @@ export const dict = { 'gitView.header.repositoryViews': 'Vues du dépôt', 'gitView.header.updateBranch': 'Mettre à jour la branche', 'gitView.header.openPullRequest': 'Ouvrir la pull request', + 'gitView.header.openMergeRequest': 'Ouvrir la demande de fusion', 'gitView.header.removeRemoteAria': 'Supprimer le remote', 'gitView.header.removeRemoteTitle': 'Supprimer le remote', 'gitView.header.upstreamSynced': 'synchronisé', @@ -980,6 +981,7 @@ export const dict = { 'walkthrough.missing.languageAndModel': 'Pas encore de parcours dans cette langue avec ce modèle — voici le dernier généré ici.', 'walkthrough.language.selectorAria': 'Choisir la langue du parcours', 'walkthrough.scope.pullRequest': 'PR n° {number}', + 'walkthrough.scope.mergeRequest': 'MR !{number}', 'walkthrough.action.generate': 'Générer le parcours', 'walkthrough.action.regenerate': 'Régénérer', 'walkthrough.action.cancel': 'Annuler', @@ -3109,6 +3111,7 @@ export const dict = { 'chat.workStatus.git.changedFileSingle': '{count} fichier modifié', 'chat.workStatus.git.changedFilePlural': '{count} fichiers modifiés', 'chat.workStatus.pr.untitled': 'Pull request sans titre', + 'chat.workStatus.mr.untitled': 'Demande de fusion sans titre', 'chat.workStatus.pr.draft': 'Brouillon', 'chat.workStatus.pr.checks': 'Vérifications', 'chat.workStatus.pr.checksFailed': '{count} en échec', @@ -3140,6 +3143,7 @@ export const dict = { 'chat.workStatus.action.openChanges': 'Ouvrir les modifications', 'chat.workStatus.action.openGit': 'Ouvrir le panneau Git', 'chat.workStatus.action.openPr': 'Ouvrir la pull request', + 'chat.workStatus.action.openMr': 'Ouvrir la demande de fusion', 'chat.workStatus.action.openSubagent': 'Ouvrir {name}', 'chat.workStatus.section.usage': 'Utilisation', 'chat.workStatus.goal.open': 'Gérer l’objectif', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index cf857617..7772970a 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -768,6 +768,7 @@ export const dict: Record = { 'gitView.header.repositoryViews': 'リポジトリビュー', 'gitView.header.updateBranch': 'ブランチを更新', 'gitView.header.openPullRequest': 'プルリクエストを開く', + 'gitView.header.openMergeRequest': 'マージリクエストを開く', 'gitView.header.removeRemoteAria': 'リモート{name}を削除', 'gitView.header.removeRemoteTitle': 'リモート{name}を削除', 'gitView.header.upstreamSynced': '同期済み', @@ -1157,6 +1158,7 @@ export const dict: Record = { 'walkthrough.missing.languageAndModel': 'この言語・このモデルのウォークスルーはまだありません。ここで最後に生成されたものを表示しています。', 'walkthrough.language.selectorAria': 'ウォークスルーの言語を選択', 'walkthrough.scope.pullRequest': 'PR #{number}', + 'walkthrough.scope.mergeRequest': 'MR !{number}', 'walkthrough.action.generate': 'ウォークスルーを生成', 'walkthrough.action.regenerate': '再生成', 'walkthrough.action.cancel': 'キャンセル', @@ -3111,6 +3113,7 @@ export const dict: Record = { 'chat.workStatus.git.changedFileSingle': '{count} 件のファイルを変更', 'chat.workStatus.git.changedFilePlural': '{count} 件のファイルを変更', 'chat.workStatus.pr.untitled': 'タイトルなしのプルリクエスト', + 'chat.workStatus.mr.untitled': 'タイトルなしのマージリクエスト', 'chat.workStatus.pr.draft': 'ドラフト', 'chat.workStatus.pr.checks': 'チェック', 'chat.workStatus.pr.checksFailed': '{count} 件失敗', @@ -3142,6 +3145,7 @@ export const dict: Record = { 'chat.workStatus.action.openChanges': '変更を開く', 'chat.workStatus.action.openGit': 'Git パネルを開く', 'chat.workStatus.action.openPr': 'プルリクエストを開く', + 'chat.workStatus.action.openMr': 'マージリクエストを開く', 'chat.workStatus.action.openSubagent': '{name} を開く', 'chat.workStatus.section.usage': '使用量', 'chat.workStatus.goal.open': '目標を管理', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index ca1aef58..3ab1d5bb 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -771,6 +771,7 @@ export const dict: Record = { 'gitView.header.repositoryViews': '저장소 보기', 'gitView.header.updateBranch': '브랜치 업데이트', 'gitView.header.openPullRequest': '풀 리퀘스트 열기', + 'gitView.header.openMergeRequest': '머지 리퀘스트 열기', 'gitView.header.removeRemoteAria': '리모트 제거', 'gitView.header.removeRemoteTitle': '리모트 제거', 'gitView.header.upstreamSynced': '동기화됨', @@ -1161,6 +1162,7 @@ export const dict: Record = { 'walkthrough.missing.languageAndModel': '이 언어와 이 모델로 생성한 워크스루가 아직 없어 마지막으로 생성된 것을 표시합니다.', 'walkthrough.language.selectorAria': '워크스루 언어 선택', 'walkthrough.scope.pullRequest': 'PR #{number}', + 'walkthrough.scope.mergeRequest': 'MR !{number}', 'walkthrough.action.generate': '워크스루 생성', 'walkthrough.action.regenerate': '다시 생성', 'walkthrough.action.cancel': '취소', @@ -3111,6 +3113,7 @@ export const dict: Record = { 'chat.workStatus.git.changedFileSingle': '파일 {count}개 변경됨', 'chat.workStatus.git.changedFilePlural': '파일 {count}개 변경됨', 'chat.workStatus.pr.untitled': '제목 없는 풀 리퀘스트', + 'chat.workStatus.mr.untitled': '제목 없는 머지 리퀘스트', 'chat.workStatus.pr.draft': '초안', 'chat.workStatus.pr.checks': '검사', 'chat.workStatus.pr.checksFailed': '{count}개 실패', @@ -3142,6 +3145,7 @@ export const dict: Record = { 'chat.workStatus.action.openChanges': '변경 사항 열기', 'chat.workStatus.action.openGit': 'Git 패널 열기', 'chat.workStatus.action.openPr': '풀 리퀘스트 열기', + 'chat.workStatus.action.openMr': '머지 리퀘스트 열기', 'chat.workStatus.action.openSubagent': '{name} 열기', 'chat.workStatus.section.usage': '사용량', 'chat.workStatus.goal.open': '목표 관리', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 8d887778..f89d31ea 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -1498,6 +1498,7 @@ export const dict: Record = { 'walkthrough.missing.languageAndModel': 'Nie ma jeszcze przewodnika w tym języku od tego modelu — pokazujemy ostatni wygenerowany tutaj.', 'walkthrough.language.selectorAria': 'Wybierz język przewodnika', 'walkthrough.scope.pullRequest': 'PR #{number}', + 'walkthrough.scope.mergeRequest': 'MR !{number}', 'walkthrough.action.generate': 'Wygeneruj przewodnik', 'walkthrough.action.regenerate': 'Wygeneruj ponownie', 'walkthrough.action.cancel': 'Anuluj', @@ -2074,6 +2075,7 @@ export const dict: Record = { 'gitView.header.repositoryViews': 'Widoki repozytorium', 'gitView.header.updateBranch': 'Zaktualizuj gałąź', 'gitView.header.openPullRequest': 'Otwórz pull request', + 'gitView.header.openMergeRequest': 'Otwórz żądanie scalenia', 'gitView.header.removeRemoteAria': 'Usuń remote {name}', 'gitView.header.removeRemoteTitle': 'Usuń remote {name}', 'gitView.header.upstreamSynced': 'zsynchronizowano', @@ -3128,6 +3130,7 @@ export const dict: Record = { 'chat.workStatus.git.changedFileSingle': 'Zmieniono {count} plik', 'chat.workStatus.git.changedFilePlural': 'Zmieniono {count} plików', 'chat.workStatus.pr.untitled': 'Pull request bez tytułu', + 'chat.workStatus.mr.untitled': 'Żądanie scalenia bez tytułu', 'chat.workStatus.pr.draft': 'Szkic', 'chat.workStatus.pr.checks': 'Sprawdzenia', 'chat.workStatus.pr.checksFailed': '{count} nieudanych', @@ -3159,6 +3162,7 @@ export const dict: Record = { 'chat.workStatus.action.openChanges': 'Otwórz zmiany', 'chat.workStatus.action.openGit': 'Otwórz panel Git', 'chat.workStatus.action.openPr': 'Otwórz pull request', + 'chat.workStatus.action.openMr': 'Otwórz żądanie scalenia', 'chat.workStatus.action.openSubagent': 'Otwórz {name}', 'chat.workStatus.section.usage': 'Zużycie', 'chat.workStatus.goal.open': 'Zarządzaj celem', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 03569a30..40aca2d7 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -771,6 +771,7 @@ export const dict: Record = { "gitView.header.repositoryViews": "Visualizações do repositório", "gitView.header.updateBranch": "Atualizar branch", "gitView.header.openPullRequest": "Abrir pull request", + "gitView.header.openMergeRequest": "Abrir solicitação de merge", "gitView.header.removeRemoteAria": "Excluir remoto", "gitView.header.removeRemoteTitle": "Excluir remoto", "gitView.header.upstreamSynced": "sincronizado", @@ -1161,6 +1162,7 @@ export const dict: Record = { "walkthrough.missing.languageAndModel": "Ainda não há um percurso neste idioma com este modelo — exibindo o último gerado aqui.", "walkthrough.language.selectorAria": "Escolher o idioma do percurso", "walkthrough.scope.pullRequest": "PR nº {number}", + "walkthrough.scope.mergeRequest": "MR !{number}", "walkthrough.action.generate": "Gerar percurso", "walkthrough.action.regenerate": "Gerar novamente", "walkthrough.action.cancel": "Cancelar", @@ -3112,6 +3114,7 @@ export const dict: Record = { 'chat.workStatus.git.changedFileSingle': '{count} arquivo alterado', 'chat.workStatus.git.changedFilePlural': '{count} arquivos alterados', 'chat.workStatus.pr.untitled': 'Pull request sem título', + 'chat.workStatus.mr.untitled': 'Solicitação de merge sem título', 'chat.workStatus.pr.draft': 'Rascunho', 'chat.workStatus.pr.checks': 'Verificações', 'chat.workStatus.pr.checksFailed': '{count} falharam', @@ -3143,6 +3146,7 @@ export const dict: Record = { 'chat.workStatus.action.openChanges': 'Abrir alterações', 'chat.workStatus.action.openGit': 'Abrir painel do Git', 'chat.workStatus.action.openPr': 'Abrir pull request', + 'chat.workStatus.action.openMr': 'Abrir solicitação de merge', 'chat.workStatus.action.openSubagent': 'Abrir {name}', 'chat.workStatus.section.usage': 'Uso', 'chat.workStatus.goal.open': 'Gerenciar objetivo', diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index ae396daf..ff4c11b4 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -771,6 +771,7 @@ export const dict: Record = { "gitView.header.repositoryViews": "Перегляди репозиторію", "gitView.header.updateBranch": "Оновити гілку", "gitView.header.openPullRequest": "Відкрити pull request", + "gitView.header.openMergeRequest": "Відкрити запит на злиття", "gitView.header.removeRemoteAria": "Видалити remote", "gitView.header.removeRemoteTitle": "Видалити remote", "gitView.header.upstreamSynced": "синхронізовано", @@ -1161,6 +1162,7 @@ export const dict: Record = { "walkthrough.missing.languageAndModel": "Розбору цією мовою від цієї моделі ще немає — показано останній згенерований тут.", "walkthrough.language.selectorAria": "Обрати мову розбору", "walkthrough.scope.pullRequest": "PR #{number}", + "walkthrough.scope.mergeRequest": "MR !{number}", "walkthrough.action.generate": "Створити розбір", "walkthrough.action.regenerate": "Створити заново", "walkthrough.action.cancel": "Скасувати", @@ -3112,6 +3114,7 @@ export const dict: Record = { 'chat.workStatus.git.changedFileSingle': 'Змінено {count} файл', 'chat.workStatus.git.changedFilePlural': 'Змінено {count} файлів', 'chat.workStatus.pr.untitled': 'Pull request без назви', + 'chat.workStatus.mr.untitled': 'Запит на злиття без назви', 'chat.workStatus.pr.draft': 'Чернетка', 'chat.workStatus.pr.checks': 'Перевірки', 'chat.workStatus.pr.checksFailed': '{count} впало', @@ -3143,6 +3146,7 @@ export const dict: Record = { 'chat.workStatus.action.openChanges': 'Відкрити зміни', 'chat.workStatus.action.openGit': 'Відкрити панель Git', 'chat.workStatus.action.openPr': 'Відкрити pull request', + 'chat.workStatus.action.openMr': 'Відкрити запит на злиття', 'chat.workStatus.action.openSubagent': 'Відкрити {name}', 'chat.workStatus.section.usage': 'Використання', 'chat.workStatus.goal.open': 'Керувати ціллю', diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index c96e3db0..b226ccc6 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -771,6 +771,7 @@ export const dict: Record = { 'gitView.header.repositoryViews': '仓库视图', 'gitView.header.updateBranch': '更新分支', 'gitView.header.openPullRequest': '打开拉取请求', + 'gitView.header.openMergeRequest': '打开合并请求', 'gitView.header.removeRemoteAria': '移除远程 {name}', 'gitView.header.removeRemoteTitle': '移除 {name}', 'gitView.header.upstreamSynced': '已同步', @@ -1161,6 +1162,7 @@ export const dict: Record = { 'walkthrough.missing.languageAndModel': '尚无使用该语言和该模型生成的导读,当前显示最近一次生成的版本。', 'walkthrough.language.selectorAria': '选择导读语言', 'walkthrough.scope.pullRequest': 'PR #{number}', + 'walkthrough.scope.mergeRequest': 'MR !{number}', 'walkthrough.action.generate': '生成导读', 'walkthrough.action.regenerate': '重新生成', 'walkthrough.action.cancel': '取消', @@ -3112,6 +3114,7 @@ export const dict: Record = { 'chat.workStatus.git.changedFileSingle': '已更改 {count} 个文件', 'chat.workStatus.git.changedFilePlural': '已更改 {count} 个文件', 'chat.workStatus.pr.untitled': '未命名的拉取请求', + 'chat.workStatus.mr.untitled': '未命名的合并请求', 'chat.workStatus.pr.draft': '草稿', 'chat.workStatus.pr.checks': '检查', 'chat.workStatus.pr.checksFailed': '{count} 项失败', @@ -3143,6 +3146,7 @@ export const dict: Record = { 'chat.workStatus.action.openChanges': '打开更改', 'chat.workStatus.action.openGit': '打开 Git 面板', 'chat.workStatus.action.openPr': '打开拉取请求', + 'chat.workStatus.action.openMr': '打开合并请求', 'chat.workStatus.action.openSubagent': '打开 {name}', 'chat.workStatus.section.usage': '用量', 'chat.workStatus.goal.open': '管理目标', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 7860b7c1..5af61b4b 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -784,6 +784,7 @@ export const dict: Record = { 'gitView.header.repositoryViews': '儲存庫檢視', 'gitView.header.updateBranch': '更新分支', 'gitView.header.openPullRequest': '開啟提取請求', + 'gitView.header.openMergeRequest': '開啟合併請求', 'gitView.header.removeRemoteAria': '移除遠端 {name}', 'gitView.header.removeRemoteTitle': '移除 {name}', 'gitView.header.upstreamSynced': '已同步', @@ -1173,6 +1174,7 @@ export const dict: Record = { 'walkthrough.missing.languageAndModel': '尚無使用該語言與該模型產生的導讀,目前顯示最近一次產生的版本。', 'walkthrough.language.selectorAria': '選擇導讀語言', 'walkthrough.scope.pullRequest': 'PR #{number}', + 'walkthrough.scope.mergeRequest': 'MR !{number}', 'walkthrough.action.generate': '產生導讀', 'walkthrough.action.regenerate': '重新產生', 'walkthrough.action.cancel': '取消', @@ -3111,6 +3113,7 @@ export const dict: Record = { 'chat.workStatus.git.changedFileSingle': '已變更 {count} 個檔案', 'chat.workStatus.git.changedFilePlural': '已變更 {count} 個檔案', 'chat.workStatus.pr.untitled': '未命名的提取請求', + 'chat.workStatus.mr.untitled': '未命名的合併請求', 'chat.workStatus.pr.draft': '草稿', 'chat.workStatus.pr.checks': '檢查', 'chat.workStatus.pr.checksFailed': '{count} 項失敗', @@ -3142,6 +3145,7 @@ export const dict: Record = { 'chat.workStatus.action.openChanges': '開啟變更', 'chat.workStatus.action.openGit': '開啟 Git 面板', 'chat.workStatus.action.openPr': '開啟提取請求', + 'chat.workStatus.action.openMr': '開啟合併請求', 'chat.workStatus.action.openSubagent': '開啟 {name}', 'chat.workStatus.section.usage': '用量', 'chat.workStatus.goal.open': '管理目標', From b8a0aa02420b694e57ca8cc705a2f7b3444bfbe6 Mon Sep 17 00:00:00 2001 From: bot-hermes Date: Thu, 13 Aug 2026 17:20:30 +0000 Subject: [PATCH 12/45] feat(web): resolve walkthrough diffs for GitLab merge requests --- .../server/lib/walkthrough/DOCUMENTATION.md | 12 +- .../server/lib/walkthrough/pull-request.js | 82 +++++++++++++- .../lib/walkthrough/pull-request.test.js | 107 ++++++++++++++++++ 3 files changed, 197 insertions(+), 4 deletions(-) diff --git a/packages/web/server/lib/walkthrough/DOCUMENTATION.md b/packages/web/server/lib/walkthrough/DOCUMENTATION.md index 1d0cc404..0ace904b 100644 --- a/packages/web/server/lib/walkthrough/DOCUMENTATION.md +++ b/packages/web/server/lib/walkthrough/DOCUMENTATION.md @@ -20,7 +20,7 @@ has to ask for it. `PROMPT_VERSION`. - `schema.js` — response schema, response normalization, tolerant JSON parsing. - `store.js` — content-addressed cache entries plus mutable pointers. -- `pull-request.js` — PR diffs via the shared GitHub octokit helper. +- `pull-request.js` — PR/MR diffs; the provider (GitHub octokit or GitLab REST client) is chosen by the git remote. - `model-settings.js` — the feature's own model override. - `languages.js` — the languages the prose may be written in. - `index.js` — orchestration. @@ -53,7 +53,15 @@ written against staged code never silently re-anchors onto an unstaged edit. |---|---|---| | `working-tree` (`all` \| `staged` \| `working`) | `staged`, `working` | Untracked files are fetched individually because `git diff` omits them | | `branch` | `branch` | `getRangeDiff` uses three-dot `base...head`, so work merged in from the base branch is excluded | -| `pr` | `pr:` | GitHub returns the merge-base diff, matching the branch semantics | +| `pr` | `pr:` | GitHub returns the merge-base diff; a GitLab remote concatenates the merge request's diffs instead — both match the branch semantics | + +Provider selection lives in `pull-request.js`: the directory's git remote +decides. A GitLab remote resolves through `resolveGitLabRepoFromDirectory` and +fetches `merge_requests/:iid/diffs` pages (capped at 10), concatenating the +per-file diffs into one patch; anything else falls back to the GitHub pull +request API. A GitLab directory without a connected GitLab account fails with +`401 gitlab-not-connected`, and an MR with no diffs fails with +`404 empty-diff`, the same code an empty GitHub PR uses. For the current-branch source, the UI takes the base from the default branch of the current branch's tracking remote (`defaultBranches` in the branches diff --git a/packages/web/server/lib/walkthrough/pull-request.js b/packages/web/server/lib/walkthrough/pull-request.js index e05a5f51..39d0ce2c 100644 --- a/packages/web/server/lib/walkthrough/pull-request.js +++ b/packages/web/server/lib/walkthrough/pull-request.js @@ -1,14 +1,20 @@ import { getOctokitOrNull } from '../github/octokit.js'; import { resolveGitHubRepoFromDirectory } from '../github/repo/index.js'; +import { getGitLabClientOrNull } from '../gitlab/client.js'; +import { resolveGitLabRepoFromDirectory } from '../gitlab/repo.js'; + +// GitLab diff pagination cap: never loop more than 10 pages of 100 files, +// mirroring gitlab/routes.js. +const GITLAB_DIFFS_MAX_PAGES = 10; /** - * Raw unified diff for a pull request. + * Raw unified diff for a GitHub pull request. * * GitHub already returns the merge-base diff for a PR, so this matches the * three-dot semantics used for local branch reviews: work merged in from the * base branch is not part of it. */ -export async function getPullRequestDiff(directory, number) { +async function getGitHubPullRequestDiff(directory, number) { const octokit = getOctokitOrNull(); if (!octokit) { throw Object.assign(new Error('Connect a GitHub account to review pull requests'), { @@ -44,3 +50,75 @@ export async function getPullRequestDiff(directory, number) { return { patch, meta: { owner: repo.owner, repo: repo.repo, number } }; } + +/** + * Raw unified diff for a GitLab merge request. + * + * GitLab's merge request diffs endpoint returns one entry per file, so the + * pages are concatenated into a single patch. `repo` comes from the + * dispatcher's `resolveGitLabRepoFromDirectory` call and is never re-resolved + * here. + */ +async function getGitLabMergeRequestDiff(repo, number) { + const client = getGitLabClientOrNull(); + if (!client) { + throw Object.assign(new Error('Connect a GitLab account to review merge requests'), { + statusCode: 401, + code: 'gitlab-not-connected', + }); + } + + // The parser always populates both fields; this guards a malformed repo so + // the failure is explicit rather than a downstream TypeError. + if (!repo?.namespace || !repo?.project) { + throw Object.assign(new Error('This directory has no GitLab remote'), { + statusCode: 400, + code: 'no-gitlab-remote', + }); + } + + // The client URL-encodes the path internally; never pre-encode it. + const projectPath = `${repo.namespace}/${repo.project}`; + + const diffs = []; + for (let page = 1; page <= GITLAB_DIFFS_MAX_PAGES; page += 1) { + const response = await client.mergeRequestDiffs(projectPath, number, { per_page: 100, page }); + if (response.status !== 200 || !Array.isArray(response.data)) { + break; + } + diffs.push(...response.data); + // The page object is the authoritative signal; the 10-page cap above is + // what stops a server that lies about hasMore from looping forever. + if (!response.page?.hasMore) { + break; + } + } + + const patch = diffs + .map((item) => (typeof item?.diff === 'string' ? item.diff : '')) + .filter(Boolean) + .join('\n'); + if (!patch.trim()) { + throw Object.assign(new Error(`Merge request #${number} has no diff`), { + statusCode: 404, + code: 'empty-diff', + }); + } + + return { patch, meta: { namespace: repo.namespace, project: repo.project, number } }; +} + +/** + * Raw unified diff for a pull request or merge request. + * + * The provider is chosen by the repository's git remote: a GitLab remote uses + * the GitLab merge request API, anything else falls back to the GitHub pull + * request API. + */ +export async function getPullRequestDiff(directory, number) { + const { repo } = await resolveGitLabRepoFromDirectory(directory); + if (repo) { + return getGitLabMergeRequestDiff(repo, number); + } + return getGitHubPullRequestDiff(directory, number); +} diff --git a/packages/web/server/lib/walkthrough/pull-request.test.js b/packages/web/server/lib/walkthrough/pull-request.test.js index 6a76a337..b2cdabcd 100644 --- a/packages/web/server/lib/walkthrough/pull-request.test.js +++ b/packages/web/server/lib/walkthrough/pull-request.test.js @@ -2,10 +2,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('../github/octokit.js', () => ({ getOctokitOrNull: vi.fn() })); vi.mock('../github/repo/index.js', () => ({ resolveGitHubRepoFromDirectory: vi.fn() })); +vi.mock('../gitlab/client.js', () => ({ getGitLabClientOrNull: vi.fn() })); +vi.mock('../gitlab/repo.js', () => ({ resolveGitLabRepoFromDirectory: vi.fn() })); const { getPullRequestDiff } = await import('./pull-request.js'); const { getOctokitOrNull } = await import('../github/octokit.js'); const { resolveGitHubRepoFromDirectory } = await import('../github/repo/index.js'); +const { getGitLabClientOrNull } = await import('../gitlab/client.js'); +const { resolveGitLabRepoFromDirectory } = await import('../gitlab/repo.js'); const PATCH = `diff --git a/src/a.ts b/src/a.ts --- a/src/a.ts @@ -14,6 +18,28 @@ const PATCH = `diff --git a/src/a.ts b/src/a.ts +const added = true; `; +const GITLAB_DIFF_ONE = `diff --git a/a.txt b/a.txt +--- a/a.txt ++++ b/a.txt +@@ -1,1 +1,2 @@ ++hello +`; + +const GITLAB_DIFF_TWO = `diff --git a/b.txt b/b.txt +--- a/b.txt ++++ b/b.txt +@@ -1 +1,2 @@ ++world +`; + +const GITLAB_REPO = { + namespace: 'acme', + project: 'widgets', + host: 'gitlab.com', + baseUrl: 'https://gitlab.com', + url: 'https://gitlab.com/acme/widgets', +}; + describe('getPullRequestDiff', () => { let request; @@ -27,6 +53,10 @@ describe('getPullRequestDiff', () => { repo: { owner: 'openchamber', repo: 'openchamber' }, remoteUrl: 'git@github.com:openchamber/openchamber.git', }); + // Default to a non-GitLab directory so the GitHub cases keep routing to + // the GitHub path. + resolveGitLabRepoFromDirectory.mockResolvedValue({ repo: null, remoteUrl: null }); + getGitLabClientOrNull.mockReturnValue(null); }); afterEach(() => { @@ -74,4 +104,81 @@ describe('getPullRequestDiff', () => { statusCode: 404, }); }); + + describe('with a GitLab repository', () => { + let mergeRequestDiffs; + + beforeEach(() => { + mergeRequestDiffs = vi.fn().mockResolvedValue({ + status: 200, + data: [{ diff: GITLAB_DIFF_ONE }], + page: { page: 1, next: null, total: 1, hasMore: false }, + }); + getGitLabClientOrNull.mockReturnValue({ mergeRequestDiffs }); + resolveGitLabRepoFromDirectory.mockResolvedValue({ + repo: GITLAB_REPO, + remoteUrl: 'git@gitlab.com:acme/widgets.git', + }); + }); + + it('concatenates merge request diffs across pages into a single patch', async () => { + mergeRequestDiffs + .mockResolvedValueOnce({ + status: 200, + data: [{ diff: GITLAB_DIFF_ONE }], + page: { page: 1, next: 2, total: 2, hasMore: true }, + }) + .mockResolvedValueOnce({ + status: 200, + data: [{ diff: GITLAB_DIFF_TWO }], + page: { page: 2, next: null, total: 2, hasMore: false }, + }); + + const result = await getPullRequestDiff('/repo', 7); + + expect(result.patch).toBe(`${GITLAB_DIFF_ONE}\n${GITLAB_DIFF_TWO}`); + expect(result.meta).toEqual({ namespace: 'acme', project: 'widgets', number: 7 }); + // The unencoded namespace/project path is passed to the client, which + // URL-encodes it internally. + expect(mergeRequestDiffs).toHaveBeenCalledTimes(2); + expect(mergeRequestDiffs).toHaveBeenNthCalledWith(1, 'acme/widgets', 7, { per_page: 100, page: 1 }); + expect(mergeRequestDiffs).toHaveBeenNthCalledWith(2, 'acme/widgets', 7, { per_page: 100, page: 2 }); + }); + + it('asks the user to connect GitLab before fetching diffs', async () => { + getGitLabClientOrNull.mockReturnValue(null); + + await expect(getPullRequestDiff('/repo', 7)).rejects.toMatchObject({ + code: 'gitlab-not-connected', + statusCode: 401, + }); + expect(mergeRequestDiffs).not.toHaveBeenCalled(); + }); + + it('treats an MR with no diff as missing rather than an empty review', async () => { + mergeRequestDiffs.mockResolvedValue({ + status: 200, + data: [{ diff: ' ' }], + page: { page: 1, next: null, total: 1, hasMore: false }, + }); + + await expect(getPullRequestDiff('/repo', 7)).rejects.toMatchObject({ + code: 'empty-diff', + statusCode: 404, + }); + }); + + it('reports a GitLab repo without namespace or project as having no remote', async () => { + resolveGitLabRepoFromDirectory.mockResolvedValue({ + repo: { namespace: '', project: '' }, + remoteUrl: 'git@gitlab.com:acme/widgets.git', + }); + + await expect(getPullRequestDiff('/repo', 7)).rejects.toMatchObject({ + code: 'no-gitlab-remote', + statusCode: 400, + }); + expect(mergeRequestDiffs).not.toHaveBeenCalled(); + }); + }); }); From 28d992ac789c26ace0cd189f50ca1e504853d68b Mon Sep 17 00:00:00 2001 From: bot-hermes Date: Thu, 13 Aug 2026 18:43:28 +0000 Subject: [PATCH 13/45] feat(web): create, update and merge GitLab merge requests --- packages/ui/src/lib/api/types.ts | 43 +++ .../web/server/lib/gitlab/DOCUMENTATION.md | 18 +- packages/web/server/lib/gitlab/client.js | 6 + packages/web/server/lib/gitlab/client.test.js | 59 ++++ packages/web/server/lib/gitlab/routes.js | 209 +++++++++++++ packages/web/server/lib/gitlab/routes.test.js | 295 +++++++++++++++++- packages/web/src/api/gitlab.test.ts | 118 +++++++ packages/web/src/api/gitlab.ts | 57 ++++ 8 files changed, 799 insertions(+), 6 deletions(-) diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index 5094f1ee..f1999077 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -1260,6 +1260,46 @@ export type GitLabBranchesResult = { branches: string[]; }; +export type GitLabMergeRequestCreateInput = { + directory: string; + title: string; + sourceBranch: string; + targetBranch: string; + description?: string; + removeSourceBranch?: boolean; +}; + +export type GitLabMergeRequestUpdateInput = { + directory: string; + number: number; + title?: string; + description?: string; +}; + +export type GitLabMergeRequestMergeInput = { + directory: string; + number: number; + squash?: boolean; +}; + +export type GitLabMergeRequestCreateResult = { + connected: boolean; + repo?: GitLabRepoRef | null; + mr?: GitLabMergeRequest; +}; + +export type GitLabMergeRequestUpdateResult = { + connected: boolean; + repo?: GitLabRepoRef | null; + mr?: GitLabMergeRequest; +}; + +export type GitLabMergeRequestMergeResult = { + connected: boolean; + merged: boolean; + message?: string; +}; + type GitLabAuthAccount = { id: string; user: { @@ -1296,6 +1336,9 @@ export interface GitLabAPI { number: number, options?: { includeDiff?: boolean; namespace?: string; project?: string } ): Promise; + mrCreate(input: GitLabMergeRequestCreateInput): Promise; + mrUpdate(input: GitLabMergeRequestUpdateInput): Promise; + mrMerge(input: GitLabMergeRequestMergeInput): Promise; repoBranches(namespace: string, project: string): Promise; } diff --git a/packages/web/server/lib/gitlab/DOCUMENTATION.md b/packages/web/server/lib/gitlab/DOCUMENTATION.md index ead6b015..c59982c1 100644 --- a/packages/web/server/lib/gitlab/DOCUMENTATION.md +++ b/packages/web/server/lib/gitlab/DOCUMENTATION.md @@ -2,8 +2,8 @@ ## Purpose -- This module owns GitLab auth (Personal Access Token), raw REST v4 client access, remote-URL repo resolution, and read-only GitLab issue / merge-request (MR) APIs for OpenChamber. -- From a user perspective, this is the layer that lets the app show GitLab issues and merge requests for a local project, including comments and per-file diffs. +- This module owns GitLab auth (Personal Access Token), raw REST v4 client access, remote-URL repo resolution, and GitLab issue / merge-request (MR) APIs for OpenChamber, including MR create/update/merge writes. +- From a user perspective, this is the layer that lets the app show GitLab issues and merge requests for a local project, including comments and per-file diffs, and create, edit, and merge merge requests. - The module mirrors `packages/web/server/lib/github/` but uses a **Personal Access Token (PAT)** with a configurable base URL (gitlab.com by default, or a self-hosted instance), and talks to GitLab's REST v4 API directly via `fetch` — no new dependencies. ## Entrypoints and structure @@ -32,7 +32,7 @@ ### Client (`client.js`) -- `createGitLabClient({ token, baseUrl })`: raw-fetch REST v4 client with `request(path, { method, query, body })` plus convenience methods `user()`, `project(path)`, `issues(path, params)`, `issue(path, iid)`, `issueNotes(path, iid, params)`, `mergeRequests(path, params)`, `mergeRequest(path, iid)`, `mergeRequestDiffs(path, iid, params)`, `branches(path, params)`. +- `createGitLabClient({ token, baseUrl })`: raw-fetch REST v4 client with `request(path, { method, query, body })` plus convenience methods `user()`, `project(path)`, `issues(path, params)`, `issue(path, iid)`, `issueNotes(path, iid, params)`, `mergeRequests(path, params)`, `mergeRequest(path, iid)`, `mergeRequestDiffs(path, iid, params)`, `createMergeRequest(path, body)`, `updateMergeRequest(path, iid, body)`, `mergeMergeRequest(path, iid, body)`, `branches(path, params)`. - `getGitLabClientOrNull()`: client for the current account, or `null`. - `isGitLabRateLimited()` / `noteGitLabRateLimit(error)`: own module-level rate-limit cooldown (not shared with the GitHub module's `rate-limit.js`). @@ -78,6 +78,9 @@ Nothing in the client or repo layers assumes the token came from a PAT. - MR detail: `GET /projects/:id/merge_requests/:merge_request_iid`. - MR diffs: `GET /projects/:id/merge_requests/:merge_request_iid/diffs?per_page=100&page=N` (paginated; the route caps at 10 pages / 3000 files). - MR notes: `GET /projects/:id/merge_requests/:merge_request_iid/notes?per_page=100`. +- MR create: `POST /projects/:id/merge_requests` with `{ source_branch, target_branch, title, description?, remove_source_branch }` (description omitted when absent; `remove_source_branch` defaults to `false`). +- MR update: `PUT /projects/:id/merge_requests/:merge_request_iid` with `{ title?, description? }` (undefined fields omitted). +- MR merge: `PUT /projects/:id/merge_requests/:merge_request_iid/merge` with `{ squash? }`. - Branches: `GET /projects/:id/repository/branches?per_page=100&page=N`. - User: `GET /user` -> `{ id, username, name, state, avatar_url, web_url, email, ... }`. @@ -95,6 +98,9 @@ Nothing in the client or repo layers assumes the token came from a PAT. | GET | `/api/gitlab/issues/comments` | `?directory&number&namespace&project` -> `{ connected, repo?, comments[] }` | | GET | `/api/gitlab/mrs/list` | `?directory&page&query&sourceBranch` -> `{ connected, repo?, mrs[], page, hasMore }` | | GET | `/api/gitlab/mrs/context` | `?directory&number&diff&namespace&project` -> `{ connected, repo?, mr, comments[], files[], diff? }` | +| POST | `/api/gitlab/mrs/create` | body `{ directory, title, sourceBranch, targetBranch, description?, removeSourceBranch? }` -> `{ connected, repo?, mr }`; `400` for missing fields, unresolvable repo, or a token without the `api` scope | +| PUT | `/api/gitlab/mrs/update` | body `{ directory, number, title?, description? }` -> `{ connected, repo?, mr }`; `404` when the MR does not exist | +| PUT | `/api/gitlab/mrs/merge` | body `{ directory, number, squash? }` -> `{ connected, merged: true }` on success; non-mergeable MRs -> the GitLab status (`405`/`406`/`409`/`422`) with `{ connected, merged: false, message }` | | GET | `/api/gitlab/repo/branches` | `?namespace&project` -> `{ branches[] }` | Conventions mirror `github/routes.js`: @@ -114,8 +120,10 @@ Conventions mirror `github/routes.js`: ## Failure handling - If GitLab is disconnected, read routes return `connected: false`. -- A repo that does not resolve from the local git remote yields `repo: null` with empty lists, matching the GitHub behavior. +- A repo that does not resolve from the local git remote yields `repo: null` with empty lists, matching the GitHub behavior. Write routes reject an unresolvable repo with `400 { error: 'Unable to resolve GitLab repo from directory' }`. - Invalid/expired tokens are cleared on `401`/`403` and reported as disconnected. +- GitLab `403` on write routes means the token lacks the `api` scope; they respond `400 { error: 'Your GitLab token needs the api scope to ...' }`. +- MR merge rejections (`405`/`406`/`409`/`422` from GitLab) are surfaced as `{ connected, merged: false, message }` with the GitLab status so clients can show the message without treating it as a transport error (mirrors `github/pr/merge`). - Rate-limit and timeout failures surface explicit `503` responses so clients keep last-known state rather than clearing UI. ## Notes for contributors @@ -124,4 +132,4 @@ Conventions mirror `github/routes.js`: - Never log tokens. Error messages must not include the access token. - Do not double-encode project paths; convenience methods already call `encodeURIComponent` on the `pathWithNamespace`. - The ETag cache and rate-limit cooldown are module-level and per-instance — they are NOT shared with the GitHub module. -- To add GitLab write operations (comment, assign, merge), add the endpoint in `routes.js`, add a convenience method in `client.js`, and extend the shared types — mirror the GitHub PR write routes. +- To add further GitLab write operations (comment, assign, issue writes), add the endpoint in `routes.js`, add a convenience method in `client.js`, and extend the shared types — mirror the existing MR write routes and the GitHub PR write routes. diff --git a/packages/web/server/lib/gitlab/client.js b/packages/web/server/lib/gitlab/client.js index 66567fd5..bb0e8e0d 100644 --- a/packages/web/server/lib/gitlab/client.js +++ b/packages/web/server/lib/gitlab/client.js @@ -267,6 +267,12 @@ export function createGitLabClient({ token, baseUrl }) { request(`/projects/${encodeProject(pathWithNamespace)}/merge_requests/${iid}`), mergeRequestDiffs: (pathWithNamespace, iid, params = {}) => request(`/projects/${encodeProject(pathWithNamespace)}/merge_requests/${iid}/diffs`, { query: params }), + createMergeRequest: (pathWithNamespace, body) => + request(`/projects/${encodeProject(pathWithNamespace)}/merge_requests`, { method: 'POST', body }), + updateMergeRequest: (pathWithNamespace, iid, body) => + request(`/projects/${encodeProject(pathWithNamespace)}/merge_requests/${iid}`, { method: 'PUT', body }), + mergeMergeRequest: (pathWithNamespace, iid, body) => + request(`/projects/${encodeProject(pathWithNamespace)}/merge_requests/${iid}/merge`, { method: 'PUT', body }), branches: (pathWithNamespace, params = {}) => request(`/projects/${encodeProject(pathWithNamespace)}/repository/branches`, { query: params }), }; diff --git a/packages/web/server/lib/gitlab/client.test.js b/packages/web/server/lib/gitlab/client.test.js index e4eace7e..6bf00d0b 100644 --- a/packages/web/server/lib/gitlab/client.test.js +++ b/packages/web/server/lib/gitlab/client.test.js @@ -214,6 +214,65 @@ describe('etag conditional cache', () => { }); }); +describe('merge request write methods', () => { + test('createMergeRequest POSTs a JSON body to the merge_requests endpoint', async () => { + const fetchMock = vi.fn(async () => jsonResponse({ iid: 5, title: 'New MR' }, { status: 201 })); + globalThis.fetch = fetchMock; + + const client = createGitLabClient({ token: 't', baseUrl: 'https://gitlab.com' }); + const result = await client.createMergeRequest('group/sub', { + source_branch: 'feat/x', + target_branch: 'main', + title: 'New MR', + }); + + const [url, options] = fetchMock.mock.calls[0]; + expect(String(url)).toBe('https://gitlab.com/api/v4/projects/group%2Fsub/merge_requests'); + expect(options.method).toBe('POST'); + expect(options.headers['content-type']).toBe('application/json'); + expect(JSON.parse(options.body)).toEqual({ source_branch: 'feat/x', target_branch: 'main', title: 'New MR' }); + expect(result.status).toBe(201); + expect(result.data).toEqual({ iid: 5, title: 'New MR' }); + }); + + test('updateMergeRequest PUTs a JSON body to the merge request endpoint', async () => { + const fetchMock = vi.fn(async () => jsonResponse({ iid: 5, title: 'Updated' })); + globalThis.fetch = fetchMock; + + const client = createGitLabClient({ token: 't', baseUrl: 'https://gitlab.com' }); + await client.updateMergeRequest('group/sub', 5, { title: 'Updated', description: 'Body text' }); + + const [url, options] = fetchMock.mock.calls[0]; + expect(String(url)).toBe('https://gitlab.com/api/v4/projects/group%2Fsub/merge_requests/5'); + expect(options.method).toBe('PUT'); + expect(options.headers['content-type']).toBe('application/json'); + expect(JSON.parse(options.body)).toEqual({ title: 'Updated', description: 'Body text' }); + }); + + test('mergeMergeRequest PUTs a JSON body to the merge endpoint', async () => { + const fetchMock = vi.fn(async () => jsonResponse({ iid: 5, state: 'merged' })); + globalThis.fetch = fetchMock; + + const client = createGitLabClient({ token: 't', baseUrl: 'https://gitlab.com' }); + await client.mergeMergeRequest('group/sub', 5, { squash: true }); + + const [url, options] = fetchMock.mock.calls[0]; + expect(String(url)).toBe('https://gitlab.com/api/v4/projects/group%2Fsub/merge_requests/5/merge'); + expect(options.method).toBe('PUT'); + expect(JSON.parse(options.body)).toEqual({ squash: true }); + }); + + test('write methods surface error statuses without throwing', async () => { + const fetchMock = vi.fn(async () => jsonResponse({ message: 'Method Not Allowed' }, { status: 405 })); + globalThis.fetch = fetchMock; + + const client = createGitLabClient({ token: 't', baseUrl: 'https://gitlab.com' }); + const result = await client.mergeMergeRequest('group/sub', 5, {}); + expect(result.status).toBe(405); + expect(result.data).toEqual({ message: 'Method Not Allowed' }); + }); +}); + describe('rate limiting', () => { // NOTE: these tests run last in this file. The rate-limit cooldown is // module-level and has no reset export, so earlier tests must not set one. diff --git a/packages/web/server/lib/gitlab/routes.js b/packages/web/server/lib/gitlab/routes.js index e825ad6a..4319a173 100644 --- a/packages/web/server/lib/gitlab/routes.js +++ b/packages/web/server/lib/gitlab/routes.js @@ -57,6 +57,7 @@ const mapAuthor = (author) => { username: typeof author.username === 'string' ? author.username : null, name: typeof author.name === 'string' ? author.name : null, avatarUrl: typeof author.avatar_url === 'string' ? author.avatar_url : null, + webUrl: typeof author.web_url === 'string' ? author.web_url : null, id: typeof author.id === 'number' ? author.id : null, }; }; @@ -90,6 +91,34 @@ const mapComment = (note, webUrl) => ({ author: mapAuthor(note.author) || {}, }); +// GitLab error bodies carry `message` as a string ("405 Method Not Allowed") or +// as a field->errors object ({ title: ['is invalid'] }); some endpoints use an +// `error` field instead. Flatten whichever shape is present into one readable +// string so write routes can surface it in { error } or { message }. +const gitLabErrorMessage = (data) => { + if (!data || typeof data !== 'object') { + return null; + } + const message = data.message; + if (typeof message === 'string' && message) { + return message; + } + if (message && typeof message === 'object') { + const parts = Object.entries(message).map(([field, errors]) => { + const list = Array.isArray(errors) ? errors : [errors]; + const detail = list.filter((item) => typeof item === 'string' && item).join(', '); + return detail ? `${field}: ${detail}` : field; + }); + if (parts.length > 0) { + return parts.join('; '); + } + } + if (typeof data.error === 'string' && data.error) { + return data.error; + } + return null; +}; + const countDiffLines = (diffText) => { if (typeof diffText !== 'string') { return { additions: 0, deletions: 0, changes: 0 }; @@ -655,6 +684,186 @@ export function registerGitLabRoutes(app, options = {}) { } }); + // ================= GitLab Merge Request Write APIs ================= + + app.post('/api/gitlab/mrs/create', async (req, res) => { + try { + const directory = asString(req.body?.directory); + const title = asString(req.body?.title); + const sourceBranch = asString(req.body?.sourceBranch); + const targetBranch = asString(req.body?.targetBranch); + if (!directory || !title || !sourceBranch || !targetBranch) { + return res.status(400).json({ error: 'directory, title, sourceBranch, targetBranch are required' }); + } + const description = typeof req.body?.description === 'string' && req.body.description + ? req.body.description + : undefined; + const removeSourceBranch = typeof req.body?.removeSourceBranch === 'boolean' + ? req.body.removeSourceBranch + : false; + + const client = await getClient(); + if (!client) { + return res.json({ connected: false }); + } + + const requestedProject = getRequestedProject(req); + const { projectPath, repo } = await resolveProjectForRequest(directory, requestedProject); + if (!projectPath) { + return res.status(400).json({ error: 'Unable to resolve GitLab repo from directory' }); + } + + const body = { + source_branch: sourceBranch, + target_branch: targetBranch, + title, + remove_source_branch: removeSourceBranch, + }; + if (description !== undefined) { + body.description = description; + } + + const resp = await withTimeout(client.createMergeRequest(projectPath, body), ROUTE_TIMEOUT_MS, 'gitlab mr create'); + if (resp.status === 429) { + return res.status(503).json({ error: 'GitLab rate limited' }); + } + if (resp.status === 403) { + return res.status(400).json({ error: 'Your GitLab token needs the api scope to create merge requests' }); + } + if (resp.status !== 200 && resp.status !== 201) { + const status = resp.status >= 500 ? 500 : 400; + return res.status(status).json({ error: gitLabErrorMessage(resp.data) || 'GitLab returned an error while creating the merge request' }); + } + if (!resp.data) { + return res.status(500).json({ error: 'GitLab returned an empty response while creating the merge request' }); + } + + return res.json({ + connected: true, + repo: repo || repoRefFromProjectPath(projectPath, client.baseUrl), + mr: mapMergeRequestSummary(resp.data), + }); + } catch (error) { + console.error('Failed to create GitLab merge request:', error); + return res.status(500).json({ error: error.message || 'Failed to create GitLab merge request' }); + } + }); + + app.put('/api/gitlab/mrs/update', async (req, res) => { + try { + const directory = asString(req.body?.directory); + const number = typeof req.body?.number === 'number' ? req.body.number : null; + if (!directory || !number) { + return res.status(400).json({ error: 'directory and number are required' }); + } + const title = asString(req.body?.title); + const description = typeof req.body?.description === 'string' ? req.body.description : undefined; + + const client = await getClient(); + if (!client) { + return res.json({ connected: false }); + } + + const requestedProject = getRequestedProject(req); + const { projectPath, repo } = await resolveProjectForRequest(directory, requestedProject); + if (!projectPath) { + return res.status(400).json({ error: 'Unable to resolve GitLab repo from directory' }); + } + + const body = {}; + if (title) { + body.title = title; + } + if (description !== undefined) { + body.description = description; + } + + const resp = await withTimeout(client.updateMergeRequest(projectPath, number, body), ROUTE_TIMEOUT_MS, 'gitlab mr update'); + if (resp.status === 429) { + return res.status(503).json({ error: 'GitLab rate limited' }); + } + if (resp.status === 403) { + return res.status(400).json({ error: 'Your GitLab token needs the api scope to update merge requests' }); + } + if (resp.status === 404) { + return res.status(404).json({ error: 'Merge request not found' }); + } + if (resp.status !== 200 && resp.status !== 201) { + const status = resp.status >= 500 ? 500 : 400; + return res.status(status).json({ error: gitLabErrorMessage(resp.data) || 'GitLab returned an error while updating the merge request' }); + } + if (!resp.data) { + return res.status(500).json({ error: 'GitLab returned an empty response while updating the merge request' }); + } + + return res.json({ + connected: true, + repo: repo || repoRefFromProjectPath(projectPath, client.baseUrl), + mr: mapMergeRequestSummary(resp.data), + }); + } catch (error) { + console.error('Failed to update GitLab merge request:', error); + return res.status(500).json({ error: error.message || 'Failed to update GitLab merge request' }); + } + }); + + app.put('/api/gitlab/mrs/merge', async (req, res) => { + try { + const directory = asString(req.body?.directory); + const number = typeof req.body?.number === 'number' ? req.body.number : null; + if (!directory || !number) { + return res.status(400).json({ error: 'directory and number are required' }); + } + const squash = typeof req.body?.squash === 'boolean' ? req.body.squash : undefined; + + const client = await getClient(); + if (!client) { + return res.json({ connected: false }); + } + + const requestedProject = getRequestedProject(req); + const { projectPath, repo } = await resolveProjectForRequest(directory, requestedProject); + if (!projectPath) { + return res.status(400).json({ error: 'Unable to resolve GitLab repo from directory' }); + } + + const body = {}; + if (squash !== undefined) { + body.squash = squash; + } + + const resp = await withTimeout(client.mergeMergeRequest(projectPath, number, body), ROUTE_TIMEOUT_MS, 'gitlab mr merge'); + if (resp.status === 429) { + return res.status(503).json({ error: 'GitLab rate limited' }); + } + if (resp.status === 403) { + return res.status(400).json({ error: 'Your GitLab token needs the api scope to create merge requests' }); + } + if (resp.status === 404) { + return res.status(404).json({ error: 'Merge request not found' }); + } + // GitLab rejects non-mergeable requests with 405/406/409/422 and a + // `message` in the body — surface it as a merge rejection (mirrors the + // GitHub pr/merge contract) instead of a generic error. + if (resp.status === 405 || resp.status === 406 || resp.status === 409 || resp.status === 422) { + return res.status(resp.status).json({ + connected: true, + merged: false, + message: gitLabErrorMessage(resp.data) || 'Merge request not mergeable', + }); + } + if (resp.status !== 200 && resp.status !== 201) { + const status = resp.status >= 500 ? 500 : 400; + return res.status(status).json({ error: gitLabErrorMessage(resp.data) || 'GitLab returned an error while merging the merge request' }); + } + + return res.json({ connected: true, merged: true }); + } catch (error) { + console.error('Failed to merge GitLab merge request:', error); + return res.status(500).json({ error: error.message || 'Failed to merge GitLab merge request' }); + } + }); + // ================= GitLab Repo APIs ================= app.get('/api/gitlab/repo/branches', async (req, res) => { diff --git a/packages/web/server/lib/gitlab/routes.test.js b/packages/web/server/lib/gitlab/routes.test.js index 82ac6721..1f5725bf 100644 --- a/packages/web/server/lib/gitlab/routes.test.js +++ b/packages/web/server/lib/gitlab/routes.test.js @@ -350,7 +350,7 @@ describe('GitLab data routes', () => { body: 'Looks good to me', createdAt: '2026-01-01T01:00:00Z', updatedAt: undefined, - author: { username: 'alice', name: 'Alice Example', avatarUrl: null, id: 42 }, + author: { username: 'alice', name: 'Alice Example', avatarUrl: null, id: 42, webUrl: null }, }, ]); }); @@ -510,6 +510,299 @@ describe('GitLab data routes', () => { expect(response.body).toEqual({ error: 'namespace and project are required' }); }); + test('mrs/create POSTs source/target/title and returns the created MR summary', async () => { + const createdMr = { + iid: 12, + title: 'Add feature', + web_url: 'https://gitlab.com/group/sub/-/merge_requests/12', + state: 'opened', + draft: false, + work_in_progress: false, + author: { + id: 42, + username: 'alice', + name: 'Alice Example', + avatar_url: 'https://gitlab.com/alice.png', + web_url: 'https://gitlab.com/alice', + }, + source_branch: 'feat/add', + target_branch: 'main', + }; + const fetchMock = scriptedFetch([ + (url, options) => { + if (matches(/\/merge_requests$/)(url) && options.method === 'POST') { + return jsonResponse(createdMr, { status: 201 }); + } + return null; + }, + ]); + + const app = createApp(); + const response = await request(app) + .post('/api/gitlab/mrs/create') + .send({ + directory: '/tmp/work', + title: 'Add feature', + sourceBranch: 'feat/add', + targetBranch: 'main', + description: 'Adds the feature', + removeSourceBranch: true, + }); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + connected: true, + repo: { namespace: 'group', project: 'sub', host: 'gitlab.com' }, + mr: { + number: 12, + title: 'Add feature', + url: 'https://gitlab.com/group/sub/-/merge_requests/12', + state: 'opened', + draft: false, + author: { + username: 'alice', + name: 'Alice Example', + avatarUrl: 'https://gitlab.com/alice.png', + webUrl: 'https://gitlab.com/alice', + }, + sourceBranch: 'feat/add', + targetBranch: 'main', + }, + }); + + const [, options] = fetchMock.mock.calls[0]; + expect(options.method).toBe('POST'); + expect(JSON.parse(options.body)).toEqual({ + source_branch: 'feat/add', + target_branch: 'main', + title: 'Add feature', + description: 'Adds the feature', + remove_source_branch: true, + }); + }); + + test('mrs/create defaults remove_source_branch to false and omits description', async () => { + const fetchMock = scriptedFetch([ + (url, options) => { + if (matches(/\/merge_requests$/)(url) && options.method === 'POST') { + return jsonResponse( + { iid: 1, title: 'T', web_url: 'u', state: 'opened', draft: false, author: {}, source_branch: 's', target_branch: 'm' }, + { status: 201 }, + ); + } + return null; + }, + ]); + + const app = createApp(); + await request(app) + .post('/api/gitlab/mrs/create') + .send({ directory: '/tmp/work', title: 'T', sourceBranch: 's', targetBranch: 'm' }); + + const [, options] = fetchMock.mock.calls[0]; + const body = JSON.parse(options.body); + expect(body).toEqual({ source_branch: 's', target_branch: 'm', title: 'T', remove_source_branch: false }); + expect(body.description).toBeUndefined(); + }); + + test('mrs/create rejects missing fields with 400', async () => { + const app = createApp(); + const response = await request(app) + .post('/api/gitlab/mrs/create') + .send({ directory: '/tmp/work', title: 'Add feature' }); + expect(response.status).toBe(400); + expect(response.body).toEqual({ error: 'directory, title, sourceBranch, targetBranch are required' }); + }); + + test('mrs/create reports connected:false when not authenticated', async () => { + clearGitLabAuth(); + const app = createApp(); + const response = await request(app) + .post('/api/gitlab/mrs/create') + .send({ directory: '/tmp/work', title: 'Add feature', sourceBranch: 'feat/add', targetBranch: 'main' }); + expect(response.status).toBe(200); + expect(response.body).toEqual({ connected: false }); + }); + + test('mrs/create surfaces a 403 as an api-scope error', async () => { + scriptedFetch([ + (url, options) => { + if (matches(/\/merge_requests$/)(url) && options.method === 'POST') { + return jsonResponse({ message: '403 Forbidden' }, { status: 403 }); + } + return null; + }, + ]); + + const app = createApp(); + const response = await request(app) + .post('/api/gitlab/mrs/create') + .send({ directory: '/tmp/work', title: 'Add feature', sourceBranch: 'feat/add', targetBranch: 'main' }); + expect(response.status).toBe(400); + expect(response.body).toEqual({ error: 'Your GitLab token needs the api scope to create merge requests' }); + }); + + test('mrs/create surfaces GitLab validation errors with the api message', async () => { + scriptedFetch([ + (url, options) => { + if (matches(/\/merge_requests$/)(url) && options.method === 'POST') { + return jsonResponse({ message: { source_branch: ['is missing'], title: ['is invalid'] } }, { status: 400 }); + } + return null; + }, + ]); + + const app = createApp(); + const response = await request(app) + .post('/api/gitlab/mrs/create') + .send({ directory: '/tmp/work', title: 'Add feature', sourceBranch: 'feat/add', targetBranch: 'main' }); + expect(response.status).toBe(400); + expect(response.body).toEqual({ error: 'source_branch: is missing; title: is invalid' }); + }); + + test('mrs/update PUTs title/description and returns the updated MR summary', async () => { + const fetchMock = scriptedFetch([ + (url, options) => { + if (matches(/\/merge_requests\/12$/)(url) && options.method === 'PUT') { + return jsonResponse({ + iid: 12, + title: 'Updated title', + web_url: 'https://gitlab.com/group/sub/-/merge_requests/12', + state: 'opened', + draft: false, + work_in_progress: false, + author: { id: 42, username: 'alice', name: 'Alice Example', avatar_url: 'https://gitlab.com/alice.png' }, + source_branch: 'feat/add', + target_branch: 'main', + }); + } + return null; + }, + ]); + + const app = createApp(); + const response = await request(app) + .put('/api/gitlab/mrs/update') + .send({ directory: '/tmp/work', number: 12, title: 'Updated title', description: 'New body' }); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + connected: true, + mr: { number: 12, title: 'Updated title', state: 'opened', sourceBranch: 'feat/add', targetBranch: 'main' }, + }); + const [, options] = fetchMock.mock.calls[0]; + expect(options.method).toBe('PUT'); + expect(JSON.parse(options.body)).toEqual({ title: 'Updated title', description: 'New body' }); + }); + + test('mrs/update omits title/description when not provided', async () => { + const fetchMock = scriptedFetch([ + (url, options) => { + if (matches(/\/merge_requests\/12$/)(url) && options.method === 'PUT') { + return jsonResponse({ + iid: 12, + title: 'T', + web_url: 'u', + state: 'opened', + draft: false, + author: {}, + source_branch: 's', + target_branch: 'm', + }); + } + return null; + }, + ]); + + const app = createApp(); + await request(app).put('/api/gitlab/mrs/update').send({ directory: '/tmp/work', number: 12 }); + + const [, options] = fetchMock.mock.calls[0]; + expect(JSON.parse(options.body)).toEqual({}); + }); + + test('mrs/update returns 404 for a missing merge request', async () => { + scriptedFetch([ + (url, options) => { + if (matches(/\/merge_requests\/999$/)(url) && options.method === 'PUT') { + return jsonResponse({ message: '404 Not Found' }, { status: 404 }); + } + return null; + }, + ]); + + const app = createApp(); + const response = await request(app) + .put('/api/gitlab/mrs/update') + .send({ directory: '/tmp/work', number: 999, title: 'x' }); + expect(response.status).toBe(404); + expect(response.body).toEqual({ error: 'Merge request not found' }); + }); + + test('mrs/merge PUTs squash and reports merged:true', async () => { + const fetchMock = scriptedFetch([ + (url, options) => { + if (matches(/\/merge_requests\/12\/merge$/)(url) && options.method === 'PUT') { + return jsonResponse({ iid: 12, state: 'merged' }); + } + return null; + }, + ]); + + const app = createApp(); + const response = await request(app) + .put('/api/gitlab/mrs/merge') + .send({ directory: '/tmp/work', number: 12, squash: true }); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ connected: true, merged: true }); + const [, options] = fetchMock.mock.calls[0]; + expect(options.method).toBe('PUT'); + expect(JSON.parse(options.body)).toEqual({ squash: true }); + }); + + test('mrs/merge passes through a GitLab merge rejection as merged:false', async () => { + scriptedFetch([ + (url, options) => { + if (matches(/\/merge_requests\/12\/merge$/)(url) && options.method === 'PUT') { + return jsonResponse({ message: '405 Method Not Allowed: This merge request is not open' }, { status: 405 }); + } + return null; + }, + ]); + + const app = createApp(); + const response = await request(app) + .put('/api/gitlab/mrs/merge') + .send({ directory: '/tmp/work', number: 12 }); + + expect(response.status).toBe(405); + expect(response.body).toEqual({ + connected: true, + merged: false, + message: '405 Method Not Allowed: This merge request is not open', + }); + }); + + test('mrs/merge surfaces a 403 as an api-scope error', async () => { + scriptedFetch([ + (url, options) => { + if (matches(/\/merge_requests\/12\/merge$/)(url) && options.method === 'PUT') { + return jsonResponse({ message: '403 Forbidden' }, { status: 403 }); + } + return null; + }, + ]); + + const app = createApp(); + const response = await request(app) + .put('/api/gitlab/mrs/merge') + .send({ directory: '/tmp/work', number: 12 }); + + expect(response.status).toBe(400); + expect(response.body).toEqual({ error: 'Your GitLab token needs the api scope to create merge requests' }); + }); + test('data routes surface a 503 when GitLab rate limits', async () => { scriptedFetch([(url) => (matches(/\/issues\?/)(url) ? jsonResponse({}, { status: 429, headers: { 'retry-after': '30' } }) : null)]); diff --git a/packages/web/src/api/gitlab.test.ts b/packages/web/src/api/gitlab.test.ts index d6804c8b..206fc8cb 100644 --- a/packages/web/src/api/gitlab.test.ts +++ b/packages/web/src/api/gitlab.test.ts @@ -145,6 +145,124 @@ describe('createWebGitLabAPI', () => { }); }); + 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('throws the server error message on {error} payloads', async () => { runtimeFetchMock.mockResolvedValueOnce(Response.json({ error: 'Not connected to GitLab' }, { status: 401 })); diff --git a/packages/web/src/api/gitlab.ts b/packages/web/src/api/gitlab.ts index cbd2856d..dac4726e 100644 --- a/packages/web/src/api/gitlab.ts +++ b/packages/web/src/api/gitlab.ts @@ -5,8 +5,15 @@ import type { GitLabIssueCommentsResult, GitLabIssueGetResult, GitLabIssuesListResult, + GitLabMergeRequest, GitLabMergeRequestContextResult, + GitLabMergeRequestCreateInput, + GitLabMergeRequestCreateResult, + GitLabMergeRequestMergeInput, + GitLabMergeRequestMergeResult, GitLabMergeRequestsListResult, + GitLabMergeRequestUpdateInput, + GitLabMergeRequestUpdateResult, GitLabUserSummary, } from '@openchamber/ui/lib/api/types'; import { runtimeFetch } from '@openchamber/ui/lib/runtime-fetch'; @@ -175,6 +182,56 @@ export const createWebGitLabAPI = ({ urls }: WebGitLabAPIOptions): GitLabAPI => return payload; }, + async mrCreate(input: GitLabMergeRequestCreateInput): Promise { + 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(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 { + 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(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 { + 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(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 repoBranches(namespace: string, project: string): Promise { const response = await runtimeFetch( `/api/gitlab/repo/branches?namespace=${encodeURIComponent(namespace)}&project=${encodeURIComponent(project)}`, From 324dca9057784ec5250603a258623285dc0b36a0 Mon Sep 17 00:00:00 2001 From: bot-hermes Date: Thu, 13 Aug 2026 19:10:35 +0000 Subject: [PATCH 14/45] feat(ui): create, update and merge GitLab merge requests from the MR view --- .../ui/src/components/views/GitLabMrView.tsx | 344 ++++++++++++++++++ packages/ui/src/lib/i18n/messages/de.ts | 21 ++ packages/ui/src/lib/i18n/messages/en.ts | 21 ++ packages/ui/src/lib/i18n/messages/es.ts | 21 ++ packages/ui/src/lib/i18n/messages/fr.ts | 21 ++ packages/ui/src/lib/i18n/messages/ja.ts | 21 ++ packages/ui/src/lib/i18n/messages/ko.ts | 21 ++ packages/ui/src/lib/i18n/messages/pl.ts | 21 ++ packages/ui/src/lib/i18n/messages/pt-BR.ts | 21 ++ packages/ui/src/lib/i18n/messages/uk.ts | 21 ++ packages/ui/src/lib/i18n/messages/zh-CN.ts | 21 ++ packages/ui/src/lib/i18n/messages/zh-TW.ts | 21 ++ 12 files changed, 575 insertions(+) diff --git a/packages/ui/src/components/views/GitLabMrView.tsx b/packages/ui/src/components/views/GitLabMrView.tsx index c81d74fb..2628e1c9 100644 --- a/packages/ui/src/components/views/GitLabMrView.tsx +++ b/packages/ui/src/components/views/GitLabMrView.tsx @@ -14,6 +14,10 @@ import { openExternalUrl } from '@/lib/url'; import { formatDateTimeForPreference } from '@/lib/timeFormat'; import type { GitLabMergeRequestContextResult, GitLabMergeRequestSummary } from '@/lib/api/types'; import { useI18n } from '@/lib/i18n'; +import { toast } from '@/components/ui'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; const mrStateColor = (state: string): string => { switch (state) { @@ -199,6 +203,19 @@ export const GitLabMrView: React.FC = () => { setContextError(null); }, [branchMr?.number]); + // A different branch MR invalidates the update/merge transient state so the + // previous MR's edit form, squash flag, and in-flight requests don't leak. + React.useEffect(() => { + setUpdateOpen(false); + setEditTitle(''); + setEditDescription(''); + setEditDescriptionKnown(false); + setEditDescriptionLoading(false); + setUpdating(false); + setMergeSquash(false); + setMerging(false); + }, [branchMr?.number]); + const toggleContext = React.useCallback(async (mr: GitLabMergeRequestSummary) => { if (!currentDirectory || !gitlab?.mrContext) { return; @@ -226,6 +243,175 @@ export const GitLabMrView: React.FC = () => { } }, [contextOpen, currentDirectory, gitlab, t]); + // ---- Create / update / merge actions ----------------------------------- + + const [createTitle, setCreateTitle] = React.useState(''); + const [createDescription, setCreateDescription] = React.useState(''); + const [createTargetBranch, setCreateTargetBranch] = React.useState('main'); + const [createRemoveSourceBranch, setCreateRemoveSourceBranch] = React.useState(false); + const [creating, setCreating] = React.useState(false); + const createTargetTouchedRef = React.useRef(false); + + // The default target branch is the target of the repository's previously + // listed open MRs when available; otherwise fall back to main. + const defaultTargetBranch = React.useMemo( + () => openMrs.find((mr) => mr.targetBranch)?.targetBranch ?? 'main', + [openMrs], + ); + + // Adopt the repository's target branch default once the open-MR list + // resolves, unless the user has already typed into the field. + React.useEffect(() => { + if (branchMrLoading || branchMr || createTargetTouchedRef.current) { + return; + } + setCreateTargetBranch(defaultTargetBranch); + }, [branchMr, branchMrLoading, defaultTargetBranch]); + + const [updateOpen, setUpdateOpen] = React.useState(false); + const [editTitle, setEditTitle] = React.useState(''); + const [editDescription, setEditDescription] = React.useState(''); + const [editDescriptionKnown, setEditDescriptionKnown] = React.useState(false); + const [editDescriptionLoading, setEditDescriptionLoading] = React.useState(false); + const [updating, setUpdating] = React.useState(false); + + const [mergeSquash, setMergeSquash] = React.useState(false); + const [merging, setMerging] = React.useState(false); + + const createMr = React.useCallback(async () => { + if (!currentDirectory || !currentBranch || !gitlab?.mrCreate) { + return; + } + const targetBranch = createTargetBranch.trim(); + if (!targetBranch) { + return; + } + setCreating(true); + try { + const created = await gitlab.mrCreate({ + directory: currentDirectory, + title: createTitle.trim() || currentBranch, + sourceBranch: currentBranch, + targetBranch, + ...(createDescription.trim() ? { description: createDescription } : {}), + ...(createRemoveSourceBranch ? { removeSourceBranch: true } : {}), + }); + toast.success(t('contextPanel.gitlabMr.createMr.toast.created')); + // Show the created MR immediately and refresh both the branch MR and + // the open list so the card flips to the opened state. + setBranchMr(created); + setRetryToken((value) => value + 1); + // Clear the form. + setCreateTitle(''); + setCreateDescription(''); + setCreateRemoveSourceBranch(false); + createTargetTouchedRef.current = false; + setCreateTargetBranch(defaultTargetBranch); + } catch (error) { + toast.error(t('contextPanel.gitlabMr.createMr.toast.createFailed'), { + description: error instanceof Error ? error.message : String(error), + }); + } finally { + setCreating(false); + } + }, [createDescription, createRemoveSourceBranch, createTargetBranch, createTitle, currentBranch, currentDirectory, defaultTargetBranch, gitlab, t]); + + const toggleUpdate = React.useCallback(async () => { + if (!branchMr) { + return; + } + if (updateOpen) { + setUpdateOpen(false); + return; + } + setUpdateOpen(true); + setEditTitle(branchMr.title); + const knownBody = contextResult?.mr?.body; + if (typeof knownBody === 'string') { + setEditDescription(knownBody); + setEditDescriptionKnown(true); + return; + } + setEditDescription(''); + setEditDescriptionKnown(false); + if (!currentDirectory || !gitlab?.mrContext) { + return; + } + setEditDescriptionLoading(true); + try { + const result = await gitlab.mrContext(currentDirectory, branchMr.number, { includeDiff: false }); + if (result.connected === false) { + setEditDescription(''); + return; + } + setEditDescription(result.mr?.body ?? ''); + setEditDescriptionKnown(true); + } catch { + // Leave the description empty; the title can still be edited. + } finally { + setEditDescriptionLoading(false); + } + }, [branchMr, contextResult?.mr?.body, currentDirectory, gitlab, updateOpen]); + + const saveMr = React.useCallback(async () => { + if (!currentDirectory || !branchMr || !gitlab?.mrUpdate) { + return; + } + const trimmedTitle = editTitle.trim(); + if (!trimmedTitle) { + return; + } + setUpdating(true); + try { + await gitlab.mrUpdate({ + directory: currentDirectory, + number: branchMr.number, + title: trimmedTitle, + // Only send the description when it was actually loaded so an + // unresolved description can never be wiped out by a title-only save. + ...(editDescriptionKnown ? { description: editDescription } : {}), + }); + toast.success(t('contextPanel.gitlabMr.updateMr.toast.updated')); + setUpdateOpen(false); + setRetryToken((value) => value + 1); + } catch (error) { + toast.error(t('contextPanel.gitlabMr.updateMr.toast.updateFailed'), { + description: error instanceof Error ? error.message : String(error), + }); + } finally { + setUpdating(false); + } + }, [branchMr, currentDirectory, editDescription, editDescriptionKnown, editTitle, gitlab, t]); + + const mergeMr = React.useCallback(async () => { + if (!currentDirectory || !branchMr || !gitlab?.mrMerge) { + return; + } + setMerging(true); + try { + const result = await gitlab.mrMerge({ + directory: currentDirectory, + number: branchMr.number, + ...(mergeSquash ? { squash: true } : {}), + }); + if (result.merged) { + toast.success(t('contextPanel.gitlabMr.mergeMr.toast.merged')); + } else { + toast.error(t('contextPanel.gitlabMr.mergeMr.toast.mergeFailed'), { + ...(result.message ? { description: result.message } : {}), + }); + } + // Refresh the branch MR (flips to the merged state) and the open list. + setRetryToken((value) => value + 1); + } catch (error) { + toast.error(t('contextPanel.gitlabMr.mergeMr.toast.mergeFailed'), { + description: error instanceof Error ? error.message : String(error), + }); + } finally { + setMerging(false); + } + }, [branchMr, currentDirectory, gitlab, mergeSquash, t]); + const formatTimestamp = React.useCallback((value?: string) => { if (!value) { return ''; @@ -361,8 +547,92 @@ export const GitLabMrView: React.FC = () => { )} {contextOpen ? t('contextPanel.gitlabMr.hideContext') : t('contextPanel.gitlabMr.loadContext')} + {branchMr.state === 'opened' ? ( + <> + +
setMergeSquash((value) => !value)} + onKeyDown={(event) => { + if (event.key === ' ' || event.key === 'Enter') { + event.preventDefault(); + setMergeSquash((value) => !value); + } + }} + > + setMergeSquash(next)} + ariaLabel={t('contextPanel.gitlabMr.mergeMr.squash')} + /> + {t('contextPanel.gitlabMr.mergeMr.squash')} +
+ + + ) : null}
+ {updateOpen && branchMr.state === 'opened' ? ( +
+ +