docs: Requirements for the project.

This commit is contained in:
2026-07-10 22:37:38 -04:00
commit 21a075f8cd
19 changed files with 5378 additions and 0 deletions

158
.claude/CONTEXT.md Normal file
View File

@@ -0,0 +1,158 @@
# gitea-axi
A thin TypeScript CLI that calls the Gitea REST API directly via `gitea-js` to give coding agents an ergonomic, low-token interface to Gitea issues and pull requests.
## Language
### The tool and its host
**gitea-axi**: The CLI tool defined by this project.
_Avoid_: wrapper, adapter, shim
**tea**: The official Gitea CLI whose login store gitea-axi reads for credential discovery; not used for command dispatch.
_Avoid_: Gitea CLI, upstream binary
**gitea-js**: The official TypeScript client for the Gitea REST API, generated from Gitea's OpenAPI spec; the sole HTTP layer in gitea-axi.
_Avoid_: API client, HTTP client, fetch wrapper
**AXI (Agent eXperience Interface)**: The set of 10 design principles that govern how gitea-axi shapes its output and behavior for coding agents.
_Avoid_: agent interface, UX principles
**axi-sdk-js**: The shared TypeScript framework package (`axi-sdk-js` on npm) that provides `runAxiCli`, `AxiError`, `exitCodeForError`, and output helpers; gitea-axi is built on it, matching gh-axi's architecture.
_Avoid_: AXI library, SDK
### Output
**TOON**: The structured text output format used for all gitea-axi output, encoded via `@toon-format/toon`.
_Avoid_: JSON output, structured output
**renderList**: The output helper that formats a collection of entities as a TOON list, preceded by a count line.
_Avoid_: list formatter, table renderer
**dashboard**: The output of `gitea-axi` with no arguments; a two-tier home view preceded by the `bin:` + `description:` header from `axi-sdk-js`.
The short tier (no flags, and what the [[SessionStart hook]] runs) matches gh-axi's home shape: up to 3 open issues (`number`, `title`, `state`, `author`) and up to 3 open PRs (`number`, `title`, `author`, `review`), plus a `help:` hint pointing at `--full`.
The full tier (`gitea-axi --full`) shows open PRs as a TOON table and open issue counts grouped by label.
Issue counts are aggregated by fetching all open issues up to a hard cap of 1000 (page size 50, 20 pages max); if capped, the count is suffixed with `+`.
Each issue contributes to all of its labels; unlabeled issues appear as a separate `unlabeled` row only when non-zero.
Full-tier PR table default fields: `number`, `title`, `author` (plucked from `user.login`), `labels` (joined label names), `review` (computed client-side, same parallel review fetch as `pr list`).
The full-tier PR table is capped at 20 rows with a standard count line (`count: 20 of T total`).
Block names: `repo:` line, then `prs:` and `issues:`.
Empty states (both tiers): `prs: 0 open` / `issues: 0 open` (raw strings, matching gh-axi's home; list commands keep `<noun>[0]: (none)`).
Outside a recognizable Gitea repo the dashboard errors with `REPO_NOT_FOUND` (help: use `-R` + `--login`) — login selection needs a hostname; the resulting hook noise in non-Gitea sessions is an accepted consequence.
_Avoid_: home view, status view
**renderDetail**: The output helper that formats a single entity's full detail as a TOON record.
_Avoid_: detail formatter, record renderer
**count line**: The leading line in list output that states how many results were returned and their relationship to the total, e.g. `count: N of T total`.
When a client-side filter is active, `T` is the true filtered total computed from the in-memory result set (the `X-Total-Count` header, which reflects the unfiltered total, is ignored); the bare `count: N` form does not exist.
_Avoid_: summary line, header
**FieldDef**: A typed descriptor that extracts and formats a single field from raw Gitea API JSON, with named extractor variants (nested pluck, array join, enum map, bool-to-text, relative time).
_Avoid_: field extractor, field descriptor
**content truncation**: Shortening body or diff text to a defined character limit and appending an inline hint — `"... (truncated, N chars total - use --full to see complete body)"` — directly into the field value.
The full content is never written to a temp file; `--full` on the relevant subcommand suppresses *all* truncation in that command's output (entity body and comment bodies alike) and returns raw values instead.
Comment bodies truncate at 800 chars wherever they appear (comment-post output and `--comments` view blocks), with cleanBody applied; `--comments` renders all comments with no count cap, matching gh-axi.
_Avoid_: truncation, clipping, temp-file approach
**cleanBody**: A preprocessing step applied to body text before truncation, to reduce token cost.
Applied only when the raw body exceeds the truncation limit.
Normalizes Gitea issue/PR URLs (using the detected hostname) to compact form: `https://<host>/<owner>/<repo>/issues/N``Issue#N`, `https://<host>/<owner>/<repo>/pulls/N``PR#N`.
Also strips markdown image embeds, long URLs in markdown links, standalone long URLs, and collapses email-style quoted blocks — matching gh-axi's cleanBody transformations.
_Avoid_: body cleaning, URL normalization
### Errors and suggestions
**AxiError**: The typed error value with one of ten named codes that gitea-axi emits on failure (TOON-encoded to stdout).
The codes: `REPO_NOT_FOUND`, `ISSUE_NOT_FOUND`, `PR_NOT_FOUND`, `AUTH_REQUIRED`, `FORBIDDEN`, `RATE_LIMITED`, `TEA_NOT_INSTALLED`, `VALIDATION_ERROR`, `GIT_ERROR`, `UNKNOWN`.
`GIT_ERROR` classifies non-zero git subprocess exits (currently only `pr checkout`), carrying git's first stderr line — the agent's recovery is local (fix the worktree), unlike API errors.
The `ISSUE_NOT_FOUND`/`PR_NOT_FOUND` split (vs gh-axi's single `NOT_FOUND`) is a deliberate divergence enabled by path-based 404 classification; `RATE_LIMITED` maps HTTP 429 from proxies in front of Gitea.
_Avoid_: error object, exception
**next-step suggestion**: A semi-dynamic hint appended to command output that tells the agent what to call next, normalized to include the current repo context flags.
Rendered as a `help[N]:` block — the same block name used for error suggestions, matching gh-axi and canonical AXI Principle 9.
Runtime values are hybrid: list output keeps placeholders (`issue view <number>`), single-entity output fills the actual id (`issue view 42`), matching canonical Principle 9 ("leave runtime values parameterized") and gh-axi.
_Avoid_: hint, tip, recommendation, next[]
**suggestion normalization**: The process of rewriting a next-step suggestion to include `-R OWNER/NAME` and `--login` flags derived from the current repository context.
Only applied when the context did not come from the git remote (i.e., when `source` is `"flag"` or `"env"`).
_Avoid_: flag injection, context enrichment
### Commands
**issue blocks**: A Gitea-specific subcommand group for managing which issues this issue blocks.
Three sub-operations: `list <n>` (issues blocked by n), `add <n> <target>` (make n block target), `remove <n> <target>`.
Idempotent: `add` of an existing relationship returns `already: true` (fetch-first check); `remove` of a nonexistent relationship is silent success; true validation failures (self-reference, cycles) still surface as `VALIDATION_ERROR`.
No gh-axi equivalent — Gitea-specific API (`/issues/{index}/blocks`).
_Avoid_: blocking, blocks list
**issue blocked-by**: A Gitea-specific subcommand group for managing which issues block this issue (i.e., must be resolved before this one).
Three sub-operations: `list <n>`, `add <n> <blocker>`, `remove <n> <blocker>`.
Same idempotency rules as [[issue blocks]].
No gh-axi equivalent — Gitea-specific API (`/issues/{index}/dependencies`).
_Avoid_: depends, depends-on, dependencies
### Gitea API patterns
**type guard**: The defense against Gitea's unified issue/PR model, where issue endpoints also serve PRs.
Every issues-list call passes `type=issues` (issue list, dashboard aggregation, client-side-filter pagination).
Issue commands invoked with a PR number refuse with `VALIDATION_ERROR` ("issue #N is a pull request") and a `pr view` help line, detected via the fetched object's non-null `pull_request` field.
Exception: `issue comment` stays permissive — PRs genuinely share the comment endpoint.
_Avoid_: PR filtering, issue-only mode
**reviewDecision**: A computed field (not returned by Gitea) that summarizes the overall review state of a PR.
Derived client-side from the reviews list: `APPROVED` if at least one review has `official=true`, `stale=false`, `dismissed=false`, and no non-dismissed `REQUEST_CHANGES` review exists; `CHANGES_REQUESTED` if any non-dismissed `REQUEST_CHANGES` exists; otherwise `REVIEW_REQUIRED`.
On `pr list`, this requires one extra parallel HTTP call per PR to fetch reviews.
_Avoid_: review status, review aggregate
**commit status**: Gitea's CI/CD state mechanism, attached to a commit SHA via `GET /repos/{owner}/{repo}/commits/{sha}/status`.
The state is one of `pending`, `success`, `error`, `failure`, `warning`, `skipped` (`skipped` exists in modern Gitea; older instances never emit it).
gitea-axi uses this as the equivalent of GitHub Check Runs for `pr checks` and the `checks` field on `pr view`.
Conclusion mapping: `success``pass`; `failure`/`error`/`warning``fail` (matching Gitea's own `Combine()` logic, which treats `warning` as failure); `skipped``skip`; `pending``pending`.
_Avoid_: check run, CI status, pipeline status
**fetch-then-patch**: The pattern used for additive or subtractive mutations on list fields (assignees, reviewers) where Gitea's PATCH replaces the entire list rather than adding/removing individual entries.
gitea-axi reads the current list first, computes the desired list, then sends a single PATCH with the full resulting list.
_Avoid_: read-modify-write, merge-then-patch
**client-side filtering**: The policy applied when Gitea's API does not support a given filter parameter.
gitea-axi paginates all results from the API (using `limit=50` pages until exhausted) and filters the full result set in-process.
When any client-side filter is active, the count line emits `count: N of T total` with `T` computed from the in-memory filtered set (the unfiltered `X-Total-Count` header is ignored as misleading).
Client-side *sort* (`issue list --sort`) is not a filter: it reorders without changing membership, so `T` comes from the `X-Total-Count` header as usual, while still requiring full pagination before sorting.
_Avoid_: in-memory filtering, local filtering
**label name lookup**: The process of resolving a `--label <name>` string to a Gitea label ID before calling endpoints that require numeric IDs (e.g. `pr list --label`, `issue list --label`).
Implemented via `GET /repos/{owner}/{repo}/labels`; matched case-insensitively.
`--label-id <id>` is a Gitea-specific shortcut flag that bypasses the lookup and passes the ID directly.
_Avoid_: label resolution, name-to-ID mapping
### Testing
**fixture server**: The local HTTP server used in tests, pointed to by `GITEA_AXI_API_URL`, that maps incoming request paths and methods to pre-recorded Gitea API JSON response files.
_Avoid_: mock server, stub server, fake API
**fixture**: A pre-recorded Gitea API JSON response file stored in `fixtures/` that the fixture server returns for a given request path and method.
_Avoid_: snapshot, recording
**test mode**: Activated when `GITEA_AXI_API_URL` is set.
In test mode: all API calls go to the fixture server; tea subprocess is bypassed (token read from `GITEA_AXI_TOKEN`); git remote detection is suppressed.
Three env vars together make tests fully hermetic: `GITEA_AXI_API_URL`, `GITEA_AXI_TOKEN`, `GITEA_AXI_REPO`.
`GITEA_AXI_REPO` and `GITEA_AXI_LOGIN` are not test-mode-specific — they are general context overrides (priority: flag > env > git remote / hostname match, mirroring gh-axi's `GH_REPO`); test mode merely relies on them.
_Avoid_: mock mode, stub mode
### Distribution
**Agent Skill**: The markdown file bundled inside the npm package and installed to `~/.claude/skills/` by the `setup` command.
_Avoid_: skill file, Claude skill
**setup**: The explicit subcommand that installs the Agent Skill into `~/.claude/skills/`; gitea-axi's primary fulfillment of AXI Principle 7 (Ambient context).
Idempotent: re-running reports already-installed/updated rather than failing.
There is no postinstall script — installation of the skill is always an explicit user action.
`setup hooks` additionally opts into the [[SessionStart hook]].
_Avoid_: postinstall, installer script
**SessionStart hook**: An opt-in ambient-context mechanism installed by `setup hooks` via `axi-sdk-js`'s `installSessionStartHooks()` (Claude Code `settings.json`, Codex `hooks.json`, OpenCode plugin).
It runs the bare `gitea-axi` binary (the short [[dashboard]] tier) in the session's working directory at session start and injects the output into the agent's context.
The SDK's installer registers the binary with no arguments, so the hook always runs the short tier; outside a Gitea repo it produces the dashboard's `REPO_NOT_FOUND` error, an accepted noise trade-off.
_Avoid_: session hook, ambient hook, postinstall hook

View File

@@ -0,0 +1,10 @@
# Authenticate diff HTTP GET via `tea login list --output json`
`pr get --diff` fetches diff content with a direct HTTP GET, bypassing the tea subprocess.
To get the auth token for that request, gitea-axi calls `tea login list --output json`, matches the login entry whose URL matches the current repo's hostname, and uses its token.
## Considered Options
**Read `~/.config/tea/config.yml` directly** — one fewer subprocess call, but couples gitea-axi to tea's internal storage format rather than its stable JSON output interface.
**Shell out to `tea login list --output json`** (chosen) — consistent with the rest of the architecture, which goes through tea's JSON interface for everything; decoupled from tea's file format internals.

View File

@@ -0,0 +1,19 @@
# Use direct Gitea API (`gitea-js`) instead of wrapping the `tea` subprocess
gitea-axi calls the Gitea REST API directly via `gitea-js` rather than shelling out to the `tea` binary.
Tea was the original plan because it provides auth, multi-instance login, and full command coverage out of the box, but hands-on evaluation found too many gaps that made subprocess wrapping a patchwork rather than a clean pipeline.
## Considered Options
**Wrap `tea` with `--output json`** (rejected) — Tea's create commands (`issues create`, `pulls create`) have no `--output json` flag, requiring text parsing plus a follow-up get call. `pulls list` has no head-branch filter, forcing a full list scan for PR idempotency checks. Tea's JSON exposes no review counts or response totals. Diff content requires a direct HTTP GET regardless. Open issues for some of these gaps (#403 for non-interactive comments) have been stale for 3+ years, making upstream fixes an unreliable dependency.
**Contribute missing features to `tea` upstream, then wrap** — Viable long-term but blocks gitea-axi's timeline on upstream PR acceptance velocity, which is low.
**Direct Gitea API via `gitea-js`** (chosen) — Typed responses, `X-Total-Count` headers for true pagination totals, head-branch filtering on PR list, review counts, and immediate JSON from create operations. All gaps from the tea approach disappear. Auth still comes from tea's login store via `tea login list --output json`, so the operator's existing `tea` configuration is reused without gitea-axi owning a credential store.
## Consequences
Tea remains a runtime dependency for credential discovery only (`tea login list --output json`).
Operators must have `tea` installed and at least one login configured.
The `TEA_NOT_INSTALLED` error code covers the case where tea is absent.
Tea improvements (especially `--output json` on create commands) should still be contributed upstream as goodwill PRs, decoupled from gitea-axi's development.

View File

@@ -0,0 +1,19 @@
# Use inline truncation hints and `--full`, not a temp-file path
Body and diff text that exceeds the truncation limit is shortened inline with a hint appended to the field value — `"... (truncated, N chars total - use --full to see complete body)"`.
The `--full` flag on `issue view` and `pr view` suppresses truncation and returns the full raw value.
No temp file is written.
## Considered Options
**Save to a temp file, emit path in `full_content` field** (rejected) — The spec draft described this approach and attributed it to gh-axi, but that description was incorrect.
Temp-file paths create a coupling to local filesystem state that does not survive across sessions or machines, and agents cannot rely on file paths persisting between calls.
**Inline hint + `--full` flag** (chosen) — This is what gh-axi actually implements (`src/body.ts``truncateBody()`), and what the AXI principle 3 specifies: *"appending a size hint like '(truncated, 2847 chars total — use --full to see complete body)'"*.
Simpler, portable, consistent with the stated reference.
## Consequences
`issue view` and `pr view` both accept `--full` to return untruncated body.
`pr diff` truncates at 4000 chars (matching gh-axi's `DIFF_TRUNCATE_LIMIT`); when truncated, a next-step suggestion to rerun with `--full` is prepended.
The `full_content` field name and temp-file design from the spec draft are dropped entirely.

View File

@@ -0,0 +1,24 @@
# Use `axi-sdk-js` as the CLI framework
gitea-axi is built on the `axi-sdk-js` npm package, matching gh-axi's architecture.
## What this provides
- `runAxiCli` — CLI runner handling `--help`, `--version`, home dispatch, error catch → stdout, and `process.exitCode`
- `AxiError` / `exitCodeForError` — typed error class and exit code mapping
- `renderOutput` / `renderError` / `homeHeaderOutput` — TOON output helpers
- Home-view `bin:` + `description:` header (AXI Principle 8)
## Considered Options
**Reimplement locally** — Duplicates the CLI runner, error routing, and output helpers without benefit.
Likely to drift from the AXI standard over time.
**Use `axi-sdk-js`** (chosen) — Correct error routing (stdout, not stderr), consistent exit codes, and `--help`/`--version` handled for free.
Keeps gitea-axi aligned with the evolving AXI ecosystem without owning that surface.
## Consequences
Error output goes to stdout (not stderr), matching gh-axi and axi-sdk-js behaviour.
The home/dashboard view automatically gets a `bin:` + `description:` header (AXI Principle 8).
`VALIDATION_ERROR` exits with code 2; all other errors exit with code 1.

View File

@@ -0,0 +1,24 @@
# Client-side filtering when the Gitea API lacks a filter parameter
When a `--filter` flag has no corresponding query parameter in Gitea's API (e.g. `pr list --assignee`), gitea-axi paginates through all results and filters in-process.
## Considered Options
**Drop the flag** (rejected) — Maintaining interface parity with gh-axi is an explicit goal.
Dropping flags that gh-axi supports degrades usability and breaks agent prompts written for the gh-axi interface.
**Emit NOT_SUPPORTED error** (rejected) — Surfacing a capability gap as a runtime error is unhelpful; the agent asked for a filtered list and received nothing.
**Client-side filtering** (chosen) — Paginate all results (`limit=50` per page until exhausted), filter in-process, return the matching set.
This produces correct results at the cost of extra HTTP calls.
The spec explicitly calls out that API call cost on the gitea-axi side does not factor into design decisions.
## Consequences
- All unsupported filter flags still appear in the CLI surface with identical semantics to gh-axi.
- The count line emits `count: N of T total` with `T` computed from the in-memory filtered set when any client-side filter is active; the `X-Total-Count` header (which reflects the unfiltered total) is ignored as misleading.
Since client-side filtering paginates everything anyway, the true filtered total is always known — reporting it satisfies canonical Principle 4 ("always report total item count").
(Amended 2026-07-10: this originally specified a bare `count: N`, which under-reported a total the tool had already computed.)
- Client-side *sort* (`issue list --sort`) is not a filter: it reorders without changing membership, so the unfiltered total remains accurate and `T` comes from the `X-Total-Count` header as usual.
Sorting still requires full pagination before ordering, like filtering.
- Full pagination is bounded by the instance's total issue/PR count, which is acceptable for single-repo agent workflows.

View File

@@ -0,0 +1,25 @@
# Compute reviewDecision client-side via parallel review fetches on pr list
Gitea has no aggregated `reviewDecision` field (neither REST nor GraphQL).
gitea-axi computes it client-side from the reviews list and includes it as a default field on `pr list` and `pr view`, matching gh-axi's interface.
## Decision
For `pr list`, fetch the reviews list for each PR in parallel (one HTTP call per PR) alongside the main list call.
Derive `reviewDecision` using: `APPROVED` if at least one review has `official=true`, `stale=false`, `dismissed=false` and no non-dismissed `REQUEST_CHANGES` exists; `CHANGES_REQUESTED` if any such `REQUEST_CHANGES` exists; `REVIEW_REQUIRED` otherwise.
## Considered Options
**Omit reviewDecision from default fields** (rejected) — The field is in gh-axi's default schema for `pr list`.
Dropping it breaks interface parity and forces agents to issue explicit follow-up calls.
**Include as opt-in `--fields` only** (rejected) — Same problem: agents trained on gh-axi expect it by default.
**Parallel fetch per PR** (chosen) — One extra HTTP call per PR in the list, all issued in parallel.
Accepted explicitly: API call cost does not factor into design decisions for this project.
## Consequences
- `pr list` with N results makes N+1 HTTP calls (list + N review fetches).
- `official` and `stale` fields are exposed on `pr view --reviews` as Gitea-specific bonus data.
- The `reviewDecision` field appears in the default schema for both `pr list` and `pr view`.

View File

@@ -0,0 +1,29 @@
# fetch-then-patch for additive/subtractive assignee and reviewer mutations
Gitea's PATCH endpoints for issues and PRs replace the entire assignee/reviewer list rather than adding or removing individual entries.
Flags like `issue edit --add-assignee` and `pr edit --add-reviewer` imply additive semantics: "add X to the current list, leave the rest alone."
## Decision
Implement additive and subtractive assignee/reviewer mutations as a fetch-then-patch:
1. Fetch the current entity (`GET .../issues/{index}` or `GET .../pulls/{index}`).
2. Compute the new list by applying the additions and removals to the current list.
3. Send a single PATCH with the resulting full list.
## Considered Options
**Single PATCH with only the new entries** (rejected) — Overwrites the existing list, dropping all current assignees/reviewers not mentioned in the command.
Correct for a "replace all" semantic but wrong for `--add` / `--remove` flags.
**Dedicated add/remove endpoints** (not available) — Gitea has additive label endpoints (`POST .../issues/{index}/labels`) but no equivalent for assignees or reviewers.
**fetch-then-patch** (chosen) — One extra GET per mutation.
Produces correct additive/subtractive semantics.
Accepted cost: same policy as client-side filtering — extra HTTP calls do not factor into design decisions.
## Consequences
- Every `--add-assignee`, `--remove-assignee`, `--add-reviewer`, `--remove-reviewer` call issues one extra GET.
- The operation is not atomic: a concurrent edit between the GET and the PATCH could cause a lost update.
Accepted as a known limitation for single-agent workflows.
- `issue label --add` / `--remove` does NOT use fetch-then-patch — Gitea has dedicated additive label endpoints that are already idempotent.

View File

@@ -0,0 +1,20 @@
# Normalize both issue comment and pr comment to the `comment` block name
gh-axi uses `comment` for `issue comment` output and `commented` for `pr comment` output.
gitea-axi normalizes both to `comment`, with the same schema: `{ number, author, created, body }`.
## Considered Options
**Match gh-axi exactly** (rejected) — `comment` for issue comment, `commented` for PR comment.
The inconsistency in gh-axi is an artifact of delegating to different `gh` subprocesses that return different data shapes, not an intentional design.
There is no semantic reason for the names to differ.
**`comment` for both** (chosen) — A single consistent block name for any "post a comment" operation.
gitea-axi gets the created `Comment` object directly from the Gitea API POST response for both issue and PR comments, so both can return the same schema without extra calls.
## Consequences
- `issue comment` and `pr comment` both emit `comment: { number, author, created, body }` (body truncated at 800 chars).
- This is a deliberate interface divergence from gh-axi.
- `number` is used instead of gh-axi's `issue` alias, since the field applies to both issue and PR numbers.
- Agents get the posted comment's data immediately (AXI Principle 4 — eliminate round trips); no follow-up `view --comments` call needed to confirm what was posted.

View File

@@ -0,0 +1,30 @@
# Install the Agent Skill via an explicit `setup` command, not npm postinstall
gitea-axi fulfills AXI Principle 7 (Ambient context) through a `setup` subcommand that copies the bundled Agent Skill markdown into `~/.claude/skills/`.
There is no postinstall script.
## Considered Options
**npm postinstall script** (rejected) — The original spec draft had `postinstall` drop the skill file automatically.
pnpm blocks lifecycle scripts by default and npm users increasingly install with `--ignore-scripts`, so the skill would silently fail to install for those users with no signal.
A package install silently writing into `~/.claude/` is also the exact pattern security tooling flags.
Finally, the canonical principle text asks for installation "from an explicit setup command" — postinstall is implicit.
**`setup` command** (chosen) — Matches gh-axi's command surface (its `cli.ts` registers `setup`), matches the canonical principle wording, works under pnpm and `--ignore-scripts`, and makes the `~/.claude/` write an explicit user action.
Discoverable via dashboard help suggestions.
**Both** (rejected) — Two install paths to test, and the postinstall path retains all its failure modes.
## Consequences
- `npm install -g gitea-axi` delivers the CLI only; the skill requires a one-time `gitea-axi setup`.
- `setup` is idempotent: re-running reports already-installed/updated rather than failing.
- The dashboard suggestion table hints at `setup` so agents and operators discover it.
## Addendum (2026-07-10): opt-in `setup hooks`
Canonical Principle 7 makes SessionStart hooks the primary ambient-context mechanism, and gh-axi ships `setup hooks` via axi-sdk-js's `installSessionStartHooks()` (Claude Code, Codex, OpenCode).
gitea-axi adds the same opt-in `setup hooks`; the skill remains the default `setup` action.
Hooks are not the default because the hook runs the dashboard in every session in every directory, and outside a Gitea repo the dashboard errors with `REPO_NOT_FOUND` — a graceful exit-0 degradation was considered and rejected in favor of keeping the error explicit, so hook noise in non-Gitea sessions is an accepted consequence for users who opt in.
The SDK registers the bare binary as the hook command, so the hook always runs the short dashboard tier (see ADR 0012).

View File

@@ -0,0 +1,23 @@
# Hard deletes refuse missing targets instead of reporting idempotent success
`issue delete` on a nonexistent issue errors with `ISSUE_NOT_FOUND`; `label delete` on a nonexistent label errors with `VALIDATION_ERROR`.
This deliberately narrows a literal reading of AXI Principle 6 ("mutations should be idempotent").
## Considered Options
**Idempotent success ("already deleted")** (rejected) — Consistent with the literal principle text and with the silent-success behavior of `--remove-label` and `blocks remove`.
But a missing hard-delete target usually means the agent's world-model is wrong (wrong number, wrong repo), and reporting success would confirm a false belief — the agent walks away thinking it deleted something it never identified correctly.
**Refuse with a specific error** (chosen) — Matches gh-axi's behavior for both commands.
The destructive command is exactly the one that should refuse to guess.
## The dividing line
Relationship removals (`--remove-label`, `blocks remove`, `blocked-by remove`) stay silent-success: the *entity* was correctly identified and fetched; only the relationship is absent, so the desired end state already holds.
Hard deletes error: the *target itself* is missing, which signals a stale or wrong reference rather than an already-achieved goal.
## Consequences
- `issue delete <n>` on a missing issue → `ISSUE_NOT_FOUND` (falls out of the path-based 404 classification automatically).
- `label delete <name>` on a missing label → `VALIDATION_ERROR`, consistent with every other label-name lookup.
- Principle 6's idempotency guarantee is scoped in the spec: state transitions and relationship add/removes are idempotent; hard deletes are not.

View File

@@ -0,0 +1,19 @@
# pr checkout fetches refs/pull/{index}/head, not the head branch name
`pr checkout <n>` runs `git fetch origin pull/<n>/head:<branch>` (branch named from the PR's `head.ref`) followed by `git checkout <branch>`.
## Considered Options
**`git fetch origin <head-branch>`** (rejected — original spec draft) — Fails structurally for fork PRs: the head branch lives in the contributor's fork, which is not a configured remote in the operator's clone.
This is not an edge case; fork PRs are the default contribution model.
**Add the fork as a remote dynamically** (rejected) — Mutates the user's git configuration, requires cleanup, and needs credentials for the fork's clone URL.
**Fetch `refs/pull/{index}/head` from the base repo** (chosen) — Gitea, like GitHub, exposes every PR's head commit on the *base* repository under `refs/pull/{index}/head`, whether the head branch lives in the same repo or a fork.
One uniform code path, no remote mutation, no fork credentials.
## Consequences
- Same-repo and fork PRs check out identically.
- Git subprocess failures (dirty worktree, network) classify as `GIT_ERROR`, carrying git's first stderr line.
- The created local branch does not track the contributor's fork; pushing back to a fork branch is out of scope.

View File

@@ -0,0 +1,23 @@
# Two-tier dashboard: gh-axi-sized default, `--full` for the rich view
The no-args home view has two tiers.
The short tier (bare `gitea-axi`, and what the SessionStart hook runs) matches gh-axi's home shape: up to 3 open issues and up to 3 open PRs.
The full tier (`gitea-axi --full`) is the rich view: a 20-row open-PR table with labels and review decision, plus open issue counts grouped by label (up to 1000 issues aggregated).
The short tier's help block always hints at `--full`.
## Considered Options
**Rich dashboard always** (rejected) — The original spec shape.
It is the heaviest command in the tool: one review fetch per listed PR (up to 20) plus up to 20 pages of issue aggregation.
With the opt-in SessionStart hook (see ADR 0009 addendum) it would run at every session start inside the SDK's 10-second hook timeout, and canonical Principle 7 asks for a "compact" dashboard.
**Short dashboard only** (rejected) — Drops the label-aggregation view entirely, losing the at-a-glance issue-state summary that motivated the rich dashboard.
**Two tiers** (chosen) — Cheap, hook-safe default with the rich view one flag away and discoverable via the default output's help block.
## Consequences
- The SessionStart hook always runs the short tier, because the SDK registers the bare binary with no arguments.
- `--full` is intentionally overloaded: on view/diff commands it suppresses truncation; on the dashboard it selects the full tier.
- The short tier costs at most 5 HTTP calls (issues, PRs, up to 3 review fetches), comfortably inside the hook timeout.
- Dashboard empty states are `issues: 0 open` / `prs: 0 open` in both tiers, matching gh-axi's home view.

View File

@@ -0,0 +1,18 @@
# Shadow the axi-sdk-js `update` built-in
axi-sdk-js reserves `update` as a built-in self-update command (`RESERVED_COMMANDS`): it queries npmjs.org for the latest published version of the tool and updates the install, throwing its own `UPDATE_ERROR` code on failure.
gitea-axi shadows it with a handler that rejects the command.
## Considered Options
**Keep the built-in** (rejected) — Free functionality and consistent with other axi-sdk-js tools, but it silently adds an unspecced command to the surface and an eleventh error code (`UPDATE_ERROR`) to the documented ten.
Self-updating from inside unattended agent sessions is also a write to the operator's toolchain that should stay an explicit human action.
**Shadow it** (chosen) — `gitea-axi update` fails with `VALIDATION_ERROR` and a help line: `` Run `npm install -g gitea-axi@latest` to update ``.
The failure is instructive rather than an opaque unknown-command error.
## Consequences
- The command surface and the ten-code `AxiError` list stay exactly as specified.
- Updating gitea-axi is always an explicit npm action.
- If the SDK's reserved-command list grows, each new built-in needs the same adopt-or-shadow decision.

1789
.claude/gh-axi-interface.md Normal file

File diff suppressed because it is too large Load Diff

1337
.claude/gh-axi.md Normal file

File diff suppressed because it is too large Load Diff

983
.claude/gitea-api.md Normal file
View File

@@ -0,0 +1,983 @@
# Gitea REST API Reference
Source: gitea-js v1.23.0 TypeScript declarations (generated from Gitea OpenAPI spec).
Base URL: `https://<host>/api/v1`
---
## Auth
**Header:** `Authorization: token <TOKEN>` — Gitea requires the word `token`, **not** `Bearer`.
**Token format:** SHA1 hash string (40 hex chars), e.g. `9fcb1158165773dd010fca5f0cf7174316c3e37d`.
Returned once on creation via `POST /users/{username}/tokens`; not stored in plain text.
**Token scopes** (Gitea 1.19+): fine-grained scopes like `read:issue`, `write:repository`, etc.
Older tokens have no scopes and are effectively admin-level for the user.
---
## Pagination
All list endpoints use `page` (1-based, default 1) and `limit` (page size).
**Not** `per_page` — GitHub uses `per_page`, Gitea uses `limit`.
**Response header:** `x-total-count` (lowercase) — total item count across all pages.
Also returns a `Link` header with `rel="next"` / `rel="last"` URLs.
---
## Issues
### List Issues
```
GET /repos/{owner}/{repo}/issues
```
| Param | Type | Notes |
|---|---|---|
| `state` | `open\|closed\|all` | default `open` |
| `type` | `issues\|pulls` | filter by type |
| `labels` | string | comma-separated label names |
| `milestones` | string | comma-separated milestone names or IDs |
| `since` | date-time | RFC 3339; updated after |
| `before` | date-time | RFC 3339; updated before |
| `created_by` | string | filter by creator username |
| `assigned_by` | string | filter by assignee username |
| `mentioned_by` | string | filter by mentioned username |
| `page` | int | 1-based |
| `limit` | int | page size |
Returns: `Issue[]`
### Search Issues (cross-repo)
```
GET /repos/issues/search
```
| Param | Type | Notes |
|---|---|---|
| `state` | `open\|closed\|all` | default `open` |
| `type` | `issues\|pulls` | |
| `labels` | string | comma-separated |
| `milestones` | string | comma-separated |
| `q` | string | search string |
| `priority_repo_id` | int64 | repo ID to rank higher |
| `since` / `before` | date-time | |
| `assigned` | bool | assigned to authed user |
| `created` | bool | created by authed user |
| `mentioned` | bool | mentioning authed user |
| `review_requested` | bool | review requested from authed user |
| `reviewed` | bool | reviewed by authed user |
| `owner` | string | filter by repo owner |
| `team` | string | requires `owner` |
| `page` / `limit` | int | |
Returns: `Issue[]`
### Get Issue
```
GET /repos/{owner}/{repo}/issues/{index}
```
Returns: `Issue`
### Create Issue
```
POST /repos/{owner}/{repo}/issues
```
Body: `CreateIssueOption`
| Field | Type | Required | Notes |
|---|---|---|---|
| `title` | string | yes | |
| `body` | string | no | |
| `assignees` | string[] | no | usernames |
| `assignee` | string | no | deprecated, use `assignees` |
| `milestone` | int64 | no | milestone ID |
| `labels` | number[] | no | label IDs |
| `due_date` | date-time | no | only date part used |
| `closed` | bool | no | create already-closed |
| `ref` | string | no | branch/commit ref |
Returns: `Issue` (HTTP 201)
### Edit Issue (close/reopen/update)
```
PATCH /repos/{owner}/{repo}/issues/{index}
```
Body: `EditIssueOption`
| Field | Type | Notes |
|---|---|---|
| `title` | string | |
| `body` | string | |
| `state` | string | `"open"` or `"closed"` — this is how you close/reopen |
| `assignees` | string[] | replaces all assignees |
| `assignee` | string | deprecated |
| `milestone` | int64 | milestone ID (0 to clear) |
| `due_date` | date-time | |
| `unset_due_date` | bool | set true to clear deadline |
| `ref` | string | |
Returns: `Issue`
### Delete Issue
```
DELETE /repos/{owner}/{repo}/issues/{index}
```
Returns: HTTP 204 (requires admin/owner)
### Pin / Unpin Issue
```
POST /repos/{owner}/{repo}/issues/{index}/pin
DELETE /repos/{owner}/{repo}/issues/{index}/pin
```
No body. Returns: HTTP 204
### Move Pin Position
```
PATCH /repos/{owner}/{repo}/issues/{index}/pin/{position}
```
`position` is a 1-based integer. Returns: HTTP 204
### List Pinned Issues
```
GET /repos/{owner}/{repo}/issues/pinned
```
Returns: `Issue[]`
### Check New Pin Allowed
```
GET /repos/{owner}/{repo}/new_pin_allowed
```
Returns: `NewIssuePinsAllowed { issues: bool, pull_requests: bool }`
---
## Issue Object Schema
```typescript
interface Issue {
id?: number; // global DB ID (not the display number)
number?: number; // repo-scoped issue number (use this in URLs)
title?: string;
body?: string;
state?: StateType; // "open" | "closed"
user?: User; // creator
assignee?: User;
assignees?: User[];
labels?: Label[];
milestone?: Milestone;
comments?: number; // comment count
created_at?: string; // ISO 8601
updated_at?: string;
closed_at?: string;
due_date?: string;
pull_request?: PullRequestMeta; // non-null if this issue is a PR
is_locked?: boolean;
pin_order?: number; // 0 if not pinned; position otherwise
ref?: string;
repository?: RepositoryMeta;
original_author?: string; // for migrated issues
original_author_id?: number;
html_url?: string;
url?: string;
assets?: Attachment[];
}
```
**Gitea-specific vs GitHub:**
- `number` is the repo-scoped index; `id` is the global DB ID. GitHub calls the display number `number` too, but Gitea has both.
- `pin_order` — no GitHub equivalent.
- `original_author` / `original_author_id` — for migrated content, no GitHub equivalent.
- `is_locked` is present but there's no dedicated lock/unlock endpoint in the public API.
- `due_date` — Gitea has native deadline support; GitHub does not.
- `StateType` is typed as `string` in TypeScript; values are `"open"` and `"closed"`.
---
## Issue Dependencies (Blocking)
Gitea models two directions: A **blocks** B (A must be resolved before B), and A **depends on** B.
From any issue's perspective: `/blocks` = issues that this issue blocks, `/dependencies` = issues that block this issue.
### List issues blocked BY this issue (this issue blocks them)
```
GET /repos/{owner}/{repo}/issues/{index}/blocks
```
Query: `page`, `limit`
Returns: `Issue[]` — the downstream issues that can't proceed until `{index}` is resolved.
### Add a blocking relationship (make this issue block another)
```
POST /repos/{owner}/{repo}/issues/{index}/blocks
```
Body: `IssueMeta { owner: string, repo: string, index: number }`
Returns: `Issue` (the issue that is now blocked)
**Note:** `{index}` in the URL path is typed as `string` in gitea-js (accepts number as string).
Body `IssueMeta.index` is `number`.
### Remove a blocking relationship
```
DELETE /repos/{owner}/{repo}/issues/{index}/blocks
```
Body: `IssueMeta { owner: string, repo: string, index: number }`
Returns: `Issue`
### List dependencies of this issue (issues that block this one)
```
GET /repos/{owner}/{repo}/issues/{index}/dependencies
```
Query: `page`, `limit`
Returns: `Issue[]` — issues that must be resolved before `{index}` can proceed.
### Add a dependency (make this issue depend on another)
```
POST /repos/{owner}/{repo}/issues/{index}/dependencies
```
Body: `IssueMeta { owner: string, repo: string, index: number }`
Returns: `Issue`
### Remove a dependency
```
DELETE /repos/{owner}/{repo}/issues/{index}/dependencies
```
Body: `IssueMeta { owner: string, repo: string, index: number }`
Returns: `Issue`
**Terminology clarification:**
- `GET /issues/{index}/blocks` → "issues blocked by {index}" = downstream dependents
- `GET /issues/{index}/dependencies` → "issues blocking {index}" = upstream blockers
- GitHub has no equivalent API; this is Gitea-only.
---
## Issue Comments
### List comments on an issue
```
GET /repos/{owner}/{repo}/issues/{index}/comments
```
| Param | Notes |
|---|---|
| `since` | date-time, RFC 3339 |
| `before` | date-time, RFC 3339 |
Returns: `Comment[]`
Note: no `page`/`limit` on this specific endpoint (lists all comments).
### List all comments in a repo
```
GET /repos/{owner}/{repo}/issues/comments
```
Query: `since`, `before`, `page`, `limit`
Returns: `Comment[]`
### Get single comment
```
GET /repos/{owner}/{repo}/issues/comments/{id}
```
Note: comment ID is the global DB ID, not a per-issue sequence.
Returns: `Comment`
### Create comment
```
POST /repos/{owner}/{repo}/issues/{index}/comments
```
Body: `{ body: string }` (required)
Returns: `Comment`
### Edit comment
```
PATCH /repos/{owner}/{repo}/issues/comments/{id}
```
Body: `{ body: string }` (required)
Returns: `Comment`
Deprecated variant: `PATCH /repos/{owner}/{repo}/issues/{index}/comments/{id}`
### Delete comment
```
DELETE /repos/{owner}/{repo}/issues/comments/{id}
```
Deprecated variant: `DELETE /repos/{owner}/{repo}/issues/{index}/comments/{id}`
### Comment schema
```typescript
interface Comment {
id?: number; // global DB ID
body?: string;
user?: User;
created_at?: string;
updated_at?: string;
html_url?: string;
issue_url?: string;
pull_request_url?: string;
original_author?: string;
original_author_id?: number;
assets?: Attachment[];
}
```
---
## Issue Timeline (comments + events)
```
GET /repos/{owner}/{repo}/issues/{index}/timeline
```
Query: `since`, `before`, `page`, `limit`
Returns: `TimelineComment[]` — includes all events (label changes, state changes, etc.) not just text comments.
---
## Labels
### Repo label CRUD
```
GET /repos/{owner}/{repo}/labels → Label[] (page, limit)
POST /repos/{owner}/{repo}/labels → Label (CreateLabelOption)
GET /repos/{owner}/{repo}/labels/{id} → Label
PATCH /repos/{owner}/{repo}/labels/{id} → Label (EditLabelOption)
DELETE /repos/{owner}/{repo}/labels/{id} → 204
```
### Label schema
```typescript
interface Label {
id?: number;
name?: string;
color?: string; // hex without #, e.g. "00aabb"
description?: string;
exclusive?: boolean; // Gitea-only: exclusive label (scoped labels)
is_archived?: boolean; // Gitea-only: archived/hidden label
url?: string;
}
```
**Gitea-specific:** `exclusive` labels are scoped — only one label with `exclusive=true` in a group can be applied at a time (like GitHub's scoped labels, but implemented differently). `is_archived` hides labels from UI while preserving existing uses.
### CreateLabelOption
```typescript
{ color: string, name: string, description?: string, exclusive?: boolean, is_archived?: boolean }
```
`color` must include the `#`, e.g. `"#00aabb"`.
### EditLabelOption
```typescript
{ color?: string, name?: string, description?: string, exclusive?: boolean, is_archived?: boolean }
```
### Labels on Issues/PRs
```
GET /repos/{owner}/{repo}/issues/{index}/labels → Label[]
POST /repos/{owner}/{repo}/issues/{index}/labels → Label[] (add labels)
PUT /repos/{owner}/{repo}/issues/{index}/labels → Label[] (replace all labels)
DELETE /repos/{owner}/{repo}/issues/{index}/labels → 204 (remove ALL labels)
DELETE /repos/{owner}/{repo}/issues/{index}/labels/{id} → 204 (remove one label)
```
Body for POST and PUT: `IssueLabelsOption`
```typescript
interface IssueLabelsOption {
labels?: (number | string)[]; // label IDs or label names (mixed array supported)
}
```
**Gitea-specific:** Labels can be specified by ID (int) or by name (string) in the same array.
GitHub only supports IDs.
---
## Milestones
```
GET /repos/{owner}/{repo}/milestones → Milestone[]
POST /repos/{owner}/{repo}/milestones → Milestone
GET /repos/{owner}/{repo}/milestones/{id} → Milestone
PATCH /repos/{owner}/{repo}/milestones/{id} → Milestone
DELETE /repos/{owner}/{repo}/milestones/{id} → 204
```
List query params: `state` (`open|closed|all`), `name` (filter by name), `page`, `limit`.
### Milestone schema
```typescript
interface Milestone {
id?: number;
title?: string;
description?: string;
state?: StateType; // "open" | "closed"
open_issues?: number;
closed_issues?: number;
created_at?: string;
updated_at?: string;
closed_at?: string;
due_on?: string; // Note: GitHub calls this "due_on" too
}
```
### CreateMilestoneOption
```typescript
{ title?: string, description?: string, due_on?: date-time, state?: 'open'|'closed' }
```
### EditMilestoneOption
```typescript
{ title?: string, description?: string, due_on?: date-time, state?: string }
```
---
## Pull Requests
### List PRs
```
GET /repos/{owner}/{repo}/pulls
```
| Param | Type | Notes |
|---|---|---|
| `state` | `open\|closed\|all` | default `open` |
| `sort` | string | `oldest\|recentupdate\|leastupdate\|mostcomment\|leastcomment\|priority` |
| `milestone` | int64 | milestone ID |
| `labels` | number[] | label IDs |
| `poster` | string | filter by PR author username |
| `page` | int | 1-based |
| `limit` | int | |
Returns: `PullRequest[]`
### Get PR
```
GET /repos/{owner}/{repo}/pulls/{index}
```
Returns: `PullRequest`
### Get PR by base and head
```
GET /repos/{owner}/{repo}/pulls/{base}/{head}
```
Returns: `PullRequest`
### Create PR
```
POST /repos/{owner}/{repo}/pulls
```
Body: `CreatePullRequestOption`
| Field | Type | Notes |
|---|---|---|
| `title` | string | |
| `body` | string | |
| `head` | string | source branch (or `fork:branch`) |
| `base` | string | target branch |
| `assignees` | string[] | |
| `assignee` | string | deprecated |
| `labels` | number[] | label IDs |
| `milestone` | int64 | |
| `reviewers` | string[] | usernames |
| `team_reviewers` | string[] | team slugs |
| `due_date` | date-time | |
| `allow_maintainer_edit` | bool | (not in gitea-js CreatePullRequestOption but supported in API) |
**Draft PR:** The gitea-js `CreatePullRequestOption` does not include a `draft` field.
The underlying Go struct (`CreatePullRequestOption`) also has no `draft` field as of v1.23.
**There is no API to create a draft PR or convert draft to ready.** This is a known Gitea limitation.
Returns: `PullRequest`
### Edit PR
```
PATCH /repos/{owner}/{repo}/pulls/{index}
```
Body: `EditPullRequestOption`
| Field | Type | Notes |
|---|---|---|
| `title` | string | |
| `body` | string | |
| `state` | string | `"open"` or `"closed"` to close/reopen |
| `base` | string | change target branch |
| `assignees` | string[] | replaces all |
| `assignee` | string | deprecated |
| `labels` | number[] | replaces all |
| `milestone` | int64 | |
| `due_date` | date-time | |
| `unset_due_date` | bool | clear deadline |
| `allow_maintainer_edit` | bool | |
**No `draft` or `ready_for_review` field.** Draft status cannot be changed via API.
Returns: `PullRequest`
### Get PR diff / patch
```
GET /repos/{owner}/{repo}/pulls/{index}.{diffType}
```
`diffType`: `diff` or `patch`
Query: `binary` (bool) — include binary changes (makes patch applicable via `git apply`)
Returns: raw string
### Get PR commits
```
GET /repos/{owner}/{repo}/pulls/{index}/commits
```
Query: `page`, `limit`, `verification` (bool, default true), `files` (bool, default true)
Returns: `Commit[]`
### Get changed files
```
GET /repos/{owner}/{repo}/pulls/{index}/files
```
| Param | Notes |
|---|---|
| `skip-to` | filename to start from (cursor-style) |
| `whitespace` | `ignore-all\|ignore-change\|ignore-eol\|show-all` |
| `page`, `limit` | |
Returns: `ChangedFile[]`
### Check if merged
```
GET /repos/{owner}/{repo}/pulls/{index}/merge
```
Returns: HTTP 204 if merged, HTTP 404 if not.
### Merge PR
```
POST /repos/{owner}/{repo}/pulls/{index}/merge
```
Body: `MergePullRequestOption`
| Field | Type | Notes |
|---|---|---|
| `Do` | string | **required**: `merge\|rebase\|rebase-merge\|squash\|fast-forward-only\|manually-merged` |
| `MergeCommitID` | string | for `manually-merged` |
| `MergeMessageField` | string | commit message body |
| `MergeTitleField` | string | commit message title |
| `delete_branch_after_merge` | bool | |
| `force_merge` | bool | override merge checks |
| `head_commit_id` | string | guard against race condition |
| `merge_when_checks_succeed` | bool | schedule auto-merge |
**Gitea-specific:** `manually-merged` value marks an already-merged PR without actually merging.
`fast-forward-only` is supported (not in GitHub API).
### Cancel auto-merge
```
DELETE /repos/{owner}/{repo}/pulls/{index}/merge
```
### Update PR branch (merge base into head)
```
POST /repos/{owner}/{repo}/pulls/{index}/update
```
Query: `style``merge` or `rebase`
### PullRequest schema
```typescript
interface PullRequest {
id?: number; // global DB ID
number?: number; // repo-scoped PR number
title?: string;
body?: string;
state?: StateType; // "open" | "closed"
draft?: boolean; // Gitea-specific: draft PR flag (read-only via API)
user?: User;
assignee?: User;
assignees?: User[];
labels?: Label[];
milestone?: Milestone;
base?: PRBranchInfo; // target branch info
head?: PRBranchInfo; // source branch info
merge_base?: string; // SHA of common ancestor
merge_commit_sha?: string; // SHA of merge commit (null if not merged)
merged?: boolean;
merged_at?: string;
merged_by?: User;
mergeable?: boolean;
allow_maintainer_edit?: boolean;
requested_reviewers?: User[];
requested_reviewers_teams?: Team[];
comments?: number;
review_comments?: number; // diff-level review comments only
additions?: number;
deletions?: number;
changed_files?: number;
created_at?: string;
updated_at?: string;
closed_at?: string;
due_date?: string;
diff_url?: string;
patch_url?: string;
html_url?: string;
url?: string;
is_locked?: boolean;
pin_order?: number;
}
interface PRBranchInfo {
label?: string; // "owner:branch"
ref?: string; // branch name
sha?: string; // HEAD SHA
repo_id?: number;
repo?: Repository;
}
```
**Gitea-specific vs GitHub:**
- `draft` is present but **read-only** — cannot be set/changed via API.
- `pin_order` — no GitHub equivalent.
- `allow_maintainer_edit` — GitHub calls this `maintainer_can_modify`.
- GitHub has `review_decision` (computed field); Gitea does not — you must compute approval state from the reviews list.
- GitHub uses `head.repo` and `base.repo` as nested objects; Gitea uses the same pattern via `PRBranchInfo.repo`.
- `due_date` — Gitea-only deadline field.
---
## PR Reviews
### List reviews
```
GET /repos/{owner}/{repo}/pulls/{index}/reviews
```
Query: `page`, `limit`
Returns: `PullReview[]`
### Create review (or start a pending review)
```
POST /repos/{owner}/{repo}/pulls/{index}/reviews
```
Body: `CreatePullReviewOptions`
| Field | Type | Notes |
|---|---|---|
| `event` | ReviewStateType | `APPROVED\|REQUEST_CHANGES\|COMMENT\|PENDING` |
| `body` | string | overall review comment |
| `commit_id` | string | commit to review at (defaults to head) |
| `comments` | CreatePullReviewComment[] | inline diff comments |
`CreatePullReviewComment`:
```typescript
{
path?: string; // file path
body?: string; // comment text
new_position?: number; // line in new file (0 = not a line comment)
old_position?: number; // line in old file (0 = not a line comment)
}
```
To create a **pending** review (accumulate comments before submitting), pass `event: "PENDING"` or omit `event`.
Returns: `PullReview`
### Get a review
```
GET /repos/{owner}/{repo}/pulls/{index}/reviews/{id}
```
Returns: `PullReview`
### Submit (publish) a pending review
```
POST /repos/{owner}/{repo}/pulls/{index}/reviews/{id}
```
Body: `SubmitPullReviewOptions`
```typescript
{ body?: string, event?: ReviewStateType }
```
`event` values: `APPROVED`, `REQUEST_CHANGES`, `COMMENT`
Returns: `PullReview`
### Delete a review
```
DELETE /repos/{owner}/{repo}/pulls/{index}/reviews/{id}
```
Returns: HTTP 204
### Get review inline comments
```
GET /repos/{owner}/{repo}/pulls/{index}/reviews/{id}/comments
```
Returns: `PullReviewComment[]`
### Dismiss a review
```
POST /repos/{owner}/{repo}/pulls/{index}/reviews/{id}/dismissals
```
Body: `DismissPullReviewOptions`
```typescript
{ message?: string, priors?: boolean }
```
`priors: true` dismisses all prior reviews from the same reviewer.
Returns: `PullReview`
### Un-dismiss a review
```
POST /repos/{owner}/{repo}/pulls/{index}/reviews/{id}/undismissals
```
Returns: `PullReview`
### Request / cancel review requests
```
POST /repos/{owner}/{repo}/pulls/{index}/requested_reviewers → PullReview[]
DELETE /repos/{owner}/{repo}/pulls/{index}/requested_reviewers → 204
```
Body: `PullReviewRequestOptions { reviewers?: string[], team_reviewers?: string[] }`
### PullReview schema
```typescript
interface PullReview {
id?: number;
user?: User;
team?: Team; // for team review requests
body?: string; // overall comment text
state?: ReviewStateType; // "APPROVED" | "REQUEST_CHANGES" | "COMMENT" | "PENDING"
commit_id?: string; // commit SHA the review is on
submitted_at?: string;
updated_at?: string;
stale?: boolean; // GITEA-SPECIFIC: true if head has moved since review
official?: boolean; // GITEA-SPECIFIC: counts toward required approvals
dismissed?: boolean;
comments_count?: number;
html_url?: string;
pull_request_url?: string;
}
```
**Gitea-specific vs GitHub:**
- `official` — whether this review counts toward branch protection required approvals. No GitHub equivalent.
- `stale` — whether the PR head has been updated since this review was submitted, making it outdated. GitHub shows this in UI but doesn't expose it as a field.
- **No `reviewDecision` equivalent** — Gitea has no computed "overall review decision" field on the PR object. You must fetch all reviews and compute:
- Count `APPROVED` reviews where `official=true` and `stale=false` and `dismissed=false`.
- Check if any `REQUEST_CHANGES` review is `official=true` and not dismissed.
- `ReviewStateType` string values: `"APPROVED"`, `"REQUEST_CHANGES"`, `"COMMENT"`, `"PENDING"`
### PullReviewComment schema
```typescript
interface PullReviewComment {
id?: number;
body?: string;
path?: string;
position?: number; // line in new file
original_position?: number; // line in old file
diff_hunk?: string; // context diff around the comment
commit_id?: string;
original_commit_id?: string;
pull_request_review_id?: number;
pull_request_url?: string;
user?: User;
resolver?: User; // GITEA-SPECIFIC: user who resolved the thread
created_at?: string;
updated_at?: string;
html_url?: string;
}
```
---
## Commit Statuses (CI checks)
Gitea uses commit statuses (like GitHub's commit status API), **not** GitHub Check Runs.
There is no Gitea equivalent to `GET /check-runs` or `GET /check-suites`.
### List statuses by ref (branch/tag/commit)
```
GET /repos/{owner}/{repo}/commits/{ref}/statuses
```
| Param | Notes |
|---|---|
| `sort` | `oldest\|recentupdate\|leastupdate\|leastindex\|highestindex` |
| `state` | `pending\|success\|error\|failure\|warning` |
| `page`, `limit` | |
Returns: `CommitStatus[]`
### Get combined status by ref
```
GET /repos/{owner}/{repo}/commits/{ref}/status
```
Returns: `CombinedStatus`
```typescript
interface CombinedStatus {
sha?: string;
state?: CommitStatusState; // overall: worst of all statuses
statuses?: CommitStatus[];
total_count?: number;
repository?: Repository;
commit_url?: string;
url?: string;
}
```
### List statuses by SHA
```
GET /repos/{owner}/{repo}/statuses/{sha}
```
Same query params as above. Returns: `CommitStatus[]`
### Create a commit status
```
POST /repos/{owner}/{repo}/statuses/{sha}
```
Body: `CreateStatusOption`
```typescript
{
context?: string; // e.g. "ci/test" — identifies the check
state?: CommitStatusState; // "pending" | "success" | "error" | "failure"
description?: string;
target_url?: string; // link to build/CI page
}
```
Returns: `CommitStatus`
### CommitStatus schema
```typescript
interface CommitStatus {
id?: number;
context?: string; // identifier, e.g. "ci/build"
status?: CommitStatusState; // "pending" | "success" | "error" | "failure"
description?: string;
target_url?: string;
creator?: User;
created_at?: string;
updated_at?: string;
url?: string;
}
```
**CommitStatusState values:** `"pending"`, `"success"`, `"error"`, `"failure"`, `"warning"`, `"skipped"`
Note: `"warning"` is valid in list/filter but the spec comment says "pending, success, error and failure" for CreateStatusOption — `warning` may not be creatable.
Note: `"skipped"` exists in current Gitea server code but is absent from older OpenAPI specs; older instances never emit it.
Gitea's own combine logic treats `warning` as a failing state and `skipped` as compatible with success.
**For PR CI status:** Use `GET /repos/{owner}/{repo}/commits/{ref}/status` where `ref` is the PR head SHA (`pr.head.sha`).
---
## Projects
Gitea has a projects feature (kanban boards) but **no REST API endpoints for projects** are exposed as of v1.23.
- `project_id` appears in `TimelineComment` (for "moved to project" events) but there's no CRUD API.
- Repository settings expose `has_projects` (bool) and `projects_mode` (`"repo"|"owner"|"all"`).
- Project management must be done through the web UI.
**GitHub comparison:** GitHub has a full Projects v2 GraphQL API and Projects REST API. Gitea has neither.
---
## Draft Pull Requests
Gitea supports draft PRs in the web UI but has **significant API limitations:**
1. **Cannot create a draft PR via API**`CreatePullRequestOption` has no `draft` field.
2. **Cannot convert draft to ready via API**`EditPullRequestOption` has no `draft` or `ready_for_review` field.
3. **Can read draft status**`PullRequest.draft` is a readable boolean field.
4. **Workaround:** Title prefix convention — some users prefix draft PR titles with `[WIP]` or `Draft:` and remove the prefix to signal readiness, but this is not enforced by the API.
The `op_type` enum in activity includes `pull_request_ready_for_review`, indicating the feature exists in the event log, but no API to trigger this transition is exposed.
---
## PR Comments (Review Comments vs Issue Comments)
Gitea separates:
1. **Issue-style PR comments** (general comments, not tied to diff lines):
- `GET/POST /repos/{owner}/{repo}/issues/{index}/comments`
- Same `Comment` schema as issue comments.
2. **Review comments** (diff-level, tied to a review):
- `GET /repos/{owner}/{repo}/pulls/{index}/reviews/{id}/comments`
- Created as part of `CreatePullReviewOptions.comments[]`.
- Schema: `PullReviewComment`.
There is no endpoint to create a standalone review comment outside of a review (unlike GitHub's `POST /pulls/{index}/comments`).
---
## Complete Endpoint Index (Relevant to gitea-axi)
### Issues
| Method | Path | Notes |
|---|---|---|
| GET | `/repos/{owner}/{repo}/issues` | list; `type=issues` for issues only |
| POST | `/repos/{owner}/{repo}/issues` | create |
| GET | `/repos/{owner}/{repo}/issues/{index}` | get |
| PATCH | `/repos/{owner}/{repo}/issues/{index}` | edit; use `state` to close/reopen |
| DELETE | `/repos/{owner}/{repo}/issues/{index}` | delete (admin) |
| POST | `/repos/{owner}/{repo}/issues/{index}/pin` | pin |
| DELETE | `/repos/{owner}/{repo}/issues/{index}/pin` | unpin |
| PATCH | `/repos/{owner}/{repo}/issues/{index}/pin/{position}` | move pin |
| GET | `/repos/{owner}/{repo}/issues/{index}/blocks` | list blocked issues |
| POST | `/repos/{owner}/{repo}/issues/{index}/blocks` | add blocking |
| DELETE | `/repos/{owner}/{repo}/issues/{index}/blocks` | remove blocking |
| GET | `/repos/{owner}/{repo}/issues/{index}/dependencies` | list blockers |
| POST | `/repos/{owner}/{repo}/issues/{index}/dependencies` | add dependency |
| DELETE | `/repos/{owner}/{repo}/issues/{index}/dependencies` | remove dependency |
| GET | `/repos/{owner}/{repo}/issues/{index}/comments` | list comments |
| POST | `/repos/{owner}/{repo}/issues/{index}/comments` | add comment |
| GET | `/repos/{owner}/{repo}/issues/comments/{id}` | get comment |
| PATCH | `/repos/{owner}/{repo}/issues/comments/{id}` | edit comment |
| DELETE | `/repos/{owner}/{repo}/issues/comments/{id}` | delete comment |
| GET | `/repos/{owner}/{repo}/issues/{index}/timeline` | comments + events |
| GET | `/repos/{owner}/{repo}/issues/{index}/labels` | get labels |
| POST | `/repos/{owner}/{repo}/issues/{index}/labels` | add labels |
| PUT | `/repos/{owner}/{repo}/issues/{index}/labels` | replace labels |
| DELETE | `/repos/{owner}/{repo}/issues/{index}/labels` | clear all labels |
| DELETE | `/repos/{owner}/{repo}/issues/{index}/labels/{id}` | remove one label |
| GET | `/repos/issues/search` | cross-repo search |
| GET | `/repos/{owner}/{repo}/issues/pinned` | list pinned |
### Pull Requests
| Method | Path | Notes |
|---|---|---|
| GET | `/repos/{owner}/{repo}/pulls` | list |
| POST | `/repos/{owner}/{repo}/pulls` | create |
| GET | `/repos/{owner}/{repo}/pulls/{index}` | get |
| PATCH | `/repos/{owner}/{repo}/pulls/{index}` | edit |
| GET | `/repos/{owner}/{repo}/pulls/{index}.diff` | get diff |
| GET | `/repos/{owner}/{repo}/pulls/{index}.patch` | get patch |
| GET | `/repos/{owner}/{repo}/pulls/{index}/commits` | get commits |
| GET | `/repos/{owner}/{repo}/pulls/{index}/files` | get changed files |
| GET | `/repos/{owner}/{repo}/pulls/{index}/merge` | check if merged |
| POST | `/repos/{owner}/{repo}/pulls/{index}/merge` | merge |
| DELETE | `/repos/{owner}/{repo}/pulls/{index}/merge` | cancel auto-merge |
| POST | `/repos/{owner}/{repo}/pulls/{index}/update` | sync base into head |
| GET | `/repos/{owner}/{repo}/pulls/{base}/{head}` | get by branches |
| GET | `/repos/{owner}/{repo}/pulls/{index}/reviews` | list reviews |
| POST | `/repos/{owner}/{repo}/pulls/{index}/reviews` | create review |
| GET | `/repos/{owner}/{repo}/pulls/{index}/reviews/{id}` | get review |
| POST | `/repos/{owner}/{repo}/pulls/{index}/reviews/{id}` | submit pending review |
| DELETE | `/repos/{owner}/{repo}/pulls/{index}/reviews/{id}` | delete review |
| GET | `/repos/{owner}/{repo}/pulls/{index}/reviews/{id}/comments` | get review comments |
| POST | `/repos/{owner}/{repo}/pulls/{index}/reviews/{id}/dismissals` | dismiss review |
| POST | `/repos/{owner}/{repo}/pulls/{index}/reviews/{id}/undismissals` | undismiss review |
| POST | `/repos/{owner}/{repo}/pulls/{index}/requested_reviewers` | request reviewers |
| DELETE | `/repos/{owner}/{repo}/pulls/{index}/requested_reviewers` | cancel review request |
| GET | `/repos/{owner}/{repo}/pulls/pinned` | list pinned PRs |
### Labels
| Method | Path | Notes |
|---|---|---|
| GET | `/repos/{owner}/{repo}/labels` | list |
| POST | `/repos/{owner}/{repo}/labels` | create |
| GET | `/repos/{owner}/{repo}/labels/{id}` | get |
| PATCH | `/repos/{owner}/{repo}/labels/{id}` | update |
| DELETE | `/repos/{owner}/{repo}/labels/{id}` | delete |
### Milestones
| Method | Path | Notes |
|---|---|---|
| GET | `/repos/{owner}/{repo}/milestones` | list (`state`, `name`, `page`, `limit`) |
| POST | `/repos/{owner}/{repo}/milestones` | create |
| GET | `/repos/{owner}/{repo}/milestones/{id}` | get |
| PATCH | `/repos/{owner}/{repo}/milestones/{id}` | update |
| DELETE | `/repos/{owner}/{repo}/milestones/{id}` | delete |
### Commit Statuses
| Method | Path | Notes |
|---|---|---|
| GET | `/repos/{owner}/{repo}/commits/{ref}/status` | combined status |
| GET | `/repos/{owner}/{repo}/commits/{ref}/statuses` | list by ref |
| GET | `/repos/{owner}/{repo}/statuses/{sha}` | list by SHA |
| POST | `/repos/{owner}/{repo}/statuses/{sha}` | create status |

242
.claude/gitea-vs-github.md Normal file
View File

@@ -0,0 +1,242 @@
# Gitea vs GitHub: API Feature Comparison for CLI Agent Tooling
Focus: features relevant to `gitea-axi` — issues, pull requests, labels, reviews, comments, auth, and API mechanics.
Sources: Gitea OpenAPI spec (docs.gitea.com/api/1.21), GitHub REST docs, Gitea source structs (`modules/structs/`).
---
## API Style
| Feature | GitHub | Gitea | Notes |
|---|---|---|---|
| REST API | Yes — `/api/v1` (actually `/repos/...` at api.github.com) | Yes — `/api/v1/repos/...` | Gitea mirrors GitHub's path structure closely |
| GraphQL API | Yes — `api.github.com/graphql` | **No** | Gitea is REST-only; no GraphQL endpoint exists |
| OpenAPI spec | Unofficial/community | Yes — `/api/swagger` on every instance | Gitea ships a first-class Swagger UI and JSON spec |
| TypeScript client | `@octokit/rest` (hand-written) | `gitea-js` (generated from OpenAPI) | `gitea-js` types are auto-generated and stay in sync with the spec |
| API versioning | `api-version` header (`2026-03-10`) | Path-prefixed (`/api/v1`) | GitHub uses a header; Gitea uses path versioning |
---
## Authentication
| Feature | GitHub | Gitea | Notes |
|---|---|---|---|
| Personal access tokens (classic) | Yes — broad scope-based | Yes — scope-based (`read:issue`, `write:repository`, etc.) | Gitea scopes map to API route groups |
| Fine-grained PATs | Yes — per-repo permissions, expiry required | **No** — scopes are coarser (category-level, not per-repo) | GitHub fine-grained tokens are more restrictive by default |
| Token creation via API | No (web only for fine-grained) | **Yes**`POST /user/tokens` | Gitea allows programmatic token creation with BasicAuth |
| Token listing/deletion via API | No | **Yes**`GET /user/tokens`, `DELETE /user/tokens/{id}` | Gitea exposes full token lifecycle over the API |
| OAuth App device flow | **Yes** | **No** — only Authorization Code (+ PKCE) | GitHub device flow is essential for headless CLI auth; Gitea lacks it |
| OAuth App web flow | Yes | Yes | Both support standard Authorization Code grant |
| OAuth App management via API | No | **Yes**`GET/POST/PATCH/DELETE /user/applications/oauth2` | Gitea lets users manage their own OAuth apps via API |
| GitHub Apps / installation tokens | Yes | **No** — no GitHub Apps concept | Gitea has no equivalent of GitHub Apps or installation tokens |
| `GITHUB_TOKEN` in Actions | Yes | No (`GITEA_TOKEN` in Gitea Actions is similar but not identical) | Gitea Actions has a built-in `GITEA_TOKEN` but it is instance-specific |
| HTTP Signatures (SSH key auth) | No | **Yes** | Gitea accepts signatures per draft-cavage-http-signatures |
| Basic auth | Deprecated/removed | Yes (for token creation only; not recommended for general use) | |
| Sudo (act-as) | No | **Yes**`?sudo=username` for admins | Admin-only; useful for automation |
| SAML SSO token authorization | Yes (org-enforced) | No | Gitea has no SAML SSO concept |
---
## Pagination
| Feature | GitHub | Gitea | Notes |
|---|---|---|---|
| Offset/page pagination | Yes — `?page=N&per_page=N` (max 100) | Yes — `?page=N&limit=N` | Same concept; parameter names differ (`per_page` vs `limit`) |
| Cursor-based pagination | Some endpoints (`before`/`after`) | **No** | Gitea is page-offset only |
| `Link` header (RFC 5988) | **Yes**`next`, `prev`, `last`, `first` | **Yes** — same format | Both provide `Link` headers for navigation |
| `X-Total-Count` response header | **No** | **Yes** | Gitea returns total count in every list response; GitHub does not |
| `x-total-count` in `gitea-js` | n/a | Available as a parsed header | The `gitea-js` client exposes this alongside the response body |
| Maximum per-page results | 100 | Configurable (instance default ~50, max configurable) | Gitea's max is admin-controlled; no hard 100 cap in the API |
---
## Rate Limiting
| Feature | GitHub | Gitea | Notes |
|---|---|---|---|
| Per-user rate limit | 5,000 req/hr (authenticated) | **No standard rate limiting** | Gitea uses QoS concurrency throttling, not per-user counters |
| `X-RateLimit-*` headers | Yes — `Limit`, `Remaining`, `Used`, `Reset` | **No** | Gitea does not emit rate-limit headers |
| Secondary limits | Yes (content creation, concurrency) | No | |
| QoS / overload protection | No | **Yes** — configurable `[qos]` section | Gitea drops or queues requests under load rather than rate-limiting |
---
## Issue Management
| Feature | GitHub | Gitea | Notes |
|---|---|---|---|
| Create issue | Yes — `POST /repos/{owner}/{repo}/issues` | Yes — same path pattern | |
| List issues | Yes | Yes | |
| Get single issue | Yes | Yes | |
| Edit issue | Yes | Yes | |
| Delete issue | **No** (close only) | **Yes**`DELETE /repos/{owner}/{repo}/issues/{index}` | Gitea allows hard deletion |
| Issue state | `open` / `closed` | `open` / `closed` | Same values |
| `state_reason` | **Yes**`completed`, `not_planned`, `duplicate`, `reopened` | **No** | Gitea has no close-reason concept |
| Issue type | **Yes** — first-class `type` field on create/edit | **No** | GitHub supports issue type classification; Gitea does not |
| `duplicate_issue_id` | **Yes** | **No** | |
| Issue dependencies (blocking) | No (sub-issue relationships only) | **Yes**`POST /repos/{owner}/{repo}/issues/{index}/dependencies` | Gitea has explicit blocking/blocked-by relationships between issues |
| Issue blocking list | No | **Yes**`GET /repos/{owner}/{repo}/issues/{index}/blocked-by` | |
| Pin issue | Partial (read-only in API response) | **Yes**`POST/DELETE /repos/{owner}/{repo}/issues/{index}/pin`, position reorder | Gitea has full pin CRUD including ordering |
| Issue timeline | Yes — 30+ event types (labels, assignments, reviews, etc.) | Yes — `GET /repos/{owner}/{repo}/issues/{index}/timeline` (comments + events) | GitHub's timeline is richer in event type variety |
| Reactions on issues | Yes — 8 types | Yes — same types | |
| Reactions on issue comments | Yes | Yes | |
| Lock/unlock conversation | Yes — with `lock_reason` (`off-topic`, `too heated`, `resolved`, `spam`) | **No** — issues have `IsLocked` field but no dedicated lock endpoint visible | |
| Subscriptions (watching) | Via notifications API | **Yes** — explicit `PUT/DELETE /repos/{owner}/{repo}/issues/{index}/subscriptions` | Gitea exposes subscribe/unsubscribe directly on issues |
| Issue search (cross-repo) | Yes — `GET /search/issues` with qualifiers | Yes — `GET /issues/search` | Both support cross-repo search |
| Custom field values | **Yes** (org repos) | **No** | GitHub-specific feature |
| Time tracking (estimate) | No | **Yes**`TimeEstimate` field on Issue struct | Gitea has built-in time tracking |
| Content versioning (conflict detect) | No | **Yes**`ContentVersion` field on create/edit | Gitea supports optimistic locking for concurrent edits |
| Original author tracking (migrations) | No | **Yes**`OriginalAuthor` / `OriginalAuthorID` fields | Preserved when importing from other platforms |
| Issue `Ref` field | No | **Yes** — git ref associated with issue | |
---
## Milestones
| Feature | GitHub | Gitea | Notes |
|---|---|---|---|
| Milestone CRUD | Yes — `GET/POST/PATCH/DELETE /repos/{owner}/{repo}/milestones` | Yes — same path pattern | |
| Milestone on issues | Yes | Yes | |
| Milestone on PRs | Yes | Yes — `milestone` field in `CreatePullRequestOption` and `EditPullRequestOption` | |
| Milestone properties | `title`, `description`, `state`, `due_on` | Same | |
| Org-level milestones | No | No | Neither supports org-level milestones |
---
## Label Management
| Feature | GitHub | Gitea | Notes |
|---|---|---|---|
| Repo label CRUD | Yes — `GET/POST/PATCH/DELETE /repos/{owner}/{repo}/labels` | Yes — same path pattern | |
| Org-level labels | No | **Yes**`GET/POST/PATCH/DELETE /orgs/{org}/labels` | Gitea supports organization-scoped labels |
| Label properties | `name`, `color`, `description` | `name`, `color`, `description` | |
| Add/remove labels on issue | Yes | Yes | |
| Add/remove labels on PR | Yes | Yes | |
| Replace all labels | Yes | Yes | |
---
## Pull Request Management
| Feature | GitHub | Gitea | Notes |
|---|---|---|---|
| Create PR | Yes | Yes | |
| List PRs | Yes | Yes | |
| Get PR | Yes | Yes | |
| Edit PR | Yes | Yes | |
| Delete PR | No | No | Neither allows hard deletion |
| Draft PRs | **Yes**`draft: true` on create | **Yes**`Draft bool` field | Both support drafts |
| Draft PR filter in list | Yes | Yes — `draft` query param available | |
| `maintainer_can_modify` | Yes | **Yes**`AllowMaintainerEdit` field | Same concept, different name |
| `ContentVersion` conflict detection | No | **Yes** | Same optimistic-locking field as issues |
| Pin PR | No | **Yes** — same pin endpoints as issues, `PinOrder` field | |
| Get PR diff | Yes — `Accept: application/vnd.github.diff` | **Yes**`GET /repos/{owner}/{repo}/pulls/{index}/diff` | Gitea returns raw diff text |
| Get PR patch | Yes — `Accept: application/vnd.github.patch` | Yes — `GET /repos/{owner}/{repo}/pulls/{index}/diff` with format param | |
| List PR files (structured) | Yes — `GET /pulls/{number}/files` | **Yes**`GET /repos/{owner}/{repo}/pulls/{index}/files` | Returns file list with additions/deletions |
| List PR commits | Yes | **Yes**`GET /repos/{owner}/{repo}/pulls/{index}/commits` | |
| Mergeable status | Yes — `mergeable` field | **Yes**`Mergeable bool` field on PR | |
| Merge strategies | merge, squash, rebase | merge, squash, rebase, **rebase-merge** (rebase + no-ff), **fast-forward-only** | **Gitea has more merge strategies** |
| Squash merge | Yes | Yes | |
| Rebase merge | Yes | Yes | |
| Fast-forward-only | No | **Yes** | |
| Rebase + explicit merge commit | No | **Yes**`rebase-merge` style | |
| Auto-merge (merge when checks pass) | Yes | **Yes**`MergeWhenChecksSucceed` field | Both support scheduling a merge after CI passes |
| Cancel auto-merge | Yes | Yes — `DELETE /repos/{owner}/{repo}/pulls/{index}/merge` | |
| Delete branch after merge | Yes | **Yes**`DeleteBranchAfterMerge` field on merge request | |
| Force merge | Limited (bypass protections with permissions) | **Yes**`ForceMerge bool` field | Gitea has an explicit force-merge flag |
| Manually merged (mark as merged) | No | **Yes**`Do: "manually-merged"` + `MergeCommitID` field | Gitea allows recording a manual merge after the fact |
| `HeadCommitSHA` validation on merge | No | **Yes**`HeadCommitID` field for merge safety | |
| PR merge commit message/title | Yes — `commit_title`, `commit_message` | **Yes**`MergeTitleField`, `MergeMessageField` | |
| Pinned PRs list | No | **Yes**`GET /repos/{owner}/{repo}/pulls/pinned` | |
---
## Review Management
| Feature | GitHub | Gitea | Notes |
|---|---|---|---|
| List reviews | Yes | Yes | |
| Create review | Yes | Yes | |
| Submit pending review | Yes | Yes | |
| Delete pending review | Yes | Yes | |
| Dismiss review | Yes — `POST /pulls/{number}/reviews/{id}/dismissals` | **Yes**`POST /repos/{owner}/{repo}/pulls/{index}/reviews/{id}/dismiss` | |
| Un-dismiss review | No | **Yes**`POST /repos/{owner}/{repo}/pulls/{index}/reviews/{id}/undismiss` | Gitea allows undoing a dismissal |
| Review states | `APPROVED`, `REQUEST_CHANGES`, `COMMENT`, `PENDING`, `DISMISSED` | `APPROVED`, `REQUEST_CHANGES`, `COMMENT`, `PENDING`, (+ `REQUEST_REVIEW`) | States match; Gitea adds `REQUEST_REVIEW` as a state type |
| `DISMISSED` state | Yes — set by dismiss action | Yes — `Dismissed bool` + `Stale bool` fields | |
| Aggregated `reviewDecision` field | **Yes (GraphQL only)**`APPROVED`, `CHANGES_REQUESTED`, `REVIEW_REQUIRED` | **No** | Gitea has no single aggregated review decision; must be computed from individual reviews |
| `reviewDecision` via REST | No (GitHub REST also lacks this) | No | Both REST APIs require client-side aggregation |
| Official/required review flag | No | **Yes**`Official bool` field on review | Gitea marks reviews from required reviewers as official |
| `Stale` review flag | No | **Yes**`Stale bool` field | Gitea marks reviews stale when new commits are pushed |
| Review request (users) | Yes — `POST /pulls/{number}/requested_reviewers` | **Yes**`POST /repos/{owner}/{repo}/pulls/{index}/requested_reviewers` | |
| Review request (teams) | Yes — `team_reviewers` param | **Yes**`TeamReviewers` param | |
| Remove review request | Yes | Yes | |
| List eligible reviewers | No (must list repo collaborators separately) | **Yes**`GET /repos/{owner}/{repo}/pulls/{index}/requested_reviewers` returns all eligible users | Gitea returns who *can* be requested, not just who has been |
| Inline code comments on review | Yes — with line, side, path, start_line | **Yes**`CreatePullReviewComment` struct with position/path | |
| Multi-line comment range | Yes — `start_line`/`start_side` + `line`/`side` | Partial — position-based rather than line-range | GitHub's multi-line range is more explicit |
| Reply to review comment | Yes — `POST /pulls/{number}/comments/{id}/replies` | Yes — reply via in_reply_to reference | |
| List review comments (inline) | Yes | Yes | |
| Edit/delete review comment | Yes | Yes | |
| Reactions on review comments | Yes | **No** — reactions are on issue comments only, not PR review comments | Gitea's reaction support does not extend to PR review comments |
| `commitId` on review | Yes | **Yes**`CommitID` field | |
---
## Issue & PR Comments
| Feature | GitHub | Gitea | Notes |
|---|---|---|---|
| List issue comments | Yes | Yes | |
| Create issue comment | Yes | Yes | |
| Edit comment | Yes | Yes | |
| Delete comment | Yes | Yes | |
| Pin/unpin comment | **Yes**`PUT/DELETE /issues/comments/{id}/pin` | No | GitHub supports pinned comments on issues |
| Reactions on issue comments | Yes — 8 types | **Yes** — same 8 types | |
| Reactions on PR review comments | Yes | **No** | Gap in Gitea: review comment reactions not supported |
| `body_html` / `body_text` fields | Yes | No — body is Markdown only | GitHub returns rendered HTML variants |
| `author_association` field | Yes | No | GitHub classifies commenter relationship to repo |
---
## Summary: Key Gaps and Gitea Advantages
### Gitea lacks (compared to GitHub)
| Missing in Gitea | Impact on CLI tooling |
|---|---|
| No GraphQL API | Cannot get aggregated `reviewDecision`; must compute from review list |
| No `reviewDecision` enum (REST or GraphQL) | CLI must fold `APPROVED`/`REQUEST_CHANGES` reviews client-side |
| No device flow OAuth | Headless login requires PAT or BasicAuth; cannot do browser-less OAuth |
| No `state_reason` on issues | Cannot distinguish "completed" vs "not planned" close reasons |
| No issue type (bug/feature classification) | Labels must substitute for issue type |
| No fine-grained per-repo PATs | Tokens are category-scoped, not repo-scoped |
| No `X-RateLimit-*` headers | Cannot back off gracefully on rate limits (no limit signal) |
| No cursor-based pagination | Must use offset pagination; large result sets may drift |
| No `body_html` on comments | Must render Markdown client-side if HTML is needed |
| No `author_association` | Cannot determine contributor relationship without separate lookup |
| No reactions on PR review comments | Review comment reactions not possible |
| No lock/unlock issue endpoint | Issue locking is not exposed as an API operation |
| No GitHub Apps / installation tokens | Cannot use app-level auth scoping |
### Gitea advantages (compared to GitHub REST)
| Gitea-only capability | Impact on CLI tooling |
|---|---|
| `X-Total-Count` response header | Accurate totals on every list response; no extra count query needed |
| Token CRUD via API | Programmatic token management without web UI |
| OAuth app management via API | Automation-friendly app registration |
| Hard delete issues | Useful for cleanup automation |
| Issue dependencies (blocking/blocked-by) | Richer workflow modelling |
| Issue pinning with position reorder | Full pin management via API |
| Issue subscriptions endpoint | Subscribe/unsubscribe without using notifications API |
| Org-level labels | Labels shared across all org repos |
| `Official` + `Stale` flags on reviews | Richer review state without client-side inference |
| `Undismiss` review endpoint | Reversible review dismissals |
| Eligible reviewer list on PR | Know who *can* review before requesting |
| `ForceMerge` flag | Explicit force-merge without separate branch protection bypass |
| `manually-merged` merge style | Record out-of-band merges |
| `rebase-merge` + `fast-forward-only` styles | More granular merge strategy control |
| `ContentVersion` on issues and PRs | Optimistic locking for concurrent edits |
| Time tracking (`TimeEstimate`) | Built-in estimation without third-party integrations |
| `Sudo` param (admin) | Act-as for admin automation |
| HTTP Signature auth | SSH key based API auth |
| `PinOrder` on PRs | PRs can also be pinned, not just issues |

586
.claude/spec/gitea-axi.md Normal file
View File

@@ -0,0 +1,586 @@
## Problem Statement
Coding agents that need to drive a Gitea-hosted workflow (issues, pull requests, labels) today have two poor options.
The official `tea` CLI is human-oriented: it has no token-efficiency, no contextual guidance, and no agent-facing error conventions.
Gitea's MCP servers expose the full API surface (dozens of tools) rather than being tuned for token or turn efficiency.
There is no Gitea-focused tool built to the same "agent ergonomics" standard that `gh-axi` established for GitHub.
## Solution
Build `gitea-axi`: a thin TypeScript CLI that calls the Gitea REST API directly via `gitea-js`, reshaping its output according to all 10 AXI (Agent eXperience Interface) principles.
The canonical principle text at https://axi.md/ is the design authority.
The `gh-axi` reference implementation is a non-binding shape reference for concrete interface details (block names, flag names, field extractors), departed from freely — with each deliberate departure documented in place.
It gives coding agents an ergonomic, low-token way to drive issues and pull requests on any Gitea instance.
It ships as both an installable npm CLI and a bundled Agent Skill, so any agent session can adopt it with one install step.
## User Stories
1. As a coding agent, I want to create a Gitea issue with a title, body, and labels, so that I can record work items for later retrieval.
2. As a coding agent, I want to find issues by label and state, so that I can locate relevant work without already knowing its issue number.
3. As a coding agent, I want to read an issue's full body, labels, and comments, so that I can load its context into a session.
4. As a coding agent, I want to add and remove labels on an existing issue, so that I can reflect state transitions as work progresses.
5. As a coding agent, I want to create a pull request from the current branch, so that completed work becomes reviewable.
6. As a coding agent, I want to fetch a pull request's metadata and diff, so that review tooling can operate on it without re-deriving it from git.
7. As a coding agent, I want to post a comment on a pull request, so that findings or notes are visible as a permanent reference on the PR itself.
8. As a coding agent, I want command output in TOON format with minimal default fields and truncated large fields, so that repeated calls across a long-running session don't consume excessive context.
9. As a coding agent, I want pre-computed aggregates in list and read output, so that I don't need follow-up calls just to derive obvious derived fields.
10. As a coding agent, I want explicit empty-state output when a query returns nothing, so that "no results" is never ambiguous with an error or a hang.
11. As a coding agent, I want structured errors with actionable suggestions and meaningful exit codes instead of prose failures, so that I can self-correct without the operator's help.
12. As a coding agent, I want mutations to be idempotent and to never prompt interactively, so that unattended, scripted use never stalls or double-applies.
13. As a coding agent, I want contextual next-step suggestions appended after output, so that I know what to call next without being taught the tool from scratch every session.
14. As a coding agent, I want a consistent per-subcommand `--help`, so that I can discover the interface on demand rather than needing it pre-loaded in context.
15. As an operator, I want `gitea-axi` run with no arguments to show live, actionable repository state instead of a help screen, so that I get immediate value without memorizing flags.
16. As an operator, I want `gitea-axi` to reuse my existing `tea` login configuration, so that I don't manage a second set of credentials.
17. As an operator, I want `gitea-axi`'s command surface to stay generic, with no workflow-specific behavior baked in, so that it's useful across different projects without code changes.
18. As an operator, I want `gitea-axi` published to npm and as an installable Agent Skill, so that I (and others) can adopt it with a single install command plus an explicit one-time `gitea-axi setup`.
19. As an operator, I want an optional `gitea-axi setup hooks` command that injects the dashboard into my agent sessions at session start, so that my agent begins each session already aware of the repository's live state.
## Implementation Decisions
### Language and Runtime
TypeScript on Node 20+, matching the `gh-axi` reference implementation.
ESM module format.
### Implementation Strategy
Call the Gitea REST API directly via `gitea-js` (the official TypeScript client generated from Gitea's OpenAPI spec).
This is not a subprocess wrapper — it is a direct HTTP API client.
Tea was evaluated as a subprocess target and rejected during design (see ADR 0002).
The short version: tea's create commands have no `--output json` flag, its PR list has no head-branch filter, it exposes no review or total counts in JSON, and diff content requires a direct HTTP GET regardless — making a tea wrapper a patchwork of subprocess calls and text parsing rather than a clean pipeline.
`gitea-js` gives clean typed responses, `X-Total-Count` headers for pagination, review counts, and immediate JSON from create operations.
### Auth
gitea-axi reads credentials from tea's login store via `tea login list --output json`.
It requires `tea` to be installed and at least one login configured via `tea login add`.
At startup, it detects the current repository's Gitea hostname (see Repository Context Detection), finds the matching login entry, and extracts the token for all subsequent API calls.
Tea is used only for credential discovery — no commands are dispatched through the tea subprocess.
### Command Surface
#### Dashboard
`gitea-axi` (no args): a two-tier home view showing live repository state, preceded by the `bin:` + `description:` header from `axi-sdk-js`, followed by next-step suggestions (see ADR 0012).
The short tier (no flags — also what the SessionStart hook runs) matches gh-axi's home shape:
up to 3 open issues (`number`, `title`, `state`, `author`) and up to 3 open PRs (`number`, `title`, `author`, `review`), fetched in parallel with `limit=3`.
The `review` field is computed client-side via the same parallel review fetch as `pr list` (see ADR 0006) — at most 3 extra HTTP calls.
The short tier's `help:` block always includes a hint pointing at `--full`.
The full tier (`gitea-axi --full`): open PRs as a TOON table and open issue counts grouped by label (a label→count record).
Full-tier PR table default fields: `number`, `title`, `author` (plucked from `user.login`), `labels` (joined label names), `review` (same client-side computation).
The full-tier PR table is capped at 20 rows with a standard count line (`count: 20 of T total`).
Full-tier issue aggregation fetches all pages of open issues up to a hard cap of 1000 (page size 50, 20 pages max); if the cap is hit, label counts are suffixed with `+`.
`--full` here selects the full tier — an intentional overload of the flag that elsewhere suppresses truncation.
Output blocks (both tiers): a `repo: owner/name` line, then `prs:` and `issues:`.
Empty states are explicit and match gh-axi's home: `prs: 0 open` / `issues: 0 open` (raw strings; list commands keep their `<noun>[0]: (none)` convention).
Issue fetching passes `type=issues` so PRs never pollute the issue block (see Issue/PR Type Guard).
Outside a recognizable Gitea repo the dashboard errors with `REPO_NOT_FOUND` (help: use `-R` + `--login`) — login selection requires a hostname.
This holds even when invoked by the SessionStart hook; error noise in non-Gitea sessions is an accepted consequence (see ADR 0009).
#### Issue Commands
**`issue list [flags]`**
`--state <open|closed|all>` (default open);
`--label <name>` (API-supported — Gitea issue list accepts comma-separated label names);
`--assignee <login>` (API-supported — maps to `assigned_by` param);
`--author <login>` (API-supported — maps to `created_by` param);
`--milestone <name>` (API-supported — maps to `milestones` param);
`--sort <created|updated|comments>` (client-side — Gitea issue list has no sort param; always descending, matching gh-axi);
`--limit <n>` (default 30);
`--fields <a,b,c>`.
`--search` is explicitly forbidden (VALIDATION_ERROR).
Always passes `type=issues` — Gitea's issue endpoints also serve PRs, which must never appear in issue lists (see Issue/PR Type Guard).
Client-side `--sort` reorders without changing membership, so the standard `count: N of T total` line is kept (see ADR 0005); full pagination still precedes sorting.
Default output fields (matching gh-axi): `number`, `title`, `state` (lowercased), `author` (plucked from `user.login`), `created` (relative time).
Extra fields via `--fields`: `body` (raw), `closedAt` (relative time, as `closed_at`), `labels` (joined names), `milestone` (title), `updatedAt` (relative time, as `updated_at`), `url`.
No `type` field in output — Gitea has no issue types.
**`issue view <n> [flags]`**
`--comments` (expand full comments — renders all comments with no count cap, each body truncated at 800 chars with cleanBody applied);
`--full` (suppress all truncation in the output — issue body and comment bodies alike).
Default output fields (matching gh-axi, minus `type`): `number`, `title`, `state`, `author`, `created`, `body` (truncated at 500), plus `comment_count`.
Type guard: if `<n>` is a pull request, fails with `VALIDATION_ERROR` ("issue #N is a pull request") and a `pr view <n>` help line (see Issue/PR Type Guard).
No `type` field in output (Gitea has no issue types).
No sub-issue augmentation (Gitea does not model issue hierarchies; use `issue blocks` and `issue blocked-by` for dependency relationships).
**`issue create [flags]`**
`--title <text>` (required);
`--body <text>` or `--body-file <path>`;
`--assignee <login>`;
`--label <name>` (repeatable; resolved to label ID via `GET /labels`, case-insensitive — `VALIDATION_ERROR` if not found);
`--milestone <name>` (resolved to milestone ID via `GET /milestones?name=<name>``VALIDATION_ERROR` if not found).
`--project` is excluded (Gitea has no projects REST API).
`--type` is excluded (Gitea has no issue types).
Output schema: `issue: { number, title, state, url }` (where `url` = `html_url`).
Extra fields available via `--fields`: `labels`, `assignees`, `milestone`, `body`.
**`issue edit <n> [flags]`**
`--title`;
`--body <text>` or `--body-file <path>`;
`--add-label <name>`;
`--remove-label <name>`;
`--add-assignee <login>`;
`--remove-assignee <login>`;
`--milestone <name>` (resolved to milestone ID via `GET /milestones?name=<name>``VALIDATION_ERROR` if not found).
Label mutations use Gitea's dedicated additive/removal label endpoints (idempotent).
`--add-label` passes the name directly via `POST /issues/{index}/labels` (Gitea accepts names here — no lookup needed).
`--remove-label` requires an ID: resolved via case-insensitive label lookup; `VALIDATION_ERROR` if the label name does not exist in the repo; if the label exists but is not applied to this issue, Gitea's 404 on `DELETE /labels/{id}` is treated as silent success.
Assignee mutations use fetch-then-patch: the current assignee list is read first, the addition or removal applied in-process, then the full resulting list is sent in a single PATCH (see ADR 0007).
**`issue close <n> [flags]`**
`--comment <text>`.
Closing sets `state: "closed"` via PATCH on the issue.
`--reason` is excluded (Gitea has no `state_reason` concept).
When `--comment` is provided, two API calls are made: PATCH to close, then POST to create the comment.
If the PATCH succeeds but the POST fails, the error is surfaced — the issue remains closed but the failure is reported rather than silently swallowed.
Idempotent: returns early with `message: "Already closed"` if already closed.
**`issue reopen <n>`**
Sets `state: "open"` via PATCH.
Idempotent: returns early with `message: "Already open"` if already open.
**`issue comment <n> [flags]`**
`--body <text>` or `--body-file <path>` (required).
Gitea's `POST /issues/{index}/comments` returns the created `Comment` object directly.
Output block: `comment: { number, author, created, body }` (body truncated at 800 chars).
`number` is the issue number the comment was posted to; the comment's own id is not output (nothing in the command surface consumes comment ids).
**`issue delete <n>`**
Hard-deletes the issue via `DELETE /issues/{index}` (requires admin or owner permissions).
Not idempotent: a nonexistent issue errors with `ISSUE_NOT_FOUND` rather than reporting success (see ADR 0010).
Output: `issue: { number, status: "deleted" }`.
**`issue pin <n>`**
`POST /issues/{index}/pin`.
Idempotent: returns early with `message: "Already pinned"` if already pinned.
Output: `issue: { number, state, pinned }`.
**`issue unpin <n>`**
`DELETE /issues/{index}/pin`.
Idempotent: returns early with `message: "Already unpinned"` if already unpinned.
Output: `issue: { number, state, pinned }`.
**`issue blocks <list|add|remove>` (Gitea-specific)**
Manages the set of issues that this issue blocks (downstream dependents that cannot proceed until this issue is resolved).
`issue blocks list <n>` — lists issues blocked by `<n>`; output block `blocked_issues`.
`issue blocks add <n> <target>` — makes `<n>` block `<target>`; output `blocks: { issue: n, blocks: target }`.
`issue blocks remove <n> <target>` — removes the blocking relationship.
Idempotent: `add` of an existing relationship returns `already: true` (fetch-first check against the current list); `remove` of a nonexistent relationship is silent success; self-reference and cycle errors still surface as `VALIDATION_ERROR` via the 422 mapping.
Gitea API: `GET/POST/DELETE /repos/{owner}/{repo}/issues/{index}/blocks`.
No gh-axi equivalent.
**`issue blocked-by <list|add|remove>` (Gitea-specific)**
Manages the set of issues that block this issue (upstream blockers that must be resolved before this issue can proceed).
`issue blocked-by list <n>` — lists issues that block `<n>`; output block `blocking_issues`.
`issue blocked-by add <n> <blocker>` — makes `<n>` depend on `<blocker>`; output `blocked_by: { issue: n, blocked_by: blocker }`.
`issue blocked-by remove <n> <blocker>` — removes the dependency.
Same idempotency rules as `issue blocks`.
Gitea API: `GET/POST/DELETE /repos/{owner}/{repo}/issues/{index}/dependencies`.
No gh-axi equivalent.
#### Excluded Issue Commands
`issue lock` / `issue unlock` — excluded: Gitea exposes `is_locked` as a readable field but has no lock/unlock API endpoint.
`issue transfer` — excluded: no Gitea equivalent.
`issue subissue` — excluded: GitHub-specific hierarchy model; Gitea uses blocking/dependency relationships instead (see `issue blocks` and `issue blocked-by`).
#### PR Commands
**`pr list [flags]`**
`--state <open|closed|all>` (default open);
`--label <name>` (requires name→ID lookup — Gitea PR list takes `labels: number[]`; see label name lookup in CONTEXT.md);
`--label-id <id>` (Gitea-specific shortcut — bypasses the name→ID lookup and passes the ID directly);
`--assignee <login>` (client-side filter — Gitea PR list has no assignee param);
`--author <login>` (API-supported — maps to `poster` param);
`--base <branch>` (client-side filter — no API param);
`--head <branch>` (client-side filter — no API param);
`--draft` (client-side filter — no API param);
`--sort <oldest|recentupdate|leastupdate|mostcomment|leastcomment|priority>` (Gitea-specific extension — maps directly to the API `sort` param);
`--limit <n>` (default 30);
`--fields <a,b,c>`.
`--search` is explicitly forbidden (VALIDATION_ERROR).
Default output fields (matching gh-axi): `number`, `title`, `state` (lowercased), `author` (plucked from `user.login`), `draft` (bool→yes/no), `review` (`reviewDecision` mapped: APPROVED→approved, CHANGES_REQUESTED→changes_requested, REVIEW_REQUIRED→required).
Extra fields via `--fields`: `body` (raw), `createdAt` (relative time, as `created`), `labels` (joined names), `milestone` (title), `mergedAt` (relative time, as `merged_at`), `url`.
`reviewDecision` is computed client-side by fetching reviews for each PR in parallel (one extra HTTP call per PR; see ADR 0006).
When any client-side filter is active, the count line shows `count: N of T total` with `T` computed from the in-memory filtered result set (see ADR 0005).
**`pr view <n> [flags]`**
`--comments` (renders all comments with no count cap, each body truncated at 800 chars with cleanBody applied);
`--reviews`;
`--full` (suppress all truncation in the output — PR body and comment bodies alike).
Default output fields (matching gh-axi): `number`, `title`, `state`, `author`, `draft`, `merged`, `checks`, `body` (truncated at 500), plus `comment_count` and `review_count`.
The `checks` field is populated from Gitea commit statuses via `GET /commits/{sha}/status` using the PR head SHA.
It renders as `"N passed, N failed[, N skipped][, N pending], N total"`, or `"0 passed, 0 failed — this PR has no CI checks configured"` when no statuses exist.
Commit status states map to gh-axi's four-value classification: `success``pass`; `failure`/`error`/`warning``fail` (matching Gitea's own combine logic, which treats `warning` as failure); `skipped``skip`; `pending``pending`.
Older Gitea instances never emit `skipped`, so the `skip` bucket is simply absent there.
`pr view` always makes three API calls: the PR fetch and `GET /pulls/{index}/reviews` are issued in parallel, then the combined-status fetch runs once the head SHA is known — so `review_count` and `checks` are always in the default output without requiring `--reviews`.
When `--reviews` is passed, additionally fetches per-review inline comments (`GET /pulls/{index}/reviews/{id}/comments` for each review).
Gitea-specific fields exposed on review objects when `--reviews` is passed: `official` (whether the review counts toward required approvals) and `stale` (whether the PR head has moved since review submission).
**`pr create [flags]`**
`--title <text>` (required);
`--body <text>` or `--body-file <path>`;
`--base <branch>`;
`--head <branch>`;
`--assignee <login>`;
`--reviewer <login>`;
`--label <name>` (repeatable; resolved to label ID via `GET /labels`, case-insensitive — `VALIDATION_ERROR` if not found);
`--milestone <name>` (resolved to milestone ID via `GET /milestones?name=<name>``VALIDATION_ERROR` if not found).
`--draft` is excluded (Gitea cannot create draft PRs via API).
`--project` is excluded (Gitea has no projects REST API).
When `--head` is not specified, defaults to the current local branch (via `git rev-parse --abbrev-ref HEAD`).
When `--base` is not specified, the repository's default branch is used (fetched via `GET /repos/{owner}/{repo}`).
Idempotent: before creating, checks `GET /pulls/{base}/{head}` for an existing open PR for the same branch pair.
If found, returns `pull_request: { number, url, already: true }` without creating a duplicate.
Output on success: `created: { number, url }` — completing gh-axi's action-block/entity-block pattern (action-named block when the mutation ran, entity-named block when it was a no-op).
**`pr edit <n> [flags]`**
`--title`;
`--body <text>` or `--body-file <path>`;
`--add-label <name>`;
`--remove-label <name>`;
`--add-assignee <login>`;
`--remove-assignee <login>`;
`--add-reviewer <login>`;
`--remove-reviewer <login>`;
`--milestone <name>` (resolved to milestone ID via `GET /milestones?name=<name>``VALIDATION_ERROR` if not found);
`--base <branch>`.
Assignee and reviewer mutations use fetch-then-patch (see ADR 0007).
Output: `edited: { number, status: "ok" }`.
**`pr close <n> [flags]`**
`--comment <text>`.
Idempotent: returns `pull_request: { number, state, already: true }` if already closed or merged.
Output on success: `closed: { number, status: "ok" }`.
**`pr merge <n> [flags]`**
`--method <merge|squash|rebase|rebase-merge|fast-forward-only|manually-merged>`;
`--merge`, `--squash`, `--rebase` (shorthands for the three common methods);
`--auto`;
`--delete-branch`;
`--body <text>` or `--body-file <path>`;
`--subject <text>`;
`--merge-commit-id <sha>` (required when `--method manually-merged`; VALIDATION_ERROR if omitted; VALIDATION_ERROR if provided with any other method).
Gitea-specific methods not in gh-axi: `rebase-merge` (rebase + explicit merge commit), `fast-forward-only`, `manually-merged` (records an out-of-band merge without actually merging).
Idempotent: if already merged, returns `pull_request: { number, state: "merged", merged_by, merged_at }` without calling the API.
Output on success: `merged: { number, status: "ok", method }`.
**`pr review <n> [flags]`**
`--approve`;
`--request-changes`;
`--comment`;
`--body <text>` or `--body-file <path>`.
Exactly one of the three action flags is required; zero or multiple → `VALIDATION_ERROR` before any API call (mirroring the `pr merge` shorthand-conflict rule).
Body requirements are not pre-validated locally: if Gitea rejects a body-less review event, its 422 surfaces as `VALIDATION_ERROR` with the server's message.
Output: `review: { number, action }`.
**`pr checks <n>`**
Fetches combined commit status for the PR head SHA via `GET /commits/{sha}/status`.
Output matches gh-axi: a `summary` line (`N passed, N failed[, N skipped][, N pending], N total`) followed by a `checks` list of `{ name, conclusion }`.
Conclusions: `pass`, `fail`, `skip`, or `pending`, using the same state mapping as `pr view` (`skipped``skip`; `warning``fail`).
When no statuses are configured: `checks: "0 passed, 0 failed — this PR has no CI checks configured"`.
**`pr diff <n> [flags]`**
`--full`.
Fetches raw diff from `GET /pulls/{index}.diff`.
Truncation limit: 4000 chars.
Output: `pr_diff: { number, diff[, truncated, original_length] }`.
**`pr checkout <n>`**
Fetches the PR head branch name from `GET /pulls/{index}` (`head.ref` field), then runs in the current working directory:
1. `git fetch origin pull/<n>/head:<branch>`
2. `git checkout <branch>`
Fetching `refs/pull/{index}/head` from the base repo works uniformly for same-repo and fork PRs — the head branch itself may live in a fork that is not a configured remote (see ADR 0011).
Git subprocess failures (dirty worktree, network) map to `GIT_ERROR`, carrying git's first stderr line and a remediation help line.
Output: `checkout: { number, branch, status: "ok" }`.
**`pr reopen <n>`**
Idempotent: returns `pull_request: { number, state: "open", already: true }` if already open.
Output on success: `reopened: { number, status: "ok" }`.
**`pr comment <n> [flags]`**
`--body <text>` or `--body-file <path>` (required).
PRs share the issue comment endpoint in Gitea (`POST /issues/{index}/comments`), which returns the created `Comment` object directly.
Output block: `comment: { number, author, created, body }` (body truncated at 800 chars).
`number` is the PR number the comment was posted to; the comment's own id is not output.
This diverges from gh-axi's `commented: { number, status: "ok" }` — returning the created comment eliminates the need for a follow-up view call (AXI Principle 4; see ADR 0008).
**`pr update-branch <n> [flags]`**
`--style <merge|rebase>` (Gitea-specific; default `merge`).
Merges the base branch into the PR head branch via `POST /pulls/{index}/update?style=<style>`.
Output: `updated: { number, status: "ok" }`.
#### Excluded PR Commands
`pr ready` — excluded: Gitea has no API to convert a draft PR to ready for review.
`pr revert` — excluded: Gitea has no revert PR endpoint.
#### Label Commands
**`label list [flags]`**
`--limit <n>` (default 500).
Output: count line followed by `labels: [ { name } ]`.
**`label create [flags]`**
`--name <text>` (required);
`--color <hex>` (required, without `#`).
The `#` prefix is automatically prepended before calling the Gitea API (which requires it in `CreateLabelOption.color`).
`--description <text>`.
Idempotent: checks for an existing label with the same name (case-insensitive) before creating.
If found: `create: already_exists`, `label: <existing-name>`.
Output on success: `created: ok`, `label: <name>`.
**`label edit <name> [flags]`**
`--name <new-name>`;
`--color <hex>`;
`--description <text>`.
`<name>` is resolved via the standard case-insensitive label lookup; `VALIDATION_ERROR` if not found.
Output: `edit: ok`, `label: <new-name-or-original-name>`.
**`label delete <name>`**
`<name>` is resolved via the standard case-insensitive label lookup.
Not idempotent: a nonexistent label errors with `VALIDATION_ERROR` rather than reporting success (see ADR 0010).
Output: `delete: ok`, `label: <name>`.
#### Setup Command
**`setup`**
Installs the bundled Agent Skill markdown into `~/.claude/skills/` (see ADR 0009).
This is gitea-axi's primary fulfillment of AXI Principle 7 (Ambient context): an explicit setup command, matching gh-axi's `setup`.
Idempotent: re-running reports already-installed/updated rather than failing.
Output: `setup: { skill, path, status: <installed|updated|unchanged> }`.
**`setup hooks`**
Opt-in: installs a SessionStart hook via axi-sdk-js's `installSessionStartHooks()` into Claude Code (`~/.claude/settings.json`), Codex (`~/.codex/hooks.json` plus `config.toml`), and OpenCode (ambient plugin) — see ADR 0009.
The hook runs the bare `gitea-axi` binary (the short dashboard tier) in the session's working directory at session start and injects its output as ambient context.
Idempotent: managed entries are updated in place by the SDK.
Output mirrors gh-axi: `hooks: { status: installed, integrations: Claude Code, Codex, OpenCode }`, with a help line to restart the agent session.
#### Shadowed Built-in Commands
`update` — axi-sdk-js ships a built-in self-update command (checks npmjs.org and updates the install) with its own `UPDATE_ERROR` code; gitea-axi shadows it (see ADR 0013).
`gitea-axi update` fails with `VALIDATION_ERROR` and a help line: `` Run `npm install -g gitea-axi@latest` to update ``.
This keeps the command surface and the ten-code error list exactly as specified here.
### Name-to-ID Resolution
Some Gitea API endpoints require integer IDs where gitea-axi accepts human-readable names.
**Milestone names** (`--milestone <name>` on `issue create`, `issue edit`, `pr create`, `pr edit`):
Resolved via `GET /repos/{owner}/{repo}/milestones?name=<name>`.
`VALIDATION_ERROR` if no milestone with that name exists.
**Label names for `pr list --label`**:
Resolved via `GET /repos/{owner}/{repo}/labels`, matched case-insensitively.
`VALIDATION_ERROR` if not found.
`--label-id <id>` bypasses this lookup.
**Label names for `issue create` and `pr create` (`--label <name>`)**:
Same case-insensitive label lookup.
`VALIDATION_ERROR` if not found.
**Label names for `issue edit` / `pr edit` `--add-label`**:
Not resolved — the label name is passed directly in the POST body; Gitea's label endpoint accepts names.
**Label names for `issue edit` / `pr edit` `--remove-label`**:
Resolved via case-insensitive label lookup.
`VALIDATION_ERROR` if the label name does not exist in the repo.
If the label exists but is not applied to the issue/PR, Gitea's 404 on `DELETE /labels/{id}` is treated as silent success.
**Label names for `label edit <name>` / `label delete <name>`**:
Resolved via the same case-insensitive label lookup.
`VALIDATION_ERROR` if not found.
### Client-Side Filtering Policy
When a filter flag has no corresponding Gitea API query parameter, gitea-axi paginates all results (`limit=50` per page until exhausted) and filters in-process (see ADR 0005).
Known client-side filters for the current surface: `pr list --assignee`, `pr list --base`, `pr list --head`, `pr list --draft`.
When any client-side filter is active, the count line shows `count: N of T total`, where `T` is the true filtered total computed from the in-memory result set; the `X-Total-Count` header (which reflects the unfiltered total) is ignored as misleading.
Client-side *sort* (`issue list --sort`) is not a filter: it reorders without changing membership, so `T` comes from the `X-Total-Count` header as usual; full pagination still precedes sorting.
### Issue/PR Type Guard
Gitea's issue endpoints also serve pull requests (`Issue.pull_request` is non-null for PRs).
Every issues-list call passes `type=issues` — `issue list`, the dashboard's issue aggregation, and any client-side-filter pagination.
Issue commands invoked with a PR number fail with `VALIDATION_ERROR` ("issue #N is a pull request") and a `pr view <n>` help line, detected via the fetched object's `pull_request` field.
Exception: `issue comment` stays permissive — PRs genuinely share the comment endpoint.
### reviewDecision Computation
Gitea has no aggregated `reviewDecision` field on the PR object.
gitea-axi computes it client-side from the reviews list (see ADR 0006).
Logic: `APPROVED` if at least one review has `official=true`, `stale=false`, `dismissed=false` and no non-dismissed `REQUEST_CHANGES` exists; `CHANGES_REQUESTED` if any non-dismissed `REQUEST_CHANGES` exists; `REVIEW_REQUIRED` otherwise.
On `pr list`, reviews for each PR are fetched in parallel (one extra HTTP call per PR in the list).
### Context Override Flags
gitea-axi accepts two top-level context override flags and two matching environment variables, mirroring gh-axi's design (`GH_REPO`):
- `-R` / `--repo <OWNER/NAME>` — overrides the repository detected from the git remote; env equivalent `GITEA_AXI_REPO`.
- `--login <name>` — selects a specific tea login profile, overriding the one matched from the git remote's hostname; env equivalent `GITEA_AXI_LOGIN`.
Resolution priority: flag > environment variable > auto-detection (git remote / hostname match).
Both flags are accepted anywhere on the command line, before or after the command — more permissive than gh-axi, which rejects them before the command; next-step suggestions always render them after the command.
These overrides are injected into next-step suggestions only when the context came from a flag or environment variable, not when it was auto-detected from the git remote (because the agent's next call will be in the same working directory and will auto-detect the same context).
### Output — The 10 AXI Principles
**Principle 1 — TOON output.**
All structured output uses the `@toon-format/toon` `encode()` function, wrapped in `renderList()` and `renderDetail()` helpers that handle the list/detail shape distinction.
**Principle 2 — Minimal default schemas.**
Each command exposes a small default field set (56 fields per list row), enumerated per command in the Command Surface section.
This is a deliberate, documented departure from the canonical 34-field guidance: each extra field (`state`, `created`, `draft`, `review`) answers a routine triage question that would otherwise cost a follow-up call.
Additional fields are opt-in via `--fields`.
Field extraction uses a `FieldDef` type system with typed extractors: nested pluck, array join, enum map, bool-to-text, and relative time formatting — matching gh-axi's internal architecture.
**Principle 3 — Content truncation.**
Body text is truncated at **500 characters** in all contexts (list and detail alike), matching gh-axi.
Comment bodies truncate at 800 characters wherever they appear (comment-post output and `--comments` view blocks), with cleanBody applied.
Diff content is truncated at 4000 characters.
When body truncation occurs, a hint is appended inline: `"... (truncated, N chars total - use --full to see complete body)"`.
When diff truncation occurs, `truncated: true` and `original_length: N` are added as separate fields, and a next-step suggestion to use `--full` is prepended.
`--full` on `issue view` and `pr view` suppresses all truncation in the command's output (entity body and comment bodies alike); `--full` on `pr diff` suppresses diff truncation.
Before truncation, a `cleanBody` step is applied **only when the raw body exceeds the truncation limit**.
`cleanBody` normalizes Gitea issue/PR URLs using the detected hostname (`https://<host>/<owner>/<repo>/issues/N` → `Issue#N`; `.../pulls/N` → `PR#N`), strips markdown image embeds, removes long URLs in markdown links and standalone text, and collapses email-style quoted blocks — matching gh-axi's transforms plus Gitea-specific URL normalization.
If cleaning brings the body within the limit, the cleaned body is returned with an appended note; if it still exceeds the limit, the cleaned body is truncated.
**Principle 4 — Pre-computed aggregates.**
List output leads with a `formatCountLine()`: `"count: N of T total"`, with `T` from the `X-Total-Count` response header, or computed from the in-memory filtered set when a client-side filter is active; `"count: N (showing first N)"` when at the request limit and no total is available.
The bare `count: N` form does not exist — the total is always reported (canonical Principle 4).
Detail output for issues includes `comment_count`; for PRs includes `review_count` and `comment_count`.
The review decision (`review` field) is a default on `pr list` and the dashboard PR table, computed client-side from parallel review fetches.
The full-tier dashboard's issue-by-label counts are computed by fetching all pages of open issues and aggregating in-process.
Both `issue comment` and `pr comment` return the created comment object directly from the POST response, eliminating the need for a follow-up view call.
**Principle 5 — Definitive empty states.**
When a list command returns no results it emits `<noun>[0]: (none)` followed by a relevant next-step suggestion.
The dashboard's empty states are `prs: 0 open` / `issues: 0 open` (raw strings, matching gh-axi's home view).
Empty output is never silent.
**Principle 6 — Structured errors, exit codes, idempotent mutations, no prompts.**
Errors are represented as a typed `AxiError` with one of ten named codes: `REPO_NOT_FOUND`, `ISSUE_NOT_FOUND`, `PR_NOT_FOUND`, `AUTH_REQUIRED`, `FORBIDDEN`, `RATE_LIMITED`, `TEA_NOT_INSTALLED`, `VALIDATION_ERROR`, `GIT_ERROR`, `UNKNOWN`.
The `ISSUE_NOT_FOUND`/`PR_NOT_FOUND` split (vs gh-axi's single `NOT_FOUND`) is a deliberate divergence enabled by path-based 404 classification.
API error responses are classified by HTTP status code and calling context:
| HTTP status | Context | Error code |
|---|---|---|
| 401 | any | `AUTH_REQUIRED` |
| 403 | any | `FORBIDDEN` |
| 404 | called on `/repos/{owner}/{repo}` itself | `REPO_NOT_FOUND` |
| 404 | called on `/repos/.../issues/{index}` | `ISSUE_NOT_FOUND` |
| 404 | called on `/repos/.../pulls/{index}` | `PR_NOT_FOUND` |
| 404 | other paths | `UNKNOWN` |
| 422 | any | `VALIDATION_ERROR` (body message surfaced) |
| 429 | any | `RATE_LIMITED` (help: wait and retry, or reduce `--limit`) |
| other | any | `UNKNOWN` |
`TEA_NOT_INSTALLED` is emitted if the tea binary is not found during credential discovery.
`AUTH_REQUIRED` is also emitted when tea is installed but no login matches the detected hostname (help: `` Run `tea login add --url <host>` ``, or pass `--login <name>`).
A `--login` value naming a nonexistent profile is `VALIDATION_ERROR`, listing the available profile names.
`GIT_ERROR` classifies non-zero git subprocess exits (currently only `pr checkout`), carrying git's first stderr line.
Error output is TOON-encoded to stdout (not stderr): `error: <message>`, `code: <CODE>`, and optionally `help[N]:` with suggestion lines.
The suggestions field is named `help`, not `hint`.
Exit codes: 0 success, 1 error, 2 for `VALIDATION_ERROR` — covering unknown flags, missing required inputs, and server-side 422 rejections alike (the `axi-sdk-js` `exitCodeForError` mapping; see ADR 0004).
This deliberately broadens the canonical "exit 2 for unknown flags" wording: exit 2 uniformly means "the input was invalid — fix the call and retry".
Mutations are idempotent, and no command ever prompts:
`pr create` checks for an existing open PR before creating; if one exists, returns its details with `already: true` rather than creating a duplicate.
`issue edit --add-label` / `--remove-label` uses Gitea's dedicated additive label endpoints, which are idempotent.
`issue close`, `issue reopen`, `pr close`, `pr reopen`, `pr merge`, `issue pin`, `issue unpin`, `issue blocks add/remove`, `issue blocked-by add/remove`, and `setup` all check current state before mutating and return early if already in the target state.
Hard deletes (`issue delete`, `label delete`) deliberately refuse missing targets instead of reporting idempotent success (see ADR 0010).
All required inputs are flags — missing ones cause an immediate `error:` exit.
**Principle 7 — Ambient context.**
Fulfilled primarily by the `setup` command, which installs the bundled Agent Skill into `~/.claude/skills/` (see ADR 0009).
The skill surfaces gitea-axi to the agent at session start whenever it is relevant, without a per-session hook cost.
The canonical principle's primary mechanism — SessionStart hooks that inject the dashboard as initial context — is offered as the opt-in `setup hooks`, not the default.
There is no postinstall script — skill and hook installation are always explicit user actions, matching the canonical principle wording ("from an explicit setup command") and gh-axi's own `setup` command.
**Principle 8 — Content first.**
Running `gitea-axi` with no arguments shows live repository state, not a help screen, preceded by the SDK's `bin:` + `description:` header (executable path and one-sentence description, per the canonical principle).
The short tier makes two parallel API calls — 3 open issues, 3 open PRs — plus up to 3 parallel review fetches.
The full tier (`--full`) additionally aggregates open issue counts by label: open issues are paginated with `limit=50` and `type=issues`, up to a hard cap of 1000 issues (20 pages max); if the cap is hit, label counts are suffixed with `+`.
Each issue contributes to all of its labels; unlabeled issues appear as a separate `unlabeled` row only when non-zero.
**Principle 9 — Contextual next-step suggestions.**
Every command appends semi-dynamic suggestions to its output, rendered as a `help[N]:` block — the same block name used for error suggestions, matching gh-axi and the canonical principle text.
Runtime values are hybrid: list output keeps placeholders (`` `gitea-axi issue view <number>` ``) since the agent must choose which result it cares about; single-entity output fills the actual id (`` `gitea-axi issue view 42` ``) since it is unambiguous — matching the canonical "leave runtime values parameterized" guidance while carrying forward known ids.
Every command emits at least one suggestion; there are no empty `help:` blocks (a departure from gh-axi, which omits suggestions on `pr view` and emits empty blocks on `pr checkout`).
Every suggestion auto-includes `-R`/`--repo` and `--login` flags when the context was not auto-detected from the git remote — matching gh-axi's suggestion normalization approach.
**Principle 10 — Consistent `--help`.**
Every subcommand responds to `--help` with a concise flag reference.
Unknown flags exit with code 2.
No subcommand ever prompts interactively.
### Repository Context Detection
Repo owner, name, and hostname are detected from the git `origin` remote URL of the current directory.
Both SSH (`git@host:owner/repo.git`) and HTTPS (`https://host/owner/repo.git`) remote formats are supported.
If no recognizable Gitea remote URL is found on `origin`, gitea-axi exits with `REPO_NOT_FOUND` and a hint to configure the remote.
The detected hostname is used to select the matching tea login profile for auth.
### Distribution
Published to npm as `gitea-axi` (unscoped).
Binary name: `gitea-axi`.
The Agent Skill markdown file is bundled inside the npm package.
There is no postinstall script: `npm install -g gitea-axi` delivers the CLI binary, and a one-time explicit `gitea-axi setup` installs the skill into `~/.claude/skills/` (see ADR 0009).
The dashboard suggestion table hints at `setup` so the skill install is discoverable.
## Testing Decisions
Good tests exercise the actual command-line interface (argv in, stdout/exit-code/stderr out) — the one seam every caller depends on.
They do not test internal functions in isolation, and they do not mock individual API calls in a way that only proves gitea-axi issued the right HTTP request.
Instead, tests verify that gitea-axi correctly reshapes real API responses into correct TOON, correct error lines, and correct exit codes.
**Test seam:** Three environment variables together activate test mode:
- `GITEA_AXI_API_URL` — overrides the API base URL to point at the fixture server; also signals test mode, suppressing both the git remote subprocess and the tea credential subprocess.
- `GITEA_AXI_TOKEN` — supplies the auth token directly, bypassing `tea login list`.
- `GITEA_AXI_REPO` — supplies the repository context as `OWNER/NAME`, equivalent to `-R`; required in test mode since git remote detection is suppressed.
`GITEA_AXI_REPO` and `GITEA_AXI_LOGIN` are general context overrides (see Context Override Flags), not test-mode-specific; test mode merely relies on them.
In tests, `GITEA_AXI_API_URL` points to a local HTTP fixture server that maps incoming request paths and methods to pre-recorded Gitea API JSON response files stored in `fixtures/`.
This means tests exercise the full reshaping pipeline — JSON parse, field extraction, TOON encoding, truncation, suggestion generation, error classification — without a live Gitea instance.
**Two-tier test strategy:**
- Local / unit tier: the fixture server runs fast with no external dependencies.
Used for all command-level assertions.
- CI integration tier: a live disposable Gitea instance serves real API responses end-to-end, verifying that fixture recordings remain accurate and that the full HTTP pipeline works correctly.
**Test runner:** Vitest.
## Out of Scope
- Any workflow-specific commands or hardcoded label/state semantics.
- Inline per-line PR review comments (a possible future addition; the primitive here is a plain PR comment).
- Multi-instance orchestration beyond what `tea`'s own login profiles already provide.
- A repo-level config file for dashboard customization (deferred post-MVP).
- A `dot` or any other host CLI's subcommand wrapping this tool — it is a standalone, independently distributed tool.
- Gitea Projects (kanban boards) — no REST API exists as of gitea-js v1.23.
## Further Notes
- AXI ("Agent eXperience Interface"): https://axi.md/ and https://github.com/kunchenguid/axi.
Its reference implementation, `gh-axi` (https://github.com/kunchenguid/gh-axi), wraps GitHub's `gh` CLI.
The canonical principle text is authoritative for gitea-axi; gh-axi is a non-binding shape reference.
gitea-axi adopts gh-axi's `FieldDef` type system, `renderList()`/`renderDetail()` output helpers, typed `AxiError` classification, and suggestion normalization, and documents each deliberate departure in place.
- TOON format spec: https://toonformat.dev/.
The official TypeScript library is `@toon-format/toon`; no Go library exists, which was a decisive factor in the TypeScript language choice.
- `gitea-js` is the official TypeScript client for the Gitea API, generated from Gitea's OpenAPI spec.
It is the sole HTTP layer; no raw `fetch` calls are made outside of it.
- Tea's login store (`~/.config/tea/config.yml`) is read indirectly via `tea login list --output json`.
gitea-axi does not parse the YAML config file directly, to avoid coupling to tea's internal storage format.
- Tea was evaluated as the primary implementation strategy (subprocess wrapping with `--output json`) and rejected — see ADR 0002.
Tea improvements relevant to the gaps found (JSON output on create commands, non-interactive comment expansion) may be contributed upstream as separate PRs.
- The Gitea Go SDK (`gitea.dev/sdk`) was evaluated as an alternative to `gitea-js`.
It was rejected because it requires Go compilation, adding cross-compilation complexity for npm distribution.
- `gitea-axi` is unclaimed on npm and GitHub as of 2026-07-09.
- Developed against the operator's personal Gitea instance at `git.alexion.dev`; push-mirrored to GitHub for npm publishing and public discoverability.