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 : []; const normalizedRepos = repos.map((repo) => (isRecord(repo) ? repo : {})); const pipelines = await Promise.all( normalizedRepos.map((repo) => this.getLatestPipeline(repo).catch(() => getPipeline(repo))), ); return { baseUrl: this.baseUrl, repos: normalizedRepos .map((repo, index) => normalizeRepo(repo, pipelines[index], this.baseUrl)) .sort((a, b) => b.createdAt - a.createdAt), }; } async listRepoPipelines(repoId: string, input: { page?: number; limit?: number }) { this.assertToken(); const page = Math.max(1, input.page || 1); const limit = Math.min(50, Math.max(1, input.limit || 5)); const { body, headers } = await this.requestWithHeaders( `/api/repos/${encodeURIComponent(repoId)}/pipelines?page=${page}&limit=${limit + 1}`, ); const rows = Array.isArray(body) ? body : isRecord(body) && Array.isArray(body.pipelines) ? body.pipelines : []; const total = getTotalCount(body, headers); const pipelines = rows .filter(isRecord) .slice(0, limit) .map((pipeline) => normalizePipeline(pipeline)); return { page, pageSize: limit, total, totalPages: total === null ? null : Math.max(1, Math.ceil(total / limit)), hasNext: total === null ? rows.length > limit : page < Math.max(1, Math.ceil(total / limit)), pipelines, }; } private async getLatestPipeline(repo: WoodpeckerRepo): Promise { const embeddedPipeline = getPipeline(repo); if (getPipelineTime(embeddedPipeline) > 0) return embeddedPipeline; const repoId = getString(repo.id); if (!repoId) return {}; const body = await this.request( `/api/repos/${encodeURIComponent(repoId)}/pipelines?page=1&limit=1`, ); const pipelines = Array.isArray(body) ? body : isRecord(body) && Array.isArray(body.pipelines) ? body.pipelines : []; const latest = pipelines[0]; return isRecord(latest) ? latest : {}; } private async request(path: string): Promise { const { body } = await this.requestWithHeaders(path); return body; } private async requestWithHeaders(path: string): Promise<{ body: T; headers: Headers }> { 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 { body: (await response.json()) as T, headers: response.headers }; } private assertToken() { if (!this.token) { throw new UnauthorizedException('WOODPECKER_TOKEN이 설정되어 있지 않습니다.'); } } } function normalizeRepo(repo: WoodpeckerRepo, pipeline = getPipeline(repo), baseUrl = '') { const createdAt = getPipelineTime(pipeline); const repository = getString(repo.full_name) || getString(repo.fullName) || joinRepoName(repo) || getString(repo.name) || '-'; const normalizedPipeline = normalizePipeline(pipeline); return { id: `${getString(repo.id) || repository}-${normalizedPipeline.id || normalizedPipeline.number || createdAt}`, repoId: getString(repo.id) || null, repository, number: normalizedPipeline.number, message: normalizedPipeline.message, branch: normalizedPipeline.branch, commitSha: normalizedPipeline.commitSha, durationSeconds: normalizedPipeline.durationSeconds, status: normalizedPipeline.status, createdAt, ciUrl: baseUrl || null, url: getString(repo.link_url) || getString(repo.forge_url) || getString(repo.url) || null, }; } function normalizePipeline(pipeline: WoodpeckerPipeline) { const createdAt = getPipelineTime(pipeline); return { id: getString(pipeline.id) || getString(pipeline.number) || String(createdAt || ''), number: getNumber(pipeline.number), message: getString(pipeline.message) || getString(pipeline.title) || getString(pipeline.event) || '-', branch: getString(pipeline.branch) || '-', commitSha: getCommitSha(pipeline), durationSeconds: getPipelineDurationSeconds(pipeline), status: getString(pipeline.status) || '-', createdAt, }; } 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 getCommitSha(pipeline: WoodpeckerPipeline) { return getString(pipeline.commit) || getString(pipeline.commit_sha) || getString(pipeline.sha) || getString(pipeline.after) || getString(pipeline.refspec); } function getPipelineDurationSeconds(pipeline: WoodpeckerPipeline) { const startedAt = getPipelineTimestamp(pipeline.started ?? pipeline.started_at ?? pipeline.created ?? pipeline.created_at); const finishedAt = getPipelineTimestamp(pipeline.finished ?? pipeline.finished_at ?? pipeline.updated ?? pipeline.updated_at); if (!startedAt || !finishedAt || finishedAt < startedAt) return null; return Math.max(0, Math.round((finishedAt - startedAt) / 1000)); } function getPipelineTimestamp(value: unknown) { if (typeof value === 'number') return value > 10_000_000_000 ? value : value * 1000; if (typeof value === 'string') { const numeric = Number(value); if (Number.isFinite(numeric) && numeric > 0) return numeric > 10_000_000_000 ? numeric : numeric * 1000; const parsed = Date.parse(value); return Number.isFinite(parsed) ? parsed : 0; } return 0; } function getTotalCount(body: unknown, headers: Headers) { const bodyTotal = isRecord(body) ? getNumber(body.total) ?? getNumber(body.count) ?? getNumber(body.total_count) ?? getNumber(body.totalCount) : null; if (bodyTotal !== null) return bodyTotal; const headerTotal = headers.get('x-total-count') ?? headers.get('x-total') ?? headers.get('x-pagination-total') ?? headers.get('x-page-total') ?? headers.get('total'); return getNumber(headerTotal); } 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 getNumber(value: unknown) { const number = typeof value === 'number' ? value : typeof value === 'string' ? Number(value) : NaN; return Number.isFinite(number) && number >= 0 ? number : null; } function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === 'object' && !Array.isArray(value); }