Add Gitea as a git provider for harness workspace repositories
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:
@@ -81,6 +81,7 @@ async function loadCredentialsFromEnv() {
|
|||||||
["GITHUB_TOKEN", "github", "GitHub (env)"],
|
["GITHUB_TOKEN", "github", "GitHub (env)"],
|
||||||
["GH_TOKEN", "github", "GitHub (env)"],
|
["GH_TOKEN", "github", "GitHub (env)"],
|
||||||
["GITLAB_TOKEN", "gitlab", "GitLab (env)"],
|
["GITLAB_TOKEN", "gitlab", "GitLab (env)"],
|
||||||
|
["GITEA_TOKEN", "gitea", "Gitea (env)"],
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const [envVar, provider, label] of envMap) {
|
for (const [envVar, provider, label] of envMap) {
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import { db } from "./db";
|
|||||||
import { credentials as credentialsTable } from "@homelab/db";
|
import { credentials as credentialsTable } from "@homelab/db";
|
||||||
|
|
||||||
export type Provider =
|
export type Provider =
|
||||||
| "github" | "gitlab"
|
| "github" | "gitlab" | "gitea"
|
||||||
| "anthropic" | "openai" | "openrouter" | "google" | "opencode-zen" | "opencode-go";
|
| "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 const AI_PROVIDERS: Provider[] = ["anthropic", "openai", "openrouter", "google", "opencode-zen", "opencode-go"];
|
||||||
|
|
||||||
export interface Credential {
|
export interface Credential {
|
||||||
|
|||||||
@@ -21,12 +21,18 @@ export function iterationDir(taskId: string, iteration: number): string {
|
|||||||
|
|
||||||
export function buildAuthenticatedCloneUrl(
|
export function buildAuthenticatedCloneUrl(
|
||||||
repo: string,
|
repo: string,
|
||||||
provider: "github" | "gitlab",
|
provider: "github" | "gitlab" | "gitea",
|
||||||
token: string,
|
token: string,
|
||||||
|
baseUrl?: string,
|
||||||
): string {
|
): string {
|
||||||
// repo format: "owner/name"
|
// repo format: "owner/name"
|
||||||
if (provider === "gitlab") {
|
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`;
|
return `https://x-access-token:${token}@github.com/${repo}.git`;
|
||||||
}
|
}
|
||||||
@@ -128,7 +134,17 @@ export async function createPullRequest(opts: {
|
|||||||
title: string;
|
title: string;
|
||||||
body: string;
|
body: string;
|
||||||
token: string;
|
token: string;
|
||||||
|
provider?: "github" | "gitlab" | "gitea";
|
||||||
|
baseUrl?: string;
|
||||||
|
baseBranch?: string;
|
||||||
}): Promise<{ number: number; url: 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(
|
const { stdout } = await exec(
|
||||||
"gh",
|
"gh",
|
||||||
[
|
[
|
||||||
@@ -147,3 +163,36 @@ export async function createPullRequest(opts: {
|
|||||||
|
|
||||||
return JSON.parse(stdout.trim());
|
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 };
|
||||||
|
}
|
||||||
|
|||||||
@@ -119,9 +119,10 @@ async function runTask(task: Task): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const gitCreds = await getRawCredentialsByProvider("github");
|
const gitProvider = task.spec.gitProvider || "github";
|
||||||
const gitToken = gitCreds[0]?.token;
|
const gitCreds = await getRawCredentialsByProvider(gitProvider);
|
||||||
if (!gitToken) {
|
const gitCred = gitCreds[0];
|
||||||
|
if (!gitCred?.token) {
|
||||||
await updateTask(task.id, {
|
await updateTask(task.id, {
|
||||||
status: "failed",
|
status: "failed",
|
||||||
completedAt: Date.now(),
|
completedAt: Date.now(),
|
||||||
@@ -129,7 +130,8 @@ async function runTask(task: Task): Promise<void> {
|
|||||||
return;
|
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, {
|
await updateTask(task.id, {
|
||||||
status: "running",
|
status: "running",
|
||||||
@@ -183,7 +185,9 @@ async function runTask(task: Task): Promise<void> {
|
|||||||
head: branchName,
|
head: branchName,
|
||||||
title: `[harness] ${task.goal}`,
|
title: `[harness] ${task.goal}`,
|
||||||
body: `Automated by harness orchestrator.\n\nTask: ${task.slug}\nIterations: ${lastIterN}`,
|
body: `Automated by harness orchestrator.\n\nTask: ${task.slug}\nIterations: ${lastIterN}`,
|
||||||
token: gitToken,
|
token: gitCred.token,
|
||||||
|
provider: gitProvider,
|
||||||
|
baseUrl: gitBaseUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
await updateTask(task.id, {
|
await updateTask(task.id, {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { getRawCredentialsByProvider } from "./credentials";
|
import { getRawCredentialsByProvider } from "./credentials";
|
||||||
|
|
||||||
export interface RepoResult {
|
export interface RepoResult {
|
||||||
provider: "github" | "gitlab";
|
provider: "github" | "gitlab" | "gitea";
|
||||||
fullName: string;
|
fullName: string;
|
||||||
url: string;
|
url: string;
|
||||||
description: string;
|
description: string;
|
||||||
@@ -15,6 +15,7 @@ export async function searchRepos(query: string): Promise<RepoResult[]> {
|
|||||||
const results = await Promise.allSettled([
|
const results = await Promise.allSettled([
|
||||||
searchGitHub(query),
|
searchGitHub(query),
|
||||||
searchGitLab(query),
|
searchGitLab(query),
|
||||||
|
searchGitea(query),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return results.flatMap(r => r.status === "fulfilled" ? r.value : []);
|
return results.flatMap(r => r.status === "fulfilled" ? r.value : []);
|
||||||
@@ -98,3 +99,42 @@ async function searchGitLab(query: string): Promise<RepoResult[]> {
|
|||||||
|
|
||||||
return results;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ export interface TaskSpec {
|
|||||||
slug: string;
|
slug: string;
|
||||||
goal: string;
|
goal: string;
|
||||||
project: string;
|
project: string;
|
||||||
|
gitProvider?: "github" | "gitlab" | "gitea";
|
||||||
|
gitBaseUrl?: string;
|
||||||
agentId: string;
|
agentId: string;
|
||||||
maxIterations: number;
|
maxIterations: number;
|
||||||
criteria: { label: string; target: string }[];
|
criteria: { label: string; target: string }[];
|
||||||
|
|||||||
Reference in New Issue
Block a user