This release is a pre-release and may not be stable for production use.
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).
Metadata
Release files for forj 0.7.0.dev1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| forj-0.7.0.dev1.tar.gz | 79.6 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| forj-0.7.0.dev1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 125.2 kB
Release files / forj-0.7.0.dev1.tar.gz
| Download URL | forj-0.7.0.dev1.tar.gz |
|---|---|
| Size | 79.6 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
3acfb236b3c4175f8f215cae9cf4e980fb0606e1c89eb0729cabd97194698206
|
|
BLAKE2b-256 checksum How to use checksums |
c75401bf3b96a04552b82083d28f1ca287a6eab001f71d380ef2f26109addf04
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.12.3
|
Release files / forj-0.7.0.dev1-py3-none-any.whl
| Download URL | forj-0.7.0.dev1-py3-none-any.whl |
|---|---|
| Size | 45.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
9bd78d9132572ac46f72243ca2a063009bf2788d17a4d962c8729326fc03df0e
|
|
BLAKE2b-256 checksum How to use checksums |
2d82793cb1eddb89956095b589401f720447d7476e2a84f1c5fb8605d0032e27
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.12.3
|