Initial commit: DevClaw with Gitea support
This commit is contained in:
@@ -22,8 +22,8 @@ export type Project = {
|
|||||||
deployBranch: string;
|
deployBranch: string;
|
||||||
/** Messaging channel for this project's group (e.g. "telegram", "whatsapp", "discord", "slack"). Stored at registration time. */
|
/** Messaging channel for this project's group (e.g. "telegram", "whatsapp", "discord", "slack"). Stored at registration time. */
|
||||||
channel?: string;
|
channel?: string;
|
||||||
/** Issue tracker provider type (github or gitlab). Auto-detected at registration, stored for reuse. */
|
/** Issue tracker provider type (github, gitlab, or gitea). Auto-detected at registration, stored for reuse. */
|
||||||
provider?: "github" | "gitlab";
|
provider?: "github" | "gitlab" | "gitea";
|
||||||
/** Project-level role execution: parallel (DEV+QA can run simultaneously) or sequential (only one role at a time). Default: parallel */
|
/** Project-level role execution: parallel (DEV+QA can run simultaneously) or sequential (only one role at a time). Default: parallel */
|
||||||
roleExecution?: "parallel" | "sequential";
|
roleExecution?: "parallel" | "sequential";
|
||||||
maxDevWorkers?: number;
|
maxDevWorkers?: number;
|
||||||
|
|||||||
287
lib/providers/gitea.ts
Normal file
287
lib/providers/gitea.ts
Normal file
@@ -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<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
|
||||||
|
// 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<any> {
|
||||||
|
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<number> {
|
||||||
|
// 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<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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 type { IssueProvider } from "./provider.js";
|
||||||
import { GitLabProvider } from "./gitlab.js";
|
import { GitLabProvider } from "./gitlab.js";
|
||||||
import { GitHubProvider } from "./github.js";
|
import { GitHubProvider } from "./github.js";
|
||||||
|
import { GiteaProvider } from "./gitea.js";
|
||||||
import { resolveRepoPath } from "../projects.js";
|
import { resolveRepoPath } from "../projects.js";
|
||||||
import { runCommand } from "../run-command.js";
|
import { runCommand } from "../run-command.js";
|
||||||
|
|
||||||
export type ProviderOptions = {
|
export type ProviderOptions = {
|
||||||
provider?: "gitlab" | "github";
|
provider?: "gitlab" | "github" | "gitea";
|
||||||
repo?: string;
|
repo?: string;
|
||||||
repoPath?: string;
|
repoPath?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ProviderWithType = {
|
export type ProviderWithType = {
|
||||||
provider: IssueProvider;
|
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 {
|
try {
|
||||||
const result = await runCommand(["git", "remote", "get-url", "origin"], { timeoutMs: 5_000, cwd: repoPath });
|
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 {
|
} catch {
|
||||||
return "gitlab";
|
return "gitlab";
|
||||||
}
|
}
|
||||||
@@ -31,6 +40,20 @@ export async function createProvider(opts: ProviderOptions): Promise<ProviderWit
|
|||||||
const repoPath = opts.repoPath ?? (opts.repo ? resolveRepoPath(opts.repo) : null);
|
const repoPath = opts.repoPath ?? (opts.repo ? resolveRepoPath(opts.repo) : null);
|
||||||
if (!repoPath) throw new Error("Either repoPath or repo must be provided");
|
if (!repoPath) throw new Error("Either repoPath or repo must be provided");
|
||||||
const type = opts.provider ?? await detectProvider(repoPath);
|
const type = opts.provider ?? await detectProvider(repoPath);
|
||||||
const provider = type === "github" ? new GitHubProvider({ repoPath }) : new GitLabProvider({ repoPath });
|
|
||||||
|
let provider: IssueProvider;
|
||||||
|
switch (type) {
|
||||||
|
case "github":
|
||||||
|
provider = new GitHubProvider({ repoPath });
|
||||||
|
break;
|
||||||
|
case "gitea":
|
||||||
|
provider = new GiteaProvider({ repoPath });
|
||||||
|
break;
|
||||||
|
case "gitlab":
|
||||||
|
default:
|
||||||
|
provider = new GitLabProvider({ repoPath });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
return { provider, type };
|
return { provider, type };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,10 +136,20 @@ export function createProjectRegisterTool() {
|
|||||||
|
|
||||||
const healthy = await provider.healthCheck();
|
const healthy = await provider.healthCheck();
|
||||||
if (!healthy) {
|
if (!healthy) {
|
||||||
const cliName = providerType === "github" ? "gh" : "glab";
|
let cliName = "";
|
||||||
const cliInstallUrl = providerType === "github"
|
let cliInstallUrl = "";
|
||||||
? "https://cli.github.com"
|
|
||||||
: "https://gitlab.com/gitlab-org/cli";
|
if (providerType === "github") {
|
||||||
|
cliName = "gh";
|
||||||
|
cliInstallUrl = "https://cli.github.com";
|
||||||
|
} else if (providerType === "gitlab") {
|
||||||
|
cliName = "glab";
|
||||||
|
cliInstallUrl = "https://gitlab.com/gitlab-org/cli";
|
||||||
|
} else if (providerType === "gitea") {
|
||||||
|
cliName = "curl";
|
||||||
|
cliInstallUrl = "https://curl.se";
|
||||||
|
}
|
||||||
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`${providerType.toUpperCase()} health check failed for ${repoPath}. ` +
|
`${providerType.toUpperCase()} health check failed for ${repoPath}. ` +
|
||||||
`Detected provider: ${providerType}. ` +
|
`Detected provider: ${providerType}. ` +
|
||||||
|
|||||||
Reference in New Issue
Block a user