This commit is contained in:
NuklearRabbit
2026-07-24 20:29:23 +02:00
commit 66060348da
107 changed files with 14771 additions and 0 deletions
+277
View File
@@ -0,0 +1,277 @@
'use strict';
const { normalizeBaseUrl } = 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 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 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) {
if (target.origin !== base.origin) throw new Error('Refusing to send the Gitea token to a different origin.');
const response = await fetch(target, {
headers: { 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 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 };