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