Archived
306 lines
8.9 KiB
TypeScript
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}`
|
|
}
|