Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -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

Expand Down
172 changes: 172 additions & 0 deletions src/backend/gitea.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
Accept: 'application/json'
};
if (token) {
headers['Authorization'] = `token ${token}`;
}
this.headers = headers;
}

private async fetchJson<T>(url: string): Promise<T> {
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<T>;
}

async release(
owner: string,
repo: string,
prerelease: boolean
): Promise<BackendRelease> {
let release: GiteaRelease;

if (prerelease) {
const releases = await this.fetchJson<GiteaRelease[]>(
`${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<GiteaRelease>(
`${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<BackendRelease[]> {
const releases = await this.fetchJson<GiteaRelease[]>(
`${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
Comment thread
oSumAtrIX marked this conversation as resolved.
): Promise<BackendContributor[]> {
// TODO: Forgejo does not have a contributors API yet.
return [];
}

async members(organization: string): Promise<BackendMember[]> {
const publicMembers = await this.fetchJson<GiteaMember[]>(
`${this.baseUrl}/orgs/${organization}/public_members`
);

const members = await Promise.all(
publicMembers.map(async (member) => {
const [user, gpgKeys] = await Promise.all([
this.fetchJson<GiteaUser>(
`${this.baseUrl}/users/${member.login}`
),
this.fetchJson<GiteaGpgKey[]>(
`${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}`;
}
}
7 changes: 1 addition & 6 deletions src/backend/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
175 changes: 175 additions & 0 deletions src/backend/gitlab.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
Accept: 'application/json'
};
if (token) {
headers['PRIVATE-TOKEN'] = token;
}
this.headers = headers;
}

private async fetchJson<T>(url: string): Promise<T> {
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<T>;
}

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<BackendRelease> {
const project = encodeProject(owner, repo);

if (prerelease) {
const releases = await this.fetchJson<GitLabRelease[]>(
`${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<GitLabRelease>(
`${this.baseUrl}/projects/${project}/releases/permalink/latest`
);
return this.mapRelease(release);
}

async releases(
owner: string,
repo: string,
count: number
): Promise<BackendRelease[]> {
const project = encodeProject(owner, repo);
const releases = await this.fetchJson<GitLabRelease[]>(
`${this.baseUrl}/projects/${project}/releases?per_page=${count}`
);

return releases.map((release) => this.mapRelease(release));
}

async contributors(
owner: string,
repo: string
): Promise<BackendContributor[]> {
const project = encodeProject(owner, repo);
const contributors = await this.fetchJson<GitLabContributor[]>(
`${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<BackendMember[]> {
const groupMembers = await this.fetchJson<GitLabMember[]>(
`${this.baseUrl}/groups/${encodeURIComponent(organization)}/members`
);

const members = await Promise.all(
groupMembers.map(async (member) => {
const gpgKeys = await this.fetchJson<GitLabGpgKey[]>(
`${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}`;
}
}
Loading