import fs from 'fs'; import os from 'os'; import path from 'path'; import { afterAll, afterEach, 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-gitea-routes-')); process.env.OPENCHAMBER_DATA_DIR = TEMP_DATA_DIR; // Resolve a fake git remote so directory-based repo resolution finds a Gitea // repo without touching the real filesystem/git. vi.mock('../git/index.js', () => ({ getRemoteUrl: vi.fn(async () => 'git@gitea.example.com:owner/repo.git'), })); const { registerGiteaRoutes } = await import('./routes.js'); const { setGiteaAuth, clearGiteaAuth, getGiteaAuth, getGiteaAuthAccounts, GITEA_AUTH_FILE } = await import('./index.js'); afterAll(() => { fs.rmSync(TEMP_DATA_DIR, { recursive: true, force: true }); }); // clearGiteaAuth 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(GITEA_AUTH_FILE)) { fs.unlinkSync(GITEA_AUTH_FILE); } }; const aliceUser = { id: 42, login: 'alice', full_name: 'Alice Example', avatar_url: 'https://gitea.example.com/avatars/alice.png', html_url: 'https://gitea.example.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()); registerGiteaRoutes(app); return app; }; describe('Gitea auth routes', () => { beforeEach(() => { resetAuthFile(); vi.restoreAllMocks(); delete globalThis.fetch; }); test('auth/status returns disconnected with no default base URL', async () => { const app = createApp(); const response = await request(app).get('/api/gitea/auth/status'); expect(response.status).toBe(200); expect(response.body).toEqual({ connected: false, accounts: [], }); expect(response.body.defaultBaseUrl).toBeUndefined(); }); test('auth/connect validates the token, stores the account, and reports connected', async () => { scriptedFetch([(url) => (matches(/\/api\/v1\/user$/)(url) ? jsonResponse(aliceUser) : null)]); const app = createApp(); const response = await request(app) .post('/api/gitea/auth/connect') .send({ accessToken: 'gitea-valid', baseUrl: 'https://gitea.example.com' }); expect(response.status).toBe(200); expect(response.body).toMatchObject({ connected: true, user: { username: 'alice', id: 42, name: 'Alice Example', avatarUrl: 'https://gitea.example.com/avatars/alice.png', webUrl: 'https://gitea.example.com/alice', email: 'alice@example.com' }, }); expect(response.body.accounts).toEqual([ { id: 'gitea.example.com:alice', user: { username: 'alice', name: 'Alice Example', avatarUrl: 'https://gitea.example.com/avatars/alice.png', webUrl: 'https://gitea.example.com/alice' }, baseUrl: 'https://gitea.example.com', current: true }, ]); expect(getGiteaAuth()?.accessToken).toBe('gitea-valid'); }); test('auth/connect rejects an invalid token with 400', async () => { scriptedFetch([(url) => (matches(/\/api\/v1\/user$/)(url) ? jsonResponse({ message: '401 Unauthorized' }, { status: 401 }) : null)]); const app = createApp(); const response = await request(app) .post('/api/gitea/auth/connect') .send({ accessToken: 'gitea-invalid', baseUrl: 'https://gitea.example.com' }); expect(response.status).toBe(400); expect(response.body).toEqual({ error: 'Invalid Gitea access token' }); expect(getGiteaAuth()).toBeNull(); }); test('auth/connect requires an access token', async () => { const app = createApp(); const response = await request(app) .post('/api/gitea/auth/connect') .send({ baseUrl: 'https://gitea.example.com' }); expect(response.status).toBe(400); expect(response.body).toEqual({ error: 'accessToken is required' }); }); test('auth/connect uses the built-in default base URL when none is provided', async () => { const fetchMock = scriptedFetch([(url) => (matches(/\/api\/v1\/user$/)(url) ? jsonResponse(aliceUser) : null)]); const app = createApp(); const response = await request(app).post('/api/gitea/auth/connect').send({ accessToken: 'gitea-valid' }); expect(fetchMock.mock.calls[0][0]).toBe('https://codeberg.org/api/v1/user'); expect(response.status).toBe(200); expect(response.body).toMatchObject({ connected: true, user: { username: 'alice', id: 42, name: 'Alice Example', avatarUrl: 'https://gitea.example.com/avatars/alice.png', webUrl: 'https://gitea.example.com/alice', email: 'alice@example.com' }, }); expect(response.body.accounts).toEqual([ { id: 'codeberg.org:alice', user: { username: 'alice', name: 'Alice Example', avatarUrl: 'https://gitea.example.com/avatars/alice.png', webUrl: 'https://gitea.example.com/alice' }, baseUrl: 'https://codeberg.org', current: true }, ]); }); test('auth/connect normalizes a scheme-less base URL', async () => { const fetchMock = scriptedFetch([(url) => (matches(/\/api\/v1\/user$/)(url) ? jsonResponse(aliceUser) : null)]); const app = createApp(); await request(app) .post('/api/gitea/auth/connect') .send({ accessToken: 'gitea-valid', baseUrl: 'gitea.example.com' }); expect(fetchMock.mock.calls[0][0]).toBe('https://gitea.example.com/api/v1/user'); }); test('auth/status reports connected with the live user', async () => { setGiteaAuth({ accessToken: 'gitea-a', baseUrl: 'gitea.example.com', user: aliceUser }); scriptedFetch([(url) => (matches(/\/api\/v1\/user$/)(url) ? jsonResponse(aliceUser) : null)]); const app = createApp(); const response = await request(app).get('/api/gitea/auth/status'); expect(response.status).toBe(200); expect(response.body).toMatchObject({ connected: true, user: { username: 'alice', id: 42 }, }); expect(response.body.accounts).toEqual([ { id: 'gitea.example.com:alice', user: { username: 'alice', name: 'Alice Example', avatarUrl: 'https://gitea.example.com/avatars/alice.png', webUrl: 'https://gitea.example.com/alice' }, baseUrl: 'https://gitea.example.com', current: true }, ]); }); test('auth/activate returns 404 for an unknown account', async () => { const app = createApp(); const response = await request(app).post('/api/gitea/auth/activate').send({ accountId: 'gitea.example.com:nobody' }); expect(response.status).toBe(404); expect(response.body).toEqual({ error: 'Gitea account not found' }); }); test('auth/activate switches the current account', async () => { setGiteaAuth({ accessToken: 'gitea-a', baseUrl: 'gitea.example.com', user: aliceUser }); setGiteaAuth({ accessToken: 'gitea-b', baseUrl: 'https://gitea.other.example', user: { ...aliceUser, login: 'bob', full_name: 'Bob' }, }); scriptedFetch([(url) => (matches(/\/api\/v1\/user$/)(url) ? jsonResponse(aliceUser) : null)]); const app = createApp(); const response = await request(app).post('/api/gitea/auth/activate').send({ accountId: 'gitea.example.com:alice' }); expect(response.status).toBe(200); expect(response.body.connected).toBe(true); expect(response.body.accounts.find((a) => a.id === 'gitea.example.com:alice')?.current).toBe(true); expect(getGiteaAuth()?.accountId).toBe('gitea.example.com:alice'); }); test('DELETE /api/gitea/auth clears the account', async () => { setGiteaAuth({ accessToken: 'gitea-a', baseUrl: 'gitea.example.com', user: aliceUser }); const app = createApp(); const response = await request(app).delete('/api/gitea/auth'); expect(response.status).toBe(200); expect(response.body).toEqual({ removed: true }); expect(getGiteaAuth()).toBeNull(); }); test('me returns 401 when not connected', async () => { const app = createApp(); const response = await request(app).get('/api/gitea/me'); expect(response.status).toBe(401); expect(response.body).toEqual({ error: 'Gitea not connected' }); }); test('me returns the connected user', async () => { setGiteaAuth({ accessToken: 'gitea-a', baseUrl: 'gitea.example.com', user: aliceUser }); scriptedFetch([(url) => (matches(/\/api\/v1\/user$/)(url) ? jsonResponse(aliceUser) : null)]); const app = createApp(); const response = await request(app).get('/api/gitea/me'); expect(response.status).toBe(200); expect(response.body).toEqual({ username: 'alice', id: 42, name: 'Alice Example', avatarUrl: 'https://gitea.example.com/avatars/alice.png', webUrl: 'https://gitea.example.com/alice', email: 'alice@example.com', }); }); }); describe('Gitea configured default base URL', () => { const SETTINGS_FILE = path.join(TEMP_DATA_DIR, 'settings.json'); afterEach(() => { if (fs.existsSync(SETTINGS_FILE)) { fs.unlinkSync(SETTINGS_FILE); } }); test('auth/status reports the configured defaultBaseUrl', async () => { fs.writeFileSync(SETTINGS_FILE, JSON.stringify({ gitProviders: { gitea: { apiBaseUrl: 'https://gitea.example.com' } }, })); setGiteaAuth({ accessToken: 'gitea-a', baseUrl: 'https://gitea.example.com', user: aliceUser }); scriptedFetch([(url) => (matches(/\/api\/v1\/user$/)(url) ? jsonResponse(aliceUser) : null)]); const app = createApp(); const response = await request(app).get('/api/gitea/auth/status'); expect(response.status).toBe(200); expect(response.body.connected).toBe(true); expect(response.body.defaultBaseUrl).toBe('https://gitea.example.com'); }); test('auth/connect falls back to the configured default base URL when baseUrl is blank', async () => { fs.writeFileSync(SETTINGS_FILE, JSON.stringify({ gitProviders: { gitea: { apiBaseUrl: 'https://gitea.example.com' } }, })); const fetchMock = scriptedFetch([(url) => (matches(/\/api\/v1\/user$/)(url) ? jsonResponse(aliceUser) : null)]); const app = createApp(); const response = await request(app) .post('/api/gitea/auth/connect') .send({ accessToken: 'gitea-valid' }); expect(response.status).toBe(200); expect(response.body).toMatchObject({ connected: true }); expect(fetchMock.mock.calls[0][0]).toBe('https://gitea.example.com/api/v1/user'); expect(getGiteaAuth()?.baseUrl).toBe('https://gitea.example.com'); }); }); describe('Gitea data routes', () => { beforeEach(() => { resetAuthFile(); setGiteaAuth({ accessToken: 'gitea-a', baseUrl: 'gitea.example.com', user: aliceUser }); vi.restoreAllMocks(); delete globalThis.fetch; }); test('issues/list returns mapped issues with pagination info', async () => { scriptedFetch([ (url) => (matches(/\/api\/v1\/repos\/owner\/repo\/issues\?/)(url) ? jsonResponse( [ { number: 3, title: 'Fix the widget', html_url: 'https://gitea.example.com/owner/repo/issues/3', state: 'open', user: { id: 42, login: 'alice', full_name: 'Alice Example', avatar_url: 'https://gitea.example.com/alice.png' }, labels: [{ id: 1, name: 'bug' }, { id: 2, name: 'priority:high' }], }, ], { headers: { link: '; rel="next"' } }, ) : null), ]); const app = createApp(); const response = await request(app).get('/api/gitea/issues/list?directory=%2Ftmp%2Fwork&page=1'); expect(response.status).toBe(200); expect(response.body).toMatchObject({ connected: true, repo: { owner: 'owner', repo: 'repo', host: 'gitea.example.com', url: 'https://gitea.example.com/owner/repo' }, issues: [ { number: 3, title: 'Fix the widget', url: 'https://gitea.example.com/owner/repo/issues/3', state: 'open', author: { username: 'alice', id: 42 }, labels: ['bug', 'priority:high'], }, ], page: 1, hasMore: true, }); }); test('issues/list sends the search query, open state, and type=issues', async () => { const fetchMock = scriptedFetch([(url) => (matches(/\/issues\?/)(url) ? jsonResponse([]) : null)]); const app = createApp(); await request(app).get('/api/gitea/issues/list?directory=%2Ftmp%2Fwork&query=login'); const requestedUrl = String(fetchMock.mock.calls[0][0]); expect(requestedUrl).toContain('state=open'); expect(requestedUrl).toContain('type=issues'); expect(requestedUrl).toContain('q=login'); expect(requestedUrl).toContain('limit=50'); }); test('issues/list skips entries carrying a pull_request field', async () => { scriptedFetch([ (url) => (matches(/\/issues\?/)(url) ? jsonResponse([ { number: 1, title: 'An issue', html_url: 'u', state: 'open', user: { login: 'alice' }, labels: [] }, { number: 2, title: 'A pull request', html_url: 'u', state: 'open', user: { login: 'alice' }, labels: [], pull_request: { number: 2 } }, ]) : null), ]); const app = createApp(); const response = await request(app).get('/api/gitea/issues/list?directory=%2Ftmp%2Fwork'); expect(response.status).toBe(200); expect(response.body.issues).toHaveLength(1); expect(response.body.issues[0].number).toBe(1); }); test('issues/list honors owner/repo query params as an override', async () => { const fetchMock = scriptedFetch([(url) => (matches(/\/issues\?/)(url) ? jsonResponse([]) : null)]); const app = createApp(); await request(app).get('/api/gitea/issues/list?directory=%2Ftmp%2Fwork&owner=acme&repo=widgets'); const requestedUrl = String(fetchMock.mock.calls[0][0]); expect(requestedUrl).toContain('/repos/acme/widgets/issues'); }); test('issues/list reports connected:false when not authenticated', async () => { clearGiteaAuth(); const app = createApp(); const response = await request(app).get('/api/gitea/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({ number: 7, title: 'Broken import', html_url: 'https://gitea.example.com/owner/repo/issues/7', state: 'open', body: 'It breaks at startup', created_at: '2026-01-01T10:00:00Z', updated_at: '2026-01-02T10:00:00Z', user: { id: 42, login: 'alice', full_name: 'Alice Example' }, labels: [{ id: 1, name: 'bug' }], }) : null), ]); const app = createApp(); const response = await request(app).get('/api/gitea/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: 'open', body: 'It breaks at startup', createdAt: '2026-01-01T10:00:00Z', updatedAt: '2026-01-02T10:00:00Z', author: { username: 'alice', id: 42 }, 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/gitea/issues/get?directory=%2Ftmp%2Fwork&number=999'); expect(response.status).toBe(404); expect(response.body).toEqual({ error: 'Issue not found' }); }); test('issues/comments maps Gitea comments', async () => { scriptedFetch([ (url) => (matches(/\/issues\/7\/comments\?/)(url) ? jsonResponse([ { id: 2, html_url: 'https://gitea.example.com/owner/repo/issues/7#issuecomment-2', body: 'Looks good to me', user: { id: 42, login: 'alice', full_name: 'Alice Example' }, created_at: '2026-01-01T01:00:00Z', }, ]) : null), ]); const app = createApp(); const response = await request(app).get('/api/gitea/issues/comments?directory=%2Ftmp%2Fwork&number=7'); expect(response.status).toBe(200); expect(response.body.comments).toEqual([ { id: 2, body: 'Looks good to me', url: 'https://gitea.example.com/owner/repo/issues/7#issuecomment-2', author: { username: 'alice', id: 42 }, createdAt: '2026-01-01T01:00:00Z', }, ]); }); test('prs/list returns mapped pull requests with open state by default', async () => { scriptedFetch([ (url) => (matches(/\/pulls\?/)(url) ? jsonResponse([ { number: 9, title: 'Add the API', html_url: 'https://gitea.example.com/owner/repo/pulls/9', state: 'open', merged: false, draft: false, user: { id: 42, login: 'alice', full_name: 'Alice Example' }, labels: [{ id: 1, name: 'feature' }], head: { ref: 'feat/api' }, base: { ref: 'main' }, }, ]) : null), ]); const app = createApp(); const response = await request(app).get('/api/gitea/prs/list?directory=%2Ftmp%2Fwork'); expect(response.status).toBe(200); expect(response.body).toMatchObject({ connected: true, prs: [ { number: 9, title: 'Add the API', state: 'open', draft: false, author: { username: 'alice', id: 42 }, labels: ['feature'], sourceBranch: 'feat/api', targetBranch: 'main', }, ], page: 1, hasMore: false, }); }); test('prs/list maps merged and closed states correctly', async () => { scriptedFetch([ (url) => (matches(/\/pulls\?/)(url) ? jsonResponse([ { number: 1, title: 'Merged PR', html_url: 'u', state: 'closed', merged: true, draft: false, user: { login: 'alice' }, labels: [], head: { ref: 'a' }, base: { ref: 'main' } }, { number: 2, title: 'Closed PR', html_url: 'u', state: 'closed', merged: false, draft: false, user: { login: 'alice' }, labels: [], head: { ref: 'b' }, base: { ref: 'main' } }, { number: 3, title: 'Open PR', html_url: 'u', state: 'open', merged: false, draft: true, user: { login: 'alice' }, labels: [], head: { ref: 'c' }, base: { ref: 'main' } }, ]) : null), ]); const app = createApp(); const response = await request(app).get('/api/gitea/prs/list?directory=%2Ftmp%2Fwork'); const prs = response.body.prs; expect(prs[0]).toMatchObject({ number: 1, state: 'merged' }); expect(prs[1]).toMatchObject({ number: 2, state: 'closed' }); expect(prs[2]).toMatchObject({ number: 3, state: 'open', draft: true }); }); test('prs/list with sourceBranch requests all states and filters by head.ref', async () => { const fetchMock = scriptedFetch([ (url) => (matches(/\/pulls\?/)(url) ? jsonResponse([ { number: 5, title: 'Open PR for feat/api', html_url: 'u', state: 'open', merged: false, draft: false, user: { login: 'alice' }, labels: [], head: { ref: 'feat/api' }, base: { ref: 'main' }, }, { number: 6, title: 'Merged PR for feat/api', html_url: 'u', state: 'closed', merged: true, draft: false, user: { login: 'alice' }, labels: [], head: { ref: 'feat/api' }, base: { ref: 'main' }, }, { number: 7, title: 'PR for another branch', html_url: 'u', state: 'open', merged: false, draft: false, user: { login: 'alice' }, labels: [], head: { ref: 'feat/other' }, base: { ref: 'main' }, }, ]) : null), ]); const app = createApp(); const response = await request(app).get('/api/gitea/prs/list?directory=%2Ftmp%2Fwork&sourceBranch=feat%2Fapi'); expect(response.status).toBe(200); const requestedUrl = String(fetchMock.mock.calls[0][0]); expect(requestedUrl).toContain('state=all'); const prs = response.body.prs; expect(prs).toHaveLength(2); expect(prs.find((pr) => pr.number === 5)).toMatchObject({ state: 'open', sourceBranch: 'feat/api' }); expect(prs.find((pr) => pr.number === 6)).toMatchObject({ state: 'merged', sourceBranch: 'feat/api' }); }); test('prs/list omits the source branch filter when not provided', async () => { const fetchMock = scriptedFetch([(url) => (matches(/\/pulls\?/)(url) ? jsonResponse([]) : null)]); const app = createApp(); await request(app).get('/api/gitea/prs/list?directory=%2Ftmp%2Fwork'); const requestedUrl = String(fetchMock.mock.calls[0][0]); expect(requestedUrl).not.toContain('state=all'); expect(requestedUrl).toContain('state=open'); }); test('pr/context returns pr, comments, files, and a raw diff', async () => { scriptedFetch([ (url) => (matches(/\/pulls\/9$/)(url) ? jsonResponse({ number: 9, title: 'Add the API', html_url: 'https://gitea.example.com/owner/repo/pulls/9', state: 'open', merged: false, draft: false, body: 'Adds the public API', created_at: '2026-01-01T10:00:00Z', updated_at: '2026-01-02T10:00:00Z', mergeable: true, user: { id: 42, login: 'alice', full_name: 'Alice Example' }, labels: [{ id: 1, name: 'feature' }], head: { ref: 'feat/api' }, base: { ref: 'main' }, }) : null), (url) => (matches(/\/issues\/9\/comments\?/)(url) ? jsonResponse([ { id: 11, html_url: 'u', body: 'LGTM', user: { id: 43, login: 'bob', full_name: 'Bob' }, created_at: '2026-01-02T11:00:00Z' }, ]) : null), (url) => (matches(/\/pulls\/9\/files\?/)(url) ? jsonResponse([ { Filename: 'src/a.ts', Status: 'modified', Additions: 1, Deletions: 1, Patch: '--- a/src/a.ts\n+++ b/src/a.ts\n@@ -1,3 +1,4 @@\n+export const added = 1\n-export const old = 2\n', }, { Filename: 'src/new.ts', Status: 'added', Additions: 2, Deletions: 0, Patch: '--- a/src/new.ts\n+++ b/src/new.ts\n@@ -0,0 +1,2 @@\n+line one\n+line two\n', }, ]) : null), (url) => (matches(/\/pulls\/9\.diff$/)(url) ? new Response('diff --git a/src/a.ts b/src/a.ts\n@@ -1,3 +1,4 @@\n+export const added = 1\n', { status: 200 }) : null), ]); const app = createApp(); const response = await request(app).get('/api/gitea/pr/context?directory=%2Ftmp%2Fwork&number=9&includeDiff=1'); expect(response.status).toBe(200); expect(response.body).toMatchObject({ connected: true, pr: { number: 9, title: 'Add the API', state: 'open', draft: false, body: 'Adds the public API', mergeable: true, merged: false, sourceBranch: 'feat/api', targetBranch: 'main', }, comments: [{ id: 11, body: 'LGTM', author: { username: 'bob', id: 43 } }], files: [ { filename: 'src/a.ts', status: 'modified', additions: 1, deletions: 1 }, { filename: 'src/new.ts', status: 'added', additions: 2, deletions: 0 }, ], }); expect(response.body.diff).toContain('export const added = 1'); }); test('pr/context falls back to empty files when the files endpoint 404s', async () => { scriptedFetch([ (url) => (matches(/\/pulls\/9$/)(url) ? jsonResponse({ number: 9, title: 'T', html_url: 'u', state: 'open', merged: false, draft: false, user: { login: 'alice' }, head: { ref: 'a' }, base: { ref: 'main' } }) : null), (url) => (matches(/\/issues\/9\/comments\?/)(url) ? jsonResponse([]) : null), (url) => (matches(/\/pulls\/9\/files\?/)(url) ? jsonResponse({ message: 'Not Found' }, { status: 404 }) : null), ]); const app = createApp(); const response = await request(app).get('/api/gitea/pr/context?directory=%2Ftmp%2Fwork&number=9'); expect(response.status).toBe(200); expect(response.body.files).toEqual([]); }); test('repo/branches returns branch names and the default branch from the repo', async () => { scriptedFetch([ (url) => (matches(/\/repos\/owner\/repo\/branches\?/)(url) ? jsonResponse([{ name: 'main' }, { name: 'feat/api' }]) : null), (url) => (matches(/\/api\/v1\/repos\/owner\/repo$/)(url) ? jsonResponse({ id: 1, full_name: 'owner/repo', default_branch: 'main' }) : null), ]); const app = createApp(); const response = await request(app).get('/api/gitea/repo/branches?owner=owner&repo=repo'); expect(response.status).toBe(200); expect(response.body).toEqual({ branches: ['main', 'feat/api'], defaultBranch: 'main' }); }); test('repo/branches returns null defaultBranch when disconnected', async () => { clearGiteaAuth(); const app = createApp(); const response = await request(app).get('/api/gitea/repo/branches?owner=owner&repo=repo'); expect(response.status).toBe(200); expect(response.body).toEqual({ branches: [], defaultBranch: null }); }); test('repo/branches requires owner and repo', async () => { const app = createApp(); const response = await request(app).get('/api/gitea/repo/branches?owner=owner'); expect(response.status).toBe(400); expect(response.body).toEqual({ error: 'owner and repo are required' }); }); test('pr/create POSTs title/head/base/body and returns the created PR', async () => { const createdPr = { number: 12, title: 'Add feature', html_url: 'https://gitea.example.com/owner/repo/pulls/12', state: 'open', merged: false, draft: false, body: 'Adds the feature', user: { id: 42, login: 'alice', full_name: 'Alice Example' }, head: { ref: 'feat/add' }, base: { ref: 'main' }, }; const fetchMock = scriptedFetch([ (url, options) => { if (matches(/\/pulls$/)(url) && options.method === 'POST') { return jsonResponse(createdPr, { status: 201 }); } return null; }, ]); const app = createApp(); const response = await request(app) .post('/api/gitea/pr/create') .send({ directory: '/tmp/work', title: 'Add feature', sourceBranch: 'feat/add', targetBranch: 'main', description: 'Adds the feature', }); expect(response.status).toBe(200); expect(response.body).toMatchObject({ connected: true, repo: { owner: 'owner', repo: 'repo', host: 'gitea.example.com' }, pr: { number: 12, title: 'Add feature', url: 'https://gitea.example.com/owner/repo/pulls/12', state: 'open', draft: false, author: { username: 'alice', id: 42 }, sourceBranch: 'feat/add', targetBranch: 'main', }, }); const [, options] = fetchMock.mock.calls[0]; expect(options.method).toBe('POST'); expect(JSON.parse(options.body)).toEqual({ title: 'Add feature', head: 'feat/add', base: 'main', body: 'Adds the feature', }); }); test('pr/create omits body when no description is given', async () => { const fetchMock = scriptedFetch([ (url, options) => { if (matches(/\/pulls$/)(url) && options.method === 'POST') { return jsonResponse( { number: 1, title: 'T', html_url: 'u', state: 'open', merged: false, draft: false, user: { login: 'alice' }, head: { ref: 's' }, base: { ref: 'm' } }, { status: 201 }, ); } return null; }, ]); const app = createApp(); await request(app) .post('/api/gitea/pr/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({ title: 'T', head: 's', base: 'm' }); expect(body.body).toBeUndefined(); }); test('pr/create rejects missing fields with 400', async () => { const app = createApp(); const response = await request(app) .post('/api/gitea/pr/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('pr/create reports connected:false when not authenticated', async () => { clearGiteaAuth(); const app = createApp(); const response = await request(app) .post('/api/gitea/pr/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('pr/create surfaces a 403 as a scope error', async () => { scriptedFetch([ (url, options) => { if (matches(/\/pulls$/)(url) && options.method === 'POST') { return jsonResponse({ message: '403 Forbidden' }, { status: 403 }); } return null; }, ]); const app = createApp(); const response = await request(app) .post('/api/gitea/pr/create') .send({ directory: '/tmp/work', title: 'Add feature', sourceBranch: 'feat/add', targetBranch: 'main' }); expect(response.status).toBe(400); expect(response.body).toEqual({ error: 'Your Gitea token needs write:repository scope to create pull requests' }); }); test('pr/update PATCHes title/body and returns the updated PR', async () => { const fetchMock = scriptedFetch([ (url, options) => { if (matches(/\/pulls\/12$/)(url) && options.method === 'PATCH') { return jsonResponse({ number: 12, title: 'Updated title', html_url: 'u', state: 'open', merged: false, draft: false, user: { id: 42, login: 'alice' }, head: { ref: 'feat/add' }, base: { ref: 'main' }, }); } return null; }, ]); const app = createApp(); const response = await request(app) .patch('/api/gitea/pr/update') .send({ directory: '/tmp/work', number: 12, title: 'Updated title', description: 'New body' }); expect(response.status).toBe(200); expect(response.body).toMatchObject({ connected: true, pr: { number: 12, title: 'Updated title', state: 'open', sourceBranch: 'feat/add', targetBranch: 'main' }, }); const [, options] = fetchMock.mock.calls[0]; expect(options.method).toBe('PATCH'); expect(JSON.parse(options.body)).toEqual({ title: 'Updated title', body: 'New body' }); }); test('pr/update omits title/body when not provided', async () => { const fetchMock = scriptedFetch([ (url, options) => { if (matches(/\/pulls\/12$/)(url) && options.method === 'PATCH') { return jsonResponse({ number: 12, title: 'T', html_url: 'u', state: 'open', merged: false, draft: false, user: { login: 'alice' }, head: { ref: 's' }, base: { ref: 'm' }, }); } return null; }, ]); const app = createApp(); await request(app).patch('/api/gitea/pr/update').send({ directory: '/tmp/work', number: 12 }); const [, options] = fetchMock.mock.calls[0]; expect(JSON.parse(options.body)).toEqual({}); }); test('pr/update returns 404 for a missing pull request', async () => { scriptedFetch([ (url, options) => { if (matches(/\/pulls\/999$/)(url) && options.method === 'PATCH') { return jsonResponse({ message: '404 Not Found' }, { status: 404 }); } return null; }, ]); const app = createApp(); const response = await request(app) .patch('/api/gitea/pr/update') .send({ directory: '/tmp/work', number: 999, title: 'x' }); expect(response.status).toBe(404); expect(response.body).toEqual({ error: 'Pull request not found' }); }); test('pr/merge POSTs the merge style in Do and reports merged:true', async () => { const fetchMock = scriptedFetch([ (url, options) => { if (matches(/\/pulls\/12\/merge$/)(url) && options.method === 'POST') { return jsonResponse({ merged: true, message: 'pull request was merged' }); } return null; }, ]); const app = createApp(); const response = await request(app) .post('/api/gitea/pr/merge') .send({ directory: '/tmp/work', number: 12 }); expect(response.status).toBe(200); expect(response.body).toEqual({ connected: true, merged: true }); const [, options] = fetchMock.mock.calls[0]; expect(options.method).toBe('POST'); expect(JSON.parse(options.body)).toEqual({ Do: 'merge' }); }); test('pr/merge maps the method to Do', async () => { const fetchMock = scriptedFetch([ (url, options) => { if (matches(/\/pulls\/12\/merge$/)(url) && options.method === 'POST') { return jsonResponse({ merged: true }); } return null; }, ]); const app = createApp(); await request(app) .post('/api/gitea/pr/merge') .send({ directory: '/tmp/work', number: 12, method: 'squash' }); const [, options] = fetchMock.mock.calls[0]; expect(JSON.parse(options.body)).toEqual({ Do: 'squash' }); }); test('pr/merge passes through a Gitea merge rejection as merged:false', async () => { scriptedFetch([ (url, options) => { if (matches(/\/pulls\/12\/merge$/)(url) && options.method === 'POST') { return jsonResponse({ message: 'This PR is already merged' }, { status: 409 }); } return null; }, ]); const app = createApp(); const response = await request(app) .post('/api/gitea/pr/merge') .send({ directory: '/tmp/work', number: 12 }); expect(response.status).toBe(409); expect(response.body).toEqual({ connected: true, merged: false, message: 'This PR is already merged', }); }); test('pr/merge surfaces a 403 as a scope error', async () => { scriptedFetch([ (url, options) => { if (matches(/\/pulls\/12\/merge$/)(url) && options.method === 'POST') { return jsonResponse({ message: '403 Forbidden' }, { status: 403 }); } return null; }, ]); const app = createApp(); const response = await request(app) .post('/api/gitea/pr/merge') .send({ directory: '/tmp/work', number: 12 }); expect(response.status).toBe(400); expect(response.body).toEqual({ error: 'Your Gitea token needs write:repository scope to merge pull requests' }); }); test('prs/commits maps pull request commits with summaries', async () => { scriptedFetch([ (url) => (matches(/\/pulls\/9\/commits\?/)(url) ? jsonResponse([ { sha: 'abc123def4567890', commit: { message: 'Add the API\n\nAdds the public API', author: { name: 'Alice Example', email: 'alice@example.com', date: '2026-01-01T10:00:00Z' }, }, author: { id: 42, login: 'alice', full_name: 'Alice Example' }, parents: [{ sha: 'parent-one' }, { sha: 'parent-two' }], }, ]) : null), ]); const app = createApp(); const response = await request(app).get('/api/gitea/prs/commits?directory=%2Ftmp%2Fwork&number=9'); expect(response.status).toBe(200); expect(response.body).toMatchObject({ connected: true, repo: { owner: 'owner', repo: 'repo' }, commits: [ { sha: 'abc123def4567890', message: 'Add the API\n\nAdds the public API', summary: 'Add the API', author: { username: 'alice', id: 42 }, committedAt: '2026-01-01T10:00:00Z', parents: ['parent-one', 'parent-two'], }, ], }); }); test('prs/reviews passes review state through and maps the author', async () => { scriptedFetch([ (url) => (matches(/\/pulls\/9\/reviews\?/)(url) ? jsonResponse([ { id: 101, state: 'APPROVED', user: { id: 42, login: 'alice', full_name: 'Alice Example' }, submitted_at: '2026-01-02T11:00:00Z', body: 'LGTM', commit_id: 'abc123def4567890', }, ]) : null), ]); const app = createApp(); const response = await request(app).get('/api/gitea/prs/reviews?directory=%2Ftmp%2Fwork&number=9'); expect(response.status).toBe(200); expect(response.body).toMatchObject({ connected: true, reviews: [ { id: '101', state: 'APPROVED', author: { username: 'alice', id: 42 }, submittedAt: '2026-01-02T11:00:00Z', body: 'LGTM', commitSha: 'abc123def4567890', }, ], }); }); test('prs/statuses resolves the PR head SHA then maps commit statuses', async () => { const fetchMock = scriptedFetch([ (url) => (matches(/\/pulls\/9$/)(url) ? jsonResponse({ number: 9, title: 'Add the API', html_url: 'u', state: 'open', merged: false, user: { login: 'alice' }, head: { ref: 'feat/api', sha: 'abc123def4567890' }, base: { ref: 'main' }, }) : null), (url) => (matches(/\/commits\/abc123def4567890\/statuses\?/)(url) ? jsonResponse([ { id: 1, state: 'success', context: 'ci/test', description: 'All good', target_url: 'https://ci.example.com/run/1', created_at: '2026-01-02T12:00:00Z' }, { id: 2, state: 'error', context: 'lint' }, { id: 3, state: 'warning', context: 'docs' }, ]) : null), ]); const app = createApp(); const response = await request(app).get('/api/gitea/prs/statuses?directory=%2Ftmp%2Fwork&number=9'); expect(response.status).toBe(200); expect(response.body).toMatchObject({ connected: true, repo: { owner: 'owner', repo: 'repo' }, statuses: [ { state: 'success', name: 'ci/test', description: 'All good', url: 'https://ci.example.com/run/1', createdAt: '2026-01-02T12:00:00Z' }, { state: 'error', name: 'lint' }, { state: 'warning', name: 'docs' }, ], }); const requestedUrls = fetchMock.mock.calls.map(([url]) => String(url)); expect(requestedUrls.some((url) => url.includes('/pulls/9'))).toBe(true); expect(requestedUrls.some((url) => url.includes('/commits/abc123def4567890/statuses'))).toBe(true); }); test('prs/commits, prs/reviews, and prs/statuses report connected:false when not authenticated', async () => { clearGiteaAuth(); const app = createApp(); const commits = await request(app).get('/api/gitea/prs/commits?directory=%2Ftmp%2Fwork&number=9'); const reviews = await request(app).get('/api/gitea/prs/reviews?directory=%2Ftmp%2Fwork&number=9'); const statuses = await request(app).get('/api/gitea/prs/statuses?directory=%2Ftmp%2Fwork&number=9'); expect(commits.body).toMatchObject({ connected: false, commits: [] }); expect(reviews.body).toMatchObject({ connected: false, reviews: [] }); expect(statuses.body).toMatchObject({ connected: false, statuses: [] }); }); test('issues/comment POSTs a comment and maps it', async () => { const fetchMock = scriptedFetch([ (url, options) => { if (matches(/\/issues\/7\/comments$/)(url) && options.method === 'POST') { return jsonResponse({ id: 5, body: 'Nice catch', html_url: 'https://gitea.example.com/owner/repo/issues/7#issuecomment-5', user: { id: 42, login: 'alice', full_name: 'Alice Example' }, created_at: '2026-01-02T11:00:00Z', }, { status: 201 }); } return null; }, ]); const app = createApp(); const response = await request(app) .post('/api/gitea/issues/comment') .send({ directory: '/tmp/work', number: 7, body: 'Nice catch' }); expect(response.status).toBe(200); expect(response.body).toMatchObject({ connected: true, repo: { owner: 'owner', repo: 'repo', host: 'gitea.example.com' }, comment: { id: 5, body: 'Nice catch', url: 'https://gitea.example.com/owner/repo/issues/7#issuecomment-5', author: { username: 'alice', id: 42 }, createdAt: '2026-01-02T11:00:00Z', }, }); const [, options] = fetchMock.mock.calls[0]; expect(options.method).toBe('POST'); expect(JSON.parse(options.body)).toEqual({ body: 'Nice catch' }); }); test('issues/comment reports connected:false when not authenticated', async () => { clearGiteaAuth(); const app = createApp(); const response = await request(app) .post('/api/gitea/issues/comment') .send({ directory: '/tmp/work', number: 7, body: 'hello' }); expect(response.status).toBe(200); expect(response.body).toEqual({ connected: false }); }); test('issues/update maps labels, assignees, state, and resolves the milestone title', async () => { const fetchMock = scriptedFetch([ (url) => (matches(/\/milestones\?/)(url) ? jsonResponse([{ id: 33, title: 'v1.0', state: 'open' }]) : null), (url, options) => { if (matches(/\/issues\/7$/)(url) && options.method === 'PATCH') { return jsonResponse({ number: 7, title: 'Updated issue', html_url: 'https://gitea.example.com/owner/repo/issues/7', state: 'closed', body: 'New body', user: { id: 42, login: 'alice' }, labels: [{ id: 1, name: 'bug' }], assignees: [{ id: 43, login: 'bob' }], milestone: { id: 33, title: 'v1.0', state: 'open' }, }); } return null; }, ]); const app = createApp(); const response = await request(app) .patch('/api/gitea/issues/update') .send({ directory: '/tmp/work', number: 7, title: 'Updated issue', body: 'New body', state: 'closed', labels: ['bug'], assignees: ['bob'], milestone: 'v1.0', }); expect(response.status).toBe(200); expect(response.body).toMatchObject({ connected: true, issue: { number: 7, title: 'Updated issue', state: 'closed', body: 'New body', labels: ['bug'], assignees: [{ username: 'bob', id: 43 }], milestone: { title: 'v1.0', state: 'open' }, }, }); const [, options] = fetchMock.mock.calls[1]; expect(options.method).toBe('PATCH'); expect(JSON.parse(options.body)).toEqual({ title: 'Updated issue', body: 'New body', state: 'closed', labels: ['bug'], assignees: ['bob'], milestone: 33, }); }); test('issues/update clears the milestone with unset_milestone', async () => { const fetchMock = scriptedFetch([ (url, options) => { if (matches(/\/issues\/7$/)(url) && options.method === 'PATCH') { return jsonResponse({ number: 7, title: 'T', html_url: 'u', state: 'open', user: { login: 'alice' } }); } return null; }, ]); const app = createApp(); await request(app) .patch('/api/gitea/issues/update') .send({ directory: '/tmp/work', number: 7, milestone: null }); const [, options] = fetchMock.mock.calls[0]; expect(JSON.parse(options.body)).toEqual({ unset_milestone: true }); expect(fetchMock).toHaveBeenCalledTimes(1); }); test('issues/update returns 400 when the milestone title does not match', async () => { scriptedFetch([ (url) => (matches(/\/milestones\?/)(url) ? jsonResponse([{ id: 33, title: 'v1.0' }]) : null), ]); const app = createApp(); const response = await request(app) .patch('/api/gitea/issues/update') .send({ directory: '/tmp/work', number: 7, milestone: 'v2.0' }); expect(response.status).toBe(400); expect(response.body).toEqual({ error: 'Milestone not found' }); }); test('prs/comment POSTs a comment on the PR index and maps it', async () => { const fetchMock = scriptedFetch([ (url, options) => { if (matches(/\/issues\/12\/comments$/)(url) && options.method === 'POST') { return jsonResponse({ id: 8, body: 'LGTM', html_url: 'https://gitea.example.com/owner/repo/pulls/12#issuecomment-8', user: { id: 43, login: 'bob' }, }, { status: 201 }); } return null; }, ]); const app = createApp(); const response = await request(app) .post('/api/gitea/prs/comment') .send({ directory: '/tmp/work', number: 12, body: 'LGTM' }); expect(response.status).toBe(200); expect(response.body).toMatchObject({ connected: true, comment: { id: 8, body: 'LGTM', url: 'https://gitea.example.com/owner/repo/pulls/12#issuecomment-8', author: { username: 'bob', id: 43 }, }, }); const [, options] = fetchMock.mock.calls[0]; expect(options.method).toBe('POST'); expect(JSON.parse(options.body)).toEqual({ body: 'LGTM' }); }); test('prs/review POSTs event/body and maps the review', async () => { const fetchMock = scriptedFetch([ (url, options) => { if (matches(/\/pulls\/12\/reviews$/)(url) && options.method === 'POST') { return jsonResponse({ id: 101, state: 'APPROVED', user: { id: 42, login: 'alice', full_name: 'Alice Example' }, submitted_at: '2026-01-02T11:00:00Z', body: 'LGTM', commit_id: 'abc123def4567890', }, { status: 201 }); } return null; }, ]); const app = createApp(); const response = await request(app) .post('/api/gitea/prs/review') .send({ directory: '/tmp/work', number: 12, event: 'APPROVED', body: 'LGTM' }); expect(response.status).toBe(200); expect(response.body).toMatchObject({ connected: true, review: { id: '101', state: 'APPROVED', author: { username: 'alice', id: 42 }, submittedAt: '2026-01-02T11:00:00Z', body: 'LGTM', commitSha: 'abc123def4567890', }, }); const [, options] = fetchMock.mock.calls[0]; expect(options.method).toBe('POST'); expect(JSON.parse(options.body)).toEqual({ event: 'APPROVED', body: 'LGTM' }); }); test('prs/review rejects an unsupported event with 400', async () => { const app = createApp(); const response = await request(app) .post('/api/gitea/prs/review') .send({ directory: '/tmp/work', number: 12, event: 'PENDING' }); expect(response.status).toBe(400); expect(response.body).toEqual({ error: 'event must be APPROVED, REQUEST_CHANGES, or COMMENT' }); }); test('repo/labels returns mapped labels', async () => { scriptedFetch([ (url) => (matches(/\/labels\?/)(url) ? jsonResponse([ { id: 1, name: 'bug', color: 'd73a4a', description: 'A bug' }, { id: 2, name: 'enhancement', color: 'a2eeef' }, ]) : null), ]); const app = createApp(); const response = await request(app).get('/api/gitea/repo/labels?directory=%2Ftmp%2Fwork'); expect(response.status).toBe(200); expect(response.body).toMatchObject({ connected: true, repo: { owner: 'owner', repo: 'repo', host: 'gitea.example.com' }, labels: [ { id: 1, name: 'bug', color: 'd73a4a', description: 'A bug' }, { id: 2, name: 'enhancement', color: 'a2eeef' }, ], }); }); test('repo/labels requires directory or owner/repo', async () => { const app = createApp(); const response = await request(app).get('/api/gitea/repo/labels'); expect(response.status).toBe(400); expect(response.body).toEqual({ error: 'directory or owner/repo is required' }); }); test('pr/update passes state through to the PATCH payload', async () => { const fetchMock = scriptedFetch([ (url, options) => { if (matches(/\/pulls\/12$/)(url) && options.method === 'PATCH') { return jsonResponse({ number: 12, title: 'T', html_url: 'u', state: 'closed', merged: false, draft: false, user: { login: 'alice' }, head: { ref: 'feat/add' }, base: { ref: 'main' }, }); } return null; }, ]); const app = createApp(); const response = await request(app) .patch('/api/gitea/pr/update') .send({ directory: '/tmp/work', number: 12, state: 'closed' }); expect(response.status).toBe(200); expect(response.body).toMatchObject({ connected: true, pr: { number: 12, state: 'closed' }, }); const [, options] = fetchMock.mock.calls[0]; expect(JSON.parse(options.body)).toEqual({ state: 'closed' }); }); test('issues/create POSTs an issue and maps it', async () => { const fetchMock = scriptedFetch([ (url, options) => { if (matches(/\/issues$/)(url) && options.method === 'POST') { return jsonResponse({ number: 9, title: 'New issue', html_url: 'https://gitea.example.com/owner/repo/issues/9', state: 'open', body: 'The body', labels: [{ id: 1, name: 'bug', color: 'd73a4a' }], user: { id: 42, login: 'alice', full_name: 'Alice Example' }, created_at: '2026-01-02T11:00:00Z', updated_at: '2026-01-02T11:00:00Z', }, { status: 201 }); } return null; }, ]); const app = createApp(); const response = await request(app) .post('/api/gitea/issues/create') .send({ directory: '/tmp/work', title: 'New issue', body: 'The body', labels: ['bug'] }); expect(response.status).toBe(200); expect(response.body).toMatchObject({ connected: true, repo: { owner: 'owner', repo: 'repo', host: 'gitea.example.com' }, issue: { number: 9, title: 'New issue', url: 'https://gitea.example.com/owner/repo/issues/9', state: 'open', body: 'The body', labels: ['bug'], author: { username: 'alice', id: 42 }, }, }); const [, options] = fetchMock.mock.calls[0]; expect(options.method).toBe('POST'); expect(JSON.parse(options.body)).toEqual({ title: 'New issue', body: 'The body', labels: ['bug'] }); }); test('issues/create requires directory and title', async () => { const app = createApp(); const response = await request(app) .post('/api/gitea/issues/create') .send({ directory: '/tmp/work' }); expect(response.status).toBe(400); expect(response.body).toEqual({ error: 'directory and title are required' }); }); test('issues/create reports connected:false when not authenticated', async () => { clearGiteaAuth(); const app = createApp(); const response = await request(app) .post('/api/gitea/issues/create') .send({ directory: '/tmp/work', title: 'Hi' }); expect(response.status).toBe(200); expect(response.body).toEqual({ connected: false }); }); describe('Gitea rich lookup routes', () => { beforeEach(() => { setGiteaAuth({ accessToken: 'gitea-secret', baseUrl: 'https://gitea.example.com', user: aliceUser }); }); test('users/search maps repo assignees and filters by query', async () => { scriptedFetch([ (url) => (matches(/\/repos\/owner\/repo\/assignees\?/)(url) ? jsonResponse([ { id: 42, login: 'alice', full_name: 'Alice Example', avatar_url: 'https://gitea.example.com/alice.png', html_url: 'https://gitea.example.com/alice' }, { id: 43, login: 'bob', full_name: 'Bob', avatar_url: 'https://gitea.example.com/bob.png', html_url: 'https://gitea.example.com/bob' }, ]) : null), ]); const app = createApp(); const response = await request(app).get('/api/gitea/users/search?directory=%2Ftmp%2Fwork&query=ali'); expect(response.status).toBe(200); expect(response.body).toMatchObject({ connected: true, repo: { owner: 'owner', repo: 'repo' }, users: [{ username: 'alice', id: 42, name: 'Alice Example', avatarUrl: 'https://gitea.example.com/alice.png' }], }); }); test('users/search reports connected:false when not authenticated', async () => { clearGiteaAuth(); const app = createApp(); const response = await request(app).get('/api/gitea/users/search?directory=%2Ftmp%2Fwork&query=ali'); expect(response.status).toBe(200); expect(response.body).toEqual({ connected: false, users: [] }); }); test('labels/search returns labeled objects filtered by name', async () => { scriptedFetch([ (url) => (matches(/\/repos\/owner\/repo\/labels\?/)(url) ? jsonResponse([ { id: 1, name: 'bug', color: 'd73a4a', description: 'a bug' }, { id: 2, name: 'feature', color: '0e8a16' }, ]) : null), ]); const app = createApp(); const response = await request(app).get('/api/gitea/labels/search?directory=%2Ftmp%2Fwork&query=bug'); expect(response.status).toBe(200); expect(response.body).toMatchObject({ connected: true, labels: [{ name: 'bug', color: 'd73a4a', description: 'a bug' }], }); }); test('milestones/search maps milestone titles and states', async () => { scriptedFetch([ (url) => (matches(/\/repos\/owner\/repo\/milestones\?/)(url) ? jsonResponse([ { id: 1, title: 'v1.0', state: 'open' }, { id: 2, title: 'v2.0', state: 'closed' }, ]) : null), ]); const app = createApp(); const response = await request(app).get('/api/gitea/milestones/search?directory=%2Ftmp%2Fwork&query=v1'); expect(response.status).toBe(200); expect(response.body).toMatchObject({ connected: true, milestones: [{ title: 'v1.0', state: 'open' }], }); }); test('branches/search returns branch names', async () => { scriptedFetch([ (url) => (matches(/\/repos\/owner\/repo\/branches\?/)(url) ? jsonResponse([{ name: 'main' }, { name: 'feat/x' }]) : null), ]); const app = createApp(); const response = await request(app).get('/api/gitea/branches/search?directory=%2Ftmp%2Fwork&query=main'); expect(response.status).toBe(200); expect(response.body).toMatchObject({ connected: true, branches: ['main'] }); }); test('tags/search returns tag names', async () => { scriptedFetch([ (url) => (matches(/\/repos\/owner\/repo\/tags\?/)(url) ? jsonResponse([{ name: 'v1.0.0' }, { name: 'v1.1.0' }]) : null), ]); const app = createApp(); const response = await request(app).get('/api/gitea/tags/search?directory=%2Ftmp%2Fwork&query=v1.0'); expect(response.status).toBe(200); expect(response.body).toMatchObject({ connected: true, tags: ['v1.0.0'] }); }); }); // NOTE: keep this test last in the file. The rate-limit cooldown is // module-level and has no reset export, so tests after it would short-circuit. test('data routes surface a 503 when Gitea 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/gitea/issues/list?directory=%2Ftmp%2Fwork'); expect(response.status).toBe(503); expect(response.body).toEqual({ error: 'Gitea rate limited' }); }); });