236 lines
8.2 KiB
TypeScript
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);
|
|
}
|