A scriptable CLI for Forgejo/Gitea/Codeberg REST APIs.
Project description
forj
A scriptable command-line client for Forgejo / Gitea / Codeberg REST APIs — issues, pull requests, comments, reviews, labels, releases, and release assets. Every operation is one unambiguous command, so a person or a bot never has to hand-write a REST call.
- Typed payloads, one dependency. The REST request/response bodies are
Pydantic models (forj's only runtime dependency) — so
responses are parsed and validated, not dict-wrangled. Everything else is the
standard library (
urllib,json,argparse,tomllib); runs on Python ≥ 3.11. - Repo-agnostic. The target forge, repository, and credentials all come from the environment — nothing is hard-coded — so one install drives any Forgejo/Gitea host.
- Safe by default.
FORJ_DRY_RUN=1previews any request without sending it, and destructive deletes are doubly gated (--yesandFORJ_ALLOW_DELETE).
Why
The token you get from a Codeberg repo (write:repository + write:issue) is enough
to file issues, open PRs, review, and cut releases — but tea's login demands the
read:user scope, which Codeberg won't grant to a repo-scoped token. forj talks to
the REST API directly and sends the token only in the Authorization header, so a
plain repo-scoped token is all it needs.
forj was extracted from
ZulipToSignal's single-file
scripts/forge.py into a standalone, MVC-structured package.
Install
pip install -e . # from a clone; add '.[dev]' for the test/lint tooling
This installs the forj console script (also runnable as python -m forj).
Configure
forj reads its target and credentials from the environment:
| Variable | Required | Default | Purpose |
|---|---|---|---|
FORJ_REPO |
yes | — | The owner/name repository to operate on |
FORJ_TOKEN |
for live calls | — | Access token (sent only in the Authorization header). Also read from ~/.config/forj/token |
FORJ_API_ROOT |
no | https://codeberg.org/api/v1 |
REST API root of the forge |
FORJ_BASE_BRANCH |
no | main |
Default base branch for pr create |
FORJ_DRY_RUN |
no | off | Preview requests on stderr without sending them (no token read, no network) |
FORJ_ALLOW_DELETE |
no | off | The environment half of the two-key delete gate |
FORJ_CHANGELOG |
no | docs/CHANGELOG.md |
Changelog read by release create --notes-from-changelog |
FORJ_CONFIG |
no | ~/.config/forj/config.toml |
Multi-token config for least-privilege / differently-scoped tokens |
A boolean flag (FORJ_DRY_RUN, FORJ_ALLOW_DELETE) is on for any value other
than 0/false/no/off/empty (case-insensitive).
export FORJ_REPO=owner/name
export FORJ_TOKEN=… # or put it in ~/.config/forj/token
Multiple tokens (preferred for anything non-trivial). When one token can't cover
everything — e.g. the Packages API needs a write:package scope a repo-scoped token
can't carry — describe each token in ~/.config/forj/config.toml; forj selects the
narrowest match (repo, then owner, then the default entry) that carries the scope an
operation needs. See CONTRIBUTING → Setup for the format.
Where does the repo come from? The repository an operation acts on is always
FORJ_REPO(owner/name) — that's the single target. A config entry'sreposandownerslists are the opposite direction: they say which configured token applies to which targets, so forj can pick the right token for theFORJ_REPO(and its owner) it's acting on.--owneronpackagecommands overrides the owner for that call only.
Usage
forj issue list # open issues, with inline metadata
forj issue list --milestone v0.2.0 --label bug --unblocked # filter by metadata
forj pr list --author alice --draft # filter PRs too
forj issue create "Title" "Body"
forj issue edit 12 --milestone v0.2.0 --assignees alice,bob
forj issue label add 12 bug enhancement # labels resolve by name or id
forj issue link 12 5 6 --kind depends # 'Depends on #5, #6' + a real blocker
forj issue dep add 12 3 --repo other/repo # cross-repo blocker (issue in another repo)
forj pr create feat/x "Add X" "Closes #12" # head -> base (default: main)
forj pr status 34
forj pr review create 34 --event REQUEST_CHANGES \
--comment "src/app.py:42:needs a guard here"
forj pr review comments 34 # all active (unresolved) inline comments
forj pr merge 34 squash
forj milestone create v0.2.0 --due-date 2026-08-01
forj milestone assign v0.2.0 12 15 34 # bulk-assign a milestone to issues/PRs
forj milestone move v0.1.0 v0.2.0 # move every issue+PR onto another milestone
forj issue edit 12 --unset-milestone # clear one item's milestone
forj pr create feat/x "Fix" "Closes #12" --inherit-milestone # PR inherits #12's milestone
forj release create v0.1.0 --notes-from-changelog
FORJ_DRY_RUN=1 forj issue delete 12 # preview a gated op safely
forj api repos/{owner}/{repo}/branches # raw passthrough (any endpoint)
forj api repos/{owner}/{repo}/issues -F index=5 -f title=Hi # POST typed/string fields
forj --json issue get 12 # machine-readable JSON (also FORJ_JSON=1)
forj --json pr status 34 | jq .mergeable # scriptable structured output
Any list subcommand takes -v/--verbose to expand each item to its full metadata.
issue list / pr list also filter by metadata: --label (repeatable), --author,
--milestone, plus issue list --assignee / --blocked / --unblocked and
pr list --draft / --ready.
The global --json flag (or FORJ_JSON=1) switches every command to machine-readable
JSON: reads/lists/creates emit their parsed model(s), and mutations emit an
{"ok": true, "message": ...} status object — so output is stable for scripts and LLM
tooling. Human text is the default. forj --version prints the installed version.
Command surface
issue—create·get·list·edit·delete, pluslabel {add|remove|set|clear|list},dep {add|remove}(blockers), andlink(post a Refs/Depends/Closes comment).pr—create·list·status·edit·delete·merge·draft·ready·retarget·update, plus the sharedlabel {…}sub-group,reviewer {add|remove}, andreview {create|list|get|comments|submit|dismiss|delete}.comment—create·list·edit·delete.label— repo labels:create·list.milestone—create·list·get·edit·assign·unassign·move·close·reopen·delete(get/edit/assign/move/close/reopen/deletetake a milestone id or title, resolved like--milestone;assign <ms> <n…>bulk-assigns it to issues/PRs,unassign <n…>clears theirs,move <from> <to>sweeps every issue and PR onto another milestone, andclose/reopenare shorthands foredit --state).get --issueslists a milestone's issues and PRs. Clear a single item's milestone withissue/pr edit --unset-milestone;pr create --inherit-milestonesets a new PR's milestone from the first issue its body closes. A milestone's open/closed counts include PRs (Forgejo models PRs as issues), and assigning to a closed milestone is allowed (with a soft note).release—create·list·get·get-tag·edit·delete.release-asset—list·get·upload·delete.package— owner-scoped packages:list·get·delete(needs aread:package/write:packagetoken via~/.config/forj/config.toml).delete-branch— delete a remote branch (e.g. after its PR merges).api— raw REST passthrough to any endpoint forj doesn't wrap (-Xmethod,-F/-ffields,--inputbody,-Hheaders;{owner}/{repo}resolve fromFORJ_REPO). The escape hatch — unguarded raw access.
Run forj --help (or forj <entity> --help) for the full tree. Deletes
(issue/pr/comment/pr review/milestone/release/release-asset/package delete)
additionally require --yes and FORJ_ALLOW_DELETE.
Development
See CONTRIBUTING.md for setup (make install), the quality
gates, the module/architecture map, and the git workflow. AI-agent-specific policies
are in AGENTS.md. Licensed MIT (see pyproject.toml).
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file forj-0.7.0.dev1.tar.gz.
File metadata
- Download URL: forj-0.7.0.dev1.tar.gz
- Upload date:
- Size: 79.6 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
3acfb236b3c4175f8f215cae9cf4e980fb0606e1c89eb0729cabd97194698206
|
|
| MD5 |
822c23aa764e26d3ce36a93d2ceeedfb
|
|
| BLAKE2b-256 |
c75401bf3b96a04552b82083d28f1ca287a6eab001f71d380ef2f26109addf04
|
File details
Details for the file forj-0.7.0.dev1-py3-none-any.whl.
File metadata
- Download URL: forj-0.7.0.dev1-py3-none-any.whl
- Upload date:
- Size: 45.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
9bd78d9132572ac46f72243ca2a063009bf2788d17a4d962c8729326fc03df0e
|
|
| MD5 |
3d8e3fbbb2d8a642f9efa3fcc503f67b
|
|
| BLAKE2b-256 |
2d82793cb1eddb89956095b589401f720447d7476e2a84f1c5fb8605d0032e27
|