feat(ui): branch selectors in the GitLab merge request create form
This commit is contained in:
@@ -12,11 +12,12 @@ 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 type { GitLabMergeRequestContextResult, GitLabMergeRequestSummary, GitLabRepoRef } 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
|
||||
const mrStateColor = (state: string): string => {
|
||||
@@ -88,6 +89,7 @@ export const GitLabMrView: React.FC = () => {
|
||||
const [branchMrLoading, setBranchMrLoading] = React.useState(false);
|
||||
const [branchMrError, setBranchMrError] = React.useState<string | null>(null);
|
||||
const [retryToken, setRetryToken] = React.useState(0);
|
||||
const [repoRef, setRepoRef] = React.useState<GitLabRepoRef | null>(null);
|
||||
|
||||
const retry = React.useCallback(() => setRetryToken((value) => value + 1), []);
|
||||
|
||||
@@ -98,6 +100,11 @@ export const GitLabMrView: React.FC = () => {
|
||||
let cancelled = false;
|
||||
setBranchMrLoading(true);
|
||||
setBranchMrError(null);
|
||||
// Re-resolving the repo context invalidates the previously fetched branch
|
||||
// list so a stale repo's branches never leak into the create form.
|
||||
setRepoRef(null);
|
||||
setBranches([]);
|
||||
setDefaultBranch(null);
|
||||
void gitlab
|
||||
.mrsList(currentDirectory, { sourceBranch: currentBranch })
|
||||
.then((result) => {
|
||||
@@ -112,6 +119,7 @@ export const GitLabMrView: React.FC = () => {
|
||||
?? candidates.find((mr) => mr.state === 'merged')
|
||||
?? null;
|
||||
setBranchMr(matching);
|
||||
setRepoRef(result.repo ?? null);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!cancelled) {
|
||||
@@ -247,11 +255,26 @@ export const GitLabMrView: React.FC = () => {
|
||||
|
||||
const [createTitle, setCreateTitle] = React.useState('');
|
||||
const [createDescription, setCreateDescription] = React.useState('');
|
||||
const [createSourceBranch, setCreateSourceBranch] = React.useState(currentBranch ?? '');
|
||||
const [createTargetBranch, setCreateTargetBranch] = React.useState('main');
|
||||
const [createRemoveSourceBranch, setCreateRemoveSourceBranch] = React.useState(false);
|
||||
const [creating, setCreating] = React.useState(false);
|
||||
const createTargetTouchedRef = React.useRef(false);
|
||||
|
||||
// Repository branches for the source/target dropdowns, fetched lazily once
|
||||
// the create form is visible.
|
||||
const [branches, setBranches] = React.useState<string[]>([]);
|
||||
const [defaultBranch, setDefaultBranch] = React.useState<string | null>(null);
|
||||
const [branchesLoading, setBranchesLoading] = React.useState(false);
|
||||
|
||||
// The current branch is only known after git status resolves, so adopt it as
|
||||
// the default source branch when it arrives without clobbering a pick.
|
||||
React.useEffect(() => {
|
||||
if (currentBranch) {
|
||||
setCreateSourceBranch((previous) => previous || currentBranch);
|
||||
}
|
||||
}, [currentBranch]);
|
||||
|
||||
// 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(
|
||||
@@ -265,8 +288,62 @@ export const GitLabMrView: React.FC = () => {
|
||||
if (branchMrLoading || branchMr || createTargetTouchedRef.current) {
|
||||
return;
|
||||
}
|
||||
setCreateTargetBranch(defaultTargetBranch);
|
||||
}, [branchMr, branchMrLoading, defaultTargetBranch]);
|
||||
setCreateTargetBranch(defaultBranch ?? defaultTargetBranch);
|
||||
}, [branchMr, branchMrLoading, defaultBranch, defaultTargetBranch]);
|
||||
|
||||
// The source dropdown must always offer the picked/current branch, even
|
||||
// before the branch list resolves.
|
||||
const sourceBranchOptions = React.useMemo(() => {
|
||||
if (!createSourceBranch) {
|
||||
return branches;
|
||||
}
|
||||
return branches.includes(createSourceBranch) ? branches : [createSourceBranch, ...branches];
|
||||
}, [branches, createSourceBranch]);
|
||||
|
||||
// A merge request cannot target its own source branch once there is more
|
||||
// than one branch to choose from.
|
||||
const targetBranchOptions = React.useMemo(
|
||||
() => (branches.length >= 2 ? branches.filter((branch) => branch !== createSourceBranch) : branches),
|
||||
[branches, createSourceBranch],
|
||||
);
|
||||
|
||||
// Fetch the repository's branches lazily once the create form is visible so
|
||||
// the source/target dropdowns can offer real values. Failure surfaces as a
|
||||
// toast and leaves the dropdowns on the current-branch fallback.
|
||||
React.useEffect(() => {
|
||||
if (!repoRef || branchMr || !connected || !gitlab?.repoBranches) {
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setBranchesLoading(true);
|
||||
void gitlab
|
||||
.repoBranches(repoRef.namespace, repoRef.project)
|
||||
.then((result) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setBranches(result.branches ?? []);
|
||||
setDefaultBranch(result.defaultBranch ?? null);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setBranches([]);
|
||||
setDefaultBranch(null);
|
||||
toast.error(t('contextPanel.gitlabMr.error.loadFailed'), {
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setBranchesLoading(false);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [branchMr, connected, gitlab, repoRef, t]);
|
||||
|
||||
const [updateOpen, setUpdateOpen] = React.useState(false);
|
||||
const [editTitle, setEditTitle] = React.useState('');
|
||||
@@ -291,7 +368,7 @@ export const GitLabMrView: React.FC = () => {
|
||||
const created = await gitlab.mrCreate({
|
||||
directory: currentDirectory,
|
||||
title: createTitle.trim() || currentBranch,
|
||||
sourceBranch: currentBranch,
|
||||
sourceBranch: createSourceBranch,
|
||||
targetBranch,
|
||||
...(createDescription.trim() ? { description: createDescription } : {}),
|
||||
...(createRemoveSourceBranch ? { removeSourceBranch: true } : {}),
|
||||
@@ -306,7 +383,7 @@ export const GitLabMrView: React.FC = () => {
|
||||
setCreateDescription('');
|
||||
setCreateRemoveSourceBranch(false);
|
||||
createTargetTouchedRef.current = false;
|
||||
setCreateTargetBranch(defaultTargetBranch);
|
||||
setCreateTargetBranch(defaultBranch ?? defaultTargetBranch);
|
||||
} catch (error) {
|
||||
toast.error(t('contextPanel.gitlabMr.createMr.toast.createFailed'), {
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
@@ -314,7 +391,7 @@ export const GitLabMrView: React.FC = () => {
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}, [createDescription, createRemoveSourceBranch, createTargetBranch, createTitle, currentBranch, currentDirectory, defaultTargetBranch, gitlab, t]);
|
||||
}, [createDescription, createRemoveSourceBranch, createSourceBranch, createTargetBranch, createTitle, currentBranch, currentDirectory, defaultBranch, defaultTargetBranch, gitlab, t]);
|
||||
|
||||
const toggleUpdate = React.useCallback(async () => {
|
||||
if (!branchMr) {
|
||||
@@ -691,19 +768,36 @@ export const GitLabMrView: React.FC = () => {
|
||||
|
||||
<label className="space-y-1">
|
||||
<div className="typography-micro text-muted-foreground">{t('contextPanel.gitlabMr.createMr.sourceBranch')}</div>
|
||||
<Input value={currentBranch} readOnly />
|
||||
<Select value={createSourceBranch} onValueChange={(value) => setCreateSourceBranch(value)}>
|
||||
<SelectTrigger size="default" className="w-full">
|
||||
<SelectValue>{branchesLoading ? t('contextPanel.gitlabMr.createMr.branchesLoading') : createSourceBranch}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sourceBranchOptions.map((branch) => (
|
||||
<SelectItem key={branch} value={branch}>{branch}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</label>
|
||||
|
||||
<label className="space-y-1">
|
||||
<div className="typography-micro text-muted-foreground">{t('contextPanel.gitlabMr.createMr.targetBranch')}</div>
|
||||
<Input
|
||||
<Select
|
||||
value={createTargetBranch}
|
||||
onChange={(event) => {
|
||||
onValueChange={(value) => {
|
||||
createTargetTouchedRef.current = true;
|
||||
setCreateTargetBranch(event.target.value);
|
||||
setCreateTargetBranch(value);
|
||||
}}
|
||||
placeholder="main"
|
||||
/>
|
||||
>
|
||||
<SelectTrigger size="default" className="w-full">
|
||||
<SelectValue>{branchesLoading ? t('contextPanel.gitlabMr.createMr.branchesLoading') : createTargetBranch}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{targetBranchOptions.map((branch) => (
|
||||
<SelectItem key={branch} value={branch}>{branch}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</label>
|
||||
|
||||
<label className="space-y-1">
|
||||
|
||||
@@ -1145,7 +1145,7 @@ export type GitLabUserSummary = {
|
||||
email?: string;
|
||||
};
|
||||
|
||||
type GitLabRepoRef = {
|
||||
export type GitLabRepoRef = {
|
||||
namespace: string;
|
||||
project: string;
|
||||
host: string;
|
||||
@@ -1258,6 +1258,7 @@ export type GitLabMergeRequestContextResult = {
|
||||
|
||||
export type GitLabBranchesResult = {
|
||||
branches: string[];
|
||||
defaultBranch?: string | null;
|
||||
};
|
||||
|
||||
export type GitLabMergeRequestCreateInput = {
|
||||
@@ -1340,7 +1341,7 @@ export interface GitLabAPI {
|
||||
mrUpdate(input: GitLabMergeRequestUpdateInput): Promise<GitLabMergeRequest>;
|
||||
mrMerge(input: GitLabMergeRequestMergeInput): Promise<GitLabMergeRequestMergeResult>;
|
||||
|
||||
repoBranches(namespace: string, project: string): Promise<string[]>;
|
||||
repoBranches(namespace: string, project: string): Promise<GitLabBranchesResult>;
|
||||
}
|
||||
|
||||
export interface RemoteClientRecord {
|
||||
|
||||
@@ -3011,6 +3011,7 @@ export const dict = {
|
||||
'contextPanel.gitlabMr.createMr.removeSourceBranch': 'Quellbranch nach dem Zusammenführen löschen',
|
||||
'contextPanel.gitlabMr.createMr.submit': 'Merge-Request erstellen',
|
||||
'contextPanel.gitlabMr.createMr.submitting': 'Wird erstellt...',
|
||||
'contextPanel.gitlabMr.createMr.branchesLoading': 'Branches werden geladen...',
|
||||
'contextPanel.gitlabMr.createMr.toast.created': 'Merge-Request erstellt',
|
||||
'contextPanel.gitlabMr.createMr.toast.createFailed': 'Merge-Request konnte nicht erstellt werden',
|
||||
'contextPanel.gitlabMr.updateMr.toggle': 'Titel und Beschreibung bearbeiten',
|
||||
|
||||
@@ -1148,6 +1148,7 @@ export const dict = {
|
||||
'contextPanel.gitlabMr.createMr.removeSourceBranch': 'Remove source branch on merge',
|
||||
'contextPanel.gitlabMr.createMr.submit': 'Create merge request',
|
||||
'contextPanel.gitlabMr.createMr.submitting': 'Creating...',
|
||||
'contextPanel.gitlabMr.createMr.branchesLoading': 'Loading branches...',
|
||||
'contextPanel.gitlabMr.createMr.toast.created': 'Merge request created',
|
||||
'contextPanel.gitlabMr.createMr.toast.createFailed': 'Failed to create merge request',
|
||||
'contextPanel.gitlabMr.updateMr.toggle': 'Edit title & description',
|
||||
|
||||
@@ -1149,6 +1149,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
"contextPanel.gitlabMr.createMr.removeSourceBranch": "Eliminar la rama de origen al fusionar",
|
||||
"contextPanel.gitlabMr.createMr.submit": "Crear solicitud de fusión",
|
||||
"contextPanel.gitlabMr.createMr.submitting": "Creando...",
|
||||
"contextPanel.gitlabMr.createMr.branchesLoading": "Cargando ramas...",
|
||||
"contextPanel.gitlabMr.createMr.toast.created": "Solicitud de fusión creada",
|
||||
"contextPanel.gitlabMr.createMr.toast.createFailed": "No se pudo crear la solicitud de fusión",
|
||||
"contextPanel.gitlabMr.updateMr.toggle": "Editar título y descripción",
|
||||
|
||||
@@ -968,6 +968,7 @@ export const dict = {
|
||||
'contextPanel.gitlabMr.createMr.removeSourceBranch': 'Supprimer la branche source lors de la fusion',
|
||||
'contextPanel.gitlabMr.createMr.submit': 'Créer une demande de fusion',
|
||||
'contextPanel.gitlabMr.createMr.submitting': 'Création...',
|
||||
'contextPanel.gitlabMr.createMr.branchesLoading': 'Chargement des branches...',
|
||||
'contextPanel.gitlabMr.createMr.toast.created': 'Demande de fusion créée',
|
||||
'contextPanel.gitlabMr.createMr.toast.createFailed': 'Échec de la création de la demande de fusion',
|
||||
'contextPanel.gitlabMr.updateMr.toggle': 'Modifier le titre et la description',
|
||||
|
||||
@@ -1145,6 +1145,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'contextPanel.gitlabMr.createMr.removeSourceBranch': 'マージ時にソースブランチを削除する',
|
||||
'contextPanel.gitlabMr.createMr.submit': 'マージリクエストを作成',
|
||||
'contextPanel.gitlabMr.createMr.submitting': '作成中...',
|
||||
'contextPanel.gitlabMr.createMr.branchesLoading': 'ブランチを読み込み中...',
|
||||
'contextPanel.gitlabMr.createMr.toast.created': 'マージリクエストを作成しました',
|
||||
'contextPanel.gitlabMr.createMr.toast.createFailed': 'マージリクエストの作成に失敗しました',
|
||||
'contextPanel.gitlabMr.updateMr.toggle': 'タイトルと説明を編集',
|
||||
|
||||
@@ -1149,6 +1149,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'contextPanel.gitlabMr.createMr.removeSourceBranch': '병합 시 소스 브랜치 삭제',
|
||||
'contextPanel.gitlabMr.createMr.submit': '병합 요청 만들기',
|
||||
'contextPanel.gitlabMr.createMr.submitting': '만드는 중...',
|
||||
'contextPanel.gitlabMr.createMr.branchesLoading': '브랜치 불러오는 중...',
|
||||
'contextPanel.gitlabMr.createMr.toast.created': '병합 요청이 생성되었습니다',
|
||||
'contextPanel.gitlabMr.createMr.toast.createFailed': '병합 요청 생성에 실패했습니다',
|
||||
'contextPanel.gitlabMr.updateMr.toggle': '제목 및 설명 편집',
|
||||
|
||||
@@ -1486,6 +1486,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'contextPanel.gitlabMr.createMr.removeSourceBranch': 'Usuń gałąź źródłową po scaleniu',
|
||||
'contextPanel.gitlabMr.createMr.submit': 'Utwórz żądanie scalenia',
|
||||
'contextPanel.gitlabMr.createMr.submitting': 'Tworzenie...',
|
||||
'contextPanel.gitlabMr.createMr.branchesLoading': 'Wczytywanie gałęzi...',
|
||||
'contextPanel.gitlabMr.createMr.toast.created': 'Utworzono żądanie scalenia',
|
||||
'contextPanel.gitlabMr.createMr.toast.createFailed': 'Nie udało się utworzyć żądania scalenia',
|
||||
'contextPanel.gitlabMr.updateMr.toggle': 'Edytuj tytuł i opis',
|
||||
|
||||
@@ -1149,6 +1149,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
"contextPanel.gitlabMr.createMr.removeSourceBranch": "Remover branch de origem ao mesclar",
|
||||
"contextPanel.gitlabMr.createMr.submit": "Criar solicitação de merge",
|
||||
"contextPanel.gitlabMr.createMr.submitting": "Criando...",
|
||||
"contextPanel.gitlabMr.createMr.branchesLoading": "Carregando branches...",
|
||||
"contextPanel.gitlabMr.createMr.toast.created": "Solicitação de merge criada",
|
||||
"contextPanel.gitlabMr.createMr.toast.createFailed": "Falha ao criar solicitação de merge",
|
||||
"contextPanel.gitlabMr.updateMr.toggle": "Editar título e descrição",
|
||||
|
||||
@@ -1149,6 +1149,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
"contextPanel.gitlabMr.createMr.removeSourceBranch": "Видалити вихідну гілку після злиття",
|
||||
"contextPanel.gitlabMr.createMr.submit": "Створити запит на злиття",
|
||||
"contextPanel.gitlabMr.createMr.submitting": "Створення...",
|
||||
"contextPanel.gitlabMr.createMr.branchesLoading": "Завантаження гілок...",
|
||||
"contextPanel.gitlabMr.createMr.toast.created": "Запит на злиття створено",
|
||||
"contextPanel.gitlabMr.createMr.toast.createFailed": "Не вдалося створити запит на злиття",
|
||||
"contextPanel.gitlabMr.updateMr.toggle": "Редагувати назву та опис",
|
||||
|
||||
@@ -1149,6 +1149,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'contextPanel.gitlabMr.createMr.removeSourceBranch': '合并后删除源分支',
|
||||
'contextPanel.gitlabMr.createMr.submit': '创建合并请求',
|
||||
'contextPanel.gitlabMr.createMr.submitting': '创建中...',
|
||||
'contextPanel.gitlabMr.createMr.branchesLoading': '正在加载分支...',
|
||||
'contextPanel.gitlabMr.createMr.toast.created': '合并请求已创建',
|
||||
'contextPanel.gitlabMr.createMr.toast.createFailed': '创建合并请求失败',
|
||||
'contextPanel.gitlabMr.updateMr.toggle': '编辑标题和描述',
|
||||
|
||||
@@ -1161,6 +1161,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'contextPanel.gitlabMr.createMr.removeSourceBranch': '合併後刪除來源分支',
|
||||
'contextPanel.gitlabMr.createMr.submit': '建立合併請求',
|
||||
'contextPanel.gitlabMr.createMr.submitting': '建立中...',
|
||||
'contextPanel.gitlabMr.createMr.branchesLoading': '正在載入分支...',
|
||||
'contextPanel.gitlabMr.createMr.toast.created': '合併請求已建立',
|
||||
'contextPanel.gitlabMr.createMr.toast.createFailed': '建立合併請求失敗',
|
||||
'contextPanel.gitlabMr.updateMr.toggle': '編輯標題與描述',
|
||||
|
||||
@@ -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`:
|
||||
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user