Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
8 changes: 7 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
API_TOKEN=

GITHUB_TOKEN=
BACKEND=github
BACKEND_TOKEN=

# Only required if using Gitea / Forgejo backend
GITEA_URL=
# Only required if using GitLab backend
GITLAB_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
174 changes: 174 additions & 0 deletions src/backend/gitlab.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
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) {
this.webUrl = url;
this.baseUrl = `${url}/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