Metadata-Version: 2.4
Name: forj
Version: 0.7.0.dev1
Summary: A scriptable CLI for Forgejo/Gitea/Codeberg REST APIs.
Project-URL: Homepage, https://codeberg.org/dma-dan/forj
Project-URL: Repository, https://codeberg.org/dma-dan/forj
Project-URL: Changelog, https://codeberg.org/dma-dan/forj/src/branch/main/docs/CHANGELOG.md
Project-URL: Issues, https://codeberg.org/dma-dan/forj/issues
License: MIT
License-File: LICENSE
Keywords: cli,codeberg,forge,forgejo,git,gitea
Classifier: Development Status :: 3 - Alpha
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Software Development :: Version Control
Classifier: Topic :: Utilities
Requires-Python: >=3.11
Requires-Dist: pydantic>=2
Provides-Extra: dev
Requires-Dist: bandit>=1.7; extra == 'dev'
Requires-Dist: build>=1.0; extra == 'dev'
Requires-Dist: interrogate>=1.7; extra == 'dev'
Requires-Dist: mcp<2,>=1.10; extra == 'dev'
Requires-Dist: pre-commit>=3.7; extra == 'dev'
Requires-Dist: pylint>=3.0; extra == 'dev'
Requires-Dist: pyright>=1.1; extra == 'dev'
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: ruff>=0.5; extra == 'dev'
Requires-Dist: twine>=5.0; extra == 'dev'
Requires-Dist: vulture>=2.11; extra == 'dev'
Provides-Extra: mcp
Requires-Dist: mcp<2,>=1.10; extra == 'mcp'
Description-Content-Type: text/markdown

# 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](https://docs.pydantic.dev) 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=1` previews any request without sending it, and
  destructive deletes are doubly gated (`--yes` **and** `FORJ_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](https://codeberg.org/dma-dan/ZulipToSignal)'s single-file
`scripts/forge.py` into a standalone, MVC-structured package.

## Install

```bash
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).

```bash
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](CONTRIBUTING.md#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's `repos`
> and `owners` lists are the *opposite* direction: they say **which configured token**
> applies to which targets, so forj can pick the right token for the `FORJ_REPO` (and
> its owner) it's acting on. `--owner` on `package` commands overrides the owner for
> that call only.

## Usage

```bash
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`, plus
  `label {add|remove|set|clear|list}`, `dep {add|remove}` (blockers), and `link`
  (post a Refs/Depends/Closes comment).
- **`pr`** — `create` · `list` · `status` · `edit` · `delete` · `merge` ·
  `draft` · `ready` · `retarget` · `update`, plus the shared `label {…}` sub-group,
  `reviewer {add|remove}`, and `review {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`/`delete` take 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, and `close`/`reopen` are shorthands for `edit --state`). `get --issues`
  lists a milestone's issues and PRs. Clear a single item's milestone with
  `issue`/`pr edit --unset-milestone`; `pr create --inherit-milestone` sets 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 a
  `read:package`/`write:package` token 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 (`-X` method, `-F`/`-f`
  fields, `--input` body, `-H` headers; `{owner}`/`{repo}` resolve from `FORJ_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](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](AGENTS.md). Licensed MIT (see `pyproject.toml`).
