grocery-app/scripts/gitea-pr.js
2026-05-30 23:34:27 -07:00

310 lines
7.9 KiB
JavaScript

#!/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 <branch> [--head <branch>] --title <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));
});