Files
Peter Foster 078b3b8174 Make Gitea provider configurable with env vars and dynamic label fetching
- Extract Gitea URL, owner, repo from .git/config (synchronous parsing)
- Get authentication token from GITEA_TOKEN environment variable
- Dynamic label fetching from Gitea API with fallback to hardcoded IDs
- Add retry logic with exponential backoff for network errors
- Add validation for required configuration (token, URL, repo info)
- Better error messages for troubleshooting

Configuration:
- Set GITEA_TOKEN environment variable before running OpenClaw
- Automatically detects repo from git remote URL
- Fallback to hardcoded URL if git parsing fails

Known issues:
- Label transitions sometimes don't persist (edge case under investigation)
- Recommend manual label updates via Gitea UI if transition fails
2026-02-15 09:26:37 +00:00

378 lines
11 KiB
TypeScript

/**
* 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<string, number> = 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<any> {
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<number> {
// 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<string, number> = {
"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<void> {
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<void> {
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<Issue> {
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<Issue[]> {
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<Issue> {
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<IssueComment[]> {
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<void> {
// 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<void> {
await this.apiRequest("PATCH", `/repos/${this.owner}/${this.repo}/issues/${issueId}`, {
state: "closed",
});
}
async reopenIssue(issueId: number): Promise<void> {
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<boolean> {
// 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<string | null> {
// Not implemented yet
return null;
}
async addComment(issueId: number, body: string): Promise<void> {
await this.apiRequest("POST", `/repos/${this.owner}/${this.repo}/issues/${issueId}/comments`, {
body,
});
}
async healthCheck(): Promise<boolean> {
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;
}
}
}