Add Gitea as a git provider for harness workspace repositories
Some checks failed
CI / lint-and-test (push) Successful in 30s
CI / build (push) Has been cancelled
Deploy Production / deploy (push) Has been cancelled

Support Gitea alongside GitHub/GitLab for repo search, authenticated
cloning, and pull request creation via Gitea API. Tasks can specify
gitProvider and gitBaseUrl in their spec (defaults to github for
backwards compat). Auto-discovers GITEA_TOKEN from env on boot.
This commit is contained in:
Julia McGhee
2026-03-21 20:33:24 +00:00
parent 11192da432
commit a687652bcd
6 changed files with 106 additions and 10 deletions

View File

@@ -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) {

View File

@@ -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 {

View File

@@ -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 };
}

View File

@@ -119,9 +119,10 @@ async function runTask(task: Task): Promise<void> {
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<void> {
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<void> {
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, {

View File

@@ -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<RepoResult[]> {
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<RepoResult[]> {
return results;
}
async function searchGitea(query: string): Promise<RepoResult[]> {
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;
}

View File

@@ -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 }[];