diff --git a/apps/harness/src/lib/boot.ts b/apps/harness/src/lib/boot.ts index f607c09..3285e39 100644 --- a/apps/harness/src/lib/boot.ts +++ b/apps/harness/src/lib/boot.ts @@ -81,6 +81,7 @@ async function loadCredentialsFromEnv() { ["GITHUB_TOKEN", "github", "GitHub (env)"], ["GH_TOKEN", "github", "GitHub (env)"], ["GITLAB_TOKEN", "gitlab", "GitLab (env)"], + ["GITEA_TOKEN", "gitea", "Gitea (env)"], ]; for (const [envVar, provider, label] of envMap) { diff --git a/apps/harness/src/lib/credentials.ts b/apps/harness/src/lib/credentials.ts index 225e31f..e1b8032 100644 --- a/apps/harness/src/lib/credentials.ts +++ b/apps/harness/src/lib/credentials.ts @@ -3,10 +3,10 @@ import { db } from "./db"; import { credentials as credentialsTable } from "@homelab/db"; export type Provider = - | "github" | "gitlab" + | "github" | "gitlab" | "gitea" | "anthropic" | "openai" | "openrouter" | "google" | "opencode-zen" | "opencode-go"; -export const GIT_PROVIDERS: Provider[] = ["github", "gitlab"]; +export const GIT_PROVIDERS: Provider[] = ["github", "gitlab", "gitea"]; export const AI_PROVIDERS: Provider[] = ["anthropic", "openai", "openrouter", "google", "opencode-zen", "opencode-go"]; export interface Credential { diff --git a/apps/harness/src/lib/git-ops.ts b/apps/harness/src/lib/git-ops.ts index 435071e..6bcc1f0 100644 --- a/apps/harness/src/lib/git-ops.ts +++ b/apps/harness/src/lib/git-ops.ts @@ -21,12 +21,18 @@ export function iterationDir(taskId: string, iteration: number): string { export function buildAuthenticatedCloneUrl( repo: string, - provider: "github" | "gitlab", + provider: "github" | "gitlab" | "gitea", token: string, + baseUrl?: string, ): string { // repo format: "owner/name" if (provider === "gitlab") { - return `https://oauth2:${token}@gitlab.com/${repo}.git`; + const host = baseUrl ? new URL(baseUrl).host : "gitlab.com"; + return `https://oauth2:${token}@${host}/${repo}.git`; + } + if (provider === "gitea") { + const host = baseUrl ? new URL(baseUrl).host : "gitea.coreworlds.io"; + return `https://x-access-token:${token}@${host}/${repo}.git`; } return `https://x-access-token:${token}@github.com/${repo}.git`; } @@ -128,7 +134,17 @@ export async function createPullRequest(opts: { title: string; body: string; token: string; + provider?: "github" | "gitlab" | "gitea"; + baseUrl?: string; + baseBranch?: string; }): Promise<{ number: number; url: string }> { + const provider = opts.provider || "github"; + + if (provider === "gitea") { + return createGiteaPullRequest(opts); + } + + // GitHub via `gh` CLI const { stdout } = await exec( "gh", [ @@ -147,3 +163,36 @@ export async function createPullRequest(opts: { return JSON.parse(stdout.trim()); } + +async function createGiteaPullRequest(opts: { + repo: string; + head: string; + title: string; + body: string; + token: string; + baseUrl?: string; + baseBranch?: string; +}): Promise<{ number: number; url: string }> { + const base = opts.baseUrl || "https://gitea.coreworlds.io"; + const res = await fetch(`${base}/api/v1/repos/${opts.repo}/pulls`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `token ${opts.token}`, + }, + body: JSON.stringify({ + title: opts.title, + body: opts.body, + head: opts.head, + base: opts.baseBranch || "main", + }), + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`Gitea PR creation failed (${res.status}): ${text}`); + } + + const data = await res.json(); + return { number: data.number, url: data.html_url }; +} diff --git a/apps/harness/src/lib/orchestrator.ts b/apps/harness/src/lib/orchestrator.ts index 6bfeea4..988bb99 100644 --- a/apps/harness/src/lib/orchestrator.ts +++ b/apps/harness/src/lib/orchestrator.ts @@ -119,9 +119,10 @@ async function runTask(task: Task): Promise { return; } - const gitCreds = await getRawCredentialsByProvider("github"); - const gitToken = gitCreds[0]?.token; - if (!gitToken) { + const gitProvider = task.spec.gitProvider || "github"; + const gitCreds = await getRawCredentialsByProvider(gitProvider); + const gitCred = gitCreds[0]; + if (!gitCred?.token) { await updateTask(task.id, { status: "failed", completedAt: Date.now(), @@ -129,7 +130,8 @@ async function runTask(task: Task): Promise { return; } - const repoUrl = buildAuthenticatedCloneUrl(task.project, "github", gitToken); + const gitBaseUrl = task.spec.gitBaseUrl || gitCred.baseUrl; + const repoUrl = buildAuthenticatedCloneUrl(task.project, gitProvider, gitCred.token, gitBaseUrl); await updateTask(task.id, { status: "running", @@ -183,7 +185,9 @@ async function runTask(task: Task): Promise { head: branchName, title: `[harness] ${task.goal}`, body: `Automated by harness orchestrator.\n\nTask: ${task.slug}\nIterations: ${lastIterN}`, - token: gitToken, + token: gitCred.token, + provider: gitProvider, + baseUrl: gitBaseUrl, }); await updateTask(task.id, { diff --git a/apps/harness/src/lib/repo-search.ts b/apps/harness/src/lib/repo-search.ts index 7df7441..3717568 100644 --- a/apps/harness/src/lib/repo-search.ts +++ b/apps/harness/src/lib/repo-search.ts @@ -1,7 +1,7 @@ import { getRawCredentialsByProvider } from "./credentials"; export interface RepoResult { - provider: "github" | "gitlab"; + provider: "github" | "gitlab" | "gitea"; fullName: string; url: string; description: string; @@ -15,6 +15,7 @@ export async function searchRepos(query: string): Promise { const results = await Promise.allSettled([ searchGitHub(query), searchGitLab(query), + searchGitea(query), ]); return results.flatMap(r => r.status === "fulfilled" ? r.value : []); @@ -98,3 +99,42 @@ async function searchGitLab(query: string): Promise { return results; } + +async function searchGitea(query: string): Promise { + const creds = await getRawCredentialsByProvider("gitea"); + if (creds.length === 0) return []; + + const results: RepoResult[] = []; + + for (const cred of creds) { + const baseUrl = cred.baseUrl || "https://gitea.coreworlds.io"; + try { + const res = await fetch( + `${baseUrl}/api/v1/repos/search?q=${encodeURIComponent(query)}&limit=10&sort=updated`, + { + headers: { + Authorization: `token ${cred.token}`, + }, + } + ); + + if (!res.ok) continue; + + const data = await res.json(); + for (const repo of data.data || []) { + results.push({ + provider: "gitea", + fullName: repo.full_name, + url: repo.html_url, + description: repo.description || "", + defaultBranch: repo.default_branch || "main", + private: repo.private, + }); + } + } catch { + // skip failed credential + } + } + + return results; +} diff --git a/apps/harness/src/lib/types.ts b/apps/harness/src/lib/types.ts index 250ed33..725e40b 100644 --- a/apps/harness/src/lib/types.ts +++ b/apps/harness/src/lib/types.ts @@ -2,6 +2,8 @@ export interface TaskSpec { slug: string; goal: string; project: string; + gitProvider?: "github" | "gitlab" | "gitea"; + gitBaseUrl?: string; agentId: string; maxIterations: number; criteria: { label: string; target: string }[];