chore: add gitea pr helper

This commit is contained in:
Nico 2026-05-30 23:34:27 -07:00
parent d271a0e34a
commit 47f306b6ac
4 changed files with 374 additions and 0 deletions

View File

@ -261,6 +261,8 @@ Before editing, run this read-only intake:
### Push and PR coordination ### Push and PR coordination
- Push the branch before opening a PR. - 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. - 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: - Use the PR body as the coordination record:
- `Owner:` - `Owner:`
@ -285,3 +287,4 @@ Before editing, run this read-only intake:
- Tests run, or a clear reason tests were not run. - Tests run, or a clear reason tests were not run.
- For broad branches, organize the summary by subsystem, workflow, or behavior area. - For broad branches, organize the summary by subsystem, workflow, or behavior area.
- Do not use auto-closing keywords such as `Closes`, `Fixes`, or `Resolves`. - 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 <pr-number>` status check.

58
docs/GITEA_PR_WORKFLOW.md Normal file
View File

@ -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 = "<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 <base>..HEAD --oneline --decorate
git diff --stat <base>..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 <base> --title "<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.

View File

@ -19,6 +19,10 @@
"db:migrate:new": "node scripts/db-migrate-new.js", "db:migrate:new": "node scripts/db-migrate-new.js",
"db:migrate:stale": "node scripts/db-stale-sql-tracker.js --write", "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", "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": "jest --runInBand",
"test:e2e": "npm --prefix frontend run test:e2e --", "test:e2e": "npm --prefix frontend run test:e2e --",
"test:e2e:headed": "npm --prefix frontend run test:e2e:headed --", "test:e2e:headed": "npm --prefix frontend run test:e2e:headed --",

309
scripts/gitea-pr.js Normal file
View File

@ -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));
});