Archived
initial commit
This commit is contained in:
+305
@@ -0,0 +1,305 @@
|
||||
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}`
|
||||
}
|
||||
Reference in New Issue
Block a user