feat(gitea): add domain model classes, helpers, and refactor GiteaClient internals; expand README with usage and docs

This commit is contained in:
2026-03-02 13:09:44 +00:00
parent 5e7a84c6c3
commit 1b685091af
14 changed files with 1387 additions and 336 deletions

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@apiclient.xyz/gitea',
version: '1.4.0',
version: '1.5.0',
description: 'A TypeScript client for the Gitea API, providing easy access to repositories, organizations, secrets, and action runs.'
}

View File

@@ -0,0 +1,124 @@
import type { GiteaClient } from './gitea.classes.giteaclient.js';
import type { IGiteaActionRun } from './gitea.interfaces.js';
import { GiteaActionRunJob } from './gitea.classes.actionrunjob.js';
import {
computeDuration,
resolveGiteaStatus,
extractRefFromPath,
extractWorkflowIdFromPath,
} from './gitea.helpers.js';
export class GiteaActionRun {
// Raw data
public readonly id: number;
public readonly runNumber: number;
public readonly runAttempt: number;
public readonly name: string;
public readonly displayTitle: string;
public readonly status: string;
public readonly conclusion: string;
public readonly headBranch: string;
public readonly headSha: string;
public readonly htmlUrl: string;
public readonly event: string;
public readonly path: string;
public readonly startedAt: string;
public readonly completedAt: string;
public readonly actorLogin: string;
public readonly triggerActorLogin: string;
// Computed
public readonly resolvedStatus: string;
public readonly duration: number;
public readonly ref: string;
public readonly workflowId: string;
/** @internal */
constructor(
private client: GiteaClient,
private ownerRepo: string,
raw: IGiteaActionRun,
) {
this.id = raw.id;
this.runNumber = raw.run_number || 0;
this.runAttempt = raw.run_attempt || 1;
this.name = raw.name || '';
this.displayTitle = raw.display_title || this.name;
this.status = raw.status || '';
this.conclusion = raw.conclusion || '';
this.headBranch = raw.head_branch || '';
this.headSha = raw.head_sha || '';
this.htmlUrl = raw.html_url || '';
this.event = raw.event || '';
this.path = raw.path || '';
this.startedAt = raw.started_at || '';
this.completedAt = raw.completed_at || '';
this.actorLogin = raw.actor?.login || '';
this.triggerActorLogin = raw.trigger_actor?.login || '';
// Computed properties
this.resolvedStatus = resolveGiteaStatus(this.status, this.conclusion);
this.duration = computeDuration(this.startedAt, this.completedAt);
this.ref = this.headBranch || extractRefFromPath(this.path);
this.workflowId = extractWorkflowIdFromPath(this.path);
}
// ---------------------------------------------------------------------------
// Jobs
// ---------------------------------------------------------------------------
async getJobs(): Promise<GiteaActionRunJob[]> {
const jobs = await this.client.requestGetActionRunJobs(this.ownerRepo, this.id);
return jobs.map(j => new GiteaActionRunJob(this.client, this.ownerRepo, j));
}
// ---------------------------------------------------------------------------
// Actions
// ---------------------------------------------------------------------------
/**
* Re-dispatch this workflow on the same ref.
* Gitea 1.25 has no rerun endpoint, so this dispatches the workflow again.
*/
async rerun(inputs?: Record<string, string>): Promise<void> {
const wfId = this.workflowId;
if (!wfId) {
throw new Error(`Cannot rerun: no workflow ID found in path "${this.path}"`);
}
await this.client.requestDispatchWorkflow(this.ownerRepo, wfId, this.ref, inputs);
}
/**
* Delete this action run.
*/
async delete(): Promise<void> {
await this.client.requestDeleteActionRun(this.ownerRepo, this.id);
}
// ---------------------------------------------------------------------------
// Serialization
// ---------------------------------------------------------------------------
toJSON(): IGiteaActionRun {
return {
id: this.id,
name: this.name,
workflow_id: this.workflowId,
status: this.status,
conclusion: this.conclusion,
head_branch: this.headBranch,
head_sha: this.headSha,
html_url: this.htmlUrl,
event: this.event,
path: this.path,
display_title: this.displayTitle,
run_number: this.runNumber,
run_attempt: this.runAttempt,
started_at: this.startedAt,
completed_at: this.completedAt,
actor: { id: 0, login: this.actorLogin, login_name: '', source_id: 0, full_name: '', email: '', avatar_url: '' },
trigger_actor: { id: 0, login: this.triggerActorLogin, login_name: '', source_id: 0, full_name: '', email: '', avatar_url: '' },
repository: { id: 0, name: '', full_name: this.ownerRepo, html_url: '' },
};
}
}

View File

@@ -0,0 +1,118 @@
import type { GiteaClient } from './gitea.classes.giteaclient.js';
import type { IGiteaActionRunJob, IGiteaActionRunJobStep } from './gitea.interfaces.js';
import { computeDuration, resolveGiteaStatus } from './gitea.helpers.js';
export class GiteaActionRunJobStep {
public readonly name: string;
public readonly number: number;
public readonly status: string;
public readonly conclusion: string;
public readonly resolvedStatus: string;
public readonly startedAt: string;
public readonly completedAt: string;
public readonly duration: number;
constructor(raw: IGiteaActionRunJobStep) {
this.name = raw.name || '';
this.number = raw.number || 0;
this.status = raw.status || '';
this.conclusion = raw.conclusion || '';
this.resolvedStatus = resolveGiteaStatus(this.status, this.conclusion);
this.startedAt = raw.started_at || '';
this.completedAt = raw.completed_at || '';
this.duration = computeDuration(this.startedAt, this.completedAt);
}
toJSON(): IGiteaActionRunJobStep {
return {
name: this.name,
number: this.number,
status: this.status,
conclusion: this.conclusion,
started_at: this.startedAt,
completed_at: this.completedAt,
};
}
}
export class GiteaActionRunJob {
// Raw data
public readonly id: number;
public readonly runId: number;
public readonly name: string;
public readonly workflowName: string;
public readonly headBranch: string;
public readonly headSha: string;
public readonly status: string;
public readonly conclusion: string;
public readonly htmlUrl: string;
public readonly startedAt: string;
public readonly completedAt: string;
public readonly labels: string[];
public readonly runnerId: number;
public readonly runnerName: string;
public readonly steps: GiteaActionRunJobStep[];
// Computed
public readonly resolvedStatus: string;
public readonly duration: number;
/** @internal */
constructor(
private client: GiteaClient,
private ownerRepo: string,
raw: IGiteaActionRunJob,
) {
this.id = raw.id;
this.runId = raw.run_id || 0;
this.name = raw.name || '';
this.workflowName = raw.workflow_name || '';
this.headBranch = raw.head_branch || '';
this.headSha = raw.head_sha || '';
this.status = raw.status || '';
this.conclusion = raw.conclusion || '';
this.htmlUrl = raw.html_url || '';
this.startedAt = raw.started_at || '';
this.completedAt = raw.completed_at || '';
this.labels = raw.labels || [];
this.runnerId = raw.runner_id || 0;
this.runnerName = raw.runner_name || '';
this.steps = (raw.steps || []).map(s => new GiteaActionRunJobStep(s));
// Computed
this.resolvedStatus = resolveGiteaStatus(this.status, this.conclusion);
this.duration = computeDuration(this.startedAt, this.completedAt);
}
// ---------------------------------------------------------------------------
// Log
// ---------------------------------------------------------------------------
async getLog(): Promise<string> {
return this.client.requestGetJobLog(this.ownerRepo, this.id);
}
// ---------------------------------------------------------------------------
// Serialization
// ---------------------------------------------------------------------------
toJSON(): IGiteaActionRunJob {
return {
id: this.id,
run_id: this.runId,
name: this.name,
workflow_name: this.workflowName,
head_branch: this.headBranch,
head_sha: this.headSha,
status: this.status,
conclusion: this.conclusion,
html_url: this.htmlUrl,
started_at: this.startedAt,
completed_at: this.completedAt,
steps: this.steps.map(s => s.toJSON()),
labels: this.labels,
runner_id: this.runnerId,
runner_name: this.runnerName,
};
}
}

View File

@@ -0,0 +1,18 @@
import type { IGiteaBranch } from './gitea.interfaces.js';
export class GiteaBranch {
public readonly name: string;
public readonly commitSha: string;
constructor(raw: IGiteaBranch) {
this.name = raw.name || '';
this.commitSha = raw.commit?.id || '';
}
toJSON(): IGiteaBranch {
return {
name: this.name,
commit: { id: this.commitSha },
};
}
}

View File

@@ -1,5 +1,4 @@
import * as plugins from './gitea.plugins.js';
import { logger } from './gitea.logging.js';
import type {
IGiteaUser,
IGiteaRepository,
@@ -13,23 +12,26 @@ import type {
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) {
// Remove trailing slash if present
this.baseUrl = baseUrl.replace(/\/+$/, '');
this.token = token;
}
// ---------------------------------------------------------------------------
// HTTP helpers
// ---------------------------------------------------------------------------
// ===========================================================================
// HTTP helpers (internal)
// ===========================================================================
private async request<T = any>(
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
/** @internal */
async request<T = any>(
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
path: string,
data?: any,
customHeaders?: Record<string, string>,
@@ -62,6 +64,9 @@ export class GiteaClient {
case 'PUT':
response = await builder.put();
break;
case 'PATCH':
response = await builder.patch();
break;
case 'DELETE':
response = await builder.delete();
break;
@@ -79,7 +84,8 @@ export class GiteaClient {
}
}
private async requestText(
/** @internal */
async requestText(
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
path: string,
): Promise<string> {
@@ -114,9 +120,22 @@ export class GiteaClient {
return response.text();
}
// ---------------------------------------------------------------------------
// Connection
// ---------------------------------------------------------------------------
/** @internal — fetch binary data (e.g. avatar images) */
async requestBinary(path: string): Promise<Uint8Array> {
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<ITestConnectionResult> {
try {
@@ -127,11 +146,81 @@ export class GiteaClient {
}
}
// ---------------------------------------------------------------------------
// Repositories
// ---------------------------------------------------------------------------
// ===========================================================================
// Public API — Organizations (returns rich objects)
// ===========================================================================
public async getRepos(opts?: IListOptions): Promise<IGiteaRepository[]> {
/**
* Get all organizations (auto-paginated).
*/
public async getOrgs(opts?: IListOptions): Promise<GiteaOrganization[]> {
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<GiteaOrganization> {
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<GiteaOrganization> {
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<GiteaRepository[]> {
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<GiteaRepository> {
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<GiteaRepository> {
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<IGiteaRepository[]> {
const page = opts?.page || 1;
const limit = opts?.perPage || 50;
let url = `/api/v1/repos/search?page=${page}&limit=${limit}&sort=updated`;
@@ -142,40 +231,104 @@ export class GiteaClient {
return body.data || body;
}
// ---------------------------------------------------------------------------
// Organizations
// ---------------------------------------------------------------------------
/** @internal */
async requestGetRepo(ownerRepo: string): Promise<IGiteaRepository> {
return this.request<IGiteaRepository>('GET', `/api/v1/repos/${ownerRepo}`);
}
public async getOrgs(opts?: IListOptions): Promise<IGiteaOrganization[]> {
/** @internal */
async requestCreateOrgRepo(orgName: string, name: string, opts?: {
description?: string;
private?: boolean;
}): Promise<IGiteaRepository> {
return this.request<IGiteaRepository>('POST', `/api/v1/orgs/${encodeURIComponent(orgName)}/repos`, {
name,
description: opts?.description || '',
private: opts?.private ?? true,
});
}
/** @internal */
async requestPatchRepo(ownerRepo: string, data: Record<string, any>): Promise<void> {
await this.request('PATCH', `/api/v1/repos/${ownerRepo}`, data);
}
/** @internal */
async requestSetRepoTopics(ownerRepo: string, topics: string[]): Promise<void> {
await this.request('PUT', `/api/v1/repos/${ownerRepo}/topics`, { topics });
}
/** @internal */
async requestPostRepoAvatar(ownerRepo: string, imageBase64: string): Promise<void> {
await this.request('POST', `/api/v1/repos/${ownerRepo}/avatar`, { image: imageBase64 });
}
/** @internal */
async requestDeleteRepoAvatar(ownerRepo: string): Promise<void> {
await this.request('DELETE', `/api/v1/repos/${ownerRepo}/avatar`);
}
/** @internal */
async requestTransferRepo(ownerRepo: string, newOwner: string, teamIds?: number[]): Promise<void> {
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<void> {
await this.request('DELETE', `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`);
}
// --- Repo Branches & Tags ---
/** @internal */
async requestGetRepoBranches(ownerRepo: string, opts?: IListOptions): Promise<IGiteaBranch[]> {
const page = opts?.page || 1;
const limit = opts?.perPage || 50;
return this.request<IGiteaBranch[]>('GET', `/api/v1/repos/${ownerRepo}/branches?page=${page}&limit=${limit}`);
}
/** @internal */
async requestGetRepoTags(ownerRepo: string, opts?: IListOptions): Promise<IGiteaTag[]> {
const page = opts?.page || 1;
const limit = opts?.perPage || 50;
return this.request<IGiteaTag[]>('GET', `/api/v1/repos/${ownerRepo}/tags?page=${page}&limit=${limit}`);
}
// --- Repo Secrets ---
/** @internal */
async requestGetRepoSecrets(ownerRepo: string): Promise<IGiteaSecret[]> {
return this.request<IGiteaSecret[]>('GET', `/api/v1/repos/${ownerRepo}/actions/secrets`);
}
/** @internal */
async requestSetRepoSecret(ownerRepo: string, key: string, value: string): Promise<void> {
await this.request('PUT', `/api/v1/repos/${ownerRepo}/actions/secrets/${key}`, { data: value });
}
/** @internal */
async requestDeleteRepoSecret(ownerRepo: string, key: string): Promise<void> {
await this.request('DELETE', `/api/v1/repos/${ownerRepo}/actions/secrets/${key}`);
}
// --- Organizations ---
/** @internal */
async requestGetOrgs(opts?: IListOptions): Promise<IGiteaOrganization[]> {
const page = opts?.page || 1;
const limit = opts?.perPage || 50;
return this.request<IGiteaOrganization[]>('GET', `/api/v1/orgs?page=${page}&limit=${limit}`);
}
/**
* Get a single organization by name
*/
public async getOrg(orgName: string): Promise<IGiteaOrganization> {
/** @internal */
async requestGetOrg(orgName: string): Promise<IGiteaOrganization> {
return this.request<IGiteaOrganization>('GET', `/api/v1/orgs/${encodeURIComponent(orgName)}`);
}
/**
* List repositories within an organization
*/
public async getOrgRepos(orgName: string, opts?: IListOptions): Promise<IGiteaRepository[]> {
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<IGiteaRepository[]>('GET', url);
}
/**
* Create a new organization
*/
public async createOrg(name: string, opts?: {
/** @internal */
async requestCreateOrg(name: string, opts?: {
fullName?: string;
description?: string;
visibility?: string;
@@ -188,87 +341,66 @@ export class GiteaClient {
});
}
/**
* Create a repository within an organization
*/
public async createOrgRepo(orgName: string, name: string, opts?: {
description?: string;
private?: boolean;
}): Promise<IGiteaRepository> {
return this.request<IGiteaRepository>('POST', `/api/v1/orgs/${encodeURIComponent(orgName)}/repos`, {
name,
description: opts?.description || '',
private: opts?.private ?? true,
});
/** @internal */
async requestPatchOrg(orgName: string, data: Record<string, any>): Promise<void> {
await this.request('PATCH', `/api/v1/orgs/${encodeURIComponent(orgName)}`, data);
}
// ---------------------------------------------------------------------------
// Repository Branches & Tags
// ---------------------------------------------------------------------------
/** @internal */
async requestPostOrgAvatar(orgName: string, imageBase64: string): Promise<void> {
await this.request('POST', `/api/v1/orgs/${encodeURIComponent(orgName)}/avatar`, { image: imageBase64 });
}
public async getRepoBranches(ownerRepo: string, opts?: IListOptions): Promise<IGiteaBranch[]> {
/** @internal */
async requestDeleteOrgAvatar(orgName: string): Promise<void> {
await this.request('DELETE', `/api/v1/orgs/${encodeURIComponent(orgName)}/avatar`);
}
/** @internal */
async requestDeleteOrg(orgName: string): Promise<void> {
await this.request('DELETE', `/api/v1/orgs/${encodeURIComponent(orgName)}`);
}
// --- Org repos ---
/** @internal */
async requestGetOrgRepos(orgName: string, opts?: IListOptions): Promise<IGiteaRepository[]> {
const page = opts?.page || 1;
const limit = opts?.perPage || 50;
return this.request<IGiteaBranch[]>(
'GET',
`/api/v1/repos/${ownerRepo}/branches?page=${page}&limit=${limit}`,
);
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<IGiteaRepository[]>('GET', url);
}
public async getRepoTags(ownerRepo: string, opts?: IListOptions): Promise<IGiteaTag[]> {
const page = opts?.page || 1;
const limit = opts?.perPage || 50;
return this.request<IGiteaTag[]>(
'GET',
`/api/v1/repos/${ownerRepo}/tags?page=${page}&limit=${limit}`,
);
}
// --- Org Secrets ---
// ---------------------------------------------------------------------------
// Repository Secrets
// ---------------------------------------------------------------------------
public async getRepoSecrets(ownerRepo: string): Promise<IGiteaSecret[]> {
return this.request<IGiteaSecret[]>('GET', `/api/v1/repos/${ownerRepo}/actions/secrets`);
}
public async setRepoSecret(ownerRepo: string, key: string, value: string): Promise<void> {
await this.request('PUT', `/api/v1/repos/${ownerRepo}/actions/secrets/${key}`, { data: value });
}
public async deleteRepoSecret(ownerRepo: string, key: string): Promise<void> {
await this.request('DELETE', `/api/v1/repos/${ownerRepo}/actions/secrets/${key}`);
}
// ---------------------------------------------------------------------------
// Organization Secrets
// ---------------------------------------------------------------------------
public async getOrgSecrets(orgName: string): Promise<IGiteaSecret[]> {
/** @internal */
async requestGetOrgSecrets(orgName: string): Promise<IGiteaSecret[]> {
return this.request<IGiteaSecret[]>('GET', `/api/v1/orgs/${orgName}/actions/secrets`);
}
public async setOrgSecret(orgName: string, key: string, value: string): Promise<void> {
/** @internal */
async requestSetOrgSecret(orgName: string, key: string, value: string): Promise<void> {
await this.request('PUT', `/api/v1/orgs/${orgName}/actions/secrets/${key}`, { data: value });
}
public async deleteOrgSecret(orgName: string, key: string): Promise<void> {
/** @internal */
async requestDeleteOrgSecret(orgName: string, key: string): Promise<void> {
await this.request('DELETE', `/api/v1/orgs/${orgName}/actions/secrets/${key}`);
}
// ---------------------------------------------------------------------------
// Action Runs
// ---------------------------------------------------------------------------
// --- Action Runs ---
/**
* List action runs for a repository with optional filters.
* Supports status, branch, event, actor filtering.
*/
public async getActionRuns(ownerRepo: string, opts?: IActionRunListOptions): Promise<IGiteaActionRun[]> {
/** @internal */
async requestGetActionRuns(ownerRepo: string, opts?: IActionRunListOptions): Promise<IGiteaActionRun[]> {
const page = opts?.page || 1;
const limit = opts?.perPage || 30;
let url = `/api/v1/repos/${ownerRepo}/actions/runs?page=${page}&limit=${limit}`;
if (opts?.status) url += `&status=${encodeURIComponent(opts.status)}`;
// 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)}`;
@@ -276,49 +408,24 @@ export class GiteaClient {
return body.workflow_runs || body;
}
/**
* Get a single action run's full details.
*/
public async getActionRun(ownerRepo: string, runId: number): Promise<IGiteaActionRun> {
return this.request<IGiteaActionRun>(
'GET',
`/api/v1/repos/${ownerRepo}/actions/runs/${runId}`,
);
/** @internal */
async requestGetActionRun(ownerRepo: string, runId: number): Promise<IGiteaActionRun> {
return this.request<IGiteaActionRun>('GET', `/api/v1/repos/${ownerRepo}/actions/runs/${runId}`);
}
/**
* List jobs for an action run.
*/
public async getActionRunJobs(ownerRepo: string, runId: number): Promise<IGiteaActionRunJob[]> {
/** @internal */
async requestGetActionRunJobs(ownerRepo: string, runId: number): Promise<IGiteaActionRunJob[]> {
const body = await this.request<any>('GET', `/api/v1/repos/${ownerRepo}/actions/runs/${runId}/jobs`);
return body.jobs || body;
}
/**
* Get a job's raw log output.
*/
public async getJobLog(ownerRepo: string, jobId: number): Promise<string> {
/** @internal */
async requestGetJobLog(ownerRepo: string, jobId: number): Promise<string> {
return this.requestText('GET', `/api/v1/repos/${ownerRepo}/actions/jobs/${jobId}/logs`);
}
/**
* Re-run an action run.
*/
public async rerunAction(ownerRepo: string, runId: number): Promise<void> {
await this.request('POST', `/api/v1/repos/${ownerRepo}/actions/runs/${runId}/rerun`);
}
/**
* Cancel a running action run.
*/
public async cancelAction(ownerRepo: string, runId: number): Promise<void> {
await this.request('POST', `/api/v1/repos/${ownerRepo}/actions/runs/${runId}/cancel`);
}
/**
* Dispatch a workflow (trigger manually).
*/
public async dispatchWorkflow(
/** @internal */
async requestDispatchWorkflow(
ownerRepo: string,
workflowId: string,
ref: string,
@@ -331,14 +438,8 @@ export class GiteaClient {
);
}
// ---------------------------------------------------------------------------
// Repository Deletion
// ---------------------------------------------------------------------------
public async deleteRepo(owner: string, repo: string): Promise<void> {
await this.request(
'DELETE',
`/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`,
);
/** @internal */
async requestDeleteActionRun(ownerRepo: string, runId: number): Promise<void> {
await this.request('DELETE', `/api/v1/repos/${ownerRepo}/actions/runs/${runId}`);
}
}

View File

@@ -0,0 +1,115 @@
import type { GiteaClient } from './gitea.classes.giteaclient.js';
import type { IGiteaOrganization, IGiteaSecret, IListOptions } from './gitea.interfaces.js';
import { GiteaRepository } from './gitea.classes.repository.js';
import { GiteaSecret } from './gitea.classes.secret.js';
import { autoPaginate } from './gitea.helpers.js';
export class GiteaOrganization {
// Raw data
public readonly id: number;
public readonly name: string;
public readonly fullName: string;
public readonly description: string;
public readonly visibility: string;
public readonly repoCount: number;
public readonly avatarUrl: string;
/** @internal */
constructor(
private client: GiteaClient,
raw: IGiteaOrganization,
) {
this.id = raw.id;
this.name = raw.name || '';
this.fullName = raw.full_name || this.name;
this.description = raw.description || '';
this.visibility = raw.visibility || 'public';
this.repoCount = raw.repo_count || 0;
this.avatarUrl = raw.avatar_url || '';
}
// ---------------------------------------------------------------------------
// Repos
// ---------------------------------------------------------------------------
async getRepos(opts?: IListOptions): Promise<GiteaRepository[]> {
return autoPaginate(
(page, perPage) => this.client.requestGetOrgRepos(this.name, { ...opts, page, perPage }),
opts,
).then(repos => repos.map(r => new GiteaRepository(this.client, r)));
}
// ---------------------------------------------------------------------------
// Secrets
// ---------------------------------------------------------------------------
async getSecrets(): Promise<GiteaSecret[]> {
const secrets = await this.client.requestGetOrgSecrets(this.name);
return secrets.map(s => new GiteaSecret(s));
}
async setSecret(key: string, value: string): Promise<void> {
await this.client.requestSetOrgSecret(this.name, key, value);
}
async deleteSecret(key: string): Promise<void> {
await this.client.requestDeleteOrgSecret(this.name, key);
}
// ---------------------------------------------------------------------------
// Mutation
// ---------------------------------------------------------------------------
/**
* Update organization properties (description, visibility, etc.)
*/
async update(data: {
description?: string;
visibility?: string;
fullName?: string;
}): Promise<void> {
await this.client.requestPatchOrg(this.name, {
description: data.description,
visibility: data.visibility,
full_name: data.fullName,
});
}
/**
* Upload an avatar image for this organization.
* @param imageBase64 - Base64-encoded image data
*/
async setAvatar(imageBase64: string): Promise<void> {
await this.client.requestPostOrgAvatar(this.name, imageBase64);
}
/**
* Remove the organization's avatar.
*/
async deleteAvatar(): Promise<void> {
await this.client.requestDeleteOrgAvatar(this.name);
}
/**
* Delete this organization.
*/
async delete(): Promise<void> {
await this.client.requestDeleteOrg(this.name);
}
// ---------------------------------------------------------------------------
// Serialization
// ---------------------------------------------------------------------------
toJSON(): IGiteaOrganization {
return {
id: this.id,
name: this.name,
full_name: this.fullName,
description: this.description,
visibility: this.visibility,
repo_count: this.repoCount,
avatar_url: this.avatarUrl,
};
}
}

View File

@@ -0,0 +1,171 @@
import type { GiteaClient } from './gitea.classes.giteaclient.js';
import type { IGiteaRepository, IGiteaSecret, IListOptions, IActionRunListOptions } from './gitea.interfaces.js';
import { GiteaBranch } from './gitea.classes.branch.js';
import { GiteaTag } from './gitea.classes.tag.js';
import { GiteaSecret } from './gitea.classes.secret.js';
import { GiteaActionRun } from './gitea.classes.actionrun.js';
import { autoPaginate } from './gitea.helpers.js';
export class GiteaRepository {
// Raw data
public readonly id: number;
public readonly name: string;
public readonly fullName: string;
public readonly description: string;
public readonly defaultBranch: string;
public readonly htmlUrl: string;
public readonly isPrivate: boolean;
public readonly topics: string[];
public readonly updatedAt: string;
public readonly ownerId: number;
public readonly ownerLogin: string;
public readonly ownerAvatarUrl: string;
/** @internal */
constructor(
private client: GiteaClient,
raw: IGiteaRepository,
) {
this.id = raw.id;
this.name = raw.name || '';
this.fullName = raw.full_name || '';
this.description = raw.description || '';
this.defaultBranch = raw.default_branch || 'main';
this.htmlUrl = raw.html_url || '';
this.isPrivate = raw.private ?? true;
this.topics = raw.topics || [];
this.updatedAt = raw.updated_at || '';
this.ownerId = raw.owner?.id || 0;
this.ownerLogin = raw.owner?.login || '';
this.ownerAvatarUrl = raw.owner?.avatar_url || '';
}
// ---------------------------------------------------------------------------
// Branches & Tags
// ---------------------------------------------------------------------------
async getBranches(opts?: IListOptions): Promise<GiteaBranch[]> {
return autoPaginate(
(page, perPage) => this.client.requestGetRepoBranches(this.fullName, { ...opts, page, perPage }),
opts,
).then(branches => branches.map(b => new GiteaBranch(b)));
}
async getTags(opts?: IListOptions): Promise<GiteaTag[]> {
return autoPaginate(
(page, perPage) => this.client.requestGetRepoTags(this.fullName, { ...opts, page, perPage }),
opts,
).then(tags => tags.map(t => new GiteaTag(t)));
}
// ---------------------------------------------------------------------------
// Secrets
// ---------------------------------------------------------------------------
async getSecrets(): Promise<GiteaSecret[]> {
const secrets = await this.client.requestGetRepoSecrets(this.fullName);
return secrets.map(s => new GiteaSecret(s));
}
async setSecret(key: string, value: string): Promise<void> {
await this.client.requestSetRepoSecret(this.fullName, key, value);
}
async deleteSecret(key: string): Promise<void> {
await this.client.requestDeleteRepoSecret(this.fullName, key);
}
// ---------------------------------------------------------------------------
// Action Runs
// ---------------------------------------------------------------------------
async getActionRuns(opts?: IActionRunListOptions): Promise<GiteaActionRun[]> {
return autoPaginate(
(page, perPage) => this.client.requestGetActionRuns(this.fullName, { ...opts, page, perPage }),
opts,
).then(runs => runs.map(r => new GiteaActionRun(this.client, this.fullName, r)));
}
// ---------------------------------------------------------------------------
// Mutation
// ---------------------------------------------------------------------------
/**
* Update repository properties.
*/
async update(data: {
name?: string;
description?: string;
defaultBranch?: string;
private?: boolean;
archived?: boolean;
}): Promise<void> {
await this.client.requestPatchRepo(this.fullName, {
name: data.name,
description: data.description,
default_branch: data.defaultBranch,
private: data.private,
archived: data.archived,
});
}
/**
* Set topics for this repository (replaces all existing topics).
*/
async setTopics(topics: string[]): Promise<void> {
await this.client.requestSetRepoTopics(this.fullName, topics);
}
/**
* Upload an avatar image for this repository.
* @param imageBase64 - Base64-encoded image data
*/
async setAvatar(imageBase64: string): Promise<void> {
await this.client.requestPostRepoAvatar(this.fullName, imageBase64);
}
/**
* Remove the repository's avatar.
*/
async deleteAvatar(): Promise<void> {
await this.client.requestDeleteRepoAvatar(this.fullName);
}
/**
* Transfer this repository to a different owner (org or user).
*/
async transfer(newOwner: string, teamIds?: number[]): Promise<void> {
await this.client.requestTransferRepo(this.fullName, newOwner, teamIds);
}
/**
* Delete this repository.
*/
async delete(): Promise<void> {
const [owner, repo] = this.fullName.split('/');
await this.client.requestDeleteRepo(owner, repo);
}
// ---------------------------------------------------------------------------
// Serialization
// ---------------------------------------------------------------------------
toJSON(): IGiteaRepository {
return {
id: this.id,
name: this.name,
full_name: this.fullName,
description: this.description,
default_branch: this.defaultBranch,
html_url: this.htmlUrl,
private: this.isPrivate,
topics: this.topics,
updated_at: this.updatedAt,
owner: {
id: this.ownerId,
login: this.ownerLogin,
avatar_url: this.ownerAvatarUrl,
},
};
}
}

View File

@@ -0,0 +1,18 @@
import type { IGiteaSecret } from './gitea.interfaces.js';
export class GiteaSecret {
public readonly name: string;
public readonly createdAt: string;
constructor(raw: IGiteaSecret) {
this.name = raw.name || '';
this.createdAt = raw.created_at || '';
}
toJSON(): IGiteaSecret {
return {
name: this.name,
created_at: this.createdAt,
};
}
}

19
ts/gitea.classes.tag.ts Normal file
View File

@@ -0,0 +1,19 @@
import type { IGiteaTag } from './gitea.interfaces.js';
export class GiteaTag {
public readonly name: string;
public readonly commitSha: string;
constructor(raw: IGiteaTag) {
this.name = raw.name || '';
this.commitSha = raw.commit?.sha || '';
}
toJSON(): IGiteaTag {
return {
name: this.name,
id: this.name,
commit: { sha: this.commitSha },
};
}
}

87
ts/gitea.helpers.ts Normal file
View File

@@ -0,0 +1,87 @@
/**
* Auto-paginate a list endpoint.
* If opts includes a specific page, returns just that page (no auto-pagination).
*/
export async function autoPaginate<T>(
fetchPage: (page: number, perPage: number) => Promise<T[]>,
opts?: { page?: number; perPage?: number },
): Promise<T[]> {
const perPage = opts?.perPage || 50;
// If caller requests a specific page, return just that page
if (opts?.page) {
return fetchPage(opts.page, perPage);
}
// Otherwise auto-paginate through all pages
const all: T[] = [];
let page = 1;
while (true) {
const items = await fetchPage(page, perPage);
all.push(...items);
if (items.length < perPage) break;
page++;
}
return all;
}
/**
* Compute duration in seconds from two ISO timestamps.
*/
export function computeDuration(startedAt?: string, completedAt?: string): number {
if (!startedAt || !completedAt) return 0;
const ms = new Date(completedAt).getTime() - new Date(startedAt).getTime();
return ms > 0 ? Math.round(ms / 1000) : 0;
}
/**
* Gitea uses `status` for run state (running, waiting, completed)
* and `conclusion` for the actual result (success, failure, cancelled, skipped).
* When status is "completed", the conclusion carries the meaningful status.
*/
export function resolveGiteaStatus(status: string, conclusion: string): string {
if (status === 'completed' && conclusion) {
return conclusion;
}
return status || conclusion || '';
}
/**
* Extract a human-readable ref from the Gitea `path` field.
* path format: "workflow.yaml@refs/tags/v1.0.0" or "workflow.yaml@refs/heads/main"
*/
export function extractRefFromPath(path?: string): string {
if (!path) return '';
const atIdx = path.indexOf('@');
if (atIdx < 0) return '';
const ref = path.substring(atIdx + 1);
return ref.replace(/^refs\/tags\//, '').replace(/^refs\/heads\//, '');
}
/**
* Extract the workflow filename from the Gitea `path` field.
* path format: "workflow.yaml@refs/tags/v1.0.0" → "workflow.yaml"
*/
export function extractWorkflowIdFromPath(path?: string): string {
if (!path) return '';
const atIdx = path.indexOf('@');
return atIdx >= 0 ? path.substring(0, atIdx) : path;
}
/**
* Translate normalized status names to Gitea API-native query parameter values.
* Gitea accepts: pending, queued, in_progress, failure, success, skipped
*/
export function toGiteaApiStatus(status?: string): string | undefined {
if (!status) return undefined;
const map: Record<string, string> = {
running: 'in_progress',
failed: 'failure',
canceled: 'cancelled',
pending: 'pending',
success: 'success',
skipped: 'skipped',
waiting: 'queued',
};
return map[status] || status;
}

View File

@@ -18,7 +18,7 @@ export interface IListOptions {
// ---------------------------------------------------------------------------
export interface IActionRunListOptions extends IListOptions {
/** Filter by run status (waiting, running, success, failure, cancelled) */
/** Filter by run status. Accepts normalized names (running, failed, etc.) — auto-translated to Gitea API values. */
status?: string;
/** Filter by head branch */
branch?: string;
@@ -35,13 +35,15 @@ export interface IActionRunListOptions extends IListOptions {
export interface IGiteaUser {
id: number;
login: string;
login_name: string;
source_id: number;
full_name: string;
email: string;
avatar_url: string;
}
// ---------------------------------------------------------------------------
// Repositories
// Repositories (raw API response)
// ---------------------------------------------------------------------------
export interface IGiteaRepository {
@@ -62,7 +64,7 @@ export interface IGiteaRepository {
}
// ---------------------------------------------------------------------------
// Organizations
// Organizations (raw API response)
// ---------------------------------------------------------------------------
export interface IGiteaOrganization {
@@ -72,6 +74,7 @@ export interface IGiteaOrganization {
description: string;
visibility: string;
repo_count: number;
avatar_url: string;
}
// ---------------------------------------------------------------------------
@@ -84,25 +87,25 @@ export interface IGiteaSecret {
}
// ---------------------------------------------------------------------------
// Action Runs
// Action Runs (raw API response — aligned with Gitea 1.25 swagger)
// ---------------------------------------------------------------------------
export interface IGiteaActionRun {
id: number;
name: string;
workflow_id: string;
status: string;
conclusion: string;
status: string; // run state: running, waiting, completed
conclusion: string; // result: success, failure, cancelled, skipped
head_branch: string;
head_sha: string;
html_url: string;
event: string;
path: string; // e.g. "workflow.yaml@refs/tags/v1.0"
display_title: string;
run_number: number;
run_attempt: number;
run_duration: number;
created_at: string;
updated_at: string;
started_at: string;
completed_at: string;
actor: IGiteaUser;
trigger_actor: IGiteaUser;
repository: {
@@ -111,15 +114,10 @@ export interface IGiteaActionRun {
full_name: string;
html_url: string;
};
head_commit: {
id: string;
message: string;
timestamp: string;
};
}
// ---------------------------------------------------------------------------
// Action Run Jobs
// Action Run Jobs (raw API response)
// ---------------------------------------------------------------------------
export interface IGiteaActionRunJob {
@@ -132,7 +130,6 @@ export interface IGiteaActionRunJob {
status: string;
conclusion: string;
html_url: string;
run_duration: number;
started_at: string;
completed_at: string;
steps: IGiteaActionRunJobStep[];

View File

@@ -1,4 +1,26 @@
// Main client
export { GiteaClient } from './gitea.classes.giteaclient.js';
// Domain classes
export { GiteaOrganization } from './gitea.classes.organization.js';
export { GiteaRepository } from './gitea.classes.repository.js';
export { GiteaActionRun } from './gitea.classes.actionrun.js';
export { GiteaActionRunJob, GiteaActionRunJobStep } from './gitea.classes.actionrunjob.js';
export { GiteaBranch } from './gitea.classes.branch.js';
export { GiteaTag } from './gitea.classes.tag.js';
export { GiteaSecret } from './gitea.classes.secret.js';
// Helpers
export {
autoPaginate,
computeDuration,
resolveGiteaStatus,
extractRefFromPath,
extractWorkflowIdFromPath,
toGiteaApiStatus,
} from './gitea.helpers.js';
// Interfaces (raw API types)
export type {
IGiteaUser,
IGiteaRepository,
@@ -13,4 +35,6 @@ export type {
IListOptions,
IActionRunListOptions,
} from './gitea.interfaces.js';
// Commit info
export { commitinfo } from './00_commitinfo_data.js';