Appearance
forge.mjs
The Forgejo client every script and agent in this repository talks to the issue tracker and pull requests through.
Agent tooling › forge.mjs
What it does
Reads and writes issues, comments, labels, pull requests, merges, collaborator grants, and branch protection on the repository's Forgejo instance, derived from git remote get-url origin unless FORGE_BASE_URL (or its pre-rename spelling ROCKET_FORGE_BASE_URL) overrides it. Resolves authentication once, through the git credential store for the remote's host or FORGE_TOKEN as a fallback, so tools/lib/auto-merge/forge-api.mjs and scripts/forge-purge-issues.mjs both import from it rather than growing their own token-reading code. Dispatches to one of twenty-two subcommands from its first argument, and exits before any network call on a usage error.
Usage
The minimal invocation that works — read one issue:
bash
node tools/forge.mjs issue REFREF is an issue number (12), a hash form (#12), or a full issue URL. A URL overrides the repository derived from the git remote, so another repository's issue is readable without leaving this one.
Every subcommand:
| Subcommand | Form | What it does |
|---|---|---|
issue | issue REF [--json] | Reads one issue. |
comments | comments REF [--json] | Reads every comment on an issue. |
list-issues | list-issues [--state open|closed|all] [--label NAME]... [--limit N] [--json] | Lists issues only, never pull requests, paging to exhaustion rather than truncating. |
labels | labels [--json] | Lists every label defined on the repository. |
create-label | create-label --label NAME [--description TEXT] [--color HEX] [--json] | Creates one label; refuses if the name already exists. |
label | label REF --label NAME... [--json] | Applies one or more labels to an issue and verifies the change by reading the issue back. |
unlabel | unlabel REF --label NAME... [--json] | Removes one or more labels, with the same read-back verification. |
comment | comment REF --body TEXT | Posts a comment on an issue. |
edit-comment | edit-comment COMMENT_ID --body TEXT | Edits an existing comment by its numeric id. |
delete-comment | delete-comment COMMENT_ID [--json] | Deletes a comment, printing what it said first — the Forge keeps no copy. |
create-issue | create-issue --title TITLE [--body TEXT] [--label NAME]... | Creates an issue. The raw primitive — nothing here checks the body against a schema. |
edit-issue | edit-issue REF [--title TITLE] [--body TEXT] | Edits an issue's title and/or body. |
close | close REF [--comment TEXT] [--json] | Closes an issue, optionally posting a comment first. |
create-pr | create-pr --base BRANCH --head BRANCH --title TITLE [--body TEXT] | Opens a pull request and always prints one compact JSON line, regardless of --json. |
edit-pr | edit-pr NUMBER [--title TITLE] [--body TEXT] | Edits a pull request's title and/or body. |
pr | pr NUMBER [--json] | Reads one pull request. |
merge | merge NUMBER [--method squash|merge|rebase|rebase-merge] [--head-sha COMMIT] | Merges a pull request and unconditionally deletes its head branch. |
grant-collaborator | grant-collaborator USER/REPO USERNAME [--perm write] | Grants a collaborator permission on a repository and reads it back to verify. |
protect | protect BRANCH [--require PATTERN]... [--push-as LOGIN] (--dry-run | --apply) [--json] | --dry-run prints the request that would be sent, without sending it; --apply sends it, creates or updates branch protection, and reads it back, failing if it does not match intent. Exactly one of the two is required. Protecting main with --apply and no --push-as refuses before any network call. |
ci-runs | ci-runs [--limit N] [--json] | Lists recent workflow runs, newest first, with wall times and a per-workflow health summary. |
ci-status | ci-status SHA|REF [--json] | Reads the combined commit status for a sha or ref back as green, red, pending, or none, with the run address (ADR-0018's proof). |
ci-why | ci-why RUN_NUMBER [--json] | Prints a red run's digest — from its pull request, or from the standing ci-log-drop mailbox issue. No HTTP route serves this Forge's job logs; the digest is the readable copy. |
ci-log | ci-log RUN_NUMBER [--out FILE] | Prints the full scrubbed run.log a run published to its ci-logs/<run> branch, or writes it to the file --out names. |
Wherever --body TEXT appears, --body-file PATH works in its place — the reliable path for multi-line bodies.
Options
| Flag | Value | What it does | Default |
|---|---|---|---|
--base | BRANCH | The target branch for create-pr. Required. | None |
--body | TEXT | The body text for comment, edit-comment, create-issue, edit-issue, create-pr, edit-pr. Mutually exclusive with --body-file; passing both exits 2. | empty |
--body-file | PATH | A file to read the body from, wherever --body is accepted. | None |
--color | HEX | The label color for create-label, as rrggbb or #rrggbb. A malformed value exits 2. | #cccccc |
--comment | TEXT | A comment close posts before closing the issue, when non-empty. | None |
--description | TEXT | The label description for create-label. | empty |
--dry-run | no value | For protect: previews the request without sending it. Exactly one of --dry-run/--apply is required; any other command exits 2 if either is passed. | off |
--apply | no value | For protect: sends the request. Protecting main with no --push-as exits 2 before any network call — main moves only by the fast-forward deploy-prod pushes, and a rule created with no push whitelist gets the Forge's own default instead. | off |
--push-as | LOGIN | For protect: the account whitelisted to push the branch. Required when protecting main with --apply. | None |
--head | BRANCH | The source branch for create-pr. Required. | None |
--head-sha | COMMIT | For merge: a guard sent to Forgejo as head_commit_id, so the merge is refused if the head has moved since. Must be 7–40 hex characters. | None |
--help | no value | Prints the usage summary and exits 0. Not parsed by parseArgs — checked directly against the raw argument list, alongside -h. | off |
--json | no value | Prints the raw API object instead of the human summary, on every read/write command except create-pr, which always prints one JSON line. | off |
--label | NAME | Repeatable. On create-issue: labels to apply at creation. On label/unlabel: the labels to add or remove — required, and verified by re-reading the issue afterward. On list-issues: a filter. On create-label: the one label name to create; more than one exits 2. | None |
--limit | N | The maximum number of issues list-issues returns. A non-integer or non-positive value throws, which exits 1. | no limit |
--method | squash | merge | rebase | rebase-merge | The merge strategy for merge. Any other value exits 2. | squash |
--out | PATH | For ci-log: write the log to this file instead of stdout, printing the byte count written. | stdout |
--perm | permission | The permission level grant-collaborator grants. | write |
--require | PATTERN | Repeatable. Required status-check pattern(s) for protect; replaces the default rather than adding to it. | Check / check * |
--state | open | closed | all | The issue state list-issues filters on. An unrecognized value throws, which exits 1. | open |
--title | TITLE | The title. Required by create-issue and create-pr; optional on edit-issue/edit-pr. | None |
Inputs and outputs
Reads four things:
git remote get-url origin, for owner, repo, and API base — unlessFORGE_BASE_URL/ROCKET_FORGE_BASE_URLor an issue-URL argument overrides it.- The git credential store for the remote's host.
FORGE_TOKENfrom the environment, as a fallback.- The file named by
--body-file.
Writes nothing to disk except the file ci-log --out names — every other effect is remote: issues, comments, labels, pull requests, merges, archive tags, collaborator grants, and branch-protection rules on the Forge.
Authentication resolves once per invocation: the git credential store for the remote's host wins, and FORGE_TOKEN is read only when the store returns nothing. That is the shape a dispatcher needs to inject a token per process. A request to a host that is neither the git remote's host nor FORGE_BASE_URL/ROCKET_FORGE_BASE_URL carries no credential at all, and isTrustedForgeHost returns before git credential fill ever runs. A private repo on an untrusted host answers 404 rather than exposing the token to the wrong host.
| Exit code | Meaning |
|---|---|
0 | No command (or --help/-h) prints the usage summary; otherwise, the command succeeded. |
1 | An API call failed (a 404 on a private repo usually means a missing credential), a write did not verify (label, unlabel, protect), the merge tag already existed at a different commit, or a check that lives deeper than the dispatch — a bad --state/--limit on list-issues, or an unresolved --label name — threw and reached the top-level handler. |
2 | A usage error checked directly in command dispatch — a missing required flag, a malformed issue/PR reference, an unknown command, or a bad --method/--head-sha/--color. |
Examples
Read a ticket the way a session opens it:
bash
node tools/forge.mjs issue 10Post a multi-line comment from a file, since a file body cannot be mangled by shell quoting:
bash
node tools/forge.mjs comment 10 --body-file /tmp/review.mdOpen a pull request from dev into staging, the flow this repository's own promotions follow:
bash
node tools/forge.mjs create-pr --base staging --head dev --title "T2: docs kit"Merge only if nobody has pushed since the head was reviewed; without --head-sha the merge takes whatever the head is at that moment:
bash
node tools/forge.mjs merge 15 --head-sha 00627d8Put branch protection on main, previewing the request first — protect --apply is idempotent, and reads the rule back to verify it before reporting success:
bash
node tools/forge.mjs protect main --require 'Check / check (push)' --push-as <login> --dry-run
node tools/forge.mjs protect main --require 'Check / check (push)' --push-as <login> --applyNotes
Never file a ticket with create-issue
The create-issue command is the raw primitive: nothing on this path checks the body against any schema, so a malformed ticket reaches the Forge intact. The filing path this repository uses instead is tools/ticket-new.mjs.
merge always sets deleteBranch: true — there is no flag to keep the branch — and before deleting it, archiveHeadTip tags the head commit as archive/<head-ref> so the history survives the branch's removal. If that tag already exists at a different commit, the merge is refused (exit 1) rather than overwriting a different history under the same name. A merge dispatched through tools/auto-merge.mjs uses the same mergePullRequest function, with deleteBranch: false, so the branch is never deleted there.
No merge method fast-forwards: MERGE_METHODS limits merge to squash, merge, rebase, and rebase-merge. Combined with merge's unconditional branch deletion, deleting the head applies to a long-lived branch the same as it does to a short-lived feature branch.
list-issues returns issues only, never pull requests, even though the Forge shares one number space between them. It pages to exhaustion and throws instead of truncating past LIST_MAX_PAGES * LIST_PAGE_SIZE (1000) matches.
--require replaces rather than adds: protect main --require "CI / build" --push-as <login> --apply leaves the default, Check / check *, unenforced. That default itself matches only the Check workflow's context — it does not cover Review / gate (pull_request), which tools/lib/auto-merge/policy.mjs also requires before merging. Pass every pattern wanted in one --require-repeated invocation.
If the remote is a bare SSH alias with no dot in the host — git@myforge:org/repo.git — there is no hostname to build an HTTPS base from. The tool dies rather than guess. Set FORGE_BASE_URL (or its pre-rename spelling ROCKET_FORGE_BASE_URL) explicitly.
Source
tools/forge.mjs — part of Agent tooling