From 47f306b6ac72e434b1e5c1320eaf934466f72ca7 Mon Sep 17 00:00:00 2001 From: Nico Date: Sat, 30 May 2026 23:34:27 -0700 Subject: [PATCH] chore: add gitea pr helper --- PROJECT_INSTRUCTIONS.md | 3 + docs/GITEA_PR_WORKFLOW.md | 58 +++++++ package.json | 4 + scripts/gitea-pr.js | 309 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 374 insertions(+) create mode 100644 docs/GITEA_PR_WORKFLOW.md create mode 100644 scripts/gitea-pr.js diff --git a/PROJECT_INSTRUCTIONS.md b/PROJECT_INSTRUCTIONS.md index 4b31bb0..b68a507 100644 --- a/PROJECT_INSTRUCTIONS.md +++ b/PROJECT_INSTRUCTIONS.md @@ -261,6 +261,8 @@ Before editing, run this read-only intake: ### Push and PR coordination - Push the branch before opening a PR. +- For this Gitea repo, use `docs/GITEA_PR_WORKFLOW.md` and `scripts/gitea-pr.js` for PR creation, lookup, and merge operations. +- PR tooling must read auth from `GITEA_TOKEN`/`GITEA_BASE_URL` shell environment only; never commit tokens or print token values. - Open a draft PR early for non-trivial, collision-prone, or multi-agent work once the first coherent commit exists. - Use the PR body as the coordination record: - `Owner:` @@ -285,3 +287,4 @@ Before editing, run this read-only intake: - Tests run, or a clear reason tests were not run. - For broad branches, organize the summary by subsystem, workflow, or behavior area. - Do not use auto-closing keywords such as `Closes`, `Fixes`, or `Resolves`. +- Merge PRs only after explicit operator approval, required checks, and a final `npm run pr:view -- --number ` status check. diff --git a/docs/GITEA_PR_WORKFLOW.md b/docs/GITEA_PR_WORKFLOW.md new file mode 100644 index 0000000..5b8576f --- /dev/null +++ b/docs/GITEA_PR_WORKFLOW.md @@ -0,0 +1,58 @@ +# Gitea PR Workflow + +Use this workflow when creating or merging PRs for this repo. It is designed for Codex and local operators to use the same commands without storing secrets in git. + +## One-time token setup + +Create a Gitea access token with repository pull request permissions, then set it only in the shell or user environment: + +```powershell +$env:GITEA_BASE_URL = "http://192.168.7.78:3000" +$env:GITEA_TOKEN = "" +``` + +Do not commit tokens, paste them into docs, or print them in logs. + +Check auth: + +```powershell +npm run pr:auth +``` + +## Create a PR + +1. Push the branch first. +2. Inspect the cumulative branch diff against the PR target: + +```powershell +git log ..HEAD --oneline --decorate +git diff --stat ..HEAD +``` + +3. Write the PR body to a temporary local file. Include the coordination record required by `PROJECT_INSTRUCTIONS.md`. +4. Create the PR: + +```powershell +npm run pr:create -- --base --title "" --body-file <body-file> +``` + +The helper checks for an existing open PR with the same base/head and returns it instead of creating a duplicate. + +For stacked work, pass the parent PR branch as `<base>`. For standalone work, pass `main`. + +## View or merge a PR + +View: + +```powershell +npm run pr:view -- --number <pr-number> +``` + +Merge after explicit operator approval and required checks: + +```powershell +npm run pr:merge -- --number <pr-number> --method merge --delete-branch --yes +``` + +The helper refuses to merge without `--yes`. Use `--method squash` or `--method rebase` only when that is the intended repo workflow. + diff --git a/package.json b/package.json index 44595c3..8cfa5ef 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,10 @@ "db:migrate:new": "node scripts/db-migrate-new.js", "db:migrate:stale": "node scripts/db-stale-sql-tracker.js --write", "db:migrate:stale:check": "node scripts/db-stale-sql-tracker.js --fail-on-stale", + "pr:auth": "node scripts/gitea-pr.js auth-check", + "pr:create": "node scripts/gitea-pr.js create", + "pr:view": "node scripts/gitea-pr.js view", + "pr:merge": "node scripts/gitea-pr.js merge", "test": "jest --runInBand", "test:e2e": "npm --prefix frontend run test:e2e --", "test:e2e:headed": "npm --prefix frontend run test:e2e:headed --", diff --git a/scripts/gitea-pr.js b/scripts/gitea-pr.js new file mode 100644 index 0000000..be6e962 --- /dev/null +++ b/scripts/gitea-pr.js @@ -0,0 +1,309 @@ +#!/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)); +});