import { execSync } from "node:child_process"; export interface GiteaReleaseConfig { baseUrl: string; token: string; owner: string; repo: string; } export interface GiteaRelease { id: number; tag_name: string; name: string; body: string; url: string; html_url: string; draft: boolean; prerelease: boolean; } export class GiteaClient { private config: GiteaReleaseConfig; constructor(config: GiteaReleaseConfig) { this.config = config; } async createRelease(params: { tag_name: string; name: string; body: string; draft?: boolean; prerelease?: boolean; }): Promise { const url = `${this.config.baseUrl}/api/v1/repos/${this.config.owner}/${this.config.repo}/releases`; const response = await fetch(url, { method: "POST", headers: { "Authorization": `token ${this.config.token}`, "Content-Type": "application/json", }, body: JSON.stringify({ tag_name: params.tag_name, name: params.name, body: params.body, draft: params.draft ?? false, prerelease: params.prerelease ?? false, }), }); if (!response.ok) { const text = await response.text(); throw new Error(`Gitea API error: ${response.status} ${text}`); } return response.json() as Promise; } async listReleases(): Promise { const url = `${this.config.baseUrl}/api/v1/repos/${this.config.owner}/${this.config.repo}/releases`; const response = await fetch(url, { method: "GET", headers: { "Authorization": `token ${this.config.token}`, }, }); if (!response.ok) { throw new Error(`Gitea API error: ${response.status}`); } return response.json() as Promise; } async getReleaseByTag(tag: string): Promise { const url = `${this.config.baseUrl}/api/v1/repos/${this.config.owner}/${this.config.repo}/releases/tags/${tag}`; const response = await fetch(url, { method: "GET", headers: { "Authorization": `token ${this.config.token}`, }, }); if (response.status === 404) { return null; } if (!response.ok) { throw new Error(`Gitea API error: ${response.status}`); } return response.json() as Promise; } } export function generateReleaseNotes(projectPath: string, fromTag: string | null, toTag: string): string { let gitLogCmd: string; if (fromTag) { gitLogCmd = `git log ${fromTag}..${toTag} --oneline`; } else { gitLogCmd = `git log ${toTag} --oneline`; } let logOutput: string; try { logOutput = execSync(gitLogCmd, { cwd: projectPath, encoding: "utf-8" }).trim(); } catch { return `## What's Changed\n\nNo commits found between ${fromTag || "beginning"} and ${toTag}.\n`; } if (!logOutput) { return `## What's Changed\n\nNo commits found between ${fromTag || "beginning"} and ${toTag}.\n`; } const lines = logOutput.split("\n").filter(Boolean); const featCommits: string[] = []; const fixCommits: string[] = []; const testCommits: string[] = []; const otherCommits: string[] = []; for (const line of lines) { const subject = line.replace(/^[a-f0-9]+\s+/, ""); if (/^feat/i.test(subject)) { featCommits.push(subject); } else if (/^fix/i.test(subject)) { fixCommits.push(subject); } else if (/^test/i.test(subject)) { testCommits.push(subject); } else { otherCommits.push(subject); } } const sections: string[] = []; if (featCommits.length > 0) { sections.push("### New Features\n"); for (const c of featCommits) { sections.push(`- ${c}`); } sections.push(""); } if (fixCommits.length > 0) { sections.push("### Bug Fixes\n"); for (const c of fixCommits) { sections.push(`- ${c}`); } sections.push(""); } if (testCommits.length > 0) { sections.push("### Tests\n"); for (const c of testCommits) { sections.push(`- ${c}`); } sections.push(""); } if (otherCommits.length > 0) { sections.push("### Other Changes\n"); for (const c of otherCommits) { sections.push(`- ${c}`); } sections.push(""); } return `## What's Changed\n\n${sections.join("\n")}`; }