diff --git a/lib/projects.ts b/lib/projects.ts index 1158147..e9208b7 100644 --- a/lib/projects.ts +++ b/lib/projects.ts @@ -22,8 +22,8 @@ export type Project = { deployBranch: string; /** Messaging channel for this project's group (e.g. "telegram", "whatsapp", "discord", "slack"). Stored at registration time. */ channel?: string; - /** Issue tracker provider type (github or gitlab). Auto-detected at registration, stored for reuse. */ - provider?: "github" | "gitlab"; + /** Issue tracker provider type (github, gitlab, or gitea). Auto-detected at registration, stored for reuse. */ + provider?: "github" | "gitlab" | "gitea"; /** Project-level role execution: parallel (DEV+QA can run simultaneously) or sequential (only one role at a time). Default: parallel */ roleExecution?: "parallel" | "sequential"; maxDevWorkers?: number; diff --git a/lib/providers/gitea.ts b/lib/providers/gitea.ts new file mode 100644 index 0000000..fdc51ee --- /dev/null +++ b/lib/providers/gitea.ts @@ -0,0 +1,287 @@ +/** + * 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 + // For now, hardcode for our Gitea instance + this.baseUrl = "http://192.168.1.150:3000"; + this.token = "1e61c82328feb943f0b9d466ccd2c1eceefb3ee8"; // Our Gitea token + this.owner = "peter"; + this.repo = "clawd"; + } + + private async apiRequest(method: string, endpoint: string, data?: any): Promise { + const url = `${this.baseUrl}/api/v1${endpoint}`; + + 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 }); + + if (result.stderr) { + console.error(`Gitea API error for ${method} ${endpoint}:`, result.stderr); + } + + 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) { + console.error(`Failed to parse JSON from Gitea API: ${result.stdout.substring(0, 200)}`); + throw new Error(`Failed to parse JSON from Gitea API: ${(err as Error).message}`); + } + } + + return null; + } + + private async getLabelId(labelName: string): Promise { + // Check cache first + if (this.labelCache.has(labelName)) { + return this.labelCache.get(labelName)!; + } + + // Fetch all labels and cache them + const labels = await this.apiRequest("GET", `/repos/${this.owner}/${this.repo}/labels`) as GiteaLabel[]; + + 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`); + } + + 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; + } + } +} \ No newline at end of file diff --git a/lib/providers/index.ts b/lib/providers/index.ts index 65e906f..ef132e3 100644 --- a/lib/providers/index.ts +++ b/lib/providers/index.ts @@ -1,27 +1,36 @@ /** - * Provider factory — auto-detects GitHub vs GitLab from git remote. + * Provider factory — auto-detects GitHub vs GitLab vs Gitea from git remote. */ import type { IssueProvider } from "./provider.js"; import { GitLabProvider } from "./gitlab.js"; import { GitHubProvider } from "./github.js"; +import { GiteaProvider } from "./gitea.js"; import { resolveRepoPath } from "../projects.js"; import { runCommand } from "../run-command.js"; export type ProviderOptions = { - provider?: "gitlab" | "github"; + provider?: "gitlab" | "github" | "gitea"; repo?: string; repoPath?: string; }; export type ProviderWithType = { provider: IssueProvider; - type: "github" | "gitlab"; + type: "github" | "gitlab" | "gitea"; }; -async function detectProvider(repoPath: string): Promise<"gitlab" | "github"> { +async function detectProvider(repoPath: string): Promise<"gitlab" | "github" | "gitea"> { try { const result = await runCommand(["git", "remote", "get-url", "origin"], { timeoutMs: 5_000, cwd: repoPath }); - return result.stdout.trim().includes("github.com") ? "github" : "gitlab"; + const remote = result.stdout.trim(); + + if (remote.includes("github.com")) { + return "github"; + } else if (remote.includes("192.168.1.150:3000")) { + return "gitea"; + } else { + return "gitlab"; + } } catch { return "gitlab"; } @@ -31,6 +40,20 @@ export async function createProvider(opts: ProviderOptions): Promise