From 267db3fb9dac057c046b0b2a1f3746df8931d178 Mon Sep 17 00:00:00 2001 From: bot-hermes Date: Thu, 13 Aug 2026 16:21:23 +0000 Subject: [PATCH] feat(web): filter GitLab merge requests by source branch --- packages/ui/src/lib/api/types.ts | 2 +- .../web/server/lib/gitlab/DOCUMENTATION.md | 4 +- packages/web/server/lib/gitlab/routes.js | 4 ++ packages/web/server/lib/gitlab/routes.test.js | 22 ++++++++++ packages/web/src/api/gitlab.test.ts | 40 +++++++++++++++++++ packages/web/src/api/gitlab.ts | 5 ++- 6 files changed, 73 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index 8b898e9f..5094f1ee 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -1290,7 +1290,7 @@ export interface GitLabAPI { 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; + mrsList(directory: string, options?: { page?: number; query?: string; sourceBranch?: string }): Promise; mrContext( directory: string, number: number, diff --git a/packages/web/server/lib/gitlab/DOCUMENTATION.md b/packages/web/server/lib/gitlab/DOCUMENTATION.md index 91dc0d08..ead6b015 100644 --- a/packages/web/server/lib/gitlab/DOCUMENTATION.md +++ b/packages/web/server/lib/gitlab/DOCUMENTATION.md @@ -74,7 +74,7 @@ Nothing in the client or repo layers assumes the token came from a PAT. - 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 list: `GET /projects/:id/merge_requests?state=opened&scope=all&per_page=50&page=N&search=&source_branch=` (the route passes `sourceBranch` through to `source_branch` when present, matching local-branch MR-status UIs). - 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`. @@ -93,7 +93,7 @@ Nothing in the client or repo layers assumes the token came from a PAT. | 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/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? }` | | GET | `/api/gitlab/repo/branches` | `?namespace&project` -> `{ branches[] }` | diff --git a/packages/web/server/lib/gitlab/routes.js b/packages/web/server/lib/gitlab/routes.js index d90f679e..e825ad6a 100644 --- a/packages/web/server/lib/gitlab/routes.js +++ b/packages/web/server/lib/gitlab/routes.js @@ -507,6 +507,7 @@ export function registerGitLabRoutes(app, options = {}) { 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 sourceBranch = asString(req.query?.sourceBranch); const client = await getClient(); if (!client) { @@ -522,6 +523,9 @@ export function registerGitLabRoutes(app, options = {}) { if (searchQuery) { params.search = searchQuery; } + if (sourceBranch) { + params.source_branch = sourceBranch; + } 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' }); diff --git a/packages/web/server/lib/gitlab/routes.test.js b/packages/web/server/lib/gitlab/routes.test.js index 54b70b0c..82ac6721 100644 --- a/packages/web/server/lib/gitlab/routes.test.js +++ b/packages/web/server/lib/gitlab/routes.test.js @@ -396,6 +396,28 @@ describe('GitLab data routes', () => { }); }); + test('mrs/list passes the source branch filter to the GitLab API', async () => { + const fetchMock = scriptedFetch([(url) => (matches(/\/merge_requests\?/)(url) ? jsonResponse([]) : null)]); + + const app = createApp(); + await request(app).get('/api/gitlab/mrs/list?directory=%2Ftmp%2Fwork&sourceBranch=feat%2Fapi'); + + const requestedUrl = String(fetchMock.mock.calls[0][0]); + expect(requestedUrl).toContain('state=opened'); + expect(requestedUrl).toContain('source_branch=feat%2Fapi'); + expect(requestedUrl).toContain('per_page=50'); + }); + + test('mrs/list omits the source branch filter when not provided', async () => { + const fetchMock = scriptedFetch([(url) => (matches(/\/merge_requests\?/)(url) ? jsonResponse([]) : null)]); + + const app = createApp(); + await request(app).get('/api/gitlab/mrs/list?directory=%2Ftmp%2Fwork'); + + const requestedUrl = String(fetchMock.mock.calls[0][0]); + expect(requestedUrl).not.toContain('source_branch'); + }); + test('mrs/context returns mr, comments, files, and a concatenated diff', async () => { scriptedFetch([ (url) => (matches(/\/merge_requests\/9$/)(url) diff --git a/packages/web/src/api/gitlab.test.ts b/packages/web/src/api/gitlab.test.ts index 971a5ea1..d6804c8b 100644 --- a/packages/web/src/api/gitlab.test.ts +++ b/packages/web/src/api/gitlab.test.ts @@ -105,6 +105,46 @@ describe('createWebGitLabAPI', () => { }); }); + it('passes sourceBranch query param to mrsList', async () => { + const result = { + connected: true, + repo: null, + mrs: [], + page: 1, + hasMore: false, + }; + runtimeFetchMock.mockResolvedValueOnce(Response.json(result)); + + const api = await createAPI(); + await expect(api.mrsList('/workspace', { page: 1, query: 'search', sourceBranch: 'feat/api' })).resolves.toEqual(result); + + const params = new URLSearchParams({ directory: '/workspace', page: '1', query: 'search', sourceBranch: 'feat/api' }); + expect(runtimeFetchMock).toHaveBeenCalledWith(`/api/gitlab/mrs/list?${params.toString()}`, { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + }); + + it('omits sourceBranch when not provided to mrsList', async () => { + const result = { + connected: true, + repo: null, + mrs: [], + page: 1, + hasMore: false, + }; + runtimeFetchMock.mockResolvedValueOnce(Response.json(result)); + + const api = await createAPI(); + await expect(api.mrsList('/workspace', { page: 1 })).resolves.toEqual(result); + + const params = new URLSearchParams({ directory: '/workspace', page: '1' }); + expect(runtimeFetchMock).toHaveBeenCalledWith(`/api/gitlab/mrs/list?${params.toString()}`, { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + }); + it('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 38da4e10..cbd2856d 100644 --- a/packages/web/src/api/gitlab.ts +++ b/packages/web/src/api/gitlab.ts @@ -129,7 +129,7 @@ export const createWebGitLabAPI = ({ urls }: WebGitLabAPIOptions): GitLabAPI => return payload; }, - async mrsList(directory: string, options?: { page?: number; query?: string }): Promise { + async mrsList(directory: string, options?: { page?: number; query?: string; sourceBranch?: string }): Promise { const page = options?.page ?? 1; const params = new URLSearchParams({ directory, @@ -138,6 +138,9 @@ export const createWebGitLabAPI = ({ urls }: WebGitLabAPIOptions): GitLabAPI => if (options?.query) { params.set('query', options.query); } + if (options?.sourceBranch) { + params.set('sourceBranch', options.sourceBranch); + } const response = await runtimeFetch( `/api/gitlab/mrs/list?${params.toString()}`, { method: 'GET', headers: { Accept: 'application/json' } }