diff --git a/src/app/api/v2/repositories/[...fullName]/route.test.ts b/src/app/api/v2/repositories/[...fullName]/route.test.ts new file mode 100644 index 00000000..c986b80b --- /dev/null +++ b/src/app/api/v2/repositories/[...fullName]/route.test.ts @@ -0,0 +1,91 @@ +/** + * Copyright 2026 Lifecycle contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { NextRequest } from 'next/server'; + +const mockRemoveRepository = jest.fn(); + +jest.mock('server/services/repository', () => ({ + __esModule: true, + default: jest.fn().mockImplementation(() => ({ + removeRepository: mockRemoveRepository, + })), +})); + +import { DELETE } from './route'; + +function makeRequest(url = 'http://localhost/api/v2/repositories/example-org/api') { + return { + headers: new Headers([['x-request-id', 'req-test']]), + nextUrl: new URL(url), + } as unknown as NextRequest; +} + +describe('DELETE /api/v2/repositories/{owner}/{repo}', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockRemoveRepository.mockResolvedValue({ + id: 1, + fullName: 'example-org/api', + onboarded: false, + deletedAt: '2026-01-01T00:00:00.000Z', + }); + }); + + test('soft-removes the repository by owner/repo path', async () => { + const response = await DELETE(makeRequest(), { + params: { + fullName: ['example-org', 'api'], + }, + }); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(mockRemoveRepository).toHaveBeenCalledWith('example-org/api', undefined); + expect(body.data.repository).toEqual({ + id: 1, + fullName: 'example-org/api', + onboarded: false, + deletedAt: '2026-01-01T00:00:00.000Z', + }); + }); + + test('passes installationId through when provided', async () => { + const response = await DELETE( + makeRequest('http://localhost/api/v2/repositories/example-org/api?installationId=34'), + { + params: { + fullName: ['example-org', 'api'], + }, + } + ); + + expect(response.status).toBe(200); + expect(mockRemoveRepository).toHaveBeenCalledWith('example-org/api', 34); + }); + + test('rejects incomplete repository paths', async () => { + const response = await DELETE(makeRequest(), { + params: { + fullName: ['example-org'], + }, + }); + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body.error.message).toContain('Invalid repository fullName'); + }); +}); diff --git a/src/app/api/v2/repositories/[...fullName]/route.ts b/src/app/api/v2/repositories/[...fullName]/route.ts new file mode 100644 index 00000000..73d2c967 --- /dev/null +++ b/src/app/api/v2/repositories/[...fullName]/route.ts @@ -0,0 +1,99 @@ +/** + * Copyright 2026 Lifecycle contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { NextRequest } from 'next/server'; +import { createApiHandler } from 'server/lib/createApiHandler'; +import { errorResponse, successResponse } from 'server/lib/response'; +import RepositoryService from 'server/services/repository'; + +interface RouteContext { + params: { + fullName?: string[]; + }; +} + +/** + * @openapi + * /api/v2/repositories/{owner}/{repo}: + * delete: + * summary: Remove an onboarded repository + * description: Soft-removes a repository from Lifecycle onboarding while preserving historical data. + * tags: + * - Repositories + * operationId: removeRepository + * parameters: + * - in: path + * name: owner + * required: true + * schema: + * type: string + * - in: path + * name: repo + * required: true + * schema: + * type: string + * - in: query + * name: installationId + * schema: + * type: integer + * responses: + * '200': + * description: Repository removed. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/RemoveRepositorySuccessResponse' + * '400': + * description: Invalid repository full name. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiErrorResponse' + * '404': + * description: Repository not found. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiErrorResponse' + */ +const deleteHandler = async (req: NextRequest, { params }: RouteContext) => { + const segments = params.fullName || []; + if (segments.length < 2) { + return errorResponse(new Error('Invalid repository fullName. Expected format: owner/repo'), { status: 400 }, req); + } + + const rawInstallationId = req.nextUrl.searchParams.get('installationId'); + const installationId = rawInstallationId ? Number(rawInstallationId) : undefined; + if (rawInstallationId && !Number.isFinite(installationId)) { + return errorResponse(new Error('installationId must be a number'), { status: 400 }, req); + } + + try { + const repository = await new RepositoryService().removeRepository(segments.join('/'), installationId); + return successResponse({ repository }, { status: 200 }, req); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message.includes('Invalid repository fullName')) { + return errorResponse(error, { status: 400 }, req); + } + if (message.includes('Repository not found')) { + return errorResponse(error, { status: 404 }, req); + } + throw error; + } +}; + +export const DELETE = createApiHandler(deleteHandler); diff --git a/src/app/api/v2/repositories/route.test.ts b/src/app/api/v2/repositories/route.test.ts new file mode 100644 index 00000000..cae952ae --- /dev/null +++ b/src/app/api/v2/repositories/route.test.ts @@ -0,0 +1,132 @@ +/** + * Copyright 2026 Lifecycle contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { NextRequest } from 'next/server'; + +const mockListOnboardedRepositories = jest.fn(); +const mockListInstalledRepositories = jest.fn(); +const mockOnboardRepository = jest.fn(); +const mockParseOnboardedParam = jest.fn((value?: string | null) => { + if (value === 'true') return true; + if (value === 'false') return false; + return undefined; +}); + +jest.mock('server/services/repository', () => ({ + __esModule: true, + default: jest.fn().mockImplementation(() => ({ + listOnboardedRepositories: mockListOnboardedRepositories, + listInstalledRepositories: mockListInstalledRepositories, + onboardRepository: mockOnboardRepository, + parseOnboardedParam: mockParseOnboardedParam, + })), +})); + +import { GET, POST } from './route'; + +function makeRequest(url: string, body?: unknown) { + return { + headers: new Headers([['x-request-id', 'req-test']]), + nextUrl: new URL(url), + json: jest.fn().mockResolvedValue(body || {}), + } as unknown as NextRequest; +} + +describe('/api/v2/repositories', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockListOnboardedRepositories.mockResolvedValue({ + repositories: [{ id: 1, fullName: 'example-org/api', onboarded: true }], + pagination: { current: 1, total: 1, items: 1, limit: 25 }, + }); + mockListInstalledRepositories.mockResolvedValue({ + repositories: [{ githubRepositoryId: 2, fullName: 'example-org/web', onboarded: false }], + pagination: { current: 1, total: 1, items: 1, limit: 25 }, + }); + mockOnboardRepository.mockResolvedValue({ + repository: { id: 1, fullName: 'example-org/api', onboarded: true }, + created: true, + }); + }); + + describe('GET', () => { + test('lists onboarded repositories by default', async () => { + const response = await GET(makeRequest('http://localhost/api/v2/repositories?q=api&page=2&limit=10')); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(mockListOnboardedRepositories).toHaveBeenCalledWith({ + query: 'api', + page: 2, + limit: 10, + installationId: undefined, + }); + expect(body.data.repositories).toEqual([{ id: 1, fullName: 'example-org/api', onboarded: true }]); + expect(body.metadata.pagination).toEqual({ current: 1, total: 1, items: 1, limit: 25 }); + }); + + test('lists installed repositories annotated for dropdown filtering', async () => { + const response = await GET( + makeRequest('http://localhost/api/v2/repositories?view=all&onboarded=false&q=web&refresh=true') + ); + + expect(response.status).toBe(200); + expect(mockParseOnboardedParam).toHaveBeenCalledWith('false'); + expect(mockListInstalledRepositories).toHaveBeenCalledWith({ + query: 'web', + page: 1, + limit: 25, + installationId: undefined, + onboarded: false, + refresh: true, + }); + }); + + test('rejects unknown views', async () => { + const response = await GET(makeRequest('http://localhost/api/v2/repositories?view=legacy')); + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body.error.message).toContain('view must be onboarded or all'); + }); + }); + + describe('POST', () => { + test('onboards a repository and returns 201 for newly created rows', async () => { + const response = await POST( + makeRequest('http://localhost/api/v2/repositories', { + fullName: 'example-org/api', + }) + ); + const body = await response.json(); + + expect(response.status).toBe(201); + expect(mockOnboardRepository).toHaveBeenCalledWith('example-org/api', undefined); + expect(body.data).toEqual({ + repository: { id: 1, fullName: 'example-org/api', onboarded: true }, + created: true, + }); + }); + + test('rejects missing fullName', async () => { + const response = await POST(makeRequest('http://localhost/api/v2/repositories', {})); + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body.error.message).toContain('Missing required field: fullName'); + }); + }); +}); diff --git a/src/app/api/v2/repositories/route.ts b/src/app/api/v2/repositories/route.ts index e1027d5e..c29328b9 100644 --- a/src/app/api/v2/repositories/route.ts +++ b/src/app/api/v2/repositories/route.ts @@ -17,59 +17,202 @@ import { NextRequest } from 'next/server'; import { createApiHandler } from 'server/lib/createApiHandler'; import { errorResponse, successResponse } from 'server/lib/response'; -import { getRequestUserIdentity } from 'server/lib/get-user'; import RepositoryService from 'server/services/repository'; /** * @openapi * /api/v2/repositories: * get: - * summary: Search repositories - * description: Search known repositories by full name for pickers and scoped configuration flows. + * summary: List repositories + * description: > + * Lists Lifecycle-onboarded repositories by default. Pass view=all to list + * repositories accessible to the configured GitHub App installation with + * Lifecycle onboarding status annotated. * tags: * - Repositories - * operationId: searchRepositories + * operationId: listRepositories * parameters: * - in: query + * name: view + * schema: + * type: string + * enum: [onboarded, all] + * default: onboarded + * - in: query * name: q * schema: * type: string * description: Case-insensitive repository search query. * - in: query + * name: page + * schema: + * type: integer + * default: 1 + * minimum: 1 + * - in: query * name: limit * schema: * type: integer - * default: 10 + * default: 25 * minimum: 1 - * maximum: 25 - * description: Maximum number of repositories to return. + * maximum: 100 + * - in: query + * name: onboarded + * schema: + * type: boolean + * description: Only supported with view=all. + * - in: query + * name: refresh + * schema: + * type: boolean + * default: false + * description: Bypass the installed GitHub repositories cache for view=all. * responses: * '200': * description: Matching repositories. * content: * application/json: * schema: - * $ref: '#/components/schemas/SearchRepositoriesSuccessResponse' - * '401': - * description: Unauthorized + * $ref: '#/components/schemas/ListRepositoriesSuccessResponse' + * '400': + * description: Invalid query parameter. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiErrorResponse' + * post: + * summary: Onboard a repository + * description: > + * Adds a GitHub repository to Lifecycle's repository allowlist. The repository + * must be accessible to the configured GitHub App installation. If the row + * already exists or was soft-deleted, Lifecycle refreshes the stored repository + * metadata and marks it active. + * tags: + * - Repositories + * operationId: onboardRepository + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/OnboardRepositoryRequest' + * responses: + * '200': + * description: Existing repository refreshed and onboarded. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/OnboardRepositorySuccessResponse' + * '201': + * description: Repository onboarded. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/OnboardRepositorySuccessResponse' + * '400': + * description: Invalid request body. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiErrorResponse' + * '404': + * description: Repository not found or unavailable to the GitHub App. * content: * application/json: * schema: * $ref: '#/components/schemas/ApiErrorResponse' */ const getHandler = async (req: NextRequest) => { - const userIdentity = getRequestUserIdentity(req); - if (!userIdentity) { - return errorResponse(new Error('Unauthorized'), { status: 401 }, req); + const service = new RepositoryService(); + const view = req.nextUrl.searchParams.get('view') || 'onboarded'; + const query = req.nextUrl.searchParams.get('q') || ''; + const page = Number.parseInt(req.nextUrl.searchParams.get('page') || '1', 10); + const limit = Number.parseInt(req.nextUrl.searchParams.get('limit') || '25', 10); + const refresh = req.nextUrl.searchParams.get('refresh') === 'true'; + const rawInstallationId = req.nextUrl.searchParams.get('installationId'); + const installationId = rawInstallationId ? Number(rawInstallationId) : undefined; + + if (rawInstallationId && !Number.isFinite(installationId)) { + return errorResponse(new Error('installationId must be a number'), { status: 400 }, req); } - const query = req.nextUrl.searchParams.get('q') || ''; - const rawLimit = Number.parseInt(req.nextUrl.searchParams.get('limit') || '10', 10); - const limit = Number.isFinite(rawLimit) ? rawLimit : 10; + if (view === 'all') { + let onboarded: boolean | undefined; + try { + onboarded = service.parseOnboardedParam(req.nextUrl.searchParams.get('onboarded')); + } catch (error) { + return errorResponse(error, { status: 400 }, req); + } + + const result = await service.listInstalledRepositories({ + query, + page, + limit, + installationId, + onboarded, + refresh, + }); - const repositories = await new RepositoryService().searchRepositories(query, limit); + return successResponse( + { repositories: result.repositories }, + { status: 200, metadata: { pagination: result.pagination } }, + req + ); + } + + if (view !== 'onboarded') { + return errorResponse(new Error('view must be onboarded or all'), { status: 400 }, req); + } - return successResponse({ repositories }, { status: 200 }, req); + const result = await service.listOnboardedRepositories({ + query, + page, + limit, + installationId, + }); + + return successResponse( + { repositories: result.repositories }, + { status: 200, metadata: { pagination: result.pagination } }, + req + ); +}; + +const postHandler = async (req: NextRequest) => { + let body: { fullName?: unknown; repository?: unknown; installationId?: unknown; githubInstallationId?: unknown }; + try { + body = await req.json(); + } catch { + return errorResponse(new Error('Invalid JSON in request body'), { status: 400 }, req); + } + + const fullName = body.fullName ?? body.repository; + if (typeof fullName !== 'string' || !fullName.trim()) { + return errorResponse(new Error('Missing required field: fullName'), { status: 400 }, req); + } + + const rawInstallationId = body.installationId ?? body.githubInstallationId; + const installationId = + rawInstallationId === undefined || rawInstallationId === null ? undefined : Number(rawInstallationId); + + if (installationId !== undefined && !Number.isFinite(installationId)) { + return errorResponse(new Error('installationId must be a number'), { status: 400 }, req); + } + + try { + const result = await new RepositoryService().onboardRepository(fullName, installationId); + return successResponse(result, { status: result.created ? 201 : 200 }, req); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message.includes('Invalid repository fullName') || message.includes('installation ID is required')) { + return errorResponse(error, { status: 400 }, req); + } + if (message.includes('Repository not found')) { + return errorResponse(error, { status: 404 }, req); + } + throw error; + } }; export const GET = createApiHandler(getHandler); +export const POST = createApiHandler(postHandler); diff --git a/src/pages/api/webhooks/github.ts b/src/pages/api/webhooks/github.ts index b890dad9..f4c234b2 100644 --- a/src/pages/api/webhooks/github.ts +++ b/src/pages/api/webhooks/github.ts @@ -51,6 +51,13 @@ export default async (req: NextApiRequest, res: NextApiResponse) => { return; } + const shouldProcessWebhook = await services.GithubService.shouldProcessWebhook(req.body); + if (!shouldProcessWebhook) { + getLogger({ stage: LogStage.WEBHOOK_SKIPPED }).debug('Webhook: skipped reason=repository_not_onboarded'); + res.status(200).end(); + return; + } + try { if (LIFECYCLE_MODE === 'all') BootstrapJobs(services); const message = stringify({ ...req, ...{ headers: req.headers } }); diff --git a/src/server/lib/errors.ts b/src/server/lib/errors.ts index 5664f21f..1ebecbd0 100644 --- a/src/server/lib/errors.ts +++ b/src/server/lib/errors.ts @@ -15,10 +15,10 @@ */ export class LifecycleError extends Error { - uuid: string; - service: string; + uuid: string | null; + service: string | null; - constructor(uuid: string, service: string, msg: string) { + constructor(uuid: string | null, service: string | null, msg: string) { super(msg); this.uuid = uuid; diff --git a/src/server/lib/github/index.ts b/src/server/lib/github/index.ts index 92a2db1d..804e62eb 100644 --- a/src/server/lib/github/index.ts +++ b/src/server/lib/github/index.ts @@ -28,6 +28,41 @@ import { getRefForBranchName } from 'server/lib/github/utils'; import { Deploy } from 'server/models'; import { LifecycleYamlConfigOptions } from 'server/models/yaml/types'; +export async function getRepositoryByFullName(fullName: string, installationId: number) { + try { + const client = await createOctokitClient({ installationId, caller: 'getRepositoryByFullName' }); + return await client.request(`GET /repos/${fullName}`); + } catch (error) { + if (error?.status === 404) { + getLogger({ repo: fullName, installationId }).info('GitHub: repository not found'); + throw new Error(`Repository not found or GitHub App cannot access it: ${fullName}`); + } + getLogger({ error, repo: fullName, installationId }).error('GitHub: repository fetch failed'); + throw new Error(error?.message || 'Unable to retrieve repository'); + } +} + +export async function listInstallationRepositories({ + installationId, + page, + perPage, +}: { + installationId: number; + page: number; + perPage: number; +}) { + try { + const client = await createOctokitClient({ installationId, caller: 'listInstallationRepositories' }); + return await client.request('GET /installation/repositories', { + page, + per_page: perPage, + }); + } catch (error) { + getLogger({ error, installationId, page, perPage }).error('GitHub: installation repositories fetch failed'); + throw new Error(error?.message || 'Unable to retrieve installation repositories'); + } +} + export async function createOrUpdatePullRequestComment({ installationId, pullRequestNumber, @@ -296,7 +331,7 @@ export async function checkIfCommentExists({ } export class ConfigFileNotFound extends LifecycleError { - constructor(msg: string, uuid: string = null, service: string = null) { + constructor(msg: string, uuid: string | null = null, service: string | null = null) { super(uuid, service, msg); } } diff --git a/src/server/middlewares/auth.test.ts b/src/server/middlewares/auth.test.ts index cccbbec4..df537f48 100644 --- a/src/server/middlewares/auth.test.ts +++ b/src/server/middlewares/auth.test.ts @@ -64,4 +64,21 @@ describe('authMiddleware', () => { expect(response.status).toBe(401); expect(body.error.message).toBe('Unauthorized'); }); + + it('rejects repository self-service API requests without valid bearer auth', async () => { + const next = jest.fn().mockResolvedValue(NextResponse.next()); + mockVerifyAuth.mockResolvedValue({ + success: false, + error: { message: 'Unauthorized', status: 401 }, + }); + const request = new NextRequest('http://localhost/api/v2/repositories'); + + const response = await authMiddleware(request, next); + const body = await response.json(); + + expect(mockVerifyAuth).toHaveBeenCalledWith(request); + expect(next).not.toHaveBeenCalled(); + expect(response.status).toBe(401); + expect(body.error.message).toBe('Unauthorized'); + }); }); diff --git a/src/server/models/Repository.ts b/src/server/models/Repository.ts index 6f1225c4..24c6a851 100644 --- a/src/server/models/Repository.ts +++ b/src/server/models/Repository.ts @@ -20,6 +20,7 @@ import { PullRequest, Environment } from '.'; export default class Repository extends Model { githubRepositoryId: number; githubInstallationId: number; + ownerId: number; fullName: string; htmlUrl: string; diff --git a/src/server/services/__tests__/github.test.ts b/src/server/services/__tests__/github.test.ts index 7c50fead..5fcb4b5a 100644 --- a/src/server/services/__tests__/github.test.ts +++ b/src/server/services/__tests__/github.test.ts @@ -16,6 +16,7 @@ import mockRedisClient from 'server/lib/__mocks__/redisClientMock'; import Github from '../github'; +import RepositoryService from '../repository'; import { DeployStatus, PullRequestStatus } from 'shared/constants'; import { PushEvent } from '@octokit/webhooks-types'; import * as githubLib from 'server/lib/github'; @@ -46,13 +47,14 @@ jest.mock('server/lib/logger', () => ({ debug: jest.fn(), child: jest.fn().mockReturnThis(), })), - withLogContext: jest.fn((ctx, fn) => fn()), + withLogContext: jest.fn((_ctx, fn) => fn()), extractContextForQueue: jest.fn(() => ({})), LogStage: {}, })); jest.mock('server/lib/github', () => ({ getYamlFileContent: jest.fn(), + verifyWebhookSignature: jest.fn(() => true), })); const createDedupeAwareResolveEnqueue = (queueAdd: jest.Mock) => { @@ -66,6 +68,98 @@ const createDedupeAwareResolveEnqueue = (queueAdd: jest.Mock) => { }); }; +describe('Github Service - repository onboarding gate', () => { + let githubService: Github; + let mockDb: any; + let mockQueueManager: any; + let isRepositoryOnboarded: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + isRepositoryOnboarded = jest.spyOn(RepositoryService.prototype, 'isRepositoryOnboarded'); + mockDb = { + services: { + Repository: { + syncRepositoryRename: jest.fn(), + }, + }, + }; + mockQueueManager = { + registerQueue: jest.fn().mockReturnValue({ + add: jest.fn(), + process: jest.fn(), + on: jest.fn(), + }), + }; + githubService = new Github(mockDb, {}, {} as any, mockQueueManager); + }); + + test('skips repo-scoped webhooks for repositories that are not onboarded', async () => { + isRepositoryOnboarded.mockResolvedValue(false); + const handlePushWebhook = jest.spyOn(githubService, 'handlePushWebhook').mockResolvedValue(undefined); + + await githubService.dispatchWebhook({ + headers: { 'x-github-event': 'push' }, + body: { + installation: { id: 34 }, + repository: { + id: 12, + full_name: 'example-org/example-repo', + }, + }, + } as any); + + expect(isRepositoryOnboarded).toHaveBeenCalledWith(34, 12); + expect(handlePushWebhook).not.toHaveBeenCalled(); + }); + + test('processes repo-scoped webhooks for onboarded repositories', async () => { + isRepositoryOnboarded.mockResolvedValue(true); + const handlePushWebhook = jest.spyOn(githubService, 'handlePushWebhook').mockResolvedValue(undefined); + const body = { + installation: { id: 34 }, + repository: { + id: 12, + full_name: 'example-org/example-repo', + }, + }; + + await githubService.dispatchWebhook({ + headers: { 'x-github-event': 'push' }, + body, + } as any); + + expect(handlePushWebhook).toHaveBeenCalledWith(body); + }); + + test('syncs repository metadata on rename webhooks', async () => { + await githubService.handleRepositoryWebhook({ + action: 'renamed', + installation: { id: 34 }, + repository: { + id: 12, + name: 'renamed-repo', + full_name: 'example-org/renamed-repo', + html_url: 'https://github.com/example-org/renamed-repo', + owner: { + id: 56, + login: 'example-org', + }, + }, + } as any); + + expect(mockDb.services.Repository.syncRepositoryRename).toHaveBeenCalledWith({ + githubRepositoryId: 12, + githubInstallationId: 34, + ownerId: 56, + ownerLogin: 'example-org', + name: 'renamed-repo', + fullName: 'example-org/renamed-repo', + htmlUrl: 'https://github.com/example-org/renamed-repo', + }); + }); +}); + describe('Github Service - handlePullRequestHook', () => { let githubService: Github; let mockDb: any; @@ -165,7 +259,7 @@ describe('Github Service - handlePullRequestHook', () => { }), }; - githubService = new Github(mockDb, {}, {}, mockQueueManager); + githubService = new Github(mockDb, {}, {} as any, mockQueueManager); }); test('queues initial build when a non-autoDeploy PR is opened with the deploy label', async () => { @@ -242,6 +336,16 @@ describe('Github Service - handlePullRequestHook', () => { ); }); + test('skips pull request webhooks for repositories that are not onboarded', async () => { + mockDb.services.Repository.findRepository.mockResolvedValue(null); + + await githubService.handlePullRequestHook(createMockPullRequestEvent()); + + expect(mockGetYamlFileContent).not.toHaveBeenCalled(); + expect(mockDb.services.PullRequest.findOrCreatePullRequest).not.toHaveBeenCalled(); + expect(mockDb.services.BuildService.createBuildAndDeploys).not.toHaveBeenCalled(); + }); + test('queues one effective build across labeled -> opened -> labeled for a pre-labeled autoDeploy PR', async () => { mockGetYamlFileContent.mockResolvedValue({ environment: { autoDeploy: true } }); mockHasDeployLabel.mockResolvedValue(true); @@ -630,7 +734,7 @@ describe('Github Service - handleLabelWebhook', () => { }), }; - githubService = new Github(mockDb, {}, {}, mockQueueManager); + githubService = new Github(mockDb, {}, {} as any, mockQueueManager); }); test('should skip processing when changed label is not a lifecycle label', async () => { diff --git a/src/server/services/__tests__/repository.test.ts b/src/server/services/__tests__/repository.test.ts index 0772aa63..5a17d74d 100644 --- a/src/server/services/__tests__/repository.test.ts +++ b/src/server/services/__tests__/repository.test.ts @@ -17,109 +17,543 @@ import mockRedisClient from 'server/lib/__mocks__/redisClientMock'; mockRedisClient(); -import RepositoryService from 'server/services/repository'; -import { GITHUB_REPOSITORY_DATA as repoData } from 'server/services/__fixtures__/github'; +jest.mock('server/lib/github', () => ({ + getRepositoryByFullName: jest.fn(), + listInstallationRepositories: jest.fn(), +})); + +import * as github from 'server/lib/github'; +import RepositoryService, { + githubInstalledRepositoriesCacheKey, + githubOnboardedRepositoryCacheKey, +} from 'server/services/repository'; +import { GITHUB_API_CACHE_EXPIRATION_SECONDS } from 'shared/constants'; + +class RepositoryQuery { + private filters: Array<(row: any) => boolean> = []; + private sortBy: { field: string; direction: string } | null = null; + + constructor(private readonly rows: any[]) {} + + where(criteria: Record | string, value?: unknown) { + if (typeof criteria === 'string') { + this.filters.push((row) => row[criteria] === value); + return this; + } + + this.filters.push((row) => Object.entries(criteria).every(([key, expected]) => row[key] === expected)); + return this; + } + + whereNull(field: string) { + this.filters.push((row) => row[field] == null); + return this; + } + + whereRaw(sql: string, values: string[]) { + const value = values[0]; + if (sql.includes('like')) { + const query = value.replace(/%/g, '').toLowerCase(); + this.filters.push((row) => String(row.fullName).toLowerCase().includes(query)); + } else if (sql.includes('=')) { + this.filters.push((row) => String(row.fullName).toLowerCase() === value.toLowerCase()); + } + return this; + } + + orderBy(field: string, direction: string) { + this.sortBy = { field, direction }; + return this; + } + + async page(pageIndex: number, pageSize: number) { + const rows = this.filteredRows(); + const start = pageIndex * pageSize; + return { + results: rows.slice(start, start + pageSize), + total: rows.length, + }; + } + + async first() { + return this.filteredRows()[0]; + } + + then(resolve: (value: any[]) => unknown, reject?: (reason: unknown) => unknown) { + return Promise.resolve(this.filteredRows()).then(resolve, reject); + } + + private filteredRows() { + const rows = this.rows.filter((row) => this.filters.every((filter) => filter(row))); + if (!this.sortBy) return rows; + const sortBy = this.sortBy; + + return [...rows].sort((a, b) => { + const compared = String(a[sortBy.field]).localeCompare(String(b[sortBy.field])); + return sortBy.direction === 'desc' ? -compared : compared; + }); + } +} + +function createRepository(overrides: Record = {}) { + const repository: any = { + id: 1, + githubRepositoryId: 12, + githubInstallationId: 34, + ownerId: 56, + fullName: 'example-org/example-repo', + htmlUrl: 'https://github.com/example-org/example-repo', + defaultEnvId: 78, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + deletedAt: null, + ...overrides, + }; + + repository.patchAndFetch = jest.fn(async (patch) => { + Object.assign(repository, patch); + return repository; + }); + repository.$query = jest.fn(() => ({ + patchAndFetch: repository.patchAndFetch, + })); + + return repository; +} + +function createInstalledRepository(overrides: Record = {}) { + return { + id: 12, + name: 'example-repo', + full_name: 'example-org/example-repo', + html_url: 'https://github.com/example-org/example-repo', + private: true, + archived: false, + disabled: false, + visibility: 'private', + default_branch: 'main', + updated_at: '2026-01-01T00:00:00.000Z', + pushed_at: '2026-01-01T00:00:00.000Z', + owner: { + id: 56, + login: 'example-org', + }, + ...overrides, + }; +} describe('RepositoryService', () => { - let service, db, redis, redlock; + let service: RepositoryService; + let repositories: any[]; + let db: any; + let redis: any; beforeEach(() => { + repositories = []; db = { models: { + Environment: { + findOne: jest.fn(async () => null), + create: jest.fn(async (input) => ({ id: 78, ...input })), + }, Repository: { - findOne: jest.fn(), - create: jest.fn(), - query: jest.fn(), + create: jest.fn(async (input) => { + const repository = createRepository({ + id: repositories.length + 1, + ...input, + }); + repositories.push(repository); + return repository; + }), + query: jest.fn(() => new RepositoryQuery(repositories)), }, }, }; - redis = {}; - redlock = {}; - service = new RepositoryService(db, redis, redlock); + + const store = new Map(); + redis = { + get: jest.fn(async (key: string) => store.get(key) ?? null), + set: jest.fn(async (key: string, value: string) => { + store.set(key, value); + }), + del: jest.fn(async (key: string) => { + store.delete(key); + }), + store, + }; + + service = new RepositoryService(db, redis, {} as any); + jest.clearAllMocks(); }); - describe('findRepository', () => { - test('returns existing repository', async () => { - db.models.Repository.findOne.mockReturnValue({ id: 1 }); - const result = await service.findRepository(1, 2, 3); - expect(result).toEqual({ id: 1 }); - expect(db.models.Repository.findOne).toHaveBeenCalledWith({ - githubRepositoryId: 2, - githubInstallationId: 3, - ownerId: 1, + describe('listOnboardedRepositories', () => { + test('returns only non-deleted repository rows', async () => { + repositories.push( + createRepository({ id: 1, fullName: 'example-org/api' }), + createRepository({ id: 2, fullName: 'example-org/legacy-api', deletedAt: '2026-01-01T00:00:00.000Z' }) + ); + + const result = await service.listOnboardedRepositories({ query: 'api', page: 1, limit: 25 }); + + expect(result.repositories).toEqual([ + expect.objectContaining({ + id: 1, + fullName: 'example-org/api', + onboarded: true, + deletedAt: null, + }), + ]); + expect(result.pagination).toEqual({ + current: 1, + total: 1, + items: 1, + limit: 25, }); - expect(db.models.Repository.create).not.toHaveBeenCalled(); }); + }); - test('creates new repository if none exists', async () => { - db.models.Repository.findOne.mockReturnValue(null); - db.models.Repository.create.mockReturnValue({ id: 1 }); - const result = await service.findOrCreateRepository( - repoData.ownerId, - repoData.githubRepositoryId, - repoData.githubInstallationId, - repoData.fullName, - repoData.htmlUrl, - repoData.defaultEnvId + describe('listInstalledRepositories', () => { + test('returns installed GitHub repositories annotated with onboarded state', async () => { + redis.store.set( + githubInstalledRepositoriesCacheKey(34), + JSON.stringify({ + installationId: 34, + fetchedAt: '2026-01-01T00:00:00.000Z', + repositories: [ + { + githubRepositoryId: 12, + ownerId: 56, + ownerLogin: 'example-org', + name: 'api', + fullName: 'example-org/api', + htmlUrl: 'https://github.com/example-org/api', + private: true, + archived: false, + disabled: false, + visibility: 'private', + defaultBranch: 'main', + updatedAt: '2026-01-01T00:00:00.000Z', + pushedAt: '2026-01-01T00:00:00.000Z', + }, + { + githubRepositoryId: 13, + ownerId: 56, + ownerLogin: 'example-org', + name: 'web', + fullName: 'example-org/web', + htmlUrl: 'https://github.com/example-org/web', + private: true, + archived: false, + disabled: false, + visibility: 'private', + defaultBranch: 'main', + updatedAt: '2026-01-01T00:00:00.000Z', + pushedAt: '2026-01-01T00:00:00.000Z', + }, + ], + }) ); - expect(result).toEqual({ id: 1 }); - expect(db.models.Repository.findOne).toHaveBeenCalledWith({ - githubRepositoryId: repoData.githubRepositoryId, - githubInstallationId: repoData.githubInstallationId, - ownerId: repoData.ownerId, - }); - expect(db.models.Repository.create).toHaveBeenCalledWith({ - githubRepositoryId: repoData.githubRepositoryId, - githubInstallationId: repoData.githubInstallationId, - ownerId: repoData.ownerId, - fullName: repoData.fullName, - htmlUrl: repoData.htmlUrl, - defaultEnvId: repoData.defaultEnvId, + repositories.push(createRepository({ githubRepositoryId: 13, fullName: 'example-org/web' })); + + const result = await service.listInstalledRepositories({ + installationId: 34, + onboarded: false, + query: 'api', }); + + expect(result.repositories).toEqual([ + expect.objectContaining({ + githubRepositoryId: 12, + fullName: 'example-org/api', + onboarded: false, + }), + ]); + expect(github.listInstallationRepositories).not.toHaveBeenCalled(); + }); + + test('writes the installed cache after all GitHub pages are fetched', async () => { + const firstPage = Array.from({ length: 100 }, (_, index) => + createInstalledRepository({ + id: index + 1, + name: `repo-${index + 1}`, + full_name: `example-org/repo-${index + 1}`, + }) + ); + const secondPage = [ + createInstalledRepository({ + id: 101, + name: 'repo-101', + full_name: 'example-org/repo-101', + }), + ]; + + (github.listInstallationRepositories as jest.Mock) + .mockResolvedValueOnce({ data: { total_count: 101, repositories: firstPage } }) + .mockResolvedValueOnce({ data: { total_count: 101, repositories: secondPage } }); + + const result = await service.listInstalledRepositories({ installationId: 34 }); + const cached = JSON.parse(redis.store.get(githubInstalledRepositoriesCacheKey(34))); + + expect(github.listInstallationRepositories).toHaveBeenCalledTimes(2); + expect(redis.set).toHaveBeenCalledTimes(1); + expect(result.repositories).toHaveLength(25); + expect(cached.repositories).toHaveLength(101); + expect(cached.repositories[100]).toEqual( + expect.objectContaining({ + githubRepositoryId: 101, + fullName: 'example-org/repo-101', + }) + ); }); }); - describe('searchRepositories', () => { - test('returns ranked repository matches for valid queries', async () => { - const limit = jest.fn().mockResolvedValue([ - { + describe('onboardRepository', () => { + test('fetches GitHub metadata and creates an active repository row', async () => { + (github.getRepositoryByFullName as jest.Mock).mockResolvedValue({ + data: createInstalledRepository(), + }); + + const result = await service.onboardRepository('https://github.com/Example-Org/Example-Repo.git', 34); + + expect(github.getRepositoryByFullName).toHaveBeenCalledWith('example-org/example-repo', 34); + expect(db.models.Environment.findOne).toHaveBeenCalledWith({ name: 'example-repo' }); + expect(db.models.Environment.create).toHaveBeenCalledWith({ + name: 'example-repo', + uuid: 'example-repo', + enableFullYaml: true, + autoDeploy: false, + }); + expect(db.models.Repository.create).toHaveBeenCalledWith({ + githubRepositoryId: 12, + githubInstallationId: 34, + ownerId: 56, + fullName: 'example-org/example-repo', + htmlUrl: 'https://github.com/example-org/example-repo', + defaultEnvId: 78, + deletedAt: null, + }); + expect(redis.set).toHaveBeenCalledWith( + githubOnboardedRepositoryCacheKey(34, 12), + JSON.stringify({ + onboarded: true, + repositoryId: 1, githubRepositoryId: 12, + githubInstallationId: 34, fullName: 'example-org/example-repo', - htmlUrl: 'https://github.com/example-org/example-repo', - }, - ]); - const orderBy = jest.fn().mockReturnValue({ limit }); - const orderByRaw = jest.fn().mockReturnValue({ orderBy }); - const whereRaw = jest.fn().mockReturnValue({ orderByRaw }); - const select = jest.fn().mockReturnValue({ whereRaw }); + }), + 'EX', + GITHUB_API_CACHE_EXPIRATION_SECONDS + ); + expect(result).toEqual({ + repository: expect.objectContaining({ + id: 1, + fullName: 'example-org/example-repo', + onboarded: true, + deletedAt: null, + }), + created: true, + }); + }); - db.models.Repository.query.mockReturnValue({ select }); + test('uses an environment service bound to the same database dependency', async () => { + (github.getRepositoryByFullName as jest.Mock).mockResolvedValue({ + data: createInstalledRepository(), + }); + db.models.Environment = { + findOne: jest.fn(async () => null), + create: jest.fn(async (input) => ({ id: 79, ...input })), + }; + service = new RepositoryService(db, redis, {} as any); - const result = await service.searchRepositories('Example-Org/Example', 50); + const result = await service.onboardRepository('example-org/example-repo', 34); - expect(result).toEqual([ - { - githubRepositoryId: 12, + expect(db.models.Environment.findOne).toHaveBeenCalledWith({ name: 'example-repo' }); + expect(db.models.Environment.create).toHaveBeenCalledWith({ + name: 'example-repo', + uuid: 'example-repo', + enableFullYaml: true, + autoDeploy: false, + }); + expect(db.models.Repository.create).toHaveBeenCalledWith( + expect.objectContaining({ fullName: 'example-org/example-repo', - htmlUrl: 'https://github.com/example-org/example-repo', - }, - ]); - expect(db.models.Repository.query).toHaveBeenCalled(); - expect(select).toHaveBeenCalledWith('githubRepositoryId', 'fullName', 'htmlUrl'); - expect(whereRaw).toHaveBeenCalledWith('lower("fullName") like ?', ['%example-org/example%']); - expect(orderByRaw).toHaveBeenCalledWith( - 'case when lower("fullName") = ? then 0 when lower("fullName") like ? then 1 else 2 end', - ['example-org/example', 'example-org/example%'] + defaultEnvId: 79, + }) + ); + expect(result.created).toBe(true); + }); + + test('undeletes and refreshes an existing soft-deleted repository row', async () => { + const repository = createRepository({ + id: 7, + fullName: 'example-org/old-name', + deletedAt: '2026-01-01T00:00:00.000Z', + }); + repositories.push(repository); + (github.getRepositoryByFullName as jest.Mock).mockResolvedValue({ + data: createInstalledRepository(), + }); + + const result = await service.onboardRepository('example-org/example-repo', 34); + + expect(db.models.Repository.create).not.toHaveBeenCalled(); + expect(repository.patchAndFetch).toHaveBeenCalledWith({ + fullName: 'example-org/example-repo', + deletedAt: null, + }); + expect(result.created).toBe(false); + expect(result.repository).toEqual( + expect.objectContaining({ + id: 7, + fullName: 'example-org/example-repo', + onboarded: true, + deletedAt: null, + }) ); - expect(orderBy).toHaveBeenCalledWith('updatedAt', 'desc'); - expect(limit).toHaveBeenCalledWith(25); }); + }); + + describe('removeRepository', () => { + test('soft deletes the active row and writes a short-lived negative cache entry', async () => { + const repository = createRepository({ id: 7, fullName: 'example-org/api' }); + repositories.push(repository); - test('returns an empty array for blank queries', async () => { - const result = await service.searchRepositories(' ', 10); + const result = await service.removeRepository('Example-Org/API', 34); + + expect(repository.patchAndFetch).toHaveBeenCalledWith({ + deletedAt: expect.any(String), + }); + expect(redis.set).toHaveBeenCalledWith( + githubOnboardedRepositoryCacheKey(34, 12), + JSON.stringify({ onboarded: false }), + 'EX', + 60 + ); + expect(result).toEqual( + expect.objectContaining({ + id: 7, + fullName: 'example-org/api', + onboarded: false, + deletedAt: expect.any(String), + }) + ); + }); + }); - expect(result).toEqual([]); + describe('isRepositoryOnboarded', () => { + test('uses a cached negative membership result without querying the database', async () => { + redis.store.set(githubOnboardedRepositoryCacheKey(34, 12), JSON.stringify({ onboarded: false })); + + const result = await service.isRepositoryOnboarded(34, 12); + + expect(result).toBe(false); expect(db.models.Repository.query).not.toHaveBeenCalled(); }); + + test('falls back to the database and writes a positive membership cache', async () => { + repositories.push(createRepository({ id: 7 })); + + const result = await service.isRepositoryOnboarded(34, 12); + + expect(result).toBe(true); + expect(redis.set).toHaveBeenCalledWith( + githubOnboardedRepositoryCacheKey(34, 12), + JSON.stringify({ + onboarded: true, + repositoryId: 7, + githubRepositoryId: 12, + githubInstallationId: 34, + fullName: 'example-org/example-repo', + }), + 'EX', + GITHUB_API_CACHE_EXPIRATION_SECONDS + ); + }); + + test('falls back to the database and writes a negative membership cache', async () => { + const result = await service.isRepositoryOnboarded(34, 12); + + expect(result).toBe(false); + expect(redis.set).toHaveBeenCalledWith( + githubOnboardedRepositoryCacheKey(34, 12), + JSON.stringify({ onboarded: false }), + 'EX', + 60 + ); + }); + }); + + describe('syncRepositoryRename', () => { + test('updates the active row and patches installed and onboarded caches when present', async () => { + const repository = createRepository({ id: 7, fullName: 'example-org/old-name' }); + repositories.push(repository); + redis.store.set( + githubInstalledRepositoriesCacheKey(34), + JSON.stringify({ + installationId: 34, + fetchedAt: '2026-01-01T00:00:00.000Z', + repositories: [ + { + githubRepositoryId: 12, + ownerId: 56, + ownerLogin: 'example-org', + name: 'old-name', + fullName: 'example-org/old-name', + htmlUrl: 'https://github.com/example-org/old-name', + private: true, + archived: false, + disabled: false, + visibility: 'private', + defaultBranch: 'main', + updatedAt: '2026-01-01T00:00:00.000Z', + pushedAt: '2026-01-01T00:00:00.000Z', + }, + ], + }) + ); + redis.store.set( + githubOnboardedRepositoryCacheKey(34, 12), + JSON.stringify({ + onboarded: true, + repositoryId: 7, + githubRepositoryId: 12, + githubInstallationId: 34, + fullName: 'example-org/old-name', + }) + ); + + const result = await service.syncRepositoryRename({ + githubRepositoryId: 12, + githubInstallationId: 34, + ownerId: 56, + ownerLogin: 'example-org', + name: 'new-name', + fullName: 'example-org/new-name', + htmlUrl: 'https://github.com/example-org/new-name', + }); + const installedCache = JSON.parse(redis.store.get(githubInstalledRepositoriesCacheKey(34))); + const onboardedCache = JSON.parse(redis.store.get(githubOnboardedRepositoryCacheKey(34, 12))); + + expect(result).toEqual( + expect.objectContaining({ + id: 7, + fullName: 'example-org/new-name', + }) + ); + expect(installedCache.repositories[0]).toEqual( + expect.objectContaining({ + name: 'new-name', + fullName: 'example-org/new-name', + htmlUrl: 'https://github.com/example-org/new-name', + }) + ); + expect(onboardedCache).toEqual({ + onboarded: true, + repositoryId: 7, + githubRepositoryId: 12, + githubInstallationId: 34, + fullName: 'example-org/new-name', + }); + }); }); }); diff --git a/src/server/services/github.ts b/src/server/services/github.ts index 5e8cd28c..20f66467 100644 --- a/src/server/services/github.ts +++ b/src/server/services/github.ts @@ -18,7 +18,12 @@ import { parse as fParse } from 'flatted'; import _ from 'lodash'; import Service from './_service'; import { withLogContext, getLogger, extractContextForQueue, LogStage } from 'server/lib/logger'; -import { IssueCommentEvent, PullRequestEvent, PushEvent } from '@octokit/webhooks-types'; +import { + IssueCommentEvent, + PullRequestEvent, + PushEvent, + RepositoryEvent as GithubRepositoryEvent, +} from '@octokit/webhooks-types'; import { GithubPullRequestActions, GithubWebhookTypes, @@ -29,11 +34,12 @@ import { import { QUEUE_NAMES } from 'shared/config'; import { NextApiRequest } from 'next'; import * as github from 'server/lib/github'; -import { Environment, Repository, Build, PullRequest } from 'server/models'; +import { Repository, Build, PullRequest } from 'server/models'; import { LifecycleYamlConfigOptions } from 'server/models/yaml/types'; import { createOrUpdateGithubDeployment, deleteGithubDeploymentAndEnvironment } from 'server/lib/github/deployments'; import { enableKillSwitch, isStaging, hasDeployLabel, isLifecycleLabel } from 'server/lib/utils'; import { redisClient } from 'server/lib/dependencies'; +import RepositoryService from './repository'; interface PullRequestPatchState { deployLabelPresent: boolean; @@ -41,17 +47,84 @@ interface PullRequestPatchState { } export default class GithubService extends Service { + private readonly repositoryService = new RepositoryService(this.db, this.redis, this.redlock, this.queueManager); + + public shouldProcessWebhook = async (body: { + repository?: { + id?: number; + full_name?: string; + html_url?: string; + owner?: { id?: number }; + }; + installation?: { id?: number }; + }): Promise => { + const repositoryPayload = body?.repository; + const installationId = body?.installation?.id; + + if (!repositoryPayload) { + return true; + } + + if (!repositoryPayload?.id) { + getLogger().warn('Webhook: skipped reason=missing_repository_id'); + return false; + } + + if (!installationId) { + getLogger({ githubRepositoryId: repositoryPayload.id }).warn('Webhook: skipped reason=missing_installation_id'); + return false; + } + + const onboarded = await this.repositoryService.isRepositoryOnboarded(installationId, repositoryPayload.id); + + if (!onboarded) { + getLogger({ + githubRepositoryId: repositoryPayload.id, + githubInstallationId: installationId, + fullName: repositoryPayload.full_name, + }).debug('Webhook: skipped reason=repository_not_onboarded'); + } + + return onboarded; + }; + + handleRepositoryWebhook = async (body: GithubRepositoryEvent) => { + const { action, repository, installation } = body; + getLogger({}).info(`GitHub: repository event action=${action} repo=${repository?.full_name}`); + + if (action !== 'renamed') { + return; + } + + if (!installation?.id || !repository?.id || !repository?.full_name) { + getLogger({ + githubRepositoryId: repository?.id, + fullName: repository?.full_name, + }).warn('GitHub: repository rename skipped reason=missing_required_metadata'); + return; + } + + await this.db.services.Repository.syncRepositoryRename({ + githubRepositoryId: repository.id, + githubInstallationId: installation.id, + ownerId: repository.owner?.id, + ownerLogin: repository.owner?.login, + name: repository.name, + fullName: repository.full_name, + htmlUrl: repository.html_url, + }); + }; + // Handle the pull request webhook mapping the entrance with webhook body async handlePullRequestHook({ action, number, repository: { id: repositoryId, - owner: { id: ownerId, html_url: htmlUrl }, - name, + owner: { id: ownerId }, full_name: fullName, }, - installation: { id: installationId }, + installation, pull_request: { id: githubPullRequestId, head: { ref: branch, sha: branchSha }, @@ -65,11 +138,23 @@ export default class GithubService extends Service { action as GithubPullRequestActions ); const isClosed = action === GithubPullRequestActions.CLOSED; - let environment = {} as Environment; let lifecycleConfig = {} as LifecycleYamlConfigOptions; - let pullRequest: PullRequest, repository: Repository, build: Build; + let pullRequest: PullRequest, repository: Repository | undefined, build: Build; try { + const installationId = installation?.id; + if (!installationId) { + getLogger({ githubRepositoryId: repositoryId }).warn('PR: skipped reason=missing_installation_id'); + return; + } + + repository = await this.db.services.Repository.findRepository(ownerId, repositoryId, installationId); + + if (!repository) { + getLogger({}).info(`PR: skipping non-onboarded repository repo=${fullName} repositoryId=${repositoryId}`); + return; + } + if (isOpened) { try { lifecycleConfig = (await github.getYamlFileContent({ @@ -82,26 +167,8 @@ export default class GithubService extends Service { getLogger({}).warn({ error }, `Config: fetch failed repo=${fullName}/${branch}`); } } - repository = await this.db.services.Repository.findRepository(ownerId, repositoryId, installationId); const autoDeploy = lifecycleConfig?.environment?.autoDeploy; - if (!repository) { - environment = await this.db.services.Environment.findOrCreateEnvironment(name, name, autoDeploy); - - repository = await this.db.services.Repository.findOrCreateRepository( - ownerId, - repositoryId, - installationId, - fullName, - htmlUrl, - environment.id - ); - - // NOTE: we don't want to create a service record by default anymore to avoid naming the service after the repo name - // const isFullYaml = this.db.services.Environment.enableFullYamlSupport(environment); - // if (isFullYaml) this.db.services.LCService.findOrCreateDefaultService(environment, repository); - } - pullRequest = await this.db.services.PullRequest.findOrCreatePullRequest(repository, githubPullRequestId, { title, status, @@ -329,9 +396,12 @@ export default class GithubService extends Service { getLogger().info(`Push: skipping dev mode service deployId=${deploy.id} service=${deploy.service?.name}`); return false; } - const serviceBranchName: string = deploy.build.enableFullYaml - ? deploy.deployable.defaultBranchName - : deploy.service.branchName; + const serviceBranchName = deploy.build.enableFullYaml + ? deploy.deployable?.defaultBranchName + : deploy.service?.branchName; + if (!serviceBranchName) { + return false; + } const shouldBuild = deploy.build.trackDefaultBranches || serviceBranchName.toLowerCase() !== branchName.toLowerCase(); @@ -439,6 +509,11 @@ export default class GithubService extends Service { throw new Error('Webhook not verified'); } + const shouldProcessWebhook = await this.shouldProcessWebhook(body); + if (!shouldProcessWebhook) { + return; + } + switch (type) { case GithubWebhookTypes.PULL_REQUEST: try { @@ -468,6 +543,13 @@ export default class GithubService extends Service { getLogger({}).error({ error: e }, `GitHub: ISSUE_COMMENT event handling failed`); throw e; } + case GithubWebhookTypes.REPOSITORY: + try { + return await this.handleRepositoryWebhook(body as GithubRepositoryEvent); + } catch (e) { + getLogger({}).error({ error: e }, `GitHub: REPOSITORY event handling failed`); + throw e; + } default: } }; diff --git a/src/server/services/repository.ts b/src/server/services/repository.ts index 896d2888..86cd442b 100644 --- a/src/server/services/repository.ts +++ b/src/server/services/repository.ts @@ -15,16 +15,599 @@ */ import { getLogger } from 'server/lib/logger'; +import { normalizeRepoFullName } from 'server/lib/normalizeRepoFullName'; +import { PaginationMetadata } from 'server/lib/paginate'; +import { getUtcTimestamp } from 'server/lib/time'; +import * as github from 'server/lib/github'; import { Repository } from 'server/models'; +import { GITHUB_API_CACHE_EXPIRATION_SECONDS } from 'shared/constants'; +import { GITHUB_APP_INSTALLATION_ID } from 'shared/config'; import BaseService from './_service'; +import EnvironmentService from './environment'; -export interface RepositorySearchResult { +const GITHUB_REPOSITORIES_PAGE_SIZE = 100; +const ONBOARDED_REPOSITORY_CACHE_TTL_SECONDS = GITHUB_API_CACHE_EXPIRATION_SECONDS; +const NOT_ONBOARDED_REPOSITORY_CACHE_TTL_SECONDS = 60; + +export const githubInstalledRepositoriesCacheKey = (installationId: number) => `github:installed:${installationId}`; +export const githubOnboardedRepositoryCacheKey = (installationId: number, githubRepositoryId: number) => + `github:onboarded:${installationId}:${githubRepositoryId}`; + +export interface RepositoryResponse { + id: number; + githubRepositoryId: number; + githubInstallationId: number; + ownerId: number | null; + fullName: string; + htmlUrl: string | null; + defaultEnvId: number | null; + onboarded: boolean; + createdAt?: string; + updatedAt?: string; + deletedAt?: string | null; +} + +export interface InstalledRepositoryResponse { githubRepositoryId: number; + ownerId: number | null; + ownerLogin: string | null; + name: string; fullName: string; htmlUrl: string | null; + private: boolean | null; + archived: boolean | null; + disabled: boolean | null; + visibility: string | null; + defaultBranch: string | null; + updatedAt: string | null; + pushedAt: string | null; + onboarded?: boolean; +} + +export interface OnboardRepositoryResult { + repository: RepositoryResponse; + created: boolean; +} + +export interface RepositoryListResult { + repositories: T[]; + pagination: PaginationMetadata; +} + +interface RepositoryMetadata { + ownerId?: number | null; + githubRepositoryId: number; + githubInstallationId: number; + name?: string | null; + ownerLogin?: string | null; + fullName: string; + htmlUrl?: string | null; + defaultEnvId?: number | null; +} + +interface ListRepositoriesOptions { + query?: string; + page?: number; + limit?: number; + installationId?: number | string | null; + onboarded?: boolean; + refresh?: boolean; +} + +interface InstalledRepositoriesCachePayload { + installationId: number; + fetchedAt: string; + repositories: InstalledRepositoryResponse[]; +} + +type OnboardedRepositoryCachePayload = + | { + onboarded: true; + repositoryId: number; + githubRepositoryId: number; + githubInstallationId: number; + fullName: string; + } + | { + onboarded: false; + }; + +function parseBooleanParam(value?: string | null): boolean | undefined { + if (value == null || value === '') return undefined; + if (value === 'true') return true; + if (value === 'false') return false; + throw new Error('onboarded must be true or false'); +} + +function paginateArray(items: T[], page = 1, limit = 25): RepositoryListResult { + const normalizedPage = Number.isFinite(page) && page > 0 ? Math.floor(page) : 1; + const normalizedLimit = Number.isFinite(limit) && limit > 0 ? Math.min(Math.floor(limit), 100) : 25; + const start = (normalizedPage - 1) * normalizedLimit; + const repositories = items.slice(start, start + normalizedLimit); + + return { + repositories, + pagination: { + current: normalizedPage, + total: Math.max(Math.ceil(items.length / normalizedLimit), 1), + items: items.length, + limit: normalizedLimit, + }, + }; } export default class RepositoryService extends BaseService { + public parseOnboardedParam = parseBooleanParam; + private readonly environmentService = new EnvironmentService(this.db, this.redis, this.redlock, this.queueManager); + + private resolveInstallationId(installationId?: number | string | null): number { + const rawInstallationId = installationId ?? GITHUB_APP_INSTALLATION_ID; + const resolvedInstallationId = + typeof rawInstallationId === 'number' ? rawInstallationId : Number.parseInt(String(rawInstallationId), 10); + + if (!Number.isFinite(resolvedInstallationId)) { + throw new Error('A valid GitHub App installation ID is required'); + } + + return resolvedInstallationId; + } + + private normalizeAndValidateFullName(rawFullName: string): string { + const fullName = normalizeRepoFullName(rawFullName || ''); + if ((fullName.match(/\//g) || []).length !== 1) { + throw new Error('Invalid repository fullName. Expected format: owner/repo'); + } + + return fullName; + } + + private normalizeQuery(query?: string): string { + return (query || '').trim().toLowerCase(); + } + + private toRepositoryResponse(repository: Repository, onboarded = true): RepositoryResponse { + return { + id: repository.id, + githubRepositoryId: repository.githubRepositoryId, + githubInstallationId: repository.githubInstallationId, + ownerId: repository.ownerId ?? null, + fullName: repository.fullName, + htmlUrl: repository.htmlUrl ?? null, + defaultEnvId: repository.defaultEnvId ?? null, + onboarded, + createdAt: repository.createdAt, + updatedAt: repository.updatedAt, + deletedAt: repository.deletedAt ?? null, + }; + } + + private normalizeGithubRepository(repo: any): InstalledRepositoryResponse { + return { + githubRepositoryId: repo.id, + ownerId: repo.owner?.id ?? null, + ownerLogin: repo.owner?.login ?? null, + name: repo.name, + fullName: repo.full_name, + htmlUrl: repo.html_url ?? null, + private: typeof repo.private === 'boolean' ? repo.private : null, + archived: typeof repo.archived === 'boolean' ? repo.archived : null, + disabled: typeof repo.disabled === 'boolean' ? repo.disabled : null, + visibility: repo.visibility ?? null, + defaultBranch: repo.default_branch ?? null, + updatedAt: repo.updated_at ?? null, + pushedAt: repo.pushed_at ?? null, + }; + } + + private normalizeWebhookRepository(metadata: RepositoryMetadata): InstalledRepositoryResponse { + const [, repoName] = metadata.fullName.split('/'); + return { + githubRepositoryId: metadata.githubRepositoryId, + ownerId: metadata.ownerId ?? null, + ownerLogin: metadata.ownerLogin ?? null, + name: metadata.name || repoName, + fullName: metadata.fullName, + htmlUrl: metadata.htmlUrl ?? null, + private: null, + archived: null, + disabled: null, + visibility: null, + defaultBranch: null, + updatedAt: null, + pushedAt: null, + }; + } + + private async patchRepositoryMetadata(repository: Repository, metadata: RepositoryMetadata): Promise { + const patch: Record = {}; + + if (metadata.ownerId != null && repository.ownerId !== metadata.ownerId) patch.ownerId = metadata.ownerId; + if (metadata.fullName && repository.fullName !== metadata.fullName) patch.fullName = metadata.fullName; + if (metadata.htmlUrl && repository.htmlUrl !== metadata.htmlUrl) patch.htmlUrl = metadata.htmlUrl; + if (metadata.defaultEnvId != null && !repository.defaultEnvId) patch.defaultEnvId = metadata.defaultEnvId; + if (repository.deletedAt) patch.deletedAt = null; + + if (!Object.keys(patch).length) { + return repository; + } + + return await repository.$query().patchAndFetch(patch); + } + + private async writeOnboardedRepositoryCache(repository: Repository): Promise { + const payload: OnboardedRepositoryCachePayload = { + onboarded: true, + repositoryId: repository.id, + githubRepositoryId: repository.githubRepositoryId, + githubInstallationId: repository.githubInstallationId, + fullName: repository.fullName, + }; + + await this.redis.set( + githubOnboardedRepositoryCacheKey(repository.githubInstallationId, repository.githubRepositoryId), + JSON.stringify(payload), + 'EX', + ONBOARDED_REPOSITORY_CACHE_TTL_SECONDS + ); + } + + private async writeNotOnboardedRepositoryCache( + githubInstallationId: number, + githubRepositoryId: number + ): Promise { + const payload: OnboardedRepositoryCachePayload = { onboarded: false }; + await this.redis.set( + githubOnboardedRepositoryCacheKey(githubInstallationId, githubRepositoryId), + JSON.stringify(payload), + 'EX', + NOT_ONBOARDED_REPOSITORY_CACHE_TTL_SECONDS + ); + } + + private async patchInstalledRepositoriesCache(metadata: RepositoryMetadata): Promise { + const cacheKey = githubInstalledRepositoriesCacheKey(metadata.githubInstallationId); + const cached = await this.redis.get(cacheKey); + if (!cached) return; + + try { + const payload = JSON.parse(cached) as InstalledRepositoriesCachePayload; + const repository = this.normalizeWebhookRepository(metadata); + const nextRepositories = payload.repositories.map((existing) => + existing.githubRepositoryId === metadata.githubRepositoryId + ? { + ...existing, + ownerId: repository.ownerId ?? existing.ownerId, + ownerLogin: repository.ownerLogin ?? existing.ownerLogin, + name: repository.name, + fullName: repository.fullName, + htmlUrl: repository.htmlUrl, + } + : existing + ); + + await this.redis.set( + cacheKey, + JSON.stringify({ + ...payload, + repositories: nextRepositories, + }), + 'EX', + GITHUB_API_CACHE_EXPIRATION_SECONDS + ); + } catch (error) { + getLogger({ error, cacheKey }).warn('Repository: installed cache patch failed'); + await this.redis.del(cacheKey); + } + } + + private async patchOnboardedRepositoryCache(repository: Repository): Promise { + const cacheKey = githubOnboardedRepositoryCacheKey(repository.githubInstallationId, repository.githubRepositoryId); + const cached = await this.redis.get(cacheKey); + if (!cached) return; + + try { + const payload = JSON.parse(cached) as OnboardedRepositoryCachePayload; + if (!payload.onboarded) return; + await this.writeOnboardedRepositoryCache(repository); + } catch (error) { + getLogger({ error, cacheKey }).warn('Repository: onboarded cache patch failed'); + await this.redis.del(cacheKey); + } + } + + private async readInstalledRepositoriesCache( + installationId: number + ): Promise { + const cached = await this.redis.get(githubInstalledRepositoriesCacheKey(installationId)); + if (!cached) return null; + + try { + return JSON.parse(cached) as InstalledRepositoriesCachePayload; + } catch (error) { + getLogger({ error, installationId }).warn('Repository: installed cache parse failed'); + await this.redis.del(githubInstalledRepositoriesCacheKey(installationId)); + return null; + } + } + + private async writeInstalledRepositoriesCache(payload: InstalledRepositoriesCachePayload): Promise { + await this.redis.set( + githubInstalledRepositoriesCacheKey(payload.installationId), + JSON.stringify(payload), + 'EX', + GITHUB_API_CACHE_EXPIRATION_SECONDS + ); + } + + private async fetchInstalledRepositoriesFromGithub(installationId: number): Promise { + const repositories: InstalledRepositoryResponse[] = []; + let page = 1; + let totalCount = Number.POSITIVE_INFINITY; + + while (repositories.length < totalCount) { + const response = await github.listInstallationRepositories({ + installationId, + page, + perPage: GITHUB_REPOSITORIES_PAGE_SIZE, + }); + const pageRepositories = response.data?.repositories || []; + totalCount = response.data?.total_count ?? repositories.length + pageRepositories.length; + repositories.push(...pageRepositories.map((repo) => this.normalizeGithubRepository(repo))); + + if (pageRepositories.length < GITHUB_REPOSITORIES_PAGE_SIZE) { + break; + } + page += 1; + } + + return repositories; + } + + private async getInstalledRepositories( + installationId: number, + refresh = false + ): Promise { + if (!refresh) { + const cached = await this.readInstalledRepositoriesCache(installationId); + if (cached) return cached.repositories; + } + + const repositories = await this.fetchInstalledRepositoriesFromGithub(installationId); + await this.writeInstalledRepositoriesCache({ + installationId, + fetchedAt: new Date().toISOString(), + repositories, + }); + + return repositories; + } + + private async getActiveOnboardedRepositories(installationId?: number): Promise { + const query = this.db.models.Repository.query().whereNull('deletedAt'); + if (installationId) { + query.where('githubInstallationId', installationId); + } + return await query; + } + + async findRepositoryByGithubId( + githubRepositoryId: number, + githubInstallationId: number, + { includeDeleted = false }: { includeDeleted?: boolean } = {} + ): Promise { + try { + const query = this.db.models.Repository.query().where({ + githubRepositoryId, + githubInstallationId, + }); + + if (!includeDeleted) { + query.whereNull('deletedAt'); + } + + return await query.first(); + } catch (error) { + getLogger({ githubRepositoryId, githubInstallationId, error }).error('Repository: find by GitHub ID failed'); + throw error; + } + } + + async listOnboardedRepositories({ + query, + page = 1, + limit = 25, + installationId, + }: ListRepositoriesOptions = {}): Promise> { + const normalizedQuery = this.normalizeQuery(query); + const githubInstallationId = installationId == null ? null : this.resolveInstallationId(installationId); + const repositoryQuery = this.db.models.Repository.query().whereNull('deletedAt'); + + if (githubInstallationId) { + repositoryQuery.where('githubInstallationId', githubInstallationId); + } + + if (normalizedQuery) { + repositoryQuery.whereRaw('lower("fullName") like ?', [`%${normalizedQuery}%`]); + } + + const normalizedPage = Number.isFinite(page) && page > 0 ? Math.floor(page) : 1; + const normalizedLimit = Number.isFinite(limit) && limit > 0 ? Math.min(Math.floor(limit), 100) : 25; + const result = await repositoryQuery.orderBy('fullName', 'asc').page(normalizedPage - 1, normalizedLimit); + + return { + repositories: result.results.map((repository) => this.toRepositoryResponse(repository, true)), + pagination: { + current: normalizedPage, + total: Math.max(Math.ceil(result.total / normalizedLimit), 1), + items: result.total, + limit: normalizedLimit, + }, + }; + } + + async listInstalledRepositories({ + query, + page = 1, + limit = 25, + installationId, + onboarded, + refresh = false, + }: ListRepositoriesOptions = {}): Promise> { + const githubInstallationId = this.resolveInstallationId(installationId); + const normalizedQuery = this.normalizeQuery(query); + const installedRepositories = await this.getInstalledRepositories(githubInstallationId, refresh); + const onboardedRepositories = await this.getActiveOnboardedRepositories(githubInstallationId); + const onboardedRepositoryIds = new Set(onboardedRepositories.map((repository) => repository.githubRepositoryId)); + + const repositories = installedRepositories + .map((repository) => ({ + ...repository, + onboarded: onboardedRepositoryIds.has(repository.githubRepositoryId), + })) + .filter((repository) => { + if (typeof onboarded === 'boolean' && repository.onboarded !== onboarded) return false; + if (!normalizedQuery) return true; + return ( + repository.fullName.toLowerCase().includes(normalizedQuery) || + repository.name.toLowerCase().includes(normalizedQuery) + ); + }) + .sort((a, b) => a.fullName.localeCompare(b.fullName)); + + return paginateArray(repositories, page, limit); + } + + async syncRepositoryRename(metadata: RepositoryMetadata): Promise { + const repository = await this.findRepositoryByGithubId(metadata.githubRepositoryId, metadata.githubInstallationId); + if (!repository) { + return null; + } + + const updatedRepository = await this.patchRepositoryMetadata(repository, metadata); + await this.patchInstalledRepositoriesCache(metadata); + await this.patchOnboardedRepositoryCache(updatedRepository); + + return updatedRepository; + } + + async isRepositoryOnboarded(githubInstallationId: number, githubRepositoryId: number): Promise { + const cacheKey = githubOnboardedRepositoryCacheKey(githubInstallationId, githubRepositoryId); + const cached = await this.redis.get(cacheKey); + if (cached) { + try { + const payload = JSON.parse(cached) as OnboardedRepositoryCachePayload; + return payload.onboarded; + } catch (error) { + getLogger({ error, cacheKey }).warn('Repository: onboarded cache parse failed'); + await this.redis.del(cacheKey); + } + } + + const repository = await this.findRepositoryByGithubId(githubRepositoryId, githubInstallationId); + if (!repository) { + await this.writeNotOnboardedRepositoryCache(githubInstallationId, githubRepositoryId); + return false; + } + + await this.writeOnboardedRepositoryCache(repository); + return true; + } + + async upsertRepositoryMetadata(metadata: RepositoryMetadata): Promise { + const repository = await this.findRepositoryByGithubId(metadata.githubRepositoryId, metadata.githubInstallationId, { + includeDeleted: true, + }); + + if (repository) { + const updatedRepository = await this.patchRepositoryMetadata(repository, metadata); + await this.writeOnboardedRepositoryCache(updatedRepository); + return { + repository: this.toRepositoryResponse(updatedRepository, true), + created: false, + }; + } + + const createdRepository = await this.db.models.Repository.create({ + githubRepositoryId: metadata.githubRepositoryId, + githubInstallationId: metadata.githubInstallationId, + ownerId: metadata.ownerId, + fullName: metadata.fullName, + htmlUrl: metadata.htmlUrl, + defaultEnvId: metadata.defaultEnvId, + deletedAt: null, + }); + + await this.writeOnboardedRepositoryCache(createdRepository); + + return { + repository: this.toRepositoryResponse(createdRepository, true), + created: true, + }; + } + + async onboardRepository(fullName: string, installationId?: number | string | null): Promise { + const normalizedFullName = this.normalizeAndValidateFullName(fullName); + const githubInstallationId = this.resolveInstallationId(installationId); + + try { + const repoResponse = await github.getRepositoryByFullName(normalizedFullName, githubInstallationId); + const repo = repoResponse.data; + const environment = await this.environmentService.findOrCreateEnvironment(repo.name, repo.name, false); + + return await this.upsertRepositoryMetadata({ + ownerId: repo.owner?.id, + ownerLogin: repo.owner?.login, + name: repo.name, + githubRepositoryId: repo.id, + githubInstallationId, + fullName: repo.full_name, + htmlUrl: repo.html_url, + defaultEnvId: environment.id, + }); + } catch (error) { + getLogger({ fullName: normalizedFullName, installationId: githubInstallationId, error }).error( + 'Repository: onboard failed' + ); + throw error; + } + } + + async removeRepository(fullName: string, installationId?: number | string | null): Promise { + const normalizedFullName = this.normalizeAndValidateFullName(fullName); + const githubInstallationId = installationId == null ? null : this.resolveInstallationId(installationId); + + try { + const query = this.db.models.Repository.query() + .whereNull('deletedAt') + .whereRaw('lower("fullName") = ?', [normalizedFullName]); + + if (githubInstallationId) { + query.where('githubInstallationId', githubInstallationId); + } + + const repository = await query.first(); + if (!repository) { + throw new Error(`Repository not found or already removed: ${normalizedFullName}`); + } + + const removedRepository = await repository.$query().patchAndFetch({ + deletedAt: getUtcTimestamp(), + }); + + await this.writeNotOnboardedRepositoryCache( + removedRepository.githubInstallationId, + removedRepository.githubRepositoryId + ); + + return this.toRepositoryResponse(removedRepository, false); + } catch (error) { + getLogger({ fullName: normalizedFullName, installationId: githubInstallationId, error }).error( + 'Repository: remove failed' + ); + throw error; + } + } + /** * Retrieve a Lifecycle Github Repository model. If it doesn't exist, create a new record. * @param ownerId Github repoistory owner ID. @@ -47,11 +630,7 @@ export default class RepositoryService extends BaseService { try { repository = - (await this.db.models.Repository.findOne({ - githubRepositoryId, - githubInstallationId, - ownerId, - })) || + (await this.findRepository(ownerId, githubRepositoryId, githubInstallationId)) || (await this.db.models.Repository.create({ githubRepositoryId, githubInstallationId, @@ -69,24 +648,28 @@ export default class RepositoryService extends BaseService { } /** - * Retrieve a Lifecycle Github Repository model. If it doesn't exist, create a new record. + * Retrieve a Lifecycle Github Repository model. * @param ownerId Github repoistory owner ID. * @param githubRepositoryId Github repository ID. * @param githubInstallationId Lifecycle Github installation ID. - * @param fullName Github repository full name (including the owner/organization name). - * @param htmlUrl Github repository owner URL. - * @param defaultEnvId Default Lifecycle environment ID. * @returns Lifecycle Github Repository model. */ - async findRepository(ownerId: number, githubRepositoryId: number, githubInstallationId: number) { - let repository: Repository; + async findRepository( + ownerId: number, + githubRepositoryId: number, + githubInstallationId: number + ): Promise { + let repository: Repository | undefined; try { - repository = await this.db.models.Repository.findOne({ - githubRepositoryId, - githubInstallationId, - ownerId, - }); + repository = await this.db.models.Repository.query() + .where({ + githubRepositoryId, + githubInstallationId, + ownerId, + }) + .whereNull('deletedAt') + .first(); } catch (error) { getLogger({ githubRepositoryId, error }).error('Repository: find failed'); throw error; @@ -95,27 +678,8 @@ export default class RepositoryService extends BaseService { return repository; } - async searchRepositories(query: string, limit = 10): Promise { - const normalizedQuery = query.trim().toLowerCase(); - if (!normalizedQuery) { - return []; - } - - const normalizedLimit = Math.min(Math.max(limit, 1), 25); - - try { - return await this.db.models.Repository.query() - .select('githubRepositoryId', 'fullName', 'htmlUrl') - .whereRaw('lower("fullName") like ?', [`%${normalizedQuery}%`]) - .orderByRaw('case when lower("fullName") = ? then 0 when lower("fullName") like ? then 1 else 2 end', [ - normalizedQuery, - `${normalizedQuery}%`, - ]) - .orderBy('updatedAt', 'desc') - .limit(normalizedLimit); - } catch (error) { - getLogger({ query: normalizedQuery, limit: normalizedLimit, error }).error('Repository: search failed'); - throw error; - } + async searchRepositories(query: string, limit = 10): Promise { + const result = await this.listOnboardedRepositories({ query, limit }); + return result.repositories; } } diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 770845dc..e1216361 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -103,6 +103,7 @@ export enum GithubWebhookTypes { PULL_REQUEST = 'pull_request', INTEGRATION_INSTALLATION = 'integration_installation', PUSH = 'push', + REPOSITORY = 'repository', LABELED = 'labeled', UNLABELED = 'unlabeled', ISSUE_COMMENT = 'issue_comment', diff --git a/src/shared/openApiSpec.ts b/src/shared/openApiSpec.ts index a8ee0cd5..9b097e53 100644 --- a/src/shared/openApiSpec.ts +++ b/src/shared/openApiSpec.ts @@ -137,11 +137,19 @@ export const openApiSpecificationForV2Api: OAS3Options = { RepositorySearchResult: { type: 'object', properties: { + id: { type: 'integer' }, githubRepositoryId: { type: 'integer' }, + githubInstallationId: { type: 'integer' }, + ownerId: { type: 'integer', nullable: true }, fullName: { type: 'string' }, htmlUrl: { type: 'string', nullable: true }, + defaultEnvId: { type: 'integer', nullable: true }, + onboarded: { type: 'boolean' }, + createdAt: { type: 'string', format: 'date-time', nullable: true }, + updatedAt: { type: 'string', format: 'date-time', nullable: true }, + deletedAt: { type: 'string', format: 'date-time', nullable: true }, }, - required: ['githubRepositoryId', 'fullName'], + required: ['githubRepositoryId', 'fullName', 'onboarded'], }, SearchRepositoriesResponse: { @@ -168,6 +176,133 @@ export const openApiSpecificationForV2Api: OAS3Options = { ], }, + OnboardRepositoryRequest: { + type: 'object', + properties: { + fullName: { + type: 'string', + description: 'GitHub repository full name. GitHub URLs and .git suffixes are accepted.', + example: 'example-org/example-repo', + }, + installationId: { + type: 'integer', + description: 'Optional GitHub App installation ID. Defaults to GITHUB_APP_INSTALLATION_ID.', + }, + }, + required: ['fullName'], + }, + + OnboardedRepository: { + type: 'object', + properties: { + id: { type: 'integer' }, + githubRepositoryId: { type: 'integer' }, + githubInstallationId: { type: 'integer' }, + ownerId: { type: 'integer', nullable: true }, + fullName: { type: 'string' }, + htmlUrl: { type: 'string', nullable: true }, + defaultEnvId: { type: 'integer', nullable: true }, + onboarded: { type: 'boolean' }, + createdAt: { type: 'string', format: 'date-time', nullable: true }, + updatedAt: { type: 'string', format: 'date-time', nullable: true }, + deletedAt: { type: 'string', format: 'date-time', nullable: true }, + }, + required: ['id', 'githubRepositoryId', 'githubInstallationId', 'fullName', 'onboarded'], + }, + + InstalledRepository: { + type: 'object', + properties: { + githubRepositoryId: { type: 'integer' }, + ownerId: { type: 'integer', nullable: true }, + ownerLogin: { type: 'string', nullable: true }, + name: { type: 'string' }, + fullName: { type: 'string' }, + htmlUrl: { type: 'string', nullable: true }, + private: { type: 'boolean', nullable: true }, + archived: { type: 'boolean', nullable: true }, + disabled: { type: 'boolean', nullable: true }, + visibility: { type: 'string', nullable: true }, + defaultBranch: { type: 'string', nullable: true }, + updatedAt: { type: 'string', format: 'date-time', nullable: true }, + pushedAt: { type: 'string', format: 'date-time', nullable: true }, + onboarded: { type: 'boolean' }, + }, + required: ['githubRepositoryId', 'name', 'fullName', 'onboarded'], + }, + + ListRepositoriesResponse: { + type: 'object', + properties: { + repositories: { + type: 'array', + items: { + oneOf: [ + { $ref: '#/components/schemas/OnboardedRepository' }, + { $ref: '#/components/schemas/InstalledRepository' }, + ], + }, + }, + }, + required: ['repositories'], + }, + + ListRepositoriesSuccessResponse: { + allOf: [ + { $ref: '#/components/schemas/SuccessApiResponse' }, + { + type: 'object', + properties: { + data: { $ref: '#/components/schemas/ListRepositoriesResponse' }, + }, + required: ['data'], + }, + ], + }, + + OnboardRepositoryResponse: { + type: 'object', + properties: { + repository: { $ref: '#/components/schemas/OnboardedRepository' }, + created: { type: 'boolean' }, + }, + required: ['repository', 'created'], + }, + + OnboardRepositorySuccessResponse: { + allOf: [ + { $ref: '#/components/schemas/SuccessApiResponse' }, + { + type: 'object', + properties: { + data: { $ref: '#/components/schemas/OnboardRepositoryResponse' }, + }, + required: ['data'], + }, + ], + }, + + RemoveRepositoryResponse: { + type: 'object', + properties: { + repository: { $ref: '#/components/schemas/OnboardedRepository' }, + }, + required: ['repository'], + }, + + RemoveRepositorySuccessResponse: { + allOf: [ + { $ref: '#/components/schemas/SuccessApiResponse' }, + { + type: 'object', + properties: { + data: { $ref: '#/components/schemas/RemoveRepositoryResponse' }, + }, + required: ['data'], + }, + ], + }, + AgentModel: { type: 'object', properties: {