diff --git a/package.json b/package.json index 77064318..18dc8273 100644 --- a/package.json +++ b/package.json @@ -90,8 +90,8 @@ "@fastify/type-provider-typebox": "^4.0.0", "@firebase/rules-unit-testing": "^4.0.1", "@ghostery/adblocker-puppeteer": "2.11.3", - "@gitbeaker/core": "43.4.0", - "@gitbeaker/rest": "43.4.0", + "@gitbeaker/core": "43.5.0", + "@gitbeaker/rest": "43.5.0", "@google-cloud/aiplatform": "^5.7.0", "@google-cloud/bigquery": "^8.1.1", "@google-cloud/discoveryengine": "^2.4.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index da8658f3..a8d02e23 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -75,11 +75,11 @@ importers: specifier: 2.11.3 version: 2.11.3(puppeteer@22.15.0(typescript@5.9.2)) '@gitbeaker/core': - specifier: 43.4.0 - version: 43.4.0 + specifier: 43.5.0 + version: 43.5.0 '@gitbeaker/rest': - specifier: 43.4.0 - version: 43.4.0 + specifier: 43.5.0 + version: 43.5.0 '@google-cloud/aiplatform': specifier: ^5.7.0 version: 5.7.0 @@ -1578,16 +1578,16 @@ packages: '@ghostery/url-parser@1.3.0': resolution: {integrity: sha512-FEzdSeiva0Mt3bR4xePFzthhjT4IzvA5QTvS1xXkNyLpMGeq40mb3V2fSs0ZItRaP9IybZthDfHUSbQ1HLdx4Q==} - '@gitbeaker/core@43.4.0': - resolution: {integrity: sha512-SN7/2raXQa99i/koOV5voQ3q5Pz9F8TkyECBDmY8lDrgufgf5QLfRvTVc1uRU/As543KCrPi2s/Hn+vZgwLdYw==} + '@gitbeaker/core@43.5.0': + resolution: {integrity: sha512-Lfsl6DE/2RkFvpSEhMEnN6sNuY0IeR68UEQq2qzR0MkUF1RMCmOFlD3OydnT9yY+fkWjB4FPSG4SA/oBVZYTFQ==} engines: {node: '>=18.20.0'} - '@gitbeaker/requester-utils@43.4.0': - resolution: {integrity: sha512-W15fZbLzEonMdqzz+/H3KmIByI3neUE2+HjnvnyMl5g4CHZuPCkj0boTkGRBH1c6ax1Fmdhz5vPv7OjRF274gQ==} + '@gitbeaker/requester-utils@43.5.0': + resolution: {integrity: sha512-C6CLAZDy6mNAKHqqt+T2s0RNXf7tmjT9PLAxTZCdtS0276eAj1xmqmPPy9RDKKPzhGaiUicn9q2pA3IyEDM1jQ==} engines: {node: '>=18.20.0'} - '@gitbeaker/rest@43.4.0': - resolution: {integrity: sha512-SmyBcC16+wNuAT77vcqk1gQr5ITC0yD4ln5/s7qplEC4U3hQtII/NKvpiygapMDrFF4GGA3GTCQAPQuUk22onw==} + '@gitbeaker/rest@43.5.0': + resolution: {integrity: sha512-HJgzKSBtdHrfpbH3vHj+1qSyH9RR0L/zMDCwvo4NE2Fg9P2nkWMfj2AzhytENHGTtL6gcfSPOS16HLLTQ4uVeg==} engines: {node: '>=18.20.0'} '@google-cloud/aiplatform@5.7.0': @@ -6954,8 +6954,8 @@ packages: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} - rate-limiter-flexible@4.0.1: - resolution: {integrity: sha512-2/dGHpDFpeA0+755oUkW+EKyklqLS9lu0go9pDsbhqQjZcxfRyJ6LA4JI0+HAdZ2bemD/oOjUeZQB2lCZqXQfQ==} + rate-limiter-flexible@7.4.0: + resolution: {integrity: sha512-IJopePGO6HnMWVdeLCihnxXZ0WCW0mxXiU5LE3bZ00GHESsCaAvgD8hN/ATIJeZhnrVdU5cfRyS1uV63Vmc4zg==} raw-body@2.5.2: resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==} @@ -9590,23 +9590,23 @@ snapshots: dependencies: tldts-experimental: 7.0.11 - '@gitbeaker/core@43.4.0': + '@gitbeaker/core@43.5.0': dependencies: - '@gitbeaker/requester-utils': 43.4.0 + '@gitbeaker/requester-utils': 43.5.0 qs: 6.14.0 xcase: 2.0.1 - '@gitbeaker/requester-utils@43.4.0': + '@gitbeaker/requester-utils@43.5.0': dependencies: picomatch-browser: 2.2.6 qs: 6.14.0 - rate-limiter-flexible: 4.0.1 + rate-limiter-flexible: 7.4.0 xcase: 2.0.1 - '@gitbeaker/rest@43.4.0': + '@gitbeaker/rest@43.5.0': dependencies: - '@gitbeaker/core': 43.4.0 - '@gitbeaker/requester-utils': 43.4.0 + '@gitbeaker/core': 43.5.0 + '@gitbeaker/requester-utils': 43.5.0 '@google-cloud/aiplatform@5.7.0': dependencies: @@ -16168,7 +16168,7 @@ snapshots: range-parser@1.2.1: {} - rate-limiter-flexible@4.0.1: {} + rate-limiter-flexible@7.4.0: {} raw-body@2.5.2: dependencies: diff --git a/src/agent/agentContextService/agentContextService.test.ts b/src/agent/agentContextService/agentContextService.test.ts index 63d8d260..6b961959 100644 --- a/src/agent/agentContextService/agentContextService.test.ts +++ b/src/agent/agentContextService/agentContextService.test.ts @@ -59,6 +59,7 @@ export const testUser: User = { name: 'John Doe', email: 'test@example.com', enabled: true, + admin: false, createdAt: new Date(Date.now() - 86400000), // Yesterday lastLoginAt: new Date(), hilBudget: 1.5, @@ -73,6 +74,7 @@ export const otherUser: User = { name: 'John Doe', email: 'other@example.com', enabled: true, + admin: false, createdAt: new Date(Date.now() - 172800000), // Day before yesterday lastLoginAt: new Date(Date.now() - 3600000), // Hour ago hilBudget: 0.5, diff --git a/src/agent/autonomous/autonomousAgentRunner.ts b/src/agent/autonomous/autonomousAgentRunner.ts index 4c56ec50..97487937 100644 --- a/src/agent/autonomous/autonomousAgentRunner.ts +++ b/src/agent/autonomous/autonomousAgentRunner.ts @@ -9,6 +9,7 @@ import { appContext } from '#app/applicationContext'; import { FUNC_SEP } from '#functionSchema/functions'; import { Git } from '#functions/scm/git'; import { GitHub } from '#functions/scm/github'; +import { GitLab } from '#functions/scm/gitlab'; import { logger } from '#o11y/logger'; import type { AgentContext } from '#shared/agent/agent.model'; import type { FunctionCallResult } from '#shared/llm/llm.model'; @@ -53,15 +54,7 @@ async function _startAgent(agent: AgentContext): Promise { await checkRepoHomeAndWorkingDirectory(agent); - const metadata = agent.metadata ?? {}; - const githubProject = metadata.github?.repository; - if (githubProject) { - runAsUser(agent.user, async () => { - const repoPath = await new GitHub().cloneProject(githubProject); - agent.fileSystem!.setWorkingDirectory(repoPath); - if (metadata.github.branch) await new Git().switchToBranch(metadata.github.branch); - }); - } + await initialiseMetadataRepository(agent); switch (agent.subtype) { case 'xml': @@ -81,6 +74,48 @@ async function _startAgent(agent: AgentContext): Promise { return execution; } +async function initialiseMetadataRepository(agent: AgentContext) { + const metadata = agent.metadata ?? {}; + let hasRepo = false; + let branch: string | undefined; + + let gitProject = metadata.github?.repository; + if (gitProject) { + hasRepo = true; + await runAsUser(agent.user, async () => { + branch = metadata.github.branch; + const repoPath = await new GitHub().cloneProject(gitProject, branch); + agent.fileSystem!.setWorkingDirectory(repoPath); + }); + } + gitProject = metadata.gitlab?.projectPath; + if (gitProject) { + hasRepo = true; + await runAsUser(agent.user, async () => { + branch = metadata.gitlab.branch; + const repoPath = await new GitLab().cloneProject(gitProject, branch); + agent.fileSystem!.setWorkingDirectory(repoPath); + }); + } + // If an agent has switched a shared repo from the main/master branch, then switch it back + if (agent.useSharedRepos && hasRepo && !branch) { + const git = new Git(); + const currentBranch = await git.getBranchName(); + if (currentBranch !== 'main' && currentBranch !== 'master') { + logger.warn(`Shared repo ${gitProject} is not on branch main or master, switching back`); + try { + await git.switchToBranch('main'); + } catch (e) { + try { + await git.switchToBranch('master'); + } catch (e) { + logger.warn({ metadata }, 'Couldnt restore branch to `main` or `master`', e); + } + } + } + } +} + export async function startAgentAndWaitForCompletion(config: RunAgentConfig): Promise { const agentExecution = await startAgent(config); diff --git a/src/chat/chatService.test.ts b/src/chat/chatService.test.ts index ea39244b..720fade8 100644 --- a/src/chat/chatService.test.ts +++ b/src/chat/chatService.test.ts @@ -6,7 +6,8 @@ import type { User } from '#shared/user/user.model'; import { runAsUser } from '#user/userContext'; export const SINGLE_USER: User = { - enabled: false, + enabled: true, + admin: false, hilBudget: 0, hilCount: 0, llmConfig: {}, @@ -27,6 +28,7 @@ export const USER_A: User = { name: 'User A', email: 'usera@example.com', enabled: true, + admin: false, hilBudget: 0, hilCount: 0, llmConfig: {}, @@ -44,6 +46,7 @@ export const USER_B: User = { name: 'User B', email: 'userb@example.com', enabled: true, + admin: false, hilBudget: 0, hilCount: 0, llmConfig: {}, diff --git a/src/functions/scm/cicdStatsService.ts b/src/functions/scm/cicdStatsService.ts new file mode 100644 index 00000000..c196633b --- /dev/null +++ b/src/functions/scm/cicdStatsService.ts @@ -0,0 +1,27 @@ +export function buildJobUrl(job: JobResult): string { + if (job.host.includes('github.com')) return `https://${job.host}/${job.project}/actions/runs/${job.buildId}`; + + return `https://${job.host}/${job.project}/-/jobs/${job.buildId}`; +} + +export interface JobResult { + buildId: number; + project: string; + status: string; + jobName: string; + stage: string; + startedAt: string; + duration: number; + pipeline: number; + host: string; + /** build_failure_reason field */ + failureReason?: string; + /** Our classification of the failure type */ + failureType?: string; +} + +export interface CICDStatsService { + saveJobResult(jobResult: JobResult): Promise; + + getRecentSuccessfulJobs(project: string, jobName: string): Promise; +} diff --git a/src/functions/scm/gitlab.ts b/src/functions/scm/gitlab.ts index 5a3ce020..a3c61f4a 100644 --- a/src/functions/scm/gitlab.ts +++ b/src/functions/scm/gitlab.ts @@ -286,7 +286,7 @@ export class GitLab extends AbstractSCM implements SourceControlManagement { * @param mergeRequestIId The merge request IID. Can be found in the URL to a pipeline */ @func() - async getLatestMergeRequestPipeline(gitlabProjectId: string | number, mergeRequestIId: number): Promise { + async getMergeRequestLatestPipeline(gitlabProjectId: string | number, mergeRequestIId: number): Promise { // allPipelines(projectId: string | number, mergerequestIId: number, options?: Sudo & ShowExpanded): Promise[], C, E, void>>; const pipelines = await this.api().MergeRequests.allPipelines(gitlabProjectId, mergeRequestIId); if (pipelines.length === 0) return null; @@ -330,28 +330,23 @@ export class GitLab extends AbstractSCM implements SourceControlManagement { /** * Gets the logs from the jobs which have failed in a pipeline * @param gitlabProjectId GitLab project full path or the numeric id - * @param mergeRequestIId The merge request IID. Can get this from the URL of the merge request. https:////[/]/-/merge_requests/ + * @param pipelineId The pipelineId. Can be determined from the URL of a pipeline. https:////[/]/-/pipelines/ * @returns A Record with the job name as the key and the logs as the value. */ @func() - async getMergeRequestPipelineFailedJobLogs(gitlabProjectId: string | number, mergeRequestIId: number): Promise { - logger.info({ gitlabProjectId, mergeRequestIId }, 'Getting pipelines'); - const pipelines = await this.api().MergeRequests.allPipelines(gitlabProjectId, mergeRequestIId); - if (pipelines.length === 0) throw new Error('No pipelines for the merge request'); + async getPipelineFailedJobLogs(gitlabProjectId: string | number, pipelineId: number): Promise { + logger.info({ gitlabProjectId, pipelineId }, 'Getting pipeline'); + const pipeline = await this.api().Pipelines.show(gitlabProjectId, pipelineId); - // pipelines.sort((a, b) => (Date.parse(a.created_at) < Date.parse(b.created_at) ? 1 : -1)); - const latestPipeline = pipelines.at(0); - if (!latestPipeline) throw new Error('No pipelines for the merge request'); - - if (latestPipeline.status !== 'failed' && latestPipeline.status !== 'blocked') throw new Error('Pipeline is not failed or blocked'); + if (pipeline.status !== 'failed' && pipeline.status !== 'blocked') throw new Error(`Pipeline status is not failed or blocked. Status: ${pipeline.status}`); - logger.info({ gitlabProjectId, pipelineId: latestPipeline.id }, 'Getting jobs'); - const jobs: JobSchema[] = await this.api().Jobs.all(gitlabProjectId, { pipelineId: latestPipeline.id }); + logger.info({ gitlabProjectId, pipelineId: pipeline.id }, 'Getting jobs'); + const jobs: JobSchema[] = await this.api().Jobs.all(gitlabProjectId, { pipelineId: pipeline.id }); const failedJobs = jobs.filter((job) => job.status === 'failed' && job.allow_failure === false); let jobLogs = ''; for (const job of failedJobs) { - logger.info({ gitlabProjectId, pipelineId: latestPipeline.id, jobId: job.id }, 'Getting job logs'); + logger.info({ gitlabProjectId, pipelineId: pipeline.id, jobId: job.id }, 'Getting job logs'); let logs = await this.getJobLogs(gitlabProjectId, job.id.toString()); // If the logs are longer than ~12,000 tokens, truncate them. @@ -365,6 +360,25 @@ export class GitLab extends AbstractSCM implements SourceControlManagement { return jobLogs; } + /** + * Gets the logs from the jobs which have failed in the latest pipeline of a merge request + * @param gitlabProjectId GitLab project full path or the numeric id + * @param mergeRequestIId The merge request IID. Can get this from the URL of the merge request. https:////[/]/-/merge_requests/ + * @returns A Record with the job name as the key and the logs as the value. + */ + @func() + async getMergeRequestPipelineFailedJobLogs(gitlabProjectId: string | number, mergeRequestIId: number): Promise { + logger.info({ gitlabProjectId, mergeRequestIId }, 'Getting pipelines'); + const pipelines = await this.api().MergeRequests.allPipelines(gitlabProjectId, mergeRequestIId); + if (pipelines.length === 0) throw new Error('No pipelines for the merge request'); + + // pipelines.sort((a, b) => (Date.parse(a.created_at) < Date.parse(b.created_at) ? 1 : -1)); + const latestPipeline = pipelines.at(0); + if (!latestPipeline) throw new Error('No pipelines for the merge request'); + + return this.getPipelineFailedJobLogs(gitlabProjectId, latestPipeline.id); + } + /** * @returns the diffs for a merge request */ diff --git a/src/modules/firestore/firestoreCICDStatsService.ts b/src/modules/firestore/firestoreCICDStatsService.ts new file mode 100644 index 00000000..e019fe8d --- /dev/null +++ b/src/modules/firestore/firestoreCICDStatsService.ts @@ -0,0 +1,39 @@ +import { randomUUID } from 'node:crypto'; +import type { Firestore } from '@google-cloud/firestore'; +import { CICDStatsService, JobResult } from '#functions/scm/cicdStatsService'; +import { logger } from '#o11y/logger'; +import { span } from '#o11y/trace'; +import { currentUser } from '#user/userContext'; +import { firestoreDb } from './firestore'; + +export class FirestoreCICDStatsService implements CICDStatsService { + private db: Firestore; + + constructor() { + this.db = firestoreDb(); + } + + @span() + async saveJobResult(jobResult: JobResult): Promise { + const docRef = this.db.collection('CICDStats').doc(randomUUID()); + await docRef.set(jobResult); + } + + @span() + async getRecentSuccessfulJobs(project: string, jobName: string): Promise { + const query = this.db + .collection('CICDStats') + .where('project', '==', project) + .where('jobName', '==', jobName) + .where('status', '==', 'success') + .orderBy('startedAt', 'desc') + .limit(20); + return query.get().then((querySnapshot) => { + const jobResults: JobResult[] = []; + querySnapshot.forEach((doc) => { + jobResults.push(doc.data() as JobResult); + }); + return jobResults; + }); + } +} diff --git a/src/routes/webhooks/gitlab/gitlabJobHandler.ts b/src/routes/webhooks/gitlab/gitlabJobHandler.ts new file mode 100644 index 00000000..fbb7da41 --- /dev/null +++ b/src/routes/webhooks/gitlab/gitlabJobHandler.ts @@ -0,0 +1,68 @@ +import { WebhookJobEventSchema } from '@gitbeaker/core'; +import { FirestoreCICDStatsService } from '#firestore/firestoreCICDStatsService'; +import { CICDStatsService, JobResult } from '#functions/scm/cicdStatsService'; +import { GitLab } from '#functions/scm/gitlab'; +import { logger } from '#o11y/logger'; +import { envVar } from '#utils/env-var'; +import { knownBuildErrors } from '../knownBuildFailures'; + +// https://docs.gitlab.com/user/project/integrations/webhook_events/#job-events + +let cicdStatsService: CICDStatsService; + +export async function handleBuildJobEvent(event: WebhookJobEventSchema) { + const status = event.build_status; + + cicdStatsService ??= new FirestoreCICDStatsService(); + + if (status === 'success' || status === 'failed') { + const jobInfo: JobResult = { + buildId: event.build_id, + status: status, + startedAt: event.build_started_at!, + stage: event.build_stage, + jobName: event.build_name, + pipeline: event.pipeline_id, + duration: event.build_duration!, + project: event.project.path_with_namespace, + host: envVar('GITLAB_HOST', ''), + }; + cicdStatsService.saveJobResult(jobInfo).catch((e) => logger.error(e, 'Error saving CICD stats')); + } + + if (status === 'running' || status === 'pending' || status === 'created' || status === 'cancelled') { + return; + } + if (event.build_allow_failure) { + return; + } + + // timeouts? + + // failed script_failure + // canceled unknown_failure + + if (status !== 'failed') { + logger.warn(`GitLab webhook unhandled build job status: ${status}`); + return; + } + + if (event.build_failure_reason !== 'script_failure') { + logger.warn(`GitLab build failure reason: ${event.build_failure_reason}`); + } + + const jobLogs = await new GitLab().getJobLogs(event.project_id, event.build_id); + + let standardResponse = ''; + let logText = ''; + for (const [text, response] of knownBuildErrors()) { + if (jobLogs.includes(text)) { + standardResponse = response; + logText = text; + break; + } + } + + if (standardResponse) { + } +} diff --git a/src/routes/webhooks/gitlab/gitlabNoteHandler.ts b/src/routes/webhooks/gitlab/gitlabNoteHandler.ts index 72f4158a..c77a715e 100644 --- a/src/routes/webhooks/gitlab/gitlabNoteHandler.ts +++ b/src/routes/webhooks/gitlab/gitlabNoteHandler.ts @@ -1,3 +1,4 @@ +import { WebhookBaseNoteEventSchema, WebhookMergeRequestNoteEventSchema } from '@gitbeaker/core'; import { AgentExecution } from '#agent/agentExecutions'; import { getLastFunctionCallArg } from '#agent/autonomous/agentCompletion'; import { startAgent } from '#agent/autonomous/autonomousAgentRunner'; @@ -10,6 +11,8 @@ import { PublicWeb } from '#functions/web/web'; import { defaultLLMs } from '#llm/services/defaultLlms'; import { logger } from '#o11y/logger'; import { AgentCompleted, AgentContext } from '#shared/agent/agent.model'; +import { LiveFiles } from '#agent/autonomous/functions/liveFiles'; +import { FileSystemTree } from '#agent/autonomous/functions/fileSystemTree'; const AGENT_TAG = '@typedai'; @@ -55,21 +58,26 @@ export class GitLabNoteCompletedHandler implements AgentCompleted { } } -export async function handleNoteEvent(event: any): Promise { +// Need to get url added into the gitbeaker type +export type MergeRequestNoteEvent = WebhookMergeRequestNoteEventSchema & { merge_request: { url: string } } & { + object_attributes: { discussion_id: string | null; description: string }; +}; + +export async function handleMergeRequestNoteEvent(event: MergeRequestNoteEvent): Promise { const note = event.object_attributes; + const noteText = note.note ?? note.description; + // Only action if the AI agent has been tagged + if (!noteText.includes(AGENT_TAG)) return null; + const project = event.project; const user = event.user; - const userName = user.name ?? user.username ?? ''; const mergeRequest = event.merge_request; - if (!note?.note || !mergeRequest || !project) return null; - if (!note.note.includes(AGENT_TAG)) return null; - - const discussionId: string | undefined = note.discussion_id ?? (await findDiscussionIdByNoteId(project.id, mergeRequest.iid, note.id)) ?? undefined; + const discussionId: string | undefined = + event.object_attributes.discussion_id ?? (await findDiscussionIdByNoteId(project.id, mergeRequest.iid, note.id)) ?? undefined; - const mergeRequestDetails = `Title: ${mergeRequest.title}\nURL: ${mergeRequest.url}\nSource branch: ${mergeRequest.source_branch}\nTarget branch: ${mergeRequest.target_branch}`; - const noteText = note.note.trim(); + const mergeRequestDetails = `Project: ${project.path_with_namespace}\nTitle: ${mergeRequest.title}\nSource branch: ${mergeRequest.source_branch}\nTarget branch: ${mergeRequest.target_branch}`; const supportFuncs = new SupportKnowledgebase(); const coreDocs = await supportFuncs.getCoreDocumentation().catch(() => ''); @@ -94,9 +102,9 @@ export async function handleNoteEvent(event: any): Promise 50000) { + // ~50k tokens + // TODO use flash to reduce the size, or just remove the middle section + } + } + } + + // TODO if this is the first time a job has failed, analyse the logs to see if it looks like it could be a transient timeout failure. If so re-try the job once, otherwise let the failure go through to tne regular processing. + + const summary = { + project: fullProjectPath, + gitRef, + mergeIId: miid, + user: user, + status: pipeline.status, + failedLogs, + }; + + // Need the firestore index + // const agent = await appContext().agentStateService.findByMetadata('gitlab', gitlabId); + + // TODO could get the project pipeline file, + + // if (!agent) { + // await startAgent({ + // initialPrompt: '', + // subtype: 'gitlab-pipeline', + // agentName: `GitLab ${gitlabId} pipeline`, + // type: 'autonomous', + // functions: [Git, LiveFiles, GitLab, CodeEditingAgent, Perplexity, FileSystemTree, FileSystemList], + // }); + // } +} diff --git a/src/routes/webhooks/gitlab/gitlabRoutes.ts b/src/routes/webhooks/gitlab/gitlabRoutes.ts index 7f914982..b355a456 100644 --- a/src/routes/webhooks/gitlab/gitlabRoutes.ts +++ b/src/routes/webhooks/gitlab/gitlabRoutes.ts @@ -1,3 +1,10 @@ +import { + WebhookBaseNoteEventSchema, + WebhookJobEventSchema, + WebhookMergeRequestEventSchema, + WebhookMergeRequestNoteEventSchema, + WebhookPipelineEventSchema, +} from '@gitbeaker/core'; import { Type } from '@sinclair/typebox'; import type { FastifyReply } from 'fastify'; import { startAgent } from '#agent/autonomous/autonomousAgentRunner'; @@ -12,11 +19,14 @@ import { GitLabCodeReview } from '#functions/scm/gitlabCodeReview'; import { defaultLLMs } from '#llm/services/defaultLlms'; import { countTokens } from '#llm/tokens'; import { logger } from '#o11y/logger'; +import { withSpan } from '#o11y/trace'; import { CodeEditingAgent } from '#swe/codeEditingAgent'; import { runAsUser } from '#user/userContext'; import { envVarHumanInLoopSettings } from '../../../cli/cliHumanInLoop'; import { getAgentUser } from '../webhookAgentUser'; -import { handleNoteEvent } from './gitlabNoteHandler'; +import { handleBuildJobEvent } from './gitlabJobHandler'; +import { MergeRequestNoteEvent, handleMergeRequestNoteEvent } from './gitlabNoteHandler'; +import { handlePipelineEvent } from './gitlabPipelineHandler'; const basePath = '/api/webhooks'; @@ -29,82 +39,31 @@ export async function gitlabRoutes(fastify: AppFastifyInstance): Promise { fastify.post(`${basePath}/gitlab`, { schema: { body: Type.Object({}, { additionalProperties: true }) } }, async (req, reply) => { const event = req.body as any; const objectKind = event.object_kind; - logger.info(event, `Gitlab webhook ${objectKind}`); - - const user = await getAgentUser(); - runAsUser(user, async () => { - switch (objectKind) { - case 'pipeline': - await handlePipelineEvent(event); - break; - case 'merge_request': - await handleMergeRequestEvent(event); - break; - case 'note': - await handleNoteEvent(event); - break; - } + logger.info({ event }, `Gitlab webhook ${objectKind}`); + + await withSpan('gitlab-webhook', async () => { + const user = await getAgentUser(); + runAsUser(user, async () => { + switch (objectKind) { + case 'build': + await handleBuildJobEvent(event as WebhookJobEventSchema); + break; + case 'pipeline': + await handlePipelineEvent(event as WebhookPipelineEventSchema); + break; + case 'merge_request': + await handleMergeRequestEvent(event as WebhookMergeRequestEventSchema); + break; + case 'note': + if (event.merge_request) await handleMergeRequestNoteEvent(event as MergeRequestNoteEvent); + break; + } + }); + send(reply, 200); }); - - send(reply, 200); }); } -/** - * https://docs.gitlab.com/user/project/integrations/webhook_events/#pipeline-events - * @param event - */ -async function handlePipelineEvent(event: any) { - const gitRef = event.ref; - const fullProjectPath = event.project.path_with_namespace; - const user = event.user; - const miid = event.merge_request?.iid; - let failedLogs = ''; - - const gitlabId = `${fullProjectPath}:${miid ?? gitRef}`; - - if (event.status === 'success') { - // check if there is a CodeTask and notify it of a successful build - } else { - failedLogs = await new GitLab().getMergeRequestPipelineFailedJobLogs(event.project.id, event.object_attributes.iid); - for (const [k, v] of Object.entries(failedLogs)) { - const lines = v.split('\n').length; - const tokens = await countTokens(v); - logger.info(`Failed pipeline job ${k}. Log size: ${tokens} tokens. ${lines} lines.`); - if (tokens > 50000) { - // ~50k tokens - // TODO use flash to reduce the size, or just remove the middle section - } - } - } - - // TODO if this is the first time a job has failed, analyse the logs to see if it looks like it could be a transient timeout failure. If so re-try the job once, otherwise let the failure go through to tne regular processing. - - const summary = { - project: fullProjectPath, - gitRef, - mergeIId: miid, - user: user, - status: event.status, - failedLogs, - }; - - // Need the firestore index - // const agent = await appContext().agentStateService.findByMetadata('gitlab', gitlabId); - - // TODO could get the project pipeline file, - - // if (!agent) { - // await startAgent({ - // initialPrompt: '', - // subtype: 'gitlab-pipeline', - // agentName: `GitLab ${gitlabId} pipeline`, - // type: 'autonomous', - // functions: [Git, LiveFiles, GitLab, CodeEditingAgent, Perplexity, FileSystemTree, FileSystemList], - // }); - // } -} - /** * * @param event @@ -125,6 +84,11 @@ async function handleMergeRequestEvent(event: any) { humanInLoop: envVarHumanInLoopSettings(), }; + // If the MR is approved and there are unchecked checkboxes, then add a comment to the MR to ask for the checkboxes to be checked + // if (event.object_attributes.state === 'approved' && hasUnchecked(event.object_attributes.description)) { + // await new GitLab().addComment(event.project.id, event.object_attributes.iid, 'Please check the checkboxes'); + // } + const mergeRequestId = `project:${event.project.name}, miid:${event.object_attributes.iid}, MR:"${event.object_attributes.title}"`; await runWorkflowAgent(config, async (context) => { @@ -137,3 +101,25 @@ async function handleMergeRequestEvent(event: any) { .catch((error) => logger.error(error, `Error reviewing merge request ${mergeRequestId}. Message: ${error.message} [error]`)); }); } + +const CHECKBOXES_START = ''; +const CHECKBOXES_END = ''; + +/** + * Checks if the MR description contains unchecked checkboxes + * @param description + * @returns + */ +export function hasUnchecked(description: string): boolean { + if (!description) return false; + const regexp = new RegExp(`${CHECKBOXES_START}((\\s|\\S)*?)${CHECKBOXES_END}`, 'gs'); + const matches: string[] = []; + let match: RegExpExecArray | null = null; + + // biome-ignore lint/suspicious/noAssignInExpressions: ignore + while ((match = regexp.exec(description)) !== null) matches.push(`${match[1]}`); + + if (matches.length) return matches.some((el) => hasUnchecked(el)); + + return description.includes('[ ]'); +} diff --git a/src/routes/webhooks/knownBuildFailures.ts b/src/routes/webhooks/knownBuildFailures.ts new file mode 100644 index 00000000..40e80530 --- /dev/null +++ b/src/routes/webhooks/knownBuildFailures.ts @@ -0,0 +1,11 @@ +const KNOWN_ERRORS = [ + ["Error: keys does not support 'str' type. Please provide or select a struct.", 'Check the yaml files in ./variables are valid yaml'], + [ + '`npm ci` can only install packages when your package.json and package-lock.json or npm-shrinkwrap.json are in sync', + 'Run `npm install` to update the lock file to match the package.json', + ], +]; + +export function knownBuildErrors() { + return KNOWN_ERRORS; +}