#!/usr/bin/env node const { execFileSync } = require("node:child_process"); const fs = require("node:fs"); const DEFAULT_REMOTE = "origin"; const DEFAULT_SSH_HTTP_PORT = process.env.GITEA_PORT || "3000"; function usage() { console.log(`Gitea PR helper Environment: GITEA_TOKEN Required for API calls. Never commit this value. GITEA_BASE_URL Optional, e.g. http://192.168.7.78:3000. GITEA_OWNER Optional repo owner override. GITEA_REPO Optional repo name override. Commands: auth-check Verify the token can authenticate with Gitea. create --base [--head ] --title [--body-file <path> | --body <text>] Create a PR, or return the existing open PR for the same base/head. view --number <pr-number> Print basic PR status. merge --number <pr-number> --yes [--method merge|squash|rebase] [--delete-branch] Merge a PR. The --yes flag is required to avoid accidental merges. Examples: npm run pr:auth npm run pr:create -- --base feature-custom-store-locations --title "Allow household switcher reordering" --body-file pr-body.md npm run pr:view -- --number 12 npm run pr:merge -- --number 12 --method merge --delete-branch --yes `); } function fail(message) { console.error(message); process.exit(1); } function runGit(args) { return execFileSync("git", args, { encoding: "utf8" }).trim(); } function parseFlags(argv) { const flags = {}; const positionals = []; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (!arg.startsWith("--")) { positionals.push(arg); continue; } const key = arg.slice(2); const next = argv[index + 1]; if (!next || next.startsWith("--")) { flags[key] = true; continue; } flags[key] = next; index += 1; } return { flags, positionals }; } function requiredFlag(flags, key) { const value = flags[key]; if (!value || value === true) { fail(`Missing required --${key}`); } return value; } function currentBranch() { return runGit(["branch", "--show-current"]); } function parseRemoteUrl(remoteUrl) { const normalized = remoteUrl.replace(/\.git$/, ""); const httpMatch = normalized.match(/^https?:\/\/([^/]+)\/([^/]+)\/([^/]+)$/); if (httpMatch) { const [, host, owner, repo] = httpMatch; return { host, owner, repo, scheme: remoteUrl.startsWith("https://") ? "https" : "http" }; } const sshMatch = normalized.match(/^(?:ssh:\/\/)?[^@]+@([^:/]+)(?::\d+)?[:/]([^/]+)\/([^/]+)$/); if (sshMatch) { const [, host, owner, repo] = sshMatch; return { host, owner, repo, scheme: "ssh" }; } fail(`Could not parse git remote URL: ${remoteUrl}`); } function repoConfig() { const remoteUrl = runGit(["remote", "get-url", DEFAULT_REMOTE]); const parsed = parseRemoteUrl(remoteUrl); const baseUrl = process.env.GITEA_BASE_URL || process.env.GITEA_URL || (parsed.scheme === "ssh" ? `http://${parsed.host}:${DEFAULT_SSH_HTTP_PORT}` : `${parsed.scheme}://${parsed.host}`); return { baseUrl: baseUrl.replace(/\/$/, ""), owner: process.env.GITEA_OWNER || parsed.owner, repo: process.env.GITEA_REPO || parsed.repo, }; } function getToken() { const token = process.env.GITEA_TOKEN || process.env.GITEA_ACCESS_TOKEN; if (!token) { fail( "Missing GITEA_TOKEN. Create a Gitea access token with repository pull request permissions and set it in the shell." ); } return token; } async function apiRequest(method, route, body) { const { baseUrl } = repoConfig(); const url = `${baseUrl}/api/v1${route}`; const response = await fetch(url, { method, headers: { Accept: "application/json", "Content-Type": "application/json", Authorization: `token ${getToken()}`, }, body: body ? JSON.stringify(body) : undefined, }); const text = await response.text(); const parsed = text ? safeJson(text) : null; if (!response.ok) { const message = parsed?.message || parsed?.error || text || `${response.status} ${response.statusText}`; fail(`Gitea API ${method} ${route} failed: ${response.status} ${message}`); } return parsed; } function safeJson(text) { try { return JSON.parse(text); } catch { return { message: text }; } } function repoRoute(pathname) { const { owner, repo } = repoConfig(); return `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}${pathname}`; } async function listOpenPulls() { const pulls = []; let page = 1; while (true) { const batch = await apiRequest("GET", repoRoute(`/pulls?state=open&limit=50&page=${page}`)); pulls.push(...batch); if (batch.length < 50) { break; } page += 1; } return pulls; } async function findOpenPull(base, head) { const pulls = await listOpenPulls(); return pulls.find((pull) => { const headLabels = [pull.head?.ref, pull.head?.label].filter(Boolean); return ( pull.base?.ref === base && headLabels.some((label) => label === head || label.endsWith(`:${head}`)) ); }); } function readBody(flags) { if (flags["body-file"]) { return fs.readFileSync(flags["body-file"], "utf8"); } return typeof flags.body === "string" ? flags.body : ""; } async function authCheck() { const user = await apiRequest("GET", "/user"); console.log(`Authenticated as ${user.login || user.username || user.full_name || "Gitea user"}`); } async function createPull(flags) { const base = requiredFlag(flags, "base"); const head = flags.head && flags.head !== true ? flags.head : currentBranch(); const title = requiredFlag(flags, "title"); const body = readBody(flags); const existing = await findOpenPull(base, head); if (existing) { console.log(`Existing PR #${existing.number}: ${existing.html_url || existing.url}`); return; } const pull = await apiRequest("POST", repoRoute("/pulls"), { base, head, title, body, }); console.log(`Created PR #${pull.number}: ${pull.html_url || pull.url}`); } async function viewPull(flags) { const number = requiredFlag(flags, "number"); const pull = await apiRequest("GET", repoRoute(`/pulls/${encodeURIComponent(number)}`)); console.log( [ `PR #${pull.number}: ${pull.title}`, `URL: ${pull.html_url || pull.url}`, `State: ${pull.state}${pull.merged ? " (merged)" : ""}`, `Base: ${pull.base?.ref || "(unknown)"}`, `Head: ${pull.head?.ref || "(unknown)"}`, `Mergeable: ${String(pull.mergeable)}`, ].join("\n") ); } async function mergePull(flags) { const number = requiredFlag(flags, "number"); if (!flags.yes) { fail("Refusing to merge without --yes"); } const method = flags.method && flags.method !== true ? flags.method : "merge"; const allowedMethods = new Set(["merge", "squash", "rebase"]); if (!allowedMethods.has(method)) { fail("--method must be one of: merge, squash, rebase"); } const pull = await apiRequest("GET", repoRoute(`/pulls/${encodeURIComponent(number)}`)); if (pull.state !== "open") { fail(`Refusing to merge PR #${number}; state is ${pull.state}`); } if (pull.merged) { fail(`PR #${number} is already merged`); } await apiRequest("POST", repoRoute(`/pulls/${encodeURIComponent(number)}/merge`), { Do: method, delete_branch_after_merge: Boolean(flags["delete-branch"]), }); console.log(`Merged PR #${number} with method ${method}`); } async function main() { const [command, ...rest] = process.argv.slice(2); const { flags } = parseFlags(rest); switch (command) { case undefined: case "help": case "--help": case "-h": usage(); break; case "auth-check": await authCheck(); break; case "create": await createPull(flags); break; case "view": await viewPull(flags); break; case "merge": await mergePull(flags); break; default: fail(`Unknown command: ${command}`); } } main().catch((error) => { fail(error.stack || error.message || String(error)); });