feat(ui): branch selectors in the GitLab merge request create form

This commit is contained in:
2026-08-16 16:27:48 +00:00
parent c25e16d093
commit d5dea0c004
18 changed files with 188 additions and 21 deletions
@@ -101,7 +101,7 @@ Nothing in the client or repo layers assumes the token came from a PAT.
| 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[] }` |
| GET | `/api/gitlab/repo/branches` | `?namespace&project` -> `{ branches[], defaultBranch? }` (`defaultBranch` is `null` when the repo has no marked default branch or GitLab is disconnected) |
Conventions mirror `github/routes.js`:
+6 -2
View File
@@ -876,10 +876,11 @@ export function registerGitLabRoutes(app, options = {}) {
const client = await getClient();
if (!client) {
return res.json({ branches: [] });
return res.json({ branches: [], defaultBranch: null });
}
const branches = [];
let defaultBranch = null;
let page = 1;
while (page <= 10) {
const resp = await client.branches(`${namespace}/${project}`, { per_page: 100, page });
@@ -893,6 +894,9 @@ export function registerGitLabRoutes(app, options = {}) {
for (const branch of chunk) {
if (typeof branch?.name === 'string') {
branches.push(branch.name);
if (defaultBranch === null && branch.default === true) {
defaultBranch = branch.name;
}
}
}
if (chunk.length < 100 || !resp.page?.hasMore) {
@@ -901,7 +905,7 @@ export function registerGitLabRoutes(app, options = {}) {
page += 1;
}
return res.json({ branches });
return res.json({ branches, defaultBranch });
} 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' });
+23 -2
View File
@@ -490,7 +490,20 @@ describe('GitLab data routes', () => {
expect(response.body.diff).toContain('line two');
});
test('repo/branches returns branch names', async () => {
test('repo/branches returns branch names and the default branch', async () => {
scriptedFetch([
(url) => (matches(/\/repository\/branches\?/)(url)
? jsonResponse([{ name: 'main', default: true }, { 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'], defaultBranch: 'main' });
});
test('repo/branches returns null defaultBranch when no branch is marked default', async () => {
scriptedFetch([
(url) => (matches(/\/repository\/branches\?/)(url)
? jsonResponse([{ name: 'main' }, { name: 'feat/api' }])
@@ -500,7 +513,15 @@ describe('GitLab data routes', () => {
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'] });
expect(response.body).toEqual({ branches: ['main', 'feat/api'], defaultBranch: null });
});
test('repo/branches returns empty branches and null defaultBranch when not connected', async () => {
clearGitLabAuth();
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: [], defaultBranch: null });
});
test('repo/branches requires namespace and project', async () => {
+33
View File
@@ -263,6 +263,39 @@ describe('createWebGitLabAPI', () => {
await expect(api.mrMerge({ directory: '/workspace', number: 12 })).rejects.toThrow('Bad Gateway');
});
it('parses branches and the default branch from repoBranches', async () => {
runtimeFetchMock.mockResolvedValueOnce(Response.json({ branches: ['main', 'feat/api'], defaultBranch: 'main' }));
const api = await createAPI();
await expect(api.repoBranches('group', 'sub')).resolves.toEqual({ branches: ['main', 'feat/api'], defaultBranch: 'main' });
expect(runtimeFetchMock).toHaveBeenCalledWith('/api/gitlab/repo/branches?namespace=group&project=sub', {
method: 'GET',
headers: { Accept: 'application/json' },
});
});
it('defaults defaultBranch to null when repoBranches omits it', async () => {
runtimeFetchMock.mockResolvedValueOnce(Response.json({ branches: ['main'] }));
const api = await createAPI();
await expect(api.repoBranches('group', 'sub')).resolves.toEqual({ branches: ['main'], defaultBranch: null });
});
it('throws the server error message when repoBranches fails', async () => {
runtimeFetchMock.mockResolvedValueOnce(Response.json({ error: 'GitLab rate limited' }, { status: 503 }));
const api = await createAPI();
await expect(api.repoBranches('group', 'sub')).rejects.toThrow('GitLab rate limited');
});
it('throws the response status text when repoBranches has no parseable payload', async () => {
runtimeFetchMock.mockResolvedValueOnce(new Response('upstream gone', { status: 502, statusText: 'Bad Gateway' }));
const api = await createAPI();
await expect(api.repoBranches('group', 'sub')).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 }));
+5 -2
View File
@@ -232,7 +232,7 @@ export const createWebGitLabAPI = ({ urls }: WebGitLabAPIOptions): GitLabAPI =>
};
},
async repoBranches(namespace: string, project: string): Promise<string[]> {
async repoBranches(namespace: string, project: string): Promise<GitLabBranchesResult> {
const response = await runtimeFetch(
`/api/gitlab/repo/branches?namespace=${encodeURIComponent(namespace)}&project=${encodeURIComponent(project)}`,
{ method: 'GET', headers: { Accept: 'application/json' } }
@@ -241,6 +241,9 @@ export const createWebGitLabAPI = ({ urls }: WebGitLabAPIOptions): GitLabAPI =>
if (!response.ok || !body) {
throw new Error(body?.error || response.statusText || 'Failed to fetch GitLab repo branches');
}
return body.branches ?? [];
return {
branches: body.branches ?? [],
defaultBranch: body.defaultBranch ?? null,
};
},
});