feat(web): filter GitLab merge requests by source branch

This commit is contained in:
2026-08-16 16:23:36 +00:00
parent 6868a58359
commit 267db3fb9d
6 changed files with 73 additions and 4 deletions
+1 -1
View File
@@ -1290,7 +1290,7 @@ export interface GitLabAPI {
issueGet(directory: string, number: number, options?: { namespace?: string; project?: string }): Promise<GitLabIssueGetResult>; issueGet(directory: string, number: number, options?: { namespace?: string; project?: string }): Promise<GitLabIssueGetResult>;
issueComments(directory: string, number: number, options?: { namespace?: string; project?: string }): Promise<GitLabIssueCommentsResult>; issueComments(directory: string, number: number, options?: { namespace?: string; project?: string }): Promise<GitLabIssueCommentsResult>;
mrsList(directory: string, options?: { page?: number; query?: string }): Promise<GitLabMergeRequestsListResult>; mrsList(directory: string, options?: { page?: number; query?: string; sourceBranch?: string }): Promise<GitLabMergeRequestsListResult>;
mrContext( mrContext(
directory: string, directory: string,
number: number, number: number,
@@ -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=<query>`. - Issue list: `GET /projects/:id/issues?state=opened&scope=all&per_page=50&page=N&search=<query>`.
- Issue detail: `GET /projects/:id/issues/:issue_iid`. - 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}`). - 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=<query>`. - MR list: `GET /projects/:id/merge_requests?state=opened&scope=all&per_page=50&page=N&search=<query>&source_branch=<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 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 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 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/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/get` | `?directory&number&namespace&project` -> `{ connected, repo?, issue }` |
| GET | `/api/gitlab/issues/comments` | `?directory&number&namespace&project` -> `{ connected, repo?, comments[] }` | | 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/mrs/context` | `?directory&number&diff&namespace&project` -> `{ connected, repo?, mr, comments[], files[], diff? }` |
| GET | `/api/gitlab/repo/branches` | `?namespace&project` -> `{ branches[] }` | | GET | `/api/gitlab/repo/branches` | `?namespace&project` -> `{ branches[] }` |
+4
View File
@@ -507,6 +507,7 @@ export function registerGitLabRoutes(app, options = {}) {
const rawPage = typeof req.query?.page === 'string' ? Number(req.query.page) : 1; const rawPage = typeof req.query?.page === 'string' ? Number(req.query.page) : 1;
const effectivePage = Number.isFinite(rawPage) && rawPage > 0 ? rawPage : 1; const effectivePage = Number.isFinite(rawPage) && rawPage > 0 ? rawPage : 1;
const searchQuery = asString(req.query?.query); const searchQuery = asString(req.query?.query);
const sourceBranch = asString(req.query?.sourceBranch);
const client = await getClient(); const client = await getClient();
if (!client) { if (!client) {
@@ -522,6 +523,9 @@ export function registerGitLabRoutes(app, options = {}) {
if (searchQuery) { if (searchQuery) {
params.search = searchQuery; params.search = searchQuery;
} }
if (sourceBranch) {
params.source_branch = sourceBranch;
}
const resp = await withTimeout(client.mergeRequests(projectPath, params), ROUTE_TIMEOUT_MS, 'gitlab mrs list'); const resp = await withTimeout(client.mergeRequests(projectPath, params), ROUTE_TIMEOUT_MS, 'gitlab mrs list');
if (resp.status === 429) { if (resp.status === 429) {
return res.status(503).json({ error: 'GitLab rate limited' }); return res.status(503).json({ error: 'GitLab rate limited' });
@@ -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 () => { test('mrs/context returns mr, comments, files, and a concatenated diff', async () => {
scriptedFetch([ scriptedFetch([
(url) => (matches(/\/merge_requests\/9$/)(url) (url) => (matches(/\/merge_requests\/9$/)(url)
+40
View File
@@ -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 () => { it('throws the server error message on {error} payloads', async () => {
runtimeFetchMock.mockResolvedValueOnce(Response.json({ error: 'Not connected to GitLab' }, { status: 401 })); runtimeFetchMock.mockResolvedValueOnce(Response.json({ error: 'Not connected to GitLab' }, { status: 401 }));
+4 -1
View File
@@ -129,7 +129,7 @@ export const createWebGitLabAPI = ({ urls }: WebGitLabAPIOptions): GitLabAPI =>
return payload; return payload;
}, },
async mrsList(directory: string, options?: { page?: number; query?: string }): Promise<GitLabMergeRequestsListResult> { async mrsList(directory: string, options?: { page?: number; query?: string; sourceBranch?: string }): Promise<GitLabMergeRequestsListResult> {
const page = options?.page ?? 1; const page = options?.page ?? 1;
const params = new URLSearchParams({ const params = new URLSearchParams({
directory, directory,
@@ -138,6 +138,9 @@ export const createWebGitLabAPI = ({ urls }: WebGitLabAPIOptions): GitLabAPI =>
if (options?.query) { if (options?.query) {
params.set('query', options.query); params.set('query', options.query);
} }
if (options?.sourceBranch) {
params.set('sourceBranch', options.sourceBranch);
}
const response = await runtimeFetch( const response = await runtimeFetch(
`/api/gitlab/mrs/list?${params.toString()}`, `/api/gitlab/mrs/list?${params.toString()}`,
{ method: 'GET', headers: { Accept: 'application/json' } } { method: 'GET', headers: { Accept: 'application/json' } }