Files
ForgeFlow/src/main/gitea-service.cjs
T

618 lines
20 KiB
JavaScript

"use strict";
const {
normalizeBaseUrl,
assertBranchName,
} = require("../shared/validation.cjs");
const { redactSecrets } = require("./log-redaction.cjs");
class GiteaService {
constructor(store, diagnostics = null) {
this.store = store;
this.diagnostics = diagnostics;
}
async request(pathname, options = {}) {
const baseUrl = normalizeBaseUrl(
options.baseUrl || this.store.data.gitea.baseUrl,
);
const token = options.token || this.store.getToken();
if (!token && options.auth !== false)
throw new Error("No Gitea access token is available.");
const headers = {
Accept: options.accept || "application/json",
...(token && options.auth !== false
? { Authorization: `token ${token}` }
: {}),
...(options.body ? { "Content-Type": "application/json" } : {}),
...(options.headers || {}),
};
const started = Date.now();
let response;
try {
response = await fetch(`${baseUrl}/api/v1${pathname}`, {
method: options.method || "GET",
headers,
body: options.body ? JSON.stringify(options.body) : undefined,
signal: AbortSignal.timeout(options.timeout || 30_000),
redirect: "follow",
});
} catch (error) {
const wrapped = new Error(
`Could not reach Gitea: ${redactSecrets(error.message, [token])}`,
);
wrapped.code = error.code || "GITEA_NETWORK_ERROR";
await this.diagnostics?.warning("gitea.request.failed", {
method: options.method || "GET",
pathname,
durationMs: Date.now() - started,
code: wrapped.code,
message: wrapped.message,
});
throw wrapped;
}
let text = "";
let payload = null;
if (options.responseType === "buffer") {
payload = Buffer.from(await response.arrayBuffer());
} else {
text = await response.text();
if (text) {
if (options.responseType === "text") payload = text;
else {
try {
payload = JSON.parse(text);
} catch {
payload = text;
}
}
}
}
if (!response.ok) {
const detail =
typeof payload === "object" &&
!Buffer.isBuffer(payload) &&
payload?.message
? payload.message
: text || response.statusText;
const error = new Error(
`Gitea returned ${response.status}: ${redactSecrets(detail, [token])}`,
);
error.status = response.status;
error.payload = payload;
await this.diagnostics?.warning("gitea.request.rejected", {
method: options.method || "GET",
pathname,
status: response.status,
durationMs: Date.now() - started,
message: error.message,
});
throw error;
}
await this.diagnostics?.debug("gitea.request.completed", {
method: options.method || "GET",
pathname,
status: response.status,
durationMs: Date.now() - started,
});
return {
status: response.status,
headers: response.headers,
data: payload,
};
}
async validateConnection(baseUrl, token) {
const normalized = normalizeBaseUrl(baseUrl);
const user = await this.request("/user", { baseUrl: normalized, token });
const repositories = await this.listRepositories({
baseUrl: normalized,
token,
limitPages: 1,
});
const version = await this.request("/version", {
baseUrl: normalized,
token,
})
.then((result) => result.data?.version || null)
.catch(() => null);
return {
baseUrl: normalized,
user: user.data,
repositoryCount: repositories.length,
version,
};
}
async listRepositories(options = {}) {
const repositories = [];
const pageSize = 50;
const limitPages = options.limitPages || 20;
for (let page = 1; page <= limitPages; page += 1) {
const result = await this.request(
`/user/repos?limit=${pageSize}&page=${page}&sort=updated`,
options,
);
const batch = Array.isArray(result.data) ? result.data : [];
repositories.push(...batch);
if (batch.length < pageSize) break;
}
return repositories;
}
async getRepository(owner, repo) {
return (
await this.request(
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`,
)
).data;
}
async repositoryFileExists({ owner, repo, filePath, ref }) {
const encodedPath = String(filePath || "")
.split("/")
.map(encodeURIComponent)
.join("/");
const query = ref ? `?ref=${encodeURIComponent(ref)}` : "";
try {
await this.request(
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${encodedPath}${query}`,
);
return true;
} catch (error) {
if (error.status === 404) return false;
throw error;
}
}
async getBranch(owner, repo, branch) {
return (
await this.request(
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/branches/${encodeURIComponent(branch)}`,
)
).data;
}
async getBranchProtection(owner, repo, branch) {
const branchInfo = await this.getBranch(owner, repo, branch);
let rule = null;
try {
const result = await this.request(
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/branch_protections`,
);
const rules = Array.isArray(result.data) ? result.data : [];
rule =
rules.find(
(item) => item.branch_name === branch || item.rule_name === branch,
) || null;
} catch (error) {
if (![403, 404].includes(error.status)) throw error;
}
return {
branch,
protected: Boolean(branchInfo?.protected || rule),
enablePush: rule?.enable_push ?? null,
enableForcePush: rule?.enable_force_push ?? false,
requiredApprovals: Number(rule?.required_approvals || 0),
requireSignedCommits: Boolean(rule?.require_signed_commits),
rule,
};
}
async createBranchProtection(owner, repo, branch) {
const target = assertBranchName(branch);
return (
await this.request(
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/branch_protections`,
{
method: "POST",
body: {
rule_name: target,
branch_name: target,
enable_push: false,
enable_force_push: false,
required_approvals: 0,
dismiss_stale_approvals: true,
block_on_rejected_reviews: true,
block_on_outdated_branch: true,
},
},
)
).data;
}
async listDeployKeys(owner, repo) {
const result = await this.request(
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/keys?limit=100`,
);
return Array.isArray(result.data) ? result.data : [];
}
async ensureReadOnlyDeployKey({ owner, repo, title, publicKey }) {
const key = String(publicKey || "").trim();
if (!/^ssh-(ed25519|rsa)\s+[A-Za-z0-9+/=]+(?:\s+.*)?$/.test(key))
throw new Error("The server did not return a valid SSH public key.");
const keys = await this.listDeployKeys(owner, repo);
const keyMaterial = key.split(/\s+/).slice(0, 2).join(" ");
const existing = keys.find((item) =>
String(item?.key || "").trim().split(/\s+/).slice(0, 2).join(" ") === keyMaterial,
);
if (existing) {
if (existing.read_only !== true) {
const error = new Error("The matching Gitea deploy key has write access. Revoke it before ForgeFlow configures a read-only server key.");
error.code = "DEPLOY_KEY_NOT_READ_ONLY";
throw error;
}
return { ...existing, created: false };
}
const result = await this.request(
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/keys`,
{
method: "POST",
body: {
title: String(title || "ForgeFlow server deploy key").trim().slice(0, 255),
key,
read_only: true,
},
},
);
return { ...result.data, created: true };
}
async createReadOnlyDeployKey({ owner, repo, title, publicKey }) {
const key = String(publicKey || "").trim();
if (!/^ssh-(ed25519|rsa)\s+[A-Za-z0-9+/=]+(?:\s+.*)?$/.test(key)) throw new Error("A valid SSH public key is required.");
const result = await this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/keys`, { method: "POST", body: { title: String(title || "ForgeFlow server deploy key").trim().slice(0, 255), key, read_only: true } });
return result.data;
}
async deleteDeployKey(owner, repo, keyId) {
if (!Number.isInteger(Number(keyId)) || Number(keyId) <= 0) throw new Error("A valid deploy-key ID is required.");
await this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/keys/${Number(keyId)}`, { method: "DELETE" });
return { deleted: true, keyId: Number(keyId) };
}
async listPullRequests({ owner, repo, state = "open", limit = 30 } = {}) {
const query = new URLSearchParams({
state,
limit: String(Math.min(Math.max(Number(limit) || 30, 1), 50)),
});
const result = await this.request(
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls?${query}`,
);
return Array.isArray(result.data) ? result.data : [];
}
async createPullRequest({ owner, repo, head, base, title, body = "" }) {
const cleanTitle = String(title || "").trim();
if (!cleanTitle || cleanTitle.length > 255)
throw new Error("Pull request title must contain 1-255 characters.");
const cleanBody = String(body || "")
.trim()
.slice(0, 50_000);
const source = assertBranchName(head);
const target = assertBranchName(base);
if (source === target)
throw new Error(
"Pull request source and target branches must be different.",
);
const result = await this.request(
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls`,
{
method: "POST",
body: {
head: source,
base: target,
title: cleanTitle,
body: cleanBody,
},
timeout: 60_000,
},
);
return result.data;
}
async getRepositoryFile({ owner, repo, filePath, ref }) {
const encodedPath = String(filePath || "")
.split("/")
.map(encodeURIComponent)
.join("/");
const query = ref ? `?ref=${encodeURIComponent(ref)}` : "";
const payload = (
await this.request(
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${encodedPath}${query}`,
)
).data;
if (!payload || Array.isArray(payload))
throw new Error(`Repository path ${filePath} is not a file.`);
if (payload.encoding === "base64" && typeof payload.content === "string") {
return {
...payload,
decoded: Buffer.from(
payload.content.replace(/\s/g, ""),
"base64",
).toString("utf8"),
};
}
if (typeof payload.content === "string")
return { ...payload, decoded: payload.content };
throw new Error(`Gitea did not return readable content for ${filePath}.`);
}
async getLatestRelease(owner, repo) {
try {
return (
await this.request(
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/latest`,
)
).data;
} catch (error) {
if (error.status === 404) return null;
throw error;
}
}
async getReleaseByTag(owner, repo, tag) {
try {
return (
await this.request(
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/tags/${encodeURIComponent(tag)}`,
)
).data;
} catch (error) {
if (error.status === 404) return null;
throw error;
}
}
async downloadAuthenticated(url, { timeout = 180_000 } = {}) {
const baseUrl = normalizeBaseUrl(this.store.data.gitea.baseUrl);
const base = new URL(baseUrl);
const token = this.store.getToken();
let target = new URL(url, `${baseUrl}/`);
for (let redirects = 0; redirects <= 5; redirects += 1) {
const sameOrigin = target.origin === base.origin;
if (!sameOrigin && target.protocol !== "https:") {
throw new Error(
"Refusing an insecure cross-origin update download redirect.",
);
}
const response = await fetch(target, {
headers: {
...(sameOrigin && token ? { Authorization: `token ${token}` } : {}),
Accept: "application/octet-stream",
},
signal: AbortSignal.timeout(timeout),
redirect: "manual",
});
if ([301, 302, 303, 307, 308].includes(response.status)) {
const location = response.headers.get("location");
if (!location)
throw new Error(
"The update download redirect did not contain a destination.",
);
target = new URL(location, target);
continue;
}
if (!response.ok)
throw new Error(`Update download failed with HTTP ${response.status}.`);
return Buffer.from(await response.arrayBuffer());
}
throw new Error("The update download exceeded the redirect limit.");
}
async downloadReleaseAsset(owner, repo, releaseId, assetId, options = {}) {
const numericReleaseId = Number(releaseId);
const numericId = Number(assetId);
if (!Number.isSafeInteger(numericReleaseId) || numericReleaseId <= 0)
throw new Error("Gitea returned an invalid release ID.");
if (!Number.isSafeInteger(numericId) || numericId <= 0)
throw new Error("Gitea returned an invalid release asset ID.");
let downloadUrl = String(options.downloadUrl || "").trim();
if (!downloadUrl) {
const metadataPath = `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/${numericReleaseId}/assets/${numericId}`;
const metadata = (await this.request(metadataPath)).data;
if (Number(metadata?.id) !== numericId) {
throw new Error("Gitea returned metadata for a different release asset.");
}
downloadUrl = String(metadata?.browser_download_url || "").trim();
}
if (!downloadUrl) {
throw new Error("Gitea did not provide a release asset download URL.");
}
return this.downloadAuthenticated(downloadUrl, options);
}
async dispatchWorkflow({ owner, repo, workflowFile, ref, inputs = {} }) {
const result = await this.request(
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/actions/workflows/${encodeURIComponent(workflowFile)}/dispatches`,
{ method: "POST", body: { ref, inputs }, timeout: 60_000 },
);
return {
accepted: [200, 201, 204].includes(result.status),
status: result.status,
};
}
normalizeRun(run) {
if (!run || typeof run !== "object") return null;
const status = String(run.status || run.conclusion || "").toLowerCase();
const conclusion =
String(run.conclusion || "").toLowerCase() ||
(["success", "failure", "cancelled", "skipped"].includes(status)
? status
: null);
return {
id: run.id ?? run.run_id ?? run.task_id ?? null,
runNumber: run.run_number ?? run.index ?? run.id ?? null,
name: run.name || run.workflow_name || run.workflow_id || "Workflow",
event: run.event || null,
status,
conclusion,
headSha: run.head_sha || run.commit_sha || run.commit?.sha || null,
headBranch: run.head_branch || run.ref || run.branch || null,
workflowPath: run.path || run.workflow_path || run.workflow_file || null,
displayTitle: run.display_title || run.title || run.name || null,
actor:
run.actor?.login || run.trigger_user?.login || run.user?.login || null,
createdAt: run.created_at || run.started || run.start_time || null,
updatedAt: run.updated_at || run.stopped || run.end_time || null,
htmlUrl: run.html_url || run.url || null,
raw: run,
};
}
async listWorkflowRuns({ owner, repo, sha, branch, limit = 30 } = {}) {
const base = `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/actions`;
const normalizedLimit = String(Math.min(Math.max(limit, 1), 100));
const filtered = new URLSearchParams({ limit: normalizedLimit });
if (sha) filtered.set("head_sha", sha);
if (branch) filtered.set("branch", branch);
const basic = new URLSearchParams({ limit: normalizedLimit });
const tryEndpoint = async (endpoint) => {
try {
return await this.request(`${base}/${endpoint}?${filtered}`);
} catch (error) {
// Action API query support differs across Gitea releases. Retry without
// optional filters and apply SHA/branch matching locally.
if (
![400, 422].includes(error.status) ||
String(filtered) === String(basic)
)
throw error;
return this.request(`${base}/${endpoint}?${basic}`);
}
};
let result;
let source = "runs";
try {
result = await tryEndpoint("runs");
} catch (error) {
if (![404, 405].includes(error.status)) throw error;
source = "tasks";
result = await tryEndpoint("tasks");
}
const data = result.data;
const items = Array.isArray(data)
? data
: data?.workflow_runs || data?.runs || data?.tasks || [];
return {
source,
runs: items.map((item) => this.normalizeRun(item)).filter(Boolean),
totalCount: data?.total_count ?? items.length,
};
}
async findWorkflowRun({
owner,
repo,
sha,
branch,
workflowFile,
dispatchedAt,
excludeRunIds = [],
}) {
const { runs, source } = await this.listWorkflowRuns({
owner,
repo,
sha,
branch,
limit: 50,
});
const earliest = dispatchedAt
? new Date(dispatchedAt).getTime() - 120_000
: 0;
const workflowBase = String(workflowFile || "")
.split("/")
.pop();
const excluded = new Set(
(excludeRunIds || []).map((value) => String(value)),
);
const candidates = runs.filter((run) => {
if (
run.id !== null &&
run.id !== undefined &&
excluded.has(String(run.id))
)
return false;
if (sha && run.headSha && run.headSha.toLowerCase() !== sha.toLowerCase())
return false;
if (
branch &&
run.headBranch &&
run.headBranch.replace(/^refs\/heads\//, "") !== branch
)
return false;
if (
earliest &&
run.createdAt &&
new Date(run.createdAt).getTime() < earliest
)
return false;
if (workflowBase && run.workflowPath) {
const runBase = String(run.workflowPath).split("/").pop();
if (runBase && runBase !== workflowBase) return false;
}
return true;
});
candidates.sort(
(a, b) => new Date(b.createdAt || 0) - new Date(a.createdAt || 0),
);
return { source, run: candidates[0] || null };
}
async listWorkflowJobs({ owner, repo, runNumber }) {
if (runNumber === null || runNumber === undefined) return [];
const result = await this.request(
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/actions/runs/${encodeURIComponent(runNumber)}/jobs?limit=100`,
);
const data = result.data;
const jobs = Array.isArray(data) ? data : data?.jobs || [];
return jobs.map((job) => ({
id: job.id,
name: job.name || job.job_name || `Job ${job.id}`,
status: String(job.status || "").toLowerCase(),
conclusion: String(job.conclusion || "").toLowerCase() || null,
startedAt: job.started_at || null,
completedAt: job.completed_at || null,
steps: Array.isArray(job.steps)
? job.steps.map((step) => ({
name: step.name,
status: String(step.status || "").toLowerCase(),
conclusion: String(step.conclusion || "").toLowerCase() || null,
number: step.number,
}))
: [],
}));
}
async getJobLogs({ owner, repo, jobId }) {
if (!jobId) return "";
try {
const result = await this.request(
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/actions/jobs/${encodeURIComponent(jobId)}/logs`,
{
accept: "text/plain, application/octet-stream",
responseType: "text",
timeout: 60_000,
},
);
return String(result.data || "").slice(-500_000);
} catch (error) {
if ([404, 410].includes(error.status)) return "";
throw error;
}
}
}
module.exports = { GiteaService };