From 3aa90d2eee1921341cee8212269fc474d8a0af43 Mon Sep 17 00:00:00 2001 From: chyoo Date: Mon, 6 Jul 2026 18:21:27 +0900 Subject: [PATCH] add woodpecker dashboard proxy --- src/app.module.ts | 2 + src/users/users.service.ts | 12 ++- src/woodpecker/woodpecker.controller.ts | 13 +++ src/woodpecker/woodpecker.module.ts | 10 +++ src/woodpecker/woodpecker.service.ts | 109 ++++++++++++++++++++++++ 5 files changed, 142 insertions(+), 4 deletions(-) create mode 100644 src/woodpecker/woodpecker.controller.ts create mode 100644 src/woodpecker/woodpecker.module.ts create mode 100644 src/woodpecker/woodpecker.service.ts diff --git a/src/app.module.ts b/src/app.module.ts index c2bce7e..0070228 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -15,6 +15,7 @@ import { NoticesModule } from './notices/notices.module'; import { PrismaModule } from './prisma/prisma.module'; import { RuntimeModule } from './runtime/runtime.module'; import { UsersModule } from './users/users.module'; +import { WoodpeckerModule } from './woodpecker/woodpecker.module'; @Module({ providers: [ @@ -39,6 +40,7 @@ import { UsersModule } from './users/users.module'; PrismaModule, RuntimeModule, UsersModule, + WoodpeckerModule, ], }) export class AppModule {} diff --git a/src/users/users.service.ts b/src/users/users.service.ts index 5001f8d..e3455ad 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -137,15 +137,19 @@ export class UsersService { async terminateSession(sessionId: string) { const session = await this.authPrisma.refreshToken.findUnique({ where: { id: sessionId }, - select: { id: true }, + select: { id: true, userId: true }, }); if (!session) { throw new NotFoundException('세션을 찾을 수 없습니다.'); } - await this.authPrisma.refreshToken.update({ - where: { id: sessionId }, + await this.authPrisma.refreshToken.updateMany({ + where: { + userId: session.userId, + revokedAt: null, + expiresAt: { gt: new Date() }, + }, data: { revokedAt: new Date() }, }); @@ -171,5 +175,5 @@ function getSessionState( return '만료'; } - return '활성'; + return '유효토큰'; } diff --git a/src/woodpecker/woodpecker.controller.ts b/src/woodpecker/woodpecker.controller.ts new file mode 100644 index 0000000..49d8397 --- /dev/null +++ b/src/woodpecker/woodpecker.controller.ts @@ -0,0 +1,13 @@ +import { Controller, Get } from '@nestjs/common'; + +import { WoodpeckerService } from './woodpecker.service'; + +@Controller('woodpecker') +export class WoodpeckerController { + constructor(private readonly woodpeckerService: WoodpeckerService) {} + + @Get('repos') + listRepos() { + return this.woodpeckerService.listRepos(); + } +} diff --git a/src/woodpecker/woodpecker.module.ts b/src/woodpecker/woodpecker.module.ts new file mode 100644 index 0000000..f9ff398 --- /dev/null +++ b/src/woodpecker/woodpecker.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; + +import { WoodpeckerController } from './woodpecker.controller'; +import { WoodpeckerService } from './woodpecker.service'; + +@Module({ + controllers: [WoodpeckerController], + providers: [WoodpeckerService], +}) +export class WoodpeckerModule {} diff --git a/src/woodpecker/woodpecker.service.ts b/src/woodpecker/woodpecker.service.ts new file mode 100644 index 0000000..e3fb310 --- /dev/null +++ b/src/woodpecker/woodpecker.service.ts @@ -0,0 +1,109 @@ +import { BadGatewayException, Injectable, UnauthorizedException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +type WoodpeckerRepo = Record; +type WoodpeckerPipeline = Record; + +@Injectable() +export class WoodpeckerService { + private readonly baseUrl: string; + private readonly token?: string; + + constructor(configService: ConfigService) { + this.baseUrl = configService + .get('WOODPECKER_BASE_URL', 'https://woodpecker.autonomystudio.net') + .replace(/\/$/, ''); + this.token = + configService.get('WOODPECKER_TOKEN') || + configService.get('WOODPECKER_ADMIN_TOKEN') || + undefined; + } + + async listRepos() { + this.assertToken(); + + const body = await this.request('/api/repos?latest=true'); + const repos = Array.isArray(body) + ? body + : isRecord(body) && Array.isArray(body.repos) + ? body.repos + : []; + + return { + repos: repos.map((repo) => normalizeRepo(repo)).sort((a, b) => b.createdAt - a.createdAt), + }; + } + + private async request(path: string): Promise { + const response = await fetch(`${this.baseUrl}${path}`, { + headers: { + Accept: 'application/json', + Authorization: `Bearer ${this.token}`, + }, + }); + + if (!response.ok) { + const detail = await response.text().catch(() => ''); + throw new BadGatewayException({ + message: 'Woodpecker API 요청이 실패했습니다.', + status: response.status, + detail, + }); + } + + return (await response.json()) as T; + } + + private assertToken() { + if (!this.token) { + throw new UnauthorizedException('WOODPECKER_TOKEN이 설정되어 있지 않습니다.'); + } + } +} + +function normalizeRepo(repo: WoodpeckerRepo) { + const pipeline = getPipeline(repo); + const createdAt = getPipelineTime(pipeline); + const repository = getString(repo.full_name) || getString(repo.fullName) || joinRepoName(repo) || getString(repo.name) || '-'; + + return { + id: `${getString(repo.id) || repository}-${getString(pipeline.id) || getString(pipeline.number) || createdAt}`, + repository, + message: getString(pipeline.message) || getString(pipeline.title) || getString(pipeline.event) || '-', + branch: getString(pipeline.branch) || '-', + status: getString(pipeline.status) || '-', + createdAt, + url: getString(repo.link_url) || getString(repo.forge_url) || getString(repo.url) || null, + }; +} + +function getPipeline(repo: WoodpeckerRepo): WoodpeckerPipeline { + const pipeline = repo.last_pipeline ?? repo.pipeline; + return isRecord(pipeline) ? pipeline : {}; +} + +function getPipelineTime(pipeline: WoodpeckerPipeline) { + const raw = pipeline.created ?? pipeline.created_at ?? pipeline.started ?? pipeline.started_at ?? pipeline.finished ?? pipeline.updated_at; + if (typeof raw === 'number') return raw > 10_000_000_000 ? raw : raw * 1000; + if (typeof raw === 'string') { + const numeric = Number(raw); + if (Number.isFinite(numeric) && numeric > 0) return numeric > 10_000_000_000 ? numeric : numeric * 1000; + const parsed = Date.parse(raw); + return Number.isFinite(parsed) ? parsed : 0; + } + return 0; +} + +function joinRepoName(repo: WoodpeckerRepo) { + const owner = getString(repo.owner); + const name = getString(repo.name); + return owner && name ? `${owner} / ${name}` : ''; +} + +function getString(value: unknown) { + return typeof value === 'string' || typeof value === 'number' ? String(value) : ''; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +}