diff --git a/src/api/ai-workflow/ai-workflow.controller.ts b/src/api/ai-workflow/ai-workflow.controller.ts index 2e7d7c1..afa4303 100644 --- a/src/api/ai-workflow/ai-workflow.controller.ts +++ b/src/api/ai-workflow/ai-workflow.controller.ts @@ -7,6 +7,7 @@ import { ValidationPipe, Query, Param, + StreamableFile, } from '@nestjs/common'; import { ApiBearerAuth, @@ -207,6 +208,81 @@ export class AiWorkflowController { return this.aiWorkflowService.updateWorkflowRun(workflowId, runId, body); } + @Get('/:workflowId/runs/:runId/attachments') + @Roles( + UserRole.Admin, + UserRole.Copilot, + UserRole.ProjectManager, + UserRole.Reviewer, + UserRole.Submitter, + UserRole.User, + ) + @Scopes(Scope.ReadWorkflowRun) + @ApiOperation({ + summary: 'List all attachments linked to the specific run', + }) + @ApiParam({ + name: 'runId', + description: 'The ID of the run to fetch the attachments for', + required: true, + }) + @ApiResponse({ + status: 200, + description: 'The AI workflow run attachments.', + }) + @ApiResponse({ status: 403, description: 'Forbidden.' }) + async getRunAttachments( + @Param('workflowId') workflowId: string, + @Param('runId') runId: string, + @User() user: JwtUser, + ) { + return await this.aiWorkflowService.getWorkflowRunAttachments( + workflowId, + runId, + user, + ); + } + + @Get('/:workflowId/runs/:runId/attachments/:attachmentId/zip') + @Roles( + UserRole.Admin, + UserRole.Copilot, + UserRole.ProjectManager, + UserRole.Reviewer, + UserRole.Submitter, + UserRole.User, + ) + @Scopes(Scope.ReadWorkflowRun) + @ApiOperation({ + summary: 'Download an attachment archive linked to the specific run', + }) + @ApiParam({ + name: 'attachmentId', + description: 'The ID of the workflow run attachment to download', + required: true, + }) + @ApiResponse({ + status: 302, + description: 'Redirect to the blob to download', + }) + @ApiResponse({ status: 403, description: 'Forbidden.' }) + async downloadRunAttachment( + @Param('workflowId') workflowId: string, + @Param('runId') runId: string, + @Param('attachmentId') attachmentId: string, + @User() user: JwtUser, + ) { + const zipResponse = + await this.aiWorkflowService.downloadWorkflowRunAttachment( + workflowId, + runId, + attachmentId, + user, + ); + + return new StreamableFile(zipResponse.data); + } + @Post('/:workflowId/runs/:runId/items') @Scopes(Scope.CreateWorkflowRun) @ApiOperation({ summary: 'Create AIWorkflowRunItems in batch' }) diff --git a/src/api/ai-workflow/ai-workflow.service.ts b/src/api/ai-workflow/ai-workflow.service.ts index 0ef8239..c475227 100644 --- a/src/api/ai-workflow/ai-workflow.service.ts +++ b/src/api/ai-workflow/ai-workflow.service.ts @@ -26,6 +26,7 @@ import { ResourceApiService } from 'src/shared/modules/global/resource.service'; import { UserRole } from 'src/shared/enums/userRole.enum'; import { ChallengeStatus } from 'src/shared/enums/challengeStatus.enum'; import { LoggerService } from 'src/shared/modules/global/logger.service'; +import { GiteaService } from 'src/shared/modules/global/gitea.service'; @Injectable() export class AiWorkflowService { @@ -35,6 +36,7 @@ export class AiWorkflowService { private readonly prisma: PrismaService, private readonly challengeApiService: ChallengeApiService, private readonly resourceApiService: ResourceApiService, + private readonly giteaService: GiteaService, ) { this.logger = LoggerService.forRoot('AiWorkflowService'); } @@ -617,7 +619,19 @@ export class AiWorkflowService { } } - async getRunItems(workflowId: string, runId: string, user: JwtUser) { + /** + * Fetches the workflow & run data for the specified workflowId and runId + * It also makes sure the specified user has the right permissions to access the run + * @param user + * @param workflowId + * @param runId + * @returns + */ + private async getWorkflowRunWithGuards( + user: JwtUser, + workflowId: string, + runId: string, + ) { const workflow = await this.prisma.aiWorkflow.findUnique({ where: { id: workflowId }, }); @@ -703,6 +717,52 @@ export class AiWorkflowService { } } + return { workflow, run }; + } + + async getWorkflowRunAttachments( + workflowId: string, + runId: string, + user: JwtUser, + ) { + const { workflow, run } = await this.getWorkflowRunWithGuards( + user, + workflowId, + runId, + ); + + const [owner, repo] = workflow.gitOwnerRepo.split('/'); + const artifacts = await this.giteaService.getWorkflowRunArtifacts( + owner, + repo, + +run.gitRunId, + ); + return artifacts; + } + + async downloadWorkflowRunAttachment( + workflowId: string, + runId: string, + attachmentId: string, + user: JwtUser, + ) { + const { workflow } = await this.getWorkflowRunWithGuards( + user, + workflowId, + runId, + ); + + const [owner, repo] = workflow.gitOwnerRepo.split('/'); + return this.giteaService.downloadWorkflowRunArtifact( + owner, + repo, + attachmentId, + ); + } + + async getRunItems(workflowId: string, runId: string, user: JwtUser) { + await this.getWorkflowRunWithGuards(user, workflowId, runId); + const items = await this.prisma.aiWorkflowRunItem.findMany({ where: { workflowRunId: runId }, include: { diff --git a/src/shared/modules/global/gitea.service.ts b/src/shared/modules/global/gitea.service.ts index 8f7a051..6f11056 100644 --- a/src/shared/modules/global/gitea.service.ts +++ b/src/shared/modules/global/gitea.service.ts @@ -1,4 +1,9 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { + HttpExceptionOptions, + Injectable, + InternalServerErrorException, + Logger, +} from '@nestjs/common'; import { Api, Repository } from 'src/shared/clients/gitea/gitea.client'; import { aiWorkflow, aiWorkflowRun } from '@prisma/client'; @@ -175,4 +180,61 @@ export class GiteaService { return this.getAiWorkflowDataFromLogs(owner, repo, jobId, retry + 1); } } + + async getWorkflowRunArtifacts(owner: string, repo: string, gitJobId: number) { + try { + const response = await this.giteaClient.repos.getArtifactsOfRun( + owner, + repo, + gitJobId, + ); + + if (response.status > 299) { + throw new InternalServerErrorException(`${response.statusText}`, { + description: (response.data as any)?.message, + } as HttpExceptionOptions); + } + + return response.data; + } catch (e) { + this.logger.error( + 'Failed to fetch Artifacts for git action run', + e?.message ?? e, + { + owner, + repo, + gitJobId, + }, + ); + throw e; + } + } + + async downloadWorkflowRunArtifact( + owner: string, + repo: string, + artifactId: string, + ) { + try { + return await this.giteaClient.repos.downloadArtifact( + owner, + repo, + artifactId, + { + format: 'stream', + }, + ); + } catch (e) { + this.logger.error( + 'Failed to download Artifact for git action run', + e?.message ?? e, + { + owner, + repo, + artifactId, + }, + ); + throw e; + } + } }