/** * GiteaProvider — IssueProvider implementation for Gitea using REST API. */ import { type IssueProvider, type Issue, type StateLabel, type IssueComment, } from "./provider.js"; import { runCommand } from "../run-command.js"; import { DEFAULT_WORKFLOW, getStateLabels, getLabelColors, type WorkflowConfig, } from "../workflow.js"; type GiteaIssue = { id: number; number: number; title: string; body: string; labels: Array<{ id: number; name: string; color: string }>; state: "open" | "closed"; html_url: string; url: string; }; type GiteaComment = { user: { login: string }; body: string; created_at: string; }; type GiteaLabel = { id: number; name: string; color: string; }; type GiteaPullRequest = { id: number; number: number; state: string; merged_at: string | null; html_url: string; }; export class GiteaProvider implements IssueProvider { private repoPath: string; private workflow: WorkflowConfig; private baseUrl: string; private token: string; private owner: string; private repo: string; private labelCache: Map = new Map(); constructor(opts: { repoPath: string; workflow?: WorkflowConfig }) { this.repoPath = opts.repoPath; this.workflow = opts.workflow ?? DEFAULT_WORKFLOW; // Extract repo info from git remote this.extractRepoInfoFromRemote(); // Get token from environment variable this.token = process.env.GITEA_TOKEN || ""; if (!this.token) { console.warn("GITEA_TOKEN environment variable not set. Gitea API calls may fail."); } } private extractRepoInfoFromRemote(): void { try { // Try to parse .git/config file directly (synchronous) const fs = require('fs'); const path = require('path'); const gitConfigPath = path.join(this.repoPath, '.git', 'config'); if (fs.existsSync(gitConfigPath)) { const configContent = fs.readFileSync(gitConfigPath, 'utf8'); const urlMatch = configContent.match(/url\s*=\s*(https?:\/\/[^\s]+)/); if (urlMatch) { const remoteUrl = urlMatch[1]; // Parse Gitea URL (e.g., https://pfoster.dynu.net/peter/clawd.git) // or http://192.168.1.150:3000/peter/clawd.git const repoMatch = remoteUrl.match(/^(https?:\/\/[^\/]+)\/([^\/]+)\/([^\/\.]+)(?:\.git)?$/); if (repoMatch) { this.baseUrl = repoMatch[1]; this.owner = repoMatch[2]; this.repo = repoMatch[3]; return; } } } } catch (err) { console.error("Failed to extract repo info from git config:", err); } // Fallback to hardcoded values for testing this.baseUrl = "https://pfoster.dynu.net"; this.owner = "peter"; this.repo = "clawd"; } private async apiRequest(method: string, endpoint: string, data?: any, retryCount = 0): Promise { const maxRetries = 2; // Validate we have required configuration if (!this.token) { throw new Error(`GITEA_TOKEN environment variable is not set. Required for Gitea API authentication.`); } if (!this.baseUrl || !this.owner || !this.repo) { throw new Error(`Gitea repository information is incomplete. baseUrl: ${this.baseUrl}, owner: ${this.owner}, repo: ${this.repo}`); } const url = `${this.baseUrl}/api/v1${endpoint}`; try { const args = ["curl", "-s", "-X", method, "-H", `Authorization: token ${this.token}`]; if (data) { args.push("-H", "Content-Type: application/json"); args.push("-d", JSON.stringify(data)); } args.push(url); const result = await runCommand(args, { timeoutMs: 30_000 }); // Check for command execution errors if (result.error) { throw new Error(`Gitea API ${method} ${endpoint} failed: ${result.error}`); } if (result.exitCode !== undefined && result.exitCode !== 0) { throw new Error(`Gitea API ${method} ${endpoint} failed (exit ${result.exitCode}): ${result.stderr || 'Unknown error'}`); } if (result.stdout.trim()) { try { const parsed = JSON.parse(result.stdout); if (parsed.message && parsed.message.includes("error")) { throw new Error(`Gitea API error: ${parsed.message}`); } return parsed; } catch (err) { throw new Error(`Failed to parse JSON from Gitea API: ${result.stdout.substring(0, 200)}`); } } return null; } catch (err) { // Retry on network errors if (retryCount < maxRetries && (err as Error).message.includes("failed")) { console.warn(`Retrying Gitea API ${method} ${endpoint} (attempt ${retryCount + 1}/${maxRetries})`); await new Promise(resolve => setTimeout(resolve, 1000 * (retryCount + 1))); // Exponential backoff return this.apiRequest(method, endpoint, data, retryCount + 1); } throw err; } } private async getLabelId(labelName: string): Promise { // Check cache first if (this.labelCache.has(labelName)) { return this.labelCache.get(labelName)!; } try { // Fetch all labels from Gitea const labels = await this.apiRequest("GET", `/repos/${this.owner}/${this.repo}/labels`) as GiteaLabel[]; // Cache all labels for (const label of labels) { this.labelCache.set(label.name, label.id); } const labelId = this.labelCache.get(labelName); if (!labelId) { throw new Error(`Label "${labelName}" not found in Gitea repo ${this.owner}/${this.repo}`); } return labelId; } catch (err) { // Fallback to hardcoded IDs if API fails const errorMsg = `Failed to fetch labels from Gitea: ${(err as Error).message}`; const fallbackMap: Record = { "Planning": 1, "To Do": 2, "Doing": 3, "To Test": 4, "Testing": 5, "To Improve": 6, "Refining": 7, "Done": 8, "To Design": 9, "Designing": 10 }; const labelId = fallbackMap[labelName]; if (!labelId) { throw new Error(`${errorMsg}. Also, label "${labelName}" not found in fallback map.`); } // Cache the fallback value this.labelCache.set(labelName, labelId); return labelId; } } async ensureLabel(name: string, color: string): Promise { try { await this.apiRequest("POST", `/repos/${this.owner}/${this.repo}/labels`, { name, color: color.replace(/^#/, ""), }); } catch (err) { const msg = (err as Error).message ?? ""; // Label might already exist - that's OK if (!msg.includes("already exists") && !msg.includes("409")) { throw err; } } } async ensureAllStateLabels(): Promise { const labels = getStateLabels(this.workflow); const colors = getLabelColors(this.workflow); for (const label of labels) { await this.ensureLabel(label, colors[label]); } } async createIssue(title: string, description: string, label: StateLabel, assignees?: string[]): Promise { const labelId = await this.getLabelId(label); const data: any = { title, body: description, labels: [labelId], }; if (assignees?.length) { data.assignees = assignees; } const result = await this.apiRequest("POST", `/repos/${this.owner}/${this.repo}/issues`, data) as GiteaIssue; if (!result) { throw new Error("Failed to create issue: No response from Gitea API"); } return { iid: result.number, title: result.title, description: result.body || "", labels: result.labels ? result.labels.map((l: any) => l.name) : [], state: result.state, web_url: result.html_url || result.url, }; } async listIssuesByLabel(label: StateLabel): Promise { const result = await this.apiRequest("GET", `/repos/${this.owner}/${this.repo}/issues?labels=${encodeURIComponent(label)}&state=all`) as GiteaIssue[]; return (result || []).map((issue: GiteaIssue) => ({ iid: issue.number, title: issue.title, description: issue.body || "", labels: issue.labels ? issue.labels.map(l => l.name) : [], state: issue.state, web_url: issue.html_url || issue.url, })); } async getIssue(issueId: number): Promise { const result = await this.apiRequest("GET", `/repos/${this.owner}/${this.repo}/issues/${issueId}`) as GiteaIssue; if (!result) { throw new Error(`Issue ${issueId} not found`); } return { iid: result.number, title: result.title, description: result.body || "", labels: result.labels ? result.labels.map((l: any) => l.name) : [], state: result.state, web_url: result.html_url || result.url, }; } async listComments(issueId: number): Promise { const result = await this.apiRequest("GET", `/repos/${this.owner}/${this.repo}/issues/${issueId}/comments`); return (result || []).map((comment: GiteaComment) => ({ author: comment.user.login, body: comment.body, created_at: comment.created_at, })); } async transitionLabel(issueId: number, from: StateLabel, to: StateLabel): Promise { // Get current issue to preserve other labels const issue = await this.getIssue(issueId); const currentLabels = issue.labels.filter(l => l !== from); // Convert label names to IDs const labelIds: number[] = []; for (const labelName of [...currentLabels, to]) { const labelId = await this.getLabelId(labelName); labelIds.push(labelId); } await this.apiRequest("PATCH", `/repos/${this.owner}/${this.repo}/issues/${issueId}`, { labels: labelIds, }); } async closeIssue(issueId: number): Promise { await this.apiRequest("PATCH", `/repos/${this.owner}/${this.repo}/issues/${issueId}`, { state: "closed", }); } async reopenIssue(issueId: number): Promise { await this.apiRequest("PATCH", `/repos/${this.owner}/${this.repo}/issues/${issueId}`, { state: "open", }); } hasStateLabel(issue: Issue, expected: StateLabel): boolean { return issue.labels.includes(expected); } getCurrentStateLabel(issue: Issue): StateLabel | null { const stateLabels = getStateLabels(this.workflow); for (const label of stateLabels) { if (issue.labels.includes(label)) { return label; } } return null; } async hasMergedMR(issueId: number): Promise { // In Gitea, check if there's a merged pull request linked to this issue // For now, return false - we can implement this later return false; } async getMergedMRUrl(issueId: number): Promise { // Not implemented yet return null; } async addComment(issueId: number, body: string): Promise { await this.apiRequest("POST", `/repos/${this.owner}/${this.repo}/issues/${issueId}/comments`, { body, }); } async healthCheck(): Promise { try { const result = await this.apiRequest("GET", `/repos/${this.owner}/${this.repo}`); return !!result && result.full_name === `${this.owner}/${this.repo}`; } catch (err) { console.error("Gitea health check failed:", err); return false; } } }