import * as plugins from './gitea.plugins.js'; import type { IGiteaUser, IGiteaRepository, IGiteaOrganization, IGiteaSecret, IGiteaBranch, IGiteaTag, IGiteaActionRun, IGiteaActionRunJob, ITestConnectionResult, IListOptions, IActionRunListOptions, } from './gitea.interfaces.js'; import { GiteaOrganization } from './gitea.classes.organization.js'; import { GiteaRepository } from './gitea.classes.repository.js'; import { autoPaginate, toGiteaApiStatus } from './gitea.helpers.js'; export class GiteaClient { private baseUrl: string; private token: string; constructor(baseUrl: string, token: string) { this.baseUrl = baseUrl.replace(/\/+$/, ''); this.token = token; } // =========================================================================== // HTTP helpers (internal) // =========================================================================== /** @internal */ async request( method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE', path: string, data?: any, customHeaders?: Record, ): Promise { const url = `${this.baseUrl}${path}`; let builder = plugins.smartrequest.SmartRequest.create() .url(url) .header('Authorization', `token ${this.token}`) .header('Content-Type', 'application/json'); if (customHeaders) { for (const [k, v] of Object.entries(customHeaders)) { builder = builder.header(k, v); } } if (data) { builder = builder.json(data); } let response: Awaited>; switch (method) { case 'GET': response = await builder.get(); break; case 'POST': response = await builder.post(); break; case 'PUT': response = await builder.put(); break; case 'PATCH': response = await builder.patch(); break; case 'DELETE': response = await builder.delete(); break; } if (!response.ok) { const errorText = await response.text(); throw new Error(`${method} ${path}: ${response.status} ${response.statusText} - ${errorText}`); } try { return await response.json() as T; } catch { return undefined as unknown as T; } } /** @internal */ async requestText( method: 'GET' | 'POST' | 'PUT' | 'DELETE', path: string, ): Promise { const url = `${this.baseUrl}${path}`; let builder = plugins.smartrequest.SmartRequest.create() .url(url) .header('Authorization', `token ${this.token}`) .header('Accept', 'text/plain'); let response: Awaited>; switch (method) { case 'GET': response = await builder.get(); break; case 'POST': response = await builder.post(); break; case 'PUT': response = await builder.put(); break; case 'DELETE': response = await builder.delete(); break; } if (!response.ok) { const errorText = await response.text(); throw new Error(`${method} ${path}: ${response.status} ${response.statusText} - ${errorText}`); } return response.text(); } /** @internal — fetch binary data (e.g. avatar images) */ async requestBinary(path: string): Promise { const url = `${this.baseUrl}${path}`; const response = await fetch(url, { headers: { 'Authorization': `token ${this.token}` }, }); if (!response.ok) { throw new Error(`GET ${path}: ${response.status} ${response.statusText}`); } const buf = await response.arrayBuffer(); return new Uint8Array(buf); } // =========================================================================== // Public API — Connection // =========================================================================== public async testConnection(): Promise { try { await this.request('GET', '/api/v1/user'); return { ok: true }; } catch (err) { return { ok: false, error: err instanceof Error ? err.message : String(err) }; } } // =========================================================================== // Public API — Organizations (returns rich objects) // =========================================================================== /** * Get all organizations (auto-paginated). */ public async getOrgs(opts?: IListOptions): Promise { return autoPaginate( (page, perPage) => this.requestGetOrgs({ ...opts, page, perPage }), opts, ).then(orgs => orgs.map(o => new GiteaOrganization(this, o))); } /** * Get a single organization by name. */ public async getOrg(orgName: string): Promise { const raw = await this.requestGetOrg(orgName); return new GiteaOrganization(this, raw); } /** * Create a new organization. */ public async createOrg(name: string, opts?: { fullName?: string; description?: string; visibility?: string; }): Promise { const raw = await this.requestCreateOrg(name, opts); return new GiteaOrganization(this, raw); } // =========================================================================== // Public API — Repositories (returns rich objects) // =========================================================================== /** * Search/list all repositories (auto-paginated). */ public async getRepos(opts?: IListOptions): Promise { return autoPaginate( (page, perPage) => this.requestGetRepos({ ...opts, page, perPage }), opts, ).then(repos => repos.map(r => new GiteaRepository(this, r))); } /** * Get a single repository by owner/repo. */ public async getRepo(ownerRepo: string): Promise { const raw = await this.requestGetRepo(ownerRepo); return new GiteaRepository(this, raw); } /** * Create a repository within an organization. */ public async createOrgRepo(orgName: string, name: string, opts?: { description?: string; private?: boolean; }): Promise { const raw = await this.requestCreateOrgRepo(orgName, name, opts); return new GiteaRepository(this, raw); } // =========================================================================== // Internal request methods — called by domain classes // =========================================================================== // --- Repos --- /** @internal */ async requestGetRepos(opts?: IListOptions): Promise { const page = opts?.page || 1; const limit = opts?.perPage || 50; let url = `/api/v1/repos/search?page=${page}&limit=${limit}&sort=updated`; if (opts?.search) { url += `&q=${encodeURIComponent(opts.search)}`; } const body = await this.request('GET', url); return body.data || body; } /** @internal */ async requestGetRepo(ownerRepo: string): Promise { return this.request('GET', `/api/v1/repos/${ownerRepo}`); } /** @internal */ async requestCreateOrgRepo(orgName: string, name: string, opts?: { description?: string; private?: boolean; }): Promise { return this.request('POST', `/api/v1/orgs/${encodeURIComponent(orgName)}/repos`, { name, description: opts?.description || '', private: opts?.private ?? true, }); } /** @internal */ async requestPatchRepo(ownerRepo: string, data: Record): Promise { await this.request('PATCH', `/api/v1/repos/${ownerRepo}`, data); } /** @internal */ async requestSetRepoTopics(ownerRepo: string, topics: string[]): Promise { await this.request('PUT', `/api/v1/repos/${ownerRepo}/topics`, { topics }); } /** @internal */ async requestPostRepoAvatar(ownerRepo: string, imageBase64: string): Promise { await this.request('POST', `/api/v1/repos/${ownerRepo}/avatar`, { image: imageBase64 }); } /** @internal */ async requestDeleteRepoAvatar(ownerRepo: string): Promise { await this.request('DELETE', `/api/v1/repos/${ownerRepo}/avatar`); } /** @internal */ async requestTransferRepo(ownerRepo: string, newOwner: string, teamIds?: number[]): Promise { const body: any = { new_owner: newOwner }; if (teamIds?.length) body.team_ids = teamIds; await this.request('POST', `/api/v1/repos/${ownerRepo}/transfer`, body); } /** @internal */ async requestDeleteRepo(owner: string, repo: string): Promise { await this.request('DELETE', `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`); } // --- Repo Branches & Tags --- /** @internal */ async requestGetRepoBranches(ownerRepo: string, opts?: IListOptions): Promise { const page = opts?.page || 1; const limit = opts?.perPage || 50; return this.request('GET', `/api/v1/repos/${ownerRepo}/branches?page=${page}&limit=${limit}`); } /** @internal */ async requestGetRepoTags(ownerRepo: string, opts?: IListOptions): Promise { const page = opts?.page || 1; const limit = opts?.perPage || 50; return this.request('GET', `/api/v1/repos/${ownerRepo}/tags?page=${page}&limit=${limit}`); } // --- Repo Secrets --- /** @internal */ async requestGetRepoSecrets(ownerRepo: string): Promise { return this.request('GET', `/api/v1/repos/${ownerRepo}/actions/secrets`); } /** @internal */ async requestSetRepoSecret(ownerRepo: string, key: string, value: string): Promise { await this.request('PUT', `/api/v1/repos/${ownerRepo}/actions/secrets/${key}`, { data: value }); } /** @internal */ async requestDeleteRepoSecret(ownerRepo: string, key: string): Promise { await this.request('DELETE', `/api/v1/repos/${ownerRepo}/actions/secrets/${key}`); } // --- Organizations --- /** @internal */ async requestGetOrgs(opts?: IListOptions): Promise { const page = opts?.page || 1; const limit = opts?.perPage || 50; return this.request('GET', `/api/v1/orgs?page=${page}&limit=${limit}`); } /** @internal */ async requestGetOrg(orgName: string): Promise { return this.request('GET', `/api/v1/orgs/${encodeURIComponent(orgName)}`); } /** @internal */ async requestCreateOrg(name: string, opts?: { fullName?: string; description?: string; visibility?: string; }): Promise { return this.request('POST', '/api/v1/orgs', { username: name, full_name: opts?.fullName || name, description: opts?.description || '', visibility: opts?.visibility || 'public', }); } /** @internal */ async requestPatchOrg(orgName: string, data: Record): Promise { await this.request('PATCH', `/api/v1/orgs/${encodeURIComponent(orgName)}`, data); } /** @internal */ async requestPostOrgAvatar(orgName: string, imageBase64: string): Promise { await this.request('POST', `/api/v1/orgs/${encodeURIComponent(orgName)}/avatar`, { image: imageBase64 }); } /** @internal */ async requestDeleteOrgAvatar(orgName: string): Promise { await this.request('DELETE', `/api/v1/orgs/${encodeURIComponent(orgName)}/avatar`); } /** @internal */ async requestDeleteOrg(orgName: string): Promise { await this.request('DELETE', `/api/v1/orgs/${encodeURIComponent(orgName)}`); } // --- Org repos --- /** @internal */ async requestGetOrgRepos(orgName: string, opts?: IListOptions): Promise { const page = opts?.page || 1; const limit = opts?.perPage || 50; let url = `/api/v1/orgs/${encodeURIComponent(orgName)}/repos?page=${page}&limit=${limit}&sort=updated`; if (opts?.search) { url += `&q=${encodeURIComponent(opts.search)}`; } return this.request('GET', url); } // --- Org Secrets --- /** @internal */ async requestGetOrgSecrets(orgName: string): Promise { return this.request('GET', `/api/v1/orgs/${orgName}/actions/secrets`); } /** @internal */ async requestSetOrgSecret(orgName: string, key: string, value: string): Promise { await this.request('PUT', `/api/v1/orgs/${orgName}/actions/secrets/${key}`, { data: value }); } /** @internal */ async requestDeleteOrgSecret(orgName: string, key: string): Promise { await this.request('DELETE', `/api/v1/orgs/${orgName}/actions/secrets/${key}`); } // --- Action Runs --- /** @internal */ async requestGetActionRuns(ownerRepo: string, opts?: IActionRunListOptions): Promise { const page = opts?.page || 1; const limit = opts?.perPage || 30; let url = `/api/v1/repos/${ownerRepo}/actions/runs?page=${page}&limit=${limit}`; // Translate user-friendly status names to Gitea API values const apiStatus = toGiteaApiStatus(opts?.status); if (apiStatus) url += `&status=${encodeURIComponent(apiStatus)}`; if (opts?.branch) url += `&branch=${encodeURIComponent(opts.branch)}`; if (opts?.event) url += `&event=${encodeURIComponent(opts.event)}`; if (opts?.actor) url += `&actor=${encodeURIComponent(opts.actor)}`; const body = await this.request('GET', url); return body.workflow_runs || body; } /** @internal */ async requestGetActionRun(ownerRepo: string, runId: number): Promise { return this.request('GET', `/api/v1/repos/${ownerRepo}/actions/runs/${runId}`); } /** @internal */ async requestGetActionRunJobs(ownerRepo: string, runId: number): Promise { const body = await this.request('GET', `/api/v1/repos/${ownerRepo}/actions/runs/${runId}/jobs`); return body.jobs || body; } /** @internal */ async requestGetJobLog(ownerRepo: string, jobId: number): Promise { return this.requestText('GET', `/api/v1/repos/${ownerRepo}/actions/jobs/${jobId}/logs`); } /** @internal */ async requestDispatchWorkflow( ownerRepo: string, workflowId: string, ref: string, inputs?: Record, ): Promise { await this.request( 'POST', `/api/v1/repos/${ownerRepo}/actions/workflows/${encodeURIComponent(workflowId)}/dispatches`, { ref, inputs: inputs || {} }, ); } /** @internal */ async requestDeleteActionRun(ownerRepo: string, runId: number): Promise { await this.request('DELETE', `/api/v1/repos/${ownerRepo}/actions/runs/${runId}`); } }