From 275fa2e5792d76446b1eb5ae4404f420d09802ba Mon Sep 17 00:00:00 2001 From: Naomi Carrigan Date: Thu, 11 Dec 2025 17:40:44 -0800 Subject: [PATCH] feat: add scripts to manage git repos --- prod.env | 19 ++- src/gitea/deleteFromAllRepos.ts | 70 +++++++++++ src/gitea/uploadToAllRepos.ts | 108 ++++++++++++++++ src/interfaces/gitea.ts | 216 ++++++++++++++++++++++++++++++++ src/utils/paginatedFetch.ts | 36 ++++++ 5 files changed, 447 insertions(+), 2 deletions(-) create mode 100644 src/gitea/deleteFromAllRepos.ts create mode 100644 src/gitea/uploadToAllRepos.ts create mode 100644 src/interfaces/gitea.ts create mode 100644 src/utils/paginatedFetch.ts diff --git a/prod.env b/prod.env index 099074f..01918a3 100644 --- a/prod.env +++ b/prod.env @@ -1,4 +1,19 @@ +# Crowdin +CROWDIN_PROJECT_ID="op://Environment Variables - Development/Scripts/Crowdin Project ID" +CROWDIN_API_URL="op://Environment Variables - Development/Scripts/Crowdin API Url" +CROWDIN_TOKEN="op://Environment Variables - Development/Scripts/Crowdin Token" + +# Github +GITHUB_TOKEN="op://Environment Variables - Development/Scripts/GitHub Token" + +# Discord +DISCORD_TOKEN="op://Environment Variables - Development/Scripts/Discord Token" +DISCORD_CLIENT_ID="op://Private/Guild Counter/client id" +DISCORD_CLIENT_SECRET="op://Private/Guild Counter/client secret" + +# AWS AWS_ACCESS_KEY_ID="op://Private/Hetzner/S3 Access Key ID" AWS_SECRET_ACCESS_KEY="op://Private/Hetzner/S3 Secret Access Key" -DISCORD_CLIENT_ID="op://Private/Guild Counter/client id" -DISCORD_CLIENT_SECRET="op://Private/Guild Counter/client secret" \ No newline at end of file + +# Gitea +GITEA_TOKEN="op://Private/Gitea/token" \ No newline at end of file diff --git a/src/gitea/deleteFromAllRepos.ts b/src/gitea/deleteFromAllRepos.ts new file mode 100644 index 0000000..0814539 --- /dev/null +++ b/src/gitea/deleteFromAllRepos.ts @@ -0,0 +1,70 @@ +/** + * @copyright NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +import { input } from "@inquirer/prompts"; +import { paginatedFetch } from "../utils/paginatedFetch.js"; +import type { File, Repository } from "../interfaces/gitea.js"; + +const giteaToken = process.env.GITEA_TOKEN; +if (giteaToken === undefined) { + throw new Error("GITEA_TOKEN is not set"); +} + +const giteaUrl = "https://git.nhcarrigan.com"; + +/** + * Will be something like "/.gitea/workflows/security.yml". + */ +const deletePath = await input({ + message: + // eslint-disable-next-line stylistic/max-len -- Big boi string. + "Enter the PATH to delete the file from, WITHOUT leading slash. Example: '.gitea/workflows/security.yml'", +}); +if (deletePath === "") { + throw new Error("Delete path is not set"); +} + +const orgs = [ "nhcarrigan", "nhcarrigan-private", "nhcarrigan-games" ]; + +for (const org of orgs) { + const repos = await paginatedFetch>(`${giteaUrl}/api/v1/orgs/${org}/repos`, 100, { headers: { authorization: `Bearer ${giteaToken}` } }); + + for (const repo of repos) { + console.log(`Checking if file exists in ${org}/${repo.name}`); + const fileResponse = await fetch(`${giteaUrl}/api/v1/repos/${org}/${repo.name}/contents/${deletePath}`, { + headers: { + authorization: `Bearer ${giteaToken}`, + }, + method: "GET", + }); + if (fileResponse.ok) { + console.log(`File exists in ${org}/${repo.name}, deleting...`); + const fileData: File = await fileResponse.json(); + console.log(`Deleting ${deletePath} from ${org}/${repo.name}`); + const response = await fetch(`${giteaUrl}/api/v1/repos/${org}/${repo.name}/contents/${deletePath}`, { + body: JSON.stringify({ + branch: repo.default_branch, + message: `feat: automated delete of ${deletePath}`, + sha: fileData.sha, + }), + headers: { + "authorization": `Bearer ${giteaToken}`, + // eslint-disable-next-line @typescript-eslint/naming-convention -- Standard header convention. + "content-type": "application/json", + }, + method: "DELETE", + }); + if (!response.ok) { + console.error(`Failed to delete ${deletePath} from ${org}/${repo.name}: ${response.statusText}`); + console.error(await response.text()); + continue; + } + console.log(`Deleted ${deletePath} from ${org}/${repo.name}`); + continue; + } + console.log(`File does not exist in ${org}/${repo.name}, skipping...`); + } +} + diff --git a/src/gitea/uploadToAllRepos.ts b/src/gitea/uploadToAllRepos.ts new file mode 100644 index 0000000..47eeb4e --- /dev/null +++ b/src/gitea/uploadToAllRepos.ts @@ -0,0 +1,108 @@ +/** + * @copyright NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { input } from "@inquirer/prompts"; +import { paginatedFetch } from "../utils/paginatedFetch.js"; +import type { File, Repository } from "../interfaces/gitea.js"; + +const giteaToken = process.env.GITEA_TOKEN; +if (giteaToken === undefined) { + throw new Error("GITEA_TOKEN is not set"); +} + +const giteaUrl = "https://git.nhcarrigan.com"; + +/** + * Will be something like "actions.yml" or "gitea/actions.yml". + */ +const fileName = await input({ + message: + // eslint-disable-next-line stylistic/max-len -- Big boi string. + "Enter the name of the file to upload. Your file MUST be in the `data` directory in this repository. WITHOUT leading slash. Example: 'actions.yml' or 'gitea/actions.yml'", +}); +if (fileName === "") { + throw new Error("File name is not set"); +} + +const file = await readFile( + join(import.meta.dirname, "..", "..", "data", fileName), + "utf-8", +); + +/** + * Will be something like "/.gitea/workflows/security.yml". + */ +const uploadPath = await input({ + message: + // eslint-disable-next-line stylistic/max-len -- Big boi string. + "Enter the PATH to upload the file to, WITHOUT leading slash. Example: '.gitea/workflows/security.yml'", +}); +if (uploadPath === "") { + throw new Error("Upload path is not set"); +} + +const orgs = [ "nhcarrigan", "nhcarrigan-private", "nhcarrigan-games" ]; + +for (const org of orgs) { + const repos = await paginatedFetch>(`${giteaUrl}/api/v1/orgs/${org}/repos`, 100, { headers: { authorization: `Bearer ${giteaToken}` } }); + + for (const repo of repos) { + console.log(`Checking if file exists in ${org}/${repo.name}`); + const fileResponse = await fetch(`${giteaUrl}/api/v1/repos/${org}/${repo.name}/contents/${uploadPath}`, { + headers: { + authorization: `Bearer ${giteaToken}`, + }, + method: "GET", + }); + if (fileResponse.ok) { + console.log(`File already exists in ${org}/${repo.name}`); + const fileData: File = await fileResponse.json(); + console.log(`Updating ${fileName} in ${org}/${repo.name}`); + const response = await fetch(`${giteaUrl}/api/v1/repos/${org}/${repo.name}/contents/${uploadPath}`, { + body: JSON.stringify({ + branch: repo.default_branch, + content: Buffer.from(file).toString("base64"), + message: `feat: automated upload of ${uploadPath}`, + sha: fileData.sha, + }), + headers: { + "authorization": `Bearer ${giteaToken}`, + // eslint-disable-next-line @typescript-eslint/naming-convention -- Standard header convention. + "content-type": "application/json", + }, + method: "PUT", + }); + if (!response.ok) { + console.error(`Failed to update ${fileName} in ${org}/${repo.name}: ${response.statusText}`); + console.error(await response.text()); + continue; + } + console.log(`Updated ${fileName} in ${org}/${repo.name}`); + continue; + } + console.log(`Uploading ${fileName} to ${org}/${repo.name}`); + const response = await fetch(`${giteaUrl}/api/v1/repos/${org}/${repo.name}/contents/${uploadPath}`, { + body: JSON.stringify({ + branch: repo.default_branch, + content: Buffer.from(file).toString("base64"), + message: `feat: automated upload of ${uploadPath}`, + }), + headers: { + "authorization": `Bearer ${giteaToken}`, + // eslint-disable-next-line @typescript-eslint/naming-convention -- Standard header convention. + "content-type": "application/json", + }, + method: "POST", + }); + if (!response.ok) { + console.error(`Failed to upload ${fileName} to ${org}/${repo.name}: ${response.statusText}`); + console.error(await response.text()); + continue; + } + console.log(`Uploaded ${fileName} to ${org}/${repo.name}`); + } +} diff --git a/src/interfaces/gitea.ts b/src/interfaces/gitea.ts new file mode 100644 index 0000000..28856eb --- /dev/null +++ b/src/interfaces/gitea.ts @@ -0,0 +1,216 @@ +/** + * @copyright NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +/* eslint-disable @typescript-eslint/naming-convention -- This is a Gitea interface. The responses from the API are not something we can control. */ + +interface Repository extends Record { + allow_fast_forward_only_merge: boolean; + allow_merge_commits: boolean; + allow_rebase: boolean; + allow_rebase_explicit: boolean; + allow_rebase_update: boolean; + allow_squash_merge: boolean; + archived: boolean; + archived_at: string; + avatar_url: string; + clone_url: string; + created_at: string; + default_allow_maintainer_edit: boolean; + default_branch: string; + default_delete_branch_after_merge: boolean; + default_merge_style: string; + description: string; + empty: boolean; + external_tracker: { + external_tracker_format: string; + external_tracker_regexp_pattern: string; + external_tracker_style: string; + external_tracker_url: string; + }; + external_wiki: { + external_wiki_url: string; + }; + fork: boolean; + forks_count: number; + full_name: string; + has_actions: boolean; + has_issues: boolean; + has_packages: boolean; + has_projects: boolean; + has_pull_requests: boolean; + has_releases: boolean; + has_wiki: boolean; + html_url: string; + id: number; + ignore_whitespace_conflicts: boolean; + internal: boolean; + internal_tracker: { + allow_only_contributors_to_track_time: boolean; + enable_issue_dependencies: boolean; + enable_time_tracker: boolean; + }; + language: string; + languages_url: string; + licenses: Array; + link: string; + mirror: boolean; + mirror_interval: string; + mirror_updated: string; + name: string; + object_format_name: string; + open_issues_count: number; + open_pr_counter: number; + original_url: string; + owner: { + active: boolean; + avatar_url: string; + created: string; + description: string; + email: string; + followers_count: number; + following_count: number; + full_name: string; + html_url: string; + id: number; + is_admin: boolean; + language: string; + last_login: string; + location: string; + login: string; + login_name: string; + prohibit_login: boolean; + restricted: boolean; + source_id: number; + starred_repos_count: number; + visibility: string; + website: string; + }; + parent: string; + permissions: { + admin: boolean; + pull: boolean; + push: boolean; + }; + private: boolean; + projects_mode: string; + release_counter: number; + repo_transfer: { + doer: { + active: boolean; + avatar_url: string; + created: string; + description: string; + email: string; + followers_count: number; + following_count: number; + full_name: string; + html_url: string; + id: number; + is_admin: boolean; + language: string; + last_login: string; + location: string; + login: string; + login_name: string; + prohibit_login: boolean; + restricted: boolean; + source_id: number; + starred_repos_count: number; + visibility: string; + website: string; + }; + recipient: { + active: boolean; + avatar_url: string; + created: string; + description: string; + email: string; + followers_count: number; + following_count: number; + full_name: string; + html_url: string; + id: number; + is_admin: boolean; + language: string; + last_login: string; + location: string; + login: string; + login_name: string; + prohibit_login: boolean; + restricted: boolean; + source_id: number; + starred_repos_count: number; + visibility: string; + website: string; + }; + teams: Array<{ + can_create_org_repo: boolean; + description: string; + id: number; + includes_all_repositories: boolean; + name: string; + organization: { + avatar_url: string; + description: string; + email: string; + full_name: string; + id: number; + location: string; + name: string; + repo_admin_change_team_access: boolean; + username: string; + visibility: string; + website: string; + }; + permission: string; + units: Array; + units_map: { + "repo.code": string; + "repo.ext_issues": string; + "repo.ext_wiki": string; + "repo.issues": string; + "repo.projects": string; + "repo.pulls": string; + "repo.releases": string; + "repo.wiki": string; + }; + }>; + }; + size: number; + ssh_url: string; + stars_count: number; + template: boolean; + topics: Array; + updated_at: string; + url: string; + watchers_count: number; + website: string; +} + +interface File { + _links: { + git: string; + html: string; + self: string; + }; + content: string; + download_url: string; + encoding: string; + git_url: string; + html_url: string; + last_author_date: string; + last_commit_sha: string; + last_committer_date: string; + name: string; + path: string; + sha: string; + size: number; + submodule_git_url: string; + target: string; + type: string; + url: string; +} + +export type { Repository, File }; diff --git a/src/utils/paginatedFetch.ts b/src/utils/paginatedFetch.ts new file mode 100644 index 0000000..7a2d586 --- /dev/null +++ b/src/utils/paginatedFetch.ts @@ -0,0 +1,36 @@ +/** + * @copyright NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +/** + * Fetches a paginated resource from a URL. Automatically handles pagination, + * and returns the complete data as an array. + * @type {Array>} T - The type of data returned from the API endpoint. This should be an array of objects. + * @param url - The URL to fetch. + * @param limit - The number of items to fetch per page. + * @param options - The standard fetch options object. + * @returns The complete data as type T. + */ +export const paginatedFetch = async >>( + url: string, + limit: number, + options: RequestInit = {}, +): Promise => { + let page = 1; + let offset = 0; + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- This is a workaround to avoid type errors. + const data: T = [] as unknown as T; + let request = await fetch(`${url}?limit=${limit.toString()}&page=${page.toString()}&offset=${offset.toString()}`, options); + let response: T = await request.json(); + data.push(...response); + while (response.length >= limit) { + page = page + 1; + offset = offset + limit; + request = await fetch(`${url}?limit=${limit.toString()}&page=${page.toString()}&offset=${offset.toString()}`, options); + response = await request.json(); + data.push(...response); + } + return data; +};