Skip to content

forge.mjs

The Forgejo client every script and agent in this repository talks to the issue tracker and pull requests through.

Agent toolingforge.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 REF

REF 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:

SubcommandFormWhat it does
issueissue REF [--json]Reads one issue.
commentscomments REF [--json]Reads every comment on an issue.
list-issueslist-issues [--state open|closed|all] [--label NAME]... [--limit N] [--json]Lists issues only, never pull requests, paging to exhaustion rather than truncating.
labelslabels [--json]Lists every label defined on the repository.
create-labelcreate-label --label NAME [--description TEXT] [--color HEX] [--json]Creates one label; refuses if the name already exists.
labellabel REF --label NAME... [--json]Applies one or more labels to an issue and verifies the change by reading the issue back.
unlabelunlabel REF --label NAME... [--json]Removes one or more labels, with the same read-back verification.
commentcomment REF --body TEXTPosts a comment on an issue.
edit-commentedit-comment COMMENT_ID --body TEXTEdits an existing comment by its numeric id.
delete-commentdelete-comment COMMENT_ID [--json]Deletes a comment, printing what it said first — the Forge keeps no copy.
create-issuecreate-issue --title TITLE [--body TEXT] [--label NAME]...Creates an issue. The raw primitive — nothing here checks the body against a schema.
edit-issueedit-issue REF [--title TITLE] [--body TEXT]Edits an issue's title and/or body.
closeclose REF [--comment TEXT] [--json]Closes an issue, optionally posting a comment first.
create-prcreate-pr --base BRANCH --head BRANCH --title TITLE [--body TEXT]Opens a pull request and always prints one compact JSON line, regardless of --json.
edit-predit-pr NUMBER [--title TITLE] [--body TEXT]Edits a pull request's title and/or body.
prpr NUMBER [--json]Reads one pull request.
mergemerge NUMBER [--method squash|merge|rebase|rebase-merge] [--head-sha COMMIT]Merges a pull request and unconditionally deletes its head branch.
grant-collaboratorgrant-collaborator USER/REPO USERNAME [--perm write]Grants a collaborator permission on a repository and reads it back to verify.
protectprotect 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-runsci-runs [--limit N] [--json]Lists recent workflow runs, newest first, with wall times and a per-workflow health summary.
ci-statusci-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-whyci-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-logci-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

FlagValueWhat it doesDefault
--baseBRANCHThe target branch for create-pr. Required.None
--bodyTEXTThe 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-filePATHA file to read the body from, wherever --body is accepted.None
--colorHEXThe label color for create-label, as rrggbb or #rrggbb. A malformed value exits 2.#cccccc
--commentTEXTA comment close posts before closing the issue, when non-empty.None
--descriptionTEXTThe label description for create-label.empty
--dry-runno valueFor 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
--applyno valueFor 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-asLOGINFor protect: the account whitelisted to push the branch. Required when protecting main with --apply.None
--headBRANCHThe source branch for create-pr. Required.None
--head-shaCOMMITFor 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
--helpno valuePrints the usage summary and exits 0. Not parsed by parseArgs — checked directly against the raw argument list, alongside -h.off
--jsonno valuePrints the raw API object instead of the human summary, on every read/write command except create-pr, which always prints one JSON line.off
--labelNAMERepeatable. 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
--limitNThe maximum number of issues list-issues returns. A non-integer or non-positive value throws, which exits 1.no limit
--methodsquash | merge | rebase | rebase-mergeThe merge strategy for merge. Any other value exits 2.squash
--outPATHFor ci-log: write the log to this file instead of stdout, printing the byte count written.stdout
--permpermissionThe permission level grant-collaborator grants.write
--requirePATTERNRepeatable. Required status-check pattern(s) for protect; replaces the default rather than adding to it.Check / check *
--stateopen | closed | allThe issue state list-issues filters on. An unrecognized value throws, which exits 1.open
--titleTITLEThe 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 — unless FORGE_BASE_URL/ROCKET_FORGE_BASE_URL or an issue-URL argument overrides it.
  • The git credential store for the remote's host.
  • FORGE_TOKEN from 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 codeMeaning
0No command (or --help/-h) prints the usage summary; otherwise, the command succeeded.
1An 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.
2A 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 10

Post 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.md

Open 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 00627d8

Put 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> --apply

Notes

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