diff --git a/CHANGELOG.md b/CHANGELOG.md index ea80d6c5e..b4716a1ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Maintained the sidebar scroll position when navigating between chats instead of resetting to the top. [#1411](https://github.com/sourcebot-dev/sourcebot/pull/1411) - Upgraded `nodemailer` to `^9.0.1`. [#1356](https://github.com/sourcebot-dev/sourcebot/pull/1356) - Upgraded `@opentelemetry/core` to `^2.8.0`. [#1413](https://github.com/sourcebot-dev/sourcebot/pull/1413) +- Prevented a Gitea connection sync from crashing when a single repository fetch returns an empty response body; the repo is now skipped with a warning. [#1416](https://github.com/sourcebot-dev/sourcebot/pull/1416) ## [5.0.4] - 2026-06-18 diff --git a/packages/backend/src/gitea.test.ts b/packages/backend/src/gitea.test.ts new file mode 100644 index 000000000..a456f9be0 --- /dev/null +++ b/packages/backend/src/gitea.test.ts @@ -0,0 +1,58 @@ +import { expect, test, vi, beforeEach } from 'vitest'; + +const repoGet = vi.fn(); + +vi.mock('gitea-js', () => ({ + giteaApi: () => ({ + repos: { repoGet }, + orgs: { orgListRepos: vi.fn() }, + users: { userListRepos: vi.fn() }, + }), +})); + +vi.mock('cross-fetch', () => ({ default: vi.fn() })); + +vi.mock('@sourcebot/shared', async (importOriginal) => ({ + ...(await importOriginal()), + getTokenFromConfig: vi.fn(async () => 'token'), +})); + +import { getGiteaReposFromConfig } from './gitea'; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +test('a repo whose fetch returns a null body is skipped with a warning instead of crashing', async () => { + repoGet.mockResolvedValue({ + data: null, + error: { message: 'Premature close', code: 'ERR_STREAM_PREMATURE_CLOSE' }, + }); + + const result = await getGiteaReposFromConfig({ + type: 'gitea', + url: 'https://gitea.example.com', + token: { env: 'GITEA_TOKEN' }, + repos: ['org/broken-repo'], + } as never); + + expect(result.repos).toHaveLength(0); + expect(result.warnings.some(w => w.includes('broken-repo'))).toBe(true); +}); + +test('a repo with a valid body is returned', async () => { + repoGet.mockResolvedValue({ + data: { id: 1, full_name: 'org/good-repo' }, + error: null, + }); + + const result = await getGiteaReposFromConfig({ + type: 'gitea', + url: 'https://gitea.example.com', + token: { env: 'GITEA_TOKEN' }, + repos: ['org/good-repo'], + } as never); + + expect(result.repos).toHaveLength(1); + expect(result.repos[0].full_name).toBe('org/good-repo'); +}); diff --git a/packages/backend/src/gitea.ts b/packages/backend/src/gitea.ts index ac7566ee8..e8dd2ee92 100644 --- a/packages/backend/src/gitea.ts +++ b/packages/backend/src/gitea.ts @@ -49,10 +49,9 @@ export const getGiteaReposFromConfig = async (config: GiteaConnectionConfig) => allWarnings = allWarnings.concat(warnings); } - allRepos = allRepos.filter(repo => repo.full_name !== undefined); allRepos = allRepos.filter(repo => { - if (repo.full_name === undefined) { - logger.warn(`Repository with undefined full_name found: repoId=${repo.id}`); + if (repo === null || repo === undefined || repo.full_name === undefined) { + logger.warn(`Skipping Gitea repository with missing data: repoId=${repo?.id}`); return false; } return true; @@ -208,6 +207,15 @@ const getRepos = async (repoList: string[], api: Api) => { api.repos.repoGet(owner, repoName), ); + if (!response.data) { + const warning = `Failed to fetch repository ${repo}: ${response.error?.message ?? 'empty response body'}`; + logger.warn(warning); + return { + type: 'warning' as const, + warning + }; + } + logger.debug(`Found repo ${repo} in ${durationMs}ms.`); return { type: 'valid' as const,