diff --git a/.env.example b/.env.example index 0d64fcba..c26b1ed4 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,10 @@ API_TOKEN= -GITHUB_TOKEN= +# Options: github, gitea, gitlab (default: github) +BACKEND= +BACKEND_TOKEN= +# Only required if using Gitea / Forgejo or self-hosted GitLab backend +BACKEND_URL= ORGANIZATION=revanced diff --git a/src/backend/gitea.ts b/src/backend/gitea.ts new file mode 100644 index 00000000..9d4376fa --- /dev/null +++ b/src/backend/gitea.ts @@ -0,0 +1,172 @@ +import type { + Backend, + BackendRelease, + BackendAsset, + BackendContributor, + BackendMember +} from './types'; + +interface GiteaAsset { + name: string; + browser_download_url: string; +} + +interface GiteaRelease { + tag_name: string; + body: string; + created_at: string; + prerelease: boolean; + assets: GiteaAsset[]; +} + +interface GiteaContributor { + login: string; + avatar_url: string; + html_url: string; + contributions: number; +} + +interface GiteaMember { + login: string; + avatar_url: string; +} + +interface GiteaUser { + login: string; + avatar_url: string; + biography: string; +} + +interface GiteaGpgKey { + key_id: string; +} + +import { formatDatetime } from '../utils'; + +export class GiteaBackend implements Backend { + private readonly baseUrl: string; + private readonly headers: HeadersInit; + + constructor(url: string, token?: string) { + this.baseUrl = `${url}/api/v1`; + const headers: Record = { + Accept: 'application/json' + }; + if (token) { + headers['Authorization'] = `token ${token}`; + } + this.headers = headers; + } + + private async fetchJson(url: string): Promise { + const response = await fetch(url, { headers: this.headers }); + if (!response.ok) { + throw new Error( + `Gitea API error: ${response.status} ${response.statusText} — ${url}` + ); + } + return response.json() as Promise; + } + + async release( + owner: string, + repo: string, + prerelease: boolean + ): Promise { + let release: GiteaRelease; + + if (prerelease) { + const releases = await this.fetchJson( + `${this.baseUrl}/repos/${owner}/${repo}/releases?limit=1` + ); + if (releases.length === 0) { + throw new Error(`No releases found for ${owner}/${repo}`); + } + release = releases[0]; + } else { + release = await this.fetchJson( + `${this.baseUrl}/repos/${owner}/${repo}/releases/latest` + ); + } + + return { + tag: release.tag_name, + releaseNote: release.body ?? '', + createdAt: formatDatetime(release.created_at), + prerelease: release.prerelease, + assets: release.assets.map( + (asset): BackendAsset => ({ + name: asset.name, + downloadUrl: asset.browser_download_url + }) + ) + }; + } + + async releases( + owner: string, + repo: string, + count: number + ): Promise { + const releases = await this.fetchJson( + `${this.baseUrl}/repos/${owner}/${repo}/releases?limit=${count}` + ); + + return releases.map((release) => ({ + tag: release.tag_name, + releaseNote: release.body ?? '', + createdAt: formatDatetime(release.created_at), + prerelease: release.prerelease, + assets: release.assets.map( + (asset): BackendAsset => ({ + name: asset.name, + downloadUrl: asset.browser_download_url + }) + ) + })); + } + + async contributors( + _owner: string, + _repo: string + ): Promise { + // TODO: Forgejo does not have a contributors API yet. + return []; + } + + async members(organization: string): Promise { + const publicMembers = await this.fetchJson( + `${this.baseUrl}/orgs/${organization}/public_members` + ); + + const members = await Promise.all( + publicMembers.map(async (member) => { + const [user, gpgKeys] = await Promise.all([ + this.fetchJson( + `${this.baseUrl}/users/${member.login}` + ), + this.fetchJson( + `${this.baseUrl}/users/${member.login}/gpg_keys` + ) + ]); + + return { + name: user.login, + avatarUrl: user.avatar_url, + url: `${this.baseUrl.replace('/api/v1', '')}/${user.login}`, + bio: user.biography || null, + gpgKeys: { + ids: gpgKeys.map((key) => key.key_id), + url: `${this.baseUrl}/users/${user.login}/gpg_keys` + } + } satisfies BackendMember; + }) + ); + + return members; + } + + repositoryUrl(owner: string, repo: string): string { + return `${this.baseUrl.replace('/api/v1', '')}/${owner}/${repo}`; + } +} diff --git a/src/backend/github.ts b/src/backend/github.ts index 747d0cf4..a8fd643f 100644 --- a/src/backend/github.ts +++ b/src/backend/github.ts @@ -43,12 +43,7 @@ interface GitHubGpgKey { key_id: string; } -function formatDatetime(isoString: string): string { - return isoString - .replace(/\.\d{3}Z$/, '') - .replace(/Z$/, '') - .replace(/[+-]\d{2}:\d{2}$/, ''); -} +import { formatDatetime } from '../utils'; export class GitHubBackend implements Backend { private readonly baseUrl = 'https://api.github.com'; diff --git a/src/backend/gitlab.ts b/src/backend/gitlab.ts new file mode 100644 index 00000000..fff85140 --- /dev/null +++ b/src/backend/gitlab.ts @@ -0,0 +1,175 @@ +import type { + Backend, + BackendRelease, + BackendAsset, + BackendContributor, + BackendMember +} from './types'; + +interface GitLabAsset { + name: string; + direct_asset_url: string; +} + +interface GitLabRelease { + tag_name: string; + description: string; + created_at: string; + upcoming_release: boolean; + assets: { + links: GitLabAsset[]; + }; +} + +interface GitLabContributor { + name: string; + avatar_url: string; + web_url: string; + commits: number; +} + +interface GitLabMember { + id: number; + username: string; + avatar_url: string; + web_url: string; + bio: string | null; +} + +interface GitLabGpgKey { + id: number; +} + +import { formatDatetime } from '../utils'; + +function encodeProject(owner: string, repo: string): string { + return encodeURIComponent(`${owner}/${repo}`); +} + +export class GitLabBackend implements Backend { + private readonly baseUrl: string; + private readonly webUrl: string; + private readonly headers: HeadersInit; + + constructor(url?: string, token?: string) { + const resolvedUrl = url || 'https://gitlab.com'; + this.webUrl = resolvedUrl; + this.baseUrl = `${resolvedUrl}/api/v4`; + const headers: Record = { + Accept: 'application/json' + }; + if (token) { + headers['PRIVATE-TOKEN'] = token; + } + this.headers = headers; + } + + private async fetchJson(url: string): Promise { + const response = await fetch(url, { headers: this.headers }); + if (!response.ok) { + throw new Error( + `GitLab API error: ${response.status} ${response.statusText} — ${url}` + ); + } + return response.json() as Promise; + } + + private mapRelease(release: GitLabRelease): BackendRelease { + return { + tag: release.tag_name, + releaseNote: release.description ?? '', + createdAt: formatDatetime(release.created_at), + prerelease: release.upcoming_release, + assets: release.assets.links.map( + (asset): BackendAsset => ({ + name: asset.name, + downloadUrl: asset.direct_asset_url + }) + ) + }; + } + + async release( + owner: string, + repo: string, + prerelease: boolean + ): Promise { + const project = encodeProject(owner, repo); + + if (prerelease) { + const releases = await this.fetchJson( + `${this.baseUrl}/projects/${project}/releases?per_page=1` + ); + if (releases.length === 0) { + throw new Error(`No releases found for ${owner}/${repo}`); + } + return this.mapRelease(releases[0]); + } + + const release = await this.fetchJson( + `${this.baseUrl}/projects/${project}/releases/permalink/latest` + ); + return this.mapRelease(release); + } + + async releases( + owner: string, + repo: string, + count: number + ): Promise { + const project = encodeProject(owner, repo); + const releases = await this.fetchJson( + `${this.baseUrl}/projects/${project}/releases?per_page=${count}` + ); + + return releases.map((release) => this.mapRelease(release)); + } + + async contributors( + owner: string, + repo: string + ): Promise { + const project = encodeProject(owner, repo); + const contributors = await this.fetchJson( + `${this.baseUrl}/projects/${project}/repository/contributors?per_page=100` + ); + + return contributors.map((contributor) => ({ + name: contributor.name, + avatarUrl: contributor.avatar_url, + url: contributor.web_url, + contributions: contributor.commits + })); + } + + async members(organization: string): Promise { + const groupMembers = await this.fetchJson( + `${this.baseUrl}/groups/${encodeURIComponent(organization)}/members` + ); + + const members = await Promise.all( + groupMembers.map(async (member) => { + const gpgKeys = await this.fetchJson( + `${this.baseUrl}/users/${member.id}/gpg_keys` + ); + + return { + name: member.username, + avatarUrl: member.avatar_url, + url: member.web_url, + bio: member.bio, + gpgKeys: { + ids: gpgKeys.map((key) => String(key.id)), + url: `${this.webUrl}/${member.username}.gpg` + } + } satisfies BackendMember; + }) + ); + + return members; + } + + repositoryUrl(owner: string, repo: string): string { + return `${this.webUrl}/${owner}/${repo}`; + } +} diff --git a/src/config.ts b/src/config.ts index 66af8ce8..781b01cd 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,5 +1,8 @@ import type { Env } from './types'; +import type { Backend } from './backend/types'; import { GitHubBackend } from './backend/github'; +import { GiteaBackend } from './backend/gitea'; +import { GitLabBackend } from './backend/gitlab'; export interface Config { organization: string; @@ -46,8 +49,21 @@ export function getConfig(env: Env): Config { }); } -let _backend: GitHubBackend | undefined; +let _backend: Backend | undefined; -export function getBackend(env: Env): GitHubBackend { - return (_backend ??= new GitHubBackend(env.GITHUB_TOKEN)); +export function getBackend(env: Env): Backend { + return (_backend ??= createBackend(env)); +} + +function createBackend(env: Env): Backend { + switch (env.BACKEND ?? 'github') { + case 'github': + return new GitHubBackend(env.BACKEND_TOKEN); + case 'gitea': + return new GiteaBackend(env.BACKEND_URL!, env.BACKEND_TOKEN); + case 'gitlab': + return new GitLabBackend(env.BACKEND_URL, env.BACKEND_TOKEN); + default: + throw new Error(`Unknown backend: ${env.BACKEND}`); + } } diff --git a/src/types.ts b/src/types.ts index e0052e08..e99076e6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -7,7 +7,9 @@ export interface Env { ASSETS: Fetcher; DB: D1Database; API_TOKEN: string; - GITHUB_TOKEN?: string; + BACKEND?: string; + BACKEND_TOKEN?: string; + BACKEND_URL?: string; ORGANIZATION: string; PATCHES_REPO: string; PATCHES_ASSET_REGEX: string; diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 00000000..d6512a8e --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,6 @@ +export function formatDatetime(isoString: string): string { + return isoString + .replace(/\.\d{3}Z$/, '') + .replace(/Z$/, '') + .replace(/[+-]\d{2}:\d{2}$/, ''); +}