import { type IssueSnapshot, type Marker, marker, parseMarker, protocolVersion, sha256, } from "./contracts.js" export interface GiteaUser { id: number login: string } export interface GiteaLabel { id: number name: string } export interface GiteaIssue { id: number number: number title: string body: string state: string html_url: string user: GiteaUser labels: GiteaLabel[] pull_request?: unknown } export interface GiteaComment { id: number body: string html_url: string created_at: string updated_at: string user: GiteaUser } export interface GiteaRepository { id: number name: string full_name: string default_branch: string html_url: string clone_url: string } export interface GiteaBranch { name: string commit: { id: string } } export interface GiteaPullRequest { id: number number: number title: string body: string state: string html_url: string head: { ref?: string; name?: string; sha?: string } base: { ref?: string; name?: string; sha?: string } } interface RequestOptions { method?: string body?: unknown retry?: boolean expected?: number[] } export class GiteaClient { private readonly apiBase: string constructor( serverUrl: string, private readonly token: string, private readonly owner: string, private readonly repo: string, ) { this.apiBase = `${serverUrl.replace(/\/$/, "")}/api/v1` } private async request(path: string, options: RequestOptions = {}): Promise { const method = options.method || "GET" const attempts = options.retry === false || method !== "GET" ? 1 : 4 let lastError: Error | undefined for (let attempt = 0; attempt < attempts; attempt += 1) { try { const response = await fetch(`${this.apiBase}${path}`, { method, headers: { Authorization: `token ${this.token}`, Accept: "application/json", ...(options.body === undefined ? {} : { "Content-Type": "application/json" }), }, ...(options.body === undefined ? {} : { body: JSON.stringify(options.body) }), signal: AbortSignal.timeout(30_000), }) const expected = options.expected || (method === "POST" ? [200, 201] : method === "DELETE" ? [204] : [200]) if (expected.includes(response.status)) { if (response.status === 204) return undefined as T return (await response.json()) as T } const detail = (await response.text()).slice(0, 2_000) const error = new Error(`${method} ${path} failed with ${response.status}: ${detail}`) if (method !== "GET" || (response.status !== 429 && response.status < 500)) throw error lastError = error } catch (error) { lastError = error instanceof Error ? error : new Error(String(error)) if (method !== "GET" || attempt === attempts - 1) throw lastError } await new Promise((resolve) => setTimeout(resolve, 1_000 * 2 ** attempt)) } throw lastError || new Error(`${method} ${path} failed`) } getCurrentUser(): Promise { return this.request("/user") } getRepository(): Promise { return this.request(`/repos/${this.owner}/${this.repo}`) } getIssue(number: number): Promise { return this.request(`/repos/${this.owner}/${this.repo}/issues/${number}`) } getComments(number: number): Promise { return this.request(`/repos/${this.owner}/${this.repo}/issues/${number}/comments`) } getBranch(branch: string): Promise { return this.request(`/repos/${this.owner}/${this.repo}/branches/${encodeURIComponent(branch)}`).catch( (error: Error) => { if (error.message.includes(" failed with 404:")) return undefined throw error }, ) } createComment(number: number, body: string): Promise { return this.request(`/repos/${this.owner}/${this.repo}/issues/${number}/comments`, { method: "POST", body: { body }, expected: [201], }) } editComment(commentId: number, body: string): Promise { return this.request(`/repos/${this.owner}/${this.repo}/issues/comments/${commentId}`, { method: "PATCH", body: { body }, expected: [200], }) } removeLabel(number: number, labelId: number): Promise { return this.request(`/repos/${this.owner}/${this.repo}/issues/${number}/labels/${labelId}`, { method: "DELETE", expected: [204], }) } async addLabelIfPresent(number: number, labelName: string): Promise { const labels = await this.listRepositoryLabels() if (!labels.some((label) => label.name === labelName)) return await this.request(`/repos/${this.owner}/${this.repo}/issues/${number}/labels`, { method: "POST", body: { labels: [labelName] }, expected: [200], }) } async listRepositoryLabels(): Promise { const labels: GiteaLabel[] = [] for (let page = 1; ; page += 1) { const batch = await this.request( `/repos/${this.owner}/${this.repo}/labels?page=${page}&limit=50`, ) labels.push(...batch) if (batch.length < 50) return labels } } async listOpenPullRequests(): Promise { const pulls: GiteaPullRequest[] = [] for (let page = 1; ; page += 1) { const batch = await this.request( `/repos/${this.owner}/${this.repo}/pulls?state=open&page=${page}&limit=50`, ) pulls.push(...batch) if (batch.length < 50) return pulls } } createPullRequest(input: { head: string base: string title: string body: string }): Promise { return this.request(`/repos/${this.owner}/${this.repo}/pulls`, { method: "POST", body: { ...input, allow_maintainer_edit: true }, expected: [201], }) } updatePullRequest(number: number, input: { title: string; body: string; base: string }): Promise { return this.request(`/repos/${this.owner}/${this.repo}/pulls/${number}`, { method: "PATCH", body: input, expected: [200, 201], }) } async upsertMarkedComment( issueNumber: number, botLogin: string, expectedMarker: Marker, body: string, ): Promise { const comments = await this.getComments(issueNumber) const existing = comments.find((comment) => { if (comment.user.login.toLowerCase() !== botLogin.toLowerCase()) return false const found = parseMarker(comment.body) return ( found?.kind === expectedMarker.kind && found.issue === expectedMarker.issue && found.mode === expectedMarker.mode ) }) return existing ? this.editComment(existing.id, body) : this.createComment(issueNumber, body) } } export function createIssueSnapshot( issue: GiteaIssue, comments: GiteaComment[], botLogin: string, ): IssueSnapshot { const humanComments = comments .filter((comment) => comment.user.login.toLowerCase() !== botLogin.toLowerCase()) .map((comment) => ({ author: comment.user.login, createdAt: comment.created_at, body: comment.body, })) const canonical = JSON.stringify({ v: protocolVersion, number: issue.number, state: issue.state, title: issue.title, body: issue.body, comments: humanComments, }) return { digest: sha256(canonical), title: issue.title, body: issue.body, comments: humanComments, } } export function findAcceptedPlan(comments: GiteaComment[], botLogin: string, issueNumber: number): { marker: Marker markdown: string comment: GiteaComment } | undefined { const candidates = comments .filter((comment) => comment.user.login.toLowerCase() === botLogin.toLowerCase()) .map((comment) => ({ comment, found: parseMarker(comment.body, "plan") })) .filter((value): value is { comment: GiteaComment; found: Marker } => Boolean(value.found)) .filter((value) => value.found.issue === issueNumber && value.found.status === "accepted") .sort((a, b) => b.comment.updated_at.localeCompare(a.comment.updated_at)) const selected = candidates[0] if (!selected) return undefined const header = "## Accepted implementation plan\n\n" const start = selected.comment.body.indexOf(header) const footer = "\n\n" const end = selected.comment.body.lastIndexOf(footer) if (start < 0) return undefined return { marker: selected.found, markdown: selected.comment.body.slice(start + header.length, end < 0 ? undefined : end).trim(), comment: selected.comment, } } export function renderStatus(input: { marker: Marker heading: string detail: string }): string { return `${marker(input.marker)}\n## ${input.heading}\n\n${input.detail}` }