chore: add gitea pr helper
This commit is contained in:
parent
d271a0e34a
commit
47f306b6ac
@ -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
58
docs/GITEA_PR_WORKFLOW.md
Normal 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.
|
||||||
|
|
||||||
@ -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
309
scripts/gitea-pr.js
Normal 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));
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user