This repository has been archived on 2026-07-19. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
ci-agent-runner/src/gitea.ts
T
2026-07-13 10:13:26 +02:00

306 lines
8.9 KiB
TypeScript

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<T>(path: string, options: RequestOptions = {}): Promise<T> {
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<GiteaUser> {
return this.request<GiteaUser>("/user")
}
getRepository(): Promise<GiteaRepository> {
return this.request<GiteaRepository>(`/repos/${this.owner}/${this.repo}`)
}
getIssue(number: number): Promise<GiteaIssue> {
return this.request<GiteaIssue>(`/repos/${this.owner}/${this.repo}/issues/${number}`)
}
getComments(number: number): Promise<GiteaComment[]> {
return this.request<GiteaComment[]>(`/repos/${this.owner}/${this.repo}/issues/${number}/comments`)
}
getBranch(branch: string): Promise<GiteaBranch | undefined> {
return this.request<GiteaBranch>(`/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<GiteaComment> {
return this.request<GiteaComment>(`/repos/${this.owner}/${this.repo}/issues/${number}/comments`, {
method: "POST",
body: { body },
expected: [201],
})
}
editComment(commentId: number, body: string): Promise<GiteaComment> {
return this.request<GiteaComment>(`/repos/${this.owner}/${this.repo}/issues/comments/${commentId}`, {
method: "PATCH",
body: { body },
expected: [200],
})
}
removeLabel(number: number, labelId: number): Promise<void> {
return this.request<void>(`/repos/${this.owner}/${this.repo}/issues/${number}/labels/${labelId}`, {
method: "DELETE",
expected: [204],
})
}
async addLabelIfPresent(number: number, labelName: string): Promise<void> {
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<GiteaLabel[]> {
const labels: GiteaLabel[] = []
for (let page = 1; ; page += 1) {
const batch = await this.request<GiteaLabel[]>(
`/repos/${this.owner}/${this.repo}/labels?page=${page}&limit=50`,
)
labels.push(...batch)
if (batch.length < 50) return labels
}
}
async listOpenPullRequests(): Promise<GiteaPullRequest[]> {
const pulls: GiteaPullRequest[] = []
for (let page = 1; ; page += 1) {
const batch = await this.request<GiteaPullRequest[]>(
`/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<GiteaPullRequest> {
return this.request<GiteaPullRequest>(`/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<GiteaPullRequest> {
return this.request<GiteaPullRequest>(`/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<GiteaComment> {
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<!-- olixero-ci-agent:plan-footer -->"
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}`
}