Files
studio-admin-be/src/woodpecker/woodpecker.service.ts
T
chyoo dceb20b26a
ci/woodpecker/push/woodpecker Pipeline was successful
improve common app pipeline history layout
2026-07-09 11:01:39 +09:00

236 lines
8.2 KiB
TypeScript

import { BadGatewayException, Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
type WoodpeckerRepo = Record<string, unknown>;
type WoodpeckerPipeline = Record<string, unknown>;
@Injectable()
export class WoodpeckerService {
private readonly baseUrl: string;
private readonly token?: string;
constructor(configService: ConfigService) {
this.baseUrl = configService
.get<string>('WOODPECKER_BASE_URL', 'https://woodpecker.autonomystudio.net')
.replace(/\/$/, '');
this.token =
configService.get<string>('WOODPECKER_TOKEN') ||
configService.get<string>('WOODPECKER_ADMIN_TOKEN') ||
undefined;
}
async listRepos() {
this.assertToken();
const body = await this.request<unknown>('/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<unknown>(
`/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<WoodpeckerPipeline> {
const embeddedPipeline = getPipeline(repo);
if (getPipelineTime(embeddedPipeline) > 0) return embeddedPipeline;
const repoId = getString(repo.id);
if (!repoId) return {};
const body = await this.request<unknown>(
`/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<T>(path: string): Promise<T> {
const { body } = await this.requestWithHeaders<T>(path);
return body;
}
private async requestWithHeaders<T>(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<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}