Files
gitea-axi/.claude/gh-axi.md

1338 lines
43 KiB
Markdown

# gh-axi Source Reference
This document is the definitive reference for what gh-axi actually does, derived
by reading every source file in the
[kunchenguid/gh-axi](https://github.com/kunchenguid/gh-axi) repository.
It is the ground truth for validating gitea-axi implementation decisions.
---
## Table of Contents
1. [Architecture Overview](#architecture-overview)
2. [Framework: axi-sdk-js and runAxiCli](#framework-axi-sdk-js-and-runaxicli)
3. [Core Infrastructure](#core-infrastructure)
- [gh.ts — Subprocess Layer](#ghts--subprocess-layer)
- [context.ts — Repository Resolution](#contextts--repository-resolution)
- [host.ts — Hostname Resolution](#hostts--hostname-resolution)
- [args.ts — Argument Parsing](#argsts--argument-parsing)
- [body.ts — Body Input Handling](#bodyts--body-input-handling)
- [toon.ts — Output Rendering and FieldDef Type System](#toonts--output-rendering-and-fielddef-type-system)
- [fields.ts — --fields Flag Parsing](#fieldsts----fields-flag-parsing)
- [format.ts — Count Line Formatting](#formatts--count-line-formatting)
- [errors.ts — Error Classification](#errorsts--error-classification)
- [suggestions.ts — Suggestion Normalization](#suggestionsts--suggestion-normalization)
4. [cli.ts — Top-Level Dispatcher](#clits--top-level-dispatcher)
5. [Commands](#commands)
- [home](#home-command)
- [issue](#issue-command)
- [pr](#pr-command)
- [label](#label-command)
- [run](#run-command-skimmed)
6. [Cross-Cutting Patterns](#cross-cutting-patterns)
---
## Architecture Overview
gh-axi is a thin ergonomic wrapper around the `gh` CLI.
Its job is to:
1. Accept structured arguments from an AI agent.
2. Delegate the actual GitHub API work to `gh` subprocess calls.
3. Format the results in TOON (a machine-readable structured text format) so
the agent can parse them efficiently.
Every command function has the signature:
```ts
async function someCommand(args: string[], ctx?: RepoContext): Promise<string>
```
The return value is always a TOON-encoded string.
Errors are thrown as `AxiError` instances; the framework catches them and
renders them in TOON format before returning to the caller.
gh-axi never calls the GitHub API directly — all API access goes through `gh`
subprocess invocations.
The one exception is REST API calls that go through `gh api <path>`, which is
still a `gh` subprocess.
---
## Framework: axi-sdk-js and runAxiCli
`cli.ts` calls `runAxiCli()` from the `axi-sdk-js` package.
That function owns:
- Parsing `-v`/`-V`/`--version` and `--help`.
- Dispatching to the correct command handler based on `argv[0]`.
- Calling `resolveContext()` to build the `RepoContext` before any command runs.
- Catching `AxiError` and rendering it as a TOON error block.
- Writing the result string to stdout.
The `AxiError` class is imported from `axi-sdk-js` and re-exported from
`errors.ts`.
Its constructor signature is:
```ts
new AxiError(message: string, code: ErrorCode, suggestions?: string[])
```
`exitCodeForError(err)` — also from `axi-sdk-js` — maps an `AxiError` code to
a POSIX exit code (used by the framework, not directly by command handlers).
---
## Core Infrastructure
### gh.ts — Subprocess Layer
All `gh` subprocess calls route through four functions.
**`ghJson<T>(args, ctx?)`**
Runs `gh <args>`, appends `--repo owner/name` when `ctx.source !== 'git'`,
parses stdout as JSON, and returns `T`.
Throws `AxiError` on non-zero exit (via `mapGhError`) or JSON parse failure.
**`ghExec(args, ctx?)`**
Same as `ghJson` but returns raw stdout string.
Used for commands that emit URLs or human-readable output rather than JSON.
**`ghRaw(args, ctx?)`**
Same invocation as `ghExec` but returns `{ stdout, stderr, exitCode }` without
throwing on non-zero exit.
Used when the caller needs to inspect the exit code (e.g., `pr revert` fallback
path, `pr list` GraphQL totalCount).
**`ghExecWithStdin(args, input, ctx?)`**
Writes `input` to the child's stdin instead of passing it as a CLI flag.
Used for secrets and variables so sensitive values never appear in argv.
**Repo injection rule:** `--repo owner/name` is appended automatically for
`ctx.source === 'flag'` or `ctx.source === 'env'`.
When `ctx.source === 'git'` (resolved from the local git remote), the flag is
omitted and `gh` auto-detects the repo.
**Buffer limit:** 10 MB (`MAX_BUFFER_BYTES`).
**ENOENT handling:** If `gh` is not installed, the `execFile` call produces an
`ENOENT` error, which is converted to a `GH_NOT_INSTALLED` `AxiError` with the
message `"gh CLI is not installed — see https://cli.github.com"`.
---
### context.ts — Repository Resolution
`resolveRepo(flagValue?)` resolves the target repository.
Priority order:
1. Explicit `--repo` / `-R` flag value passed in `flagValue`.
2. `GH_REPO` environment variable.
3. `git remote get-url origin` (parsed for both SSH and HTTPS URL forms).
Returns `RepoContext | undefined`.
```ts
interface RepoContext {
owner: string;
name: string;
nwo: string; // "OWNER/NAME"
source: "flag" | "env" | "git";
host?: HostContext;
}
```
The `source` field controls whether `--repo` is appended to `gh` calls (see
gh.ts above).
URL parsing supports:
- SSH: `git@<host>:OWNER/NAME.git`
- HTTPS: `https://<host>/OWNER/NAME.git`
- The host matched against is the configured GH_HOST (defaults to `github.com`).
---
### host.ts — Hostname Resolution
`resolveHost(flagValue?)` resolves the effective GitHub hostname.
Priority: explicit `--hostname` flag > `GH_HOST` env > `"github.com"`.
When `--hostname` is provided, `cli.ts` writes it into `process.env["GH_HOST"]`
before running any command.
This means the child `gh` process inherits the hostname via env, and
`resolveRepo()`'s URL parser also sees the correct host via `resolveHost()`.
`DEFAULT_HOST = "github.com"`.
`HostContext`:
```ts
interface HostContext {
value: string;
source: "flag" | "env" | "default";
}
```
---
### args.ts — Argument Parsing
All argument parsing is done by hand — no external argument parser library.
Key functions:
**`getFlag(args, name)`** — read a flag's value without modifying `args`.
Supports `--flag value` and `--flag=value` forms.
**`takeFlag(args, flag)`** — read and remove a flag (and its value) from `args`.
**`hasFlag(args, flag)`** — boolean presence check, does not modify `args`.
**`takeBoolFlag(args, flag)`** — check presence and remove from `args`.
**`getAllFlags(args, flag)`** — collect all values for a repeatable flag
(e.g., multiple `--label` flags).
**`getPositional(args, startIndex)`** — return the first non-flag arg starting
from `startIndex`.
**`requireNumber(raw, label)`** — parse a string as an integer, throw
`VALIDATION_ERROR` if missing or not numeric.
**`takeNumber(args, label)`** — find the first all-digit positional in `args`,
remove it, and return it as a number.
Used by most `pr` subcommands to extract the PR number even when it appears
anywhere in the argument list.
---
### body.ts — Body Input Handling
Handles `--body` (inline text) and `--body-file` (file path) flags for all
commands that accept a body.
**`takeBody(args, options?)`**
Removes the matched flag(s) from `args` and returns the body string.
- `options.required: true` — throws `VALIDATION_ERROR` if no body source is
provided.
- `options.inlineFlags` — defaults to `["--body"]`.
- `options.fileFlags` — defaults to `["--body-file"]`.
- `options.valueBoundaryFlags` — additional flags that signal the end of a
value (prevents consuming the next flag as the value).
- `options.label` — used in error messages (defaults to `"body"`).
Conflict rule: only one body source is allowed; providing both `--body` and
`--body-file` throws `VALIDATION_ERROR`.
File reading: Uses synchronous `readFileSync` with UTF-8 encoding.
Throws `VALIDATION_ERROR` with the appropriate message for `ENOENT` (file not
found) and `EISDIR` (path is a directory).
**`cleanBody(text)`**
Applied before truncation to reduce token cost.
Transformations applied in order:
1. Normalize GitHub PR/issue URLs in markdown links to `PR#N` / `Issue#N`.
2. Normalize bare GitHub PR/issue URLs to `PR#N` / `Issue#N`.
3. Strip markdown image embeds `![alt](url)``[image: alt]` or `[image]`.
4. Strip long URLs (>80 chars) in markdown links: `[text](longurl)``[text]`.
5. Strip standalone long URLs (>100 chars) not in markdown → `[long URL removed]`.
6. Collapse email-style quoted blocks (3+ consecutive `> ...` lines) →
`[quoted text removed]`.
**`truncateBody(body, maxLen=500, options?)`**
Returns the body for display.
- If `body.length <= maxLen`: returns body as-is (no cleaning applied).
- If cleaned body fits within `maxLen`: returns the cleaned body.
If cleaning changed the content, appends
`"\n(cleaned, N chars original - use --full to see original)"`.
- If cleaned body still exceeds `maxLen`: returns first `maxLen` chars of the
cleaned body plus
`"\n... (truncated, N chars total - use --full to see complete body)"`.
Key invariant: **cleaning is only applied when truncation is needed**.
The raw body is preserved when it fits.
`--full` flag bypasses truncation entirely at the command level by swapping
`viewSchema` for `viewSchemaFull` (which uses the raw body directly).
---
### toon.ts — Output Rendering and FieldDef Type System
TOON is the structured text format used for all gh-axi output.
The actual encoding is delegated to `@toon-format/toon`'s `encode()` function.
#### FieldDef Type System
`FieldDef` is a discriminated union describing how to extract and format one
field from a raw JSON item.
```ts
type FieldDef =
| { type: 'field'; key: string; as?: string }
| { type: 'pluck'; key: string; subkey: string; as?: string }
| { type: 'joinArray'; key: string; subkey: string; as?: string; empty?: string }
| { type: 'relativeTime'; key: string; as?: string }
| { type: 'boolYesNo'; key: string; as?: string }
| { type: 'mapEnum'; key: string; map: Record<string,string>; fallback?: string; as?: string }
| { type: 'lower'; key: string; as?: string }
| { type: 'checksSummary'; key: string; as?: string }
| { type: 'custom'; as: string; fn: (item: any) => any }
```
Constructor helpers (all exported from `toon.ts`):
| Helper | What it produces |
|--------|-----------------|
| `field(key, as?)` | Raw value from `item[key]`, null if missing |
| `pluck(key, subkey, as?)` | `item[key][subkey]`, null if missing |
| `joinArray(key, subkey, as?, empty?)` | Comma-joined array of `item[key][n][subkey]` or raw strings; `empty` defaults to `"none"` |
| `relativeTime(key, as?)` | ISO date → human string: "just now", "Nm ago", "Nh ago", "Nd ago", "Nmo ago", "Ny ago" |
| `boolYesNo(key, as?)` | boolean → `"yes"` / `"no"` |
| `mapEnum(key, map, fallback?, as?)` | Maps enum string through `map`; uses `fallback` or raw value if not in map |
| `lower(key, as?)` | `.toLowerCase()` on a string value |
| `checksSummary(key, as?)` | Array of check runs → `"N/M pass"` |
| `custom(as, fn)` | Arbitrary extractor; `fn` receives the full raw item |
The `as` parameter overrides the output key name (defaults to `key` for all
non-custom types).
#### Rendering Functions
**`extract(item, schema)`** — applies a `FieldDef[]` schema to one raw item,
returning a flat `Record<string, unknown>`.
**`renderList(label, items, schema)`** — encodes `{ [label]: extractedItems[] }`
as TOON.
**`renderDetail(label, item, schema)`** — encodes `{ [label]: extractedItem }`
as TOON.
**`renderHelp(lines)`** — renders a help section.
Format: `help[N]:\n line1\n line2\n...`
(Manual formatting — not using `encode()` because that inlines primitive
arrays.)
**`renderError(message, code, suggestions?)`** — encodes
`{ error: message, code }` as TOON, followed by a `renderHelp(suggestions)`
block if suggestions are provided.
**`renderOutput(blocks)`** — joins non-empty blocks with `"\n"`.
---
### fields.ts — --fields Flag Parsing
`parseFields(fieldsArg, available)` processes the value of `--fields`.
- `fieldsArg` undefined → returns `{ extraDefs: [], extraJsonKeys: [] }` (no-op).
- Splits the value on commas, deduplicates (using `Set`), trims whitespace.
- Validates against `available: Record<string, ExtraFieldSpec>`.
Unknown fields throw `VALIDATION_ERROR` listing all available names.
- Returns:
- `extraDefs: FieldDef[]` — additional FieldDef entries to append to the base
schema.
- `extraJsonKeys: string[]` — additional JSON field keys to pass to `gh --json`.
`ExtraFieldSpec`:
```ts
interface ExtraFieldSpec {
jsonKey: string; // the gh JSON field name to add to --json
def: FieldDef; // the FieldDef to render it
}
```
The caller merges `extraJsonKeys` into the base JSON fields string and appends
`extraDefs` to the base schema before rendering.
---
### format.ts — Count Line Formatting
`formatCountLine(opts)` produces a single count line for list commands.
Logic (checked in order):
1. `apiLimitHit: true``"count: N+ (GitHub search API limit reached)"`
2. `totalCount` defined → `"count: N of T total"`
3. `displayLimit` defined and `count > displayLimit`
`"count: N (showing first D)"`
4. `limit` defined and `count === limit && count > 0`
`"count: N (showing first N)"`
5. Otherwise → `"count: N"`
---
### errors.ts — Error Classification
Re-exports `AxiError` and `exitCodeForError` from `axi-sdk-js`.
Defines `ErrorCode`:
```ts
type ErrorCode =
| "REPO_NOT_FOUND" | "NOT_FOUND" | "AUTH_REQUIRED" | "FORBIDDEN"
| "VALIDATION_ERROR" | "RATE_LIMITED" | "GH_NOT_INSTALLED" | "UNKNOWN"
```
`mapGhError(stderr, exitCode)` — classifies stderr text from a failed `gh`
invocation into one of the above codes.
Patterns matched (in order):
| Pattern | Code | Message |
|---------|------|---------|
| `Could not resolve to a Repository with the name 'X'` | `REPO_NOT_FOUND` | `Repository "X" not found` |
| `Could not resolve to an? .+? with the number of N` | `NOT_FOUND` | `Item #N does not exist` |
| `issue N not found` | `NOT_FOUND` | `Issue #N does not exist` |
| `pull request N not found` | `NOT_FOUND` | `Pull request #N does not exist` |
| `release with tag "X" not found` | `NOT_FOUND` | `Release "X" not found` |
| `run N not found` | `NOT_FOUND` | `Run N not found` |
| `gh auth login` | `AUTH_REQUIRED` | auth prompt message |
| `authentication token is missing required scopes [X]` | `FORBIDDEN` | missing scopes message |
| `secondary rate limit` | `RATE_LIMITED` | rate limit message |
| `API rate limit exceeded` | `RATE_LIMITED` | rate limit message |
| `sub-issue is already a sub-issue of issue with number N` | `VALIDATION_ERROR` | already sub-issue message |
| `sub-issue cycle/circular` | `VALIDATION_ERROR` | cycle message |
| `issue cannot be a sub-issue of itself` | `VALIDATION_ERROR` | self message |
| `HTTP 403` | `FORBIDDEN` | permissions message |
| `HTTP 422` | `VALIDATION_ERROR` | message from response body if available |
| `not found` (generic) | `NOT_FOUND` | first stderr line |
| fallback | `UNKNOWN` | first stderr line or `"gh exited with code N"` |
`ghNotInstalledError()` — returns `AxiError("gh CLI is not installed...", "GH_NOT_INSTALLED")`.
---
### suggestions.ts — Suggestion Normalization
`getSuggestions(ctx)` returns a list of hint strings for the `help[N]:` block
that follows most command output.
`SuggestionContext`:
```ts
interface SuggestionContext {
domain: string; // "issue", "pr", "run", "label", "home", ...
action: string; // "list", "view", "create", ...
state?: string; // "open", "closed", "merged", "in_progress", ...
isEmpty?: boolean; // for list commands
id?: string | number; // entity number/id/tag for template substitution
repo?: RepoContext;
host?: HostContext;
owner?: string; // for project commands
}
```
The table is matched top-to-bottom; the first matching entry wins.
**-R flag injection rule:**
`repoFlag(ctx)` returns `" -R owner/name"` when `ctx.repo.source !== "git"`.
When `source === "git"`, the empty string is returned (no `-R` in suggestions).
This means suggestion strings automatically include `-R` when the repo was
specified via flag or env, but omit it when detected from the git remote.
**-R flag position normalization:**
After generating suggestion lines, each line is passed through
`normalizeRepoFlagLine()`, which rewrites any
`` `gh-axi -R X/Y subcommand args` `` into
`` `gh-axi subcommand args -R X/Y` ``.
This enforces the convention that `-R` comes after the subcommand.
**--hostname flag injection:**
After normalizing `-R`, `appendHostnameFlag()` appends ` --hostname HOST` to
every backtick-quoted `gh-axi ...` invocation in the line — but only when the
host was set via `--hostname` flag (not via `GH_HOST` env or default).
`withSuggestionHost(host, callback)` — sets a thread-local active host so that
`getSuggestions()` can read the hostname context even when it is not included in
the `SuggestionContext` arg.
Called by `withRepoContext()` in `cli.ts` to propagate the resolved
`HostContext` into every command handler's suggestion calls.
---
## cli.ts — Top-Level Dispatcher
Registers 14 commands: `issue`, `pr`, `run`, `workflow`, `release`, `repo`,
`label`, `project`, `secret`, `variable`, `search`, `api`, `setup`.
The no-arg case (home dashboard) is handled by `homeCommand`.
**Global flags** (stripped before dispatch):
- `-R <nwo>` or `-R=<nwo>` — repo override.
- `--repo <nwo>` or `--repo=<nwo>` — repo override.
Exception: for the `search` command, `--repo` is passed through to `gh search`
rather than being stripped.
- `--hostname <host>` or `--hostname=<host>` — host override, written to
`process.env["GH_HOST"]` so it propagates to child `gh` processes.
- `--help`, `-v`, `-V`, `--version` — handled by `runAxiCli`.
`parseRepoContextArgs(command, args)` strips the repo/host flags and returns:
- `repoFlag` — the repo NWO string (or undefined).
- `hostFlag` — the hostname string (or undefined).
- `strippedArgs` — remaining args passed to the command handler.
`withRepoContext(command, handler)` wraps each command handler:
1. Calls `parseRepoContextArgs` to strip repo/host flags from args.
2. Extracts the `RepoContext` from the resolved CLI context.
3. Calls `withSuggestionHost` to set the host for suggestion rendering.
4. Calls the underlying handler with stripped args and repo context.
---
## Commands
### home Command
**File:** `src/commands/home.ts`
**What it does:**
Dashboard view.
Runs two `gh` calls in parallel:
- `gh issue list --json number,title,state,author --limit 3`
- `gh pr list --json number,title,author,reviewDecision --limit 3`
If either call fails, it silently returns an empty array (`.catch(() => [])`).
**Delegation:** Fully delegates to `gh` subprocess.
**Output:**
```
repo: owner/name ← only when ctx is defined
issues: [ { number, title, state, author }, ... ]
prs: [ { number, title, author, review }, ... ]
help[N]:
Run `gh-axi issue list` for full issue list ← only if issues >= 3
Run `gh-axi pr list` for full PR list ← only if prs >= 3
Run `gh-axi <command> <subcommand>` ... ← from suggestions table
```
**Idempotency:** Read-only; not applicable.
**Truncation:** None (only 3 items shown per entity type; no body fields).
---
### issue Command
**File:** `src/commands/issue.ts`
**Subcommands:** list, view, create, edit, close, reopen, comment, delete, lock,
unlock, pin, unpin, transfer, subissue (add/remove/list)
---
#### issue list
**Delegation:** `gh issue list --json --limit` with optional filters.
**Flags:**
- `--state <open|closed|all>` (default: unset, gh defaults to `open`)
- `--label <name>`
- `--assignee <login>`
- `--author <login>`
- `--milestone <name>`
- `--sort <created|updated|comments>` — translated to `--search sort:X-desc`
passed to `gh`
- `--limit <n>` (default 30)
- `--fields <a,b,c>` — extra fields (see below)
- `--search` — **explicitly forbidden**; throws `VALIDATION_ERROR` redirecting
to `gh-axi search issues`
**Default JSON fields:** `number,title,state,author,createdAt`
**Extra fields available via --fields:**
| Name | JSON key | Output key |
|------|----------|------------|
| `body` | `body` | `body` (raw, not truncated) |
| `closedAt` | `closedAt` | `closed_at` (relative time) |
| `labels` | `labels` | `labels` (comma-joined names) |
| `milestone` | `milestone` | `milestone` (title) |
| `updatedAt` | `updatedAt` | `updated_at` (relative time) |
| `url` | `url` | `url` |
**Default output schema (list):**
| Output key | Source | Transform |
|------------|--------|-----------|
| `number` | `number` | raw |
| `title` | `title` | raw |
| `state` | `state` | lowercase |
| `author` | `author.login` | pluck |
| `created` | `createdAt` | relative time |
**True-count behavior:**
If `items.length === limit` and `ctx` is defined, gh-axi makes a secondary
GraphQL call to get the real `totalCount`:
```graphql
{ repository(owner:"...", name:"...") { issues(states:[STATE]) { totalCount } } }
```
On success, the count line becomes `"count: N of T total"`.
On failure, falls back to `"count: N (showing first N)"`.
**Error handling:** `--search` is a hard `VALIDATION_ERROR` before any `gh`
call.
---
#### issue view
**Delegation:** `gh issue view --json` then optional GraphQL for sub-issue
relationships.
**Flags:**
- `<number>` — required positional (uses `requireNumber(getPositional(args, 1))`)
- `--comments` — fetches and renders all comments in a separate block
- `--full` — bypasses body truncation (swaps schema variant)
**JSON fields fetched:**
`number,title,state,author,createdAt,body[,comments],issueType`
`issueType` is attempted first.
If `gh` errors with a message containing `"issueType"`, the field is dropped
and a second call is made without it (`supportsIssueType = false`).
**Default output schema (view):**
| Output key | Source | Transform |
|------------|--------|-----------|
| `number` | `number` | raw |
| `title` | `title` | raw |
| `state` | `state` | lowercase |
| `author` | `author.login` | pluck |
| `created` | `createdAt` | relative time |
| `type` | `issueType.name` | custom (returns `"none"` if absent) |
| `body` | `body` | `truncateBody(body, 500)` |
With `--full`: body field is swapped to return the raw string with no truncation.
With `--comments`: a separate `comments` list block is appended:
```
comments: [ { author, body, created }, ... ]
```
Comments schema filters out the `number` field (it belongs to the issue, not
the comment).
**Sub-issue augmentation (when ctx is defined):**
A best-effort GraphQL call fetches `parent` and `subIssues(first:100)`.
If `childNums.length > 0`, a `subissues: ["#N", ...]` field is added.
If `parentNum != null`, a `parent: "#N"` field is added.
Failures are silently swallowed.
**Body truncation:** 500 chars max (cleaning applied before truncation).
---
#### issue create
**Delegation:** `gh issue create` then `gh issue view` to get structured output.
If `--type` is specified, an additional GraphQL mutation is applied after create.
**Flags:**
- `--title <text>` — required
- `--body <text>` or `--body-file <path>` — optional
- `--assignee <login>`
- `--label <name>` — repeatable
- `--milestone <name>`
- `--project <name>`
- `--type <name>` — issue type (resolved via GraphQL before create)
**Type resolution:** Calls `resolveIssueType(typeName, ctx)` before creating
the issue.
This makes a GraphQL query for `issueTypes(first:25)` and matches
case-insensitively.
On match failure, throws `VALIDATION_ERROR` with the list of available types.
**Output:** `gh issue create` emits a URL; the number is extracted via
regex `/\/issues\/(\d+)/`.
A follow-up `gh issue view N --json number,title,state,url,id` fetches the
structured result.
**Output schema:**
```
issue: { number, title, state, url [, type] }
```
**Idempotency:** None (create is not idempotent).
---
#### issue edit
**Delegation:** `gh issue edit N` then `gh issue view N` for structured output.
If `--type` or `--no-type` is specified, a GraphQL mutation is applied.
**Flags:**
- `<number>` — required positional
- `--title <text>`
- `--body <text>` or `--body-file <path>`
- `--add-label <name>` / `--remove-label <name>`
- `--add-assignee <login>` / `--remove-assignee <login>`
- `--milestone <name>`
- `--type <name>` — set issue type (resolved via GraphQL)
- `--no-type` — clear issue type (sends `issueTypeId: null` via GraphQL)
**Important:** `gh issue edit` is only called when there is at least one
non-type field to update (i.e., `ghArgs.length > 3`).
If only `--type` or `--no-type` is given, the edit call is skipped and only
the GraphQL mutation runs.
**Output schema:**
```
issue: { number, title, state, labels, assignees [, type] }
```
---
#### issue close
**Delegation:** `gh issue view` (idempotency check) then `gh issue close`.
**Flags:**
- `<number>` — required positional
- `--reason <completed|not_planned>`
- `--comment <text>`
**Idempotency:** Checks current state first.
If already `"closed"`, returns the issue detail with `message: "Already closed"`
without calling `gh issue close`.
**Output schema (success):** `issue: { number, state }`
---
#### issue reopen
**Delegation:** `gh issue view` (idempotency check) then `gh issue reopen`.
**Idempotency:** Checks current state.
If already `"open"`, returns `message: "Already open"`.
**Output schema:** `issue: { number, state }`
---
#### issue comment
**Delegation:** `gh issue comment N --body <body>` then `gh issue view N
--json comments` to get the last comment for structured output.
**Flags:**
- `<number>` — required positional
- `--body <text>` or `--body-file <path>` — **required** (`takeBody(args, { required: true })`)
**Body truncation:** The comment body in the output is truncated to 800 chars
(larger than issue body's 500 chars).
**Output schema:** `comment: { issue, author, created, body }`
---
#### issue delete
**Delegation:** `gh issue delete N --yes`
**Flags:** `<number>` — required positional.
**Idempotency:** None (will error if issue does not exist; error is mapped by
`mapGhError`).
**Output schema:** `issue: { number, status: "deleted" }`
---
#### issue lock / unlock
**Delegation:** `gh issue view` (idempotency check) then `gh issue lock/unlock N`.
**Idempotency:**
- `lock`: returns early with `message: "Already locked"` if `current.locked === true`.
- `unlock`: returns early with `message: "Already unlocked"` if `current.locked === false`.
**Output schema:** `issue: { number, state, locked }`
---
#### issue pin / unpin
**Delegation:** `gh issue view` (idempotency check) then `gh issue pin/unpin N`.
**Idempotency:**
- `pin`: returns early with `message: "Already pinned"` if `current.isPinned === true`.
- `unpin`: returns early with `message: "Already unpinned"` if `current.isPinned === false`.
**Output schema:** `issue: { number, state, pinned }`
---
#### issue transfer
**Delegation:** `gh issue transfer N destRepo` then attempts `gh issue view N
--json number,url --repo destRepo` to get the new URL.
**Flags:**
- `<number>` — required positional
- `--to-repo <owner/name>` — **required**
**Fallback:** If the post-transfer view fails, constructs a best-effort URL from
`https://resolveHost()/destRepo/issues/N`.
**Output schema:** `issue: { number, url }`
---
#### issue subissue
Three-layer dispatch: `issue subissue <add|remove|list>`.
All subissue operations require `ctx` (will throw `VALIDATION_ERROR` if no
repo can be determined).
All use GraphQL directly (not `gh issue` subcommands) because GitHub does not
expose sub-issues via the `gh` REST CLI.
**subissue add `<parent> <child> [<child> ...]`**
1. Batch-resolves all node IDs via one GraphQL query.
2. Adds each child sequentially via `addSubIssue` mutation.
3. On partial failure, throws with a message listing already-added children.
Output schema: `subissue_add: { parent: "#N", added: ["#N", ...] }`
**subissue remove `<parent> <child>`**
Resolves node IDs then calls `removeSubIssue` mutation.
Output schema: `subissue_remove: { parent: "#N", removed: "#N" }`
**subissue list `<parent>`**
GraphQL query for `subIssues(first:100)`.
Limit is hard-coded at 100; `formatCountLine` is called with `totalCount` from
GraphQL so the output shows `"count: N of T total"` when there are more than 100.
Output:
```
parent: #N
count: N [of T total]
subissues: [ { number, title, state }, ... ]
```
---
### pr Command
**File:** `src/commands/pr.ts`
**Subcommands:** list, view, create, edit, close, merge, review, checks, diff,
checkout, ready, reopen, comment, update-branch, revert
---
#### pr list
**Delegation:** `gh pr list --json --state --limit` with optional filters.
**Flags:**
- `--state <open|closed|all>` (default `"open"`, consumed by `takeFlag`)
- `--label <name>`
- `--assignee <login>`
- `--author <login>`
- `--base <branch>`
- `--head <branch>`
- `--draft` — boolean flag
- `--limit <n>` (default 30)
- `--fields <a,b,c>`
- `--search` — **explicitly forbidden**, throws `VALIDATION_ERROR`
**Default JSON fields:** `number,title,state,author,isDraft,reviewDecision`
**Extra fields via --fields:**
| Name | JSON key | Output key |
|------|----------|------------|
| `body` | `body` | `body` (raw) |
| `createdAt` | `createdAt` | `created` (relative time) |
| `labels` | `labels` | `labels` (comma-joined names) |
| `milestone` | `milestone` | `milestone` (title) |
| `mergedAt` | `mergedAt` | `merged_at` (relative time) |
| `url` | `url` | `url` |
**Default output schema (list):**
| Output key | Source | Transform |
|------------|--------|-----------|
| `number` | `number` | raw |
| `title` | `title` | raw |
| `state` | `state` | lowercase |
| `author` | `author.login` | pluck |
| `draft` | `isDraft` | bool→yes/no |
| `review` | `reviewDecision` | mapEnum: APPROVED→approved, CHANGES\_REQUESTED→changes\_requested, REVIEW\_REQUIRED→required; fallback "none" |
**True-count behavior:** Same pattern as issue list — secondary GraphQL when
`items.length === limit` and `ctx` is defined.
GraphQL query:
```graphql
{ repository(owner:"...", name:"...") { pullRequests(states:[STATE]) { totalCount } } }
```
State mapping: `ALL` → no `states:[]` filter; `CLOSED` → `states:[CLOSED,MERGED]`;
otherwise `states:[STATE]`.
---
#### pr view
**Delegation:** `gh pr view N --json` then optional REST API calls for reviews.
**Flags:**
- `<number>` — required (via `takeNumber`)
- `--comments` — includes full comment content
- `--reviews` — fetches reviews and inline review comments via REST API
- `--full` — bypasses body truncation
**JSON fields always fetched:**
`number,title,state,author,isDraft,mergedAt,statusCheckRollup,body,comments,reviews`
**Default output schema (view):**
| Output key | Source | Transform |
|------------|--------|-----------|
| `number` | `number` | raw |
| `title` | `title` | raw |
| `state` | `state` | lowercase |
| `author` | `author.login` | pluck |
| `draft` | `isDraft` | bool→yes/no |
| `merged` | custom | `mergedAt` value if state is MERGED, else `"no"` |
| `checks` | custom | classifies `statusCheckRollup`: "N passed, N failed[, N skipped], N total" or "0 passed, 0 failed — this PR has no CI checks configured" |
| `body` | custom | `truncateBody(body, 500)` |
With `--full`: body field returns raw string.
**comment_count vs comments:**
Without `--comments`: `comment_count: "N — use --comments to see full comments"`.
With `--comments`: `comments: [ { author, body, created }, ... ]` appended to schema.
**review_count vs reviews:**
Without `--reviews`: `review_count: "N — use --reviews to see full reviews"`.
With `--reviews`: two REST API calls:
1. `gh api repos/{owner}/{repo}/pulls/N/reviews --paginate --slurp`
2. `gh api repos/{owner}/{repo}/pulls/N/comments --paginate --slurp`
(only if reviews exist)
Reviews are correlated with inline comments by `pull_request_review_id`.
Output: `reviews: [ { author, state, submitted, body, inline_comments: [...] } ]`
Review states mapped via `REVIEW_STATE_MAP`:
APPROVED→approved, CHANGES\_REQUESTED→changes\_requested,
COMMENTED→commented, DISMISSED→dismissed, PENDING→pending.
**CI check classification:**
```
"pass": conclusion === SUCCESS or NEUTRAL
"fail": conclusion === FAILURE, TIMED_OUT, or ACTION_REQUIRED
"skip": conclusion === SKIPPED or CANCELLED; or state === EXPECTED or NEUTRAL
"pending": everything else
```
**Body truncation:** 500 chars (same as issue view).
---
#### pr create
**Delegation:** `gh pr create` then parses stdout URL.
**Flags:**
- `--title <text>` — **required**
- `--body <text>` or `--body-file <path>` — optional
- `--base <branch>`
- `--head <branch>`
- `--draft`
- `--assignee <login>`
- `--reviewer <login>`
- `--label <name>` — repeatable (via `getAllFlags`)
- `--milestone <name>`
- `--project <name>`
**Number extraction:** Regex `/\/pull\/(\d+)/` on stdout.
**Output schema:** `created: { number, url }`
---
#### pr edit
**Delegation:** `gh pr edit N` (no post-fetch; returns minimal success object).
**Flags:**
- `<number>` — required (via `takeNumber`)
- `--title <text>`
- `--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>`
- `--base <branch>`
**Output schema:** `edited: { number, status: "ok" }`
---
#### pr close
**Delegation:** `gh pr view` (idempotency check) then `gh pr close N`.
**Flags:**
- `<number>` — required
- `--comment <text>`
**Idempotency:** If state is already `"CLOSED"` or `"MERGED"`, returns early
with `pull_request: { number, state, already: true }`.
**Output schema (success):** `closed: { number, status: "ok" }`
---
#### pr merge
**Delegation:** `gh pr view` (idempotency check) then `gh pr merge N`.
**Flags:**
- `<number>` — required
- `--method <merge|squash|rebase>`
- `--merge`, `--squash`, `--rebase` — shorthands (mutually exclusive with
each other and with `--method` unless they agree)
- `--auto`
- `--delete-branch`
- `--body <text>` or `--body-file <path>`
- `--subject <text>`
**Method validation:**
Two or more shorthand flags → `VALIDATION_ERROR`.
`--method` and a non-matching shorthand → `VALIDATION_ERROR`.
Invalid `--method` value → `VALIDATION_ERROR`.
**Idempotency:** If already MERGED, returns:
`pull_request: { number, state: "merged", merged_by, merged_at }`
**Output schema (success):** `merged: { number, status: "ok", method }`
`method` is the resolved method string or `"default"` if none specified.
---
#### pr review
**Delegation:** `gh pr review N [--approve|--request-changes|--comment]`.
**Flags:**
- `<number>` — required
- `--approve`
- `--request-changes`
- `--comment`
- `--body <text>` or `--body-file <path>` — optional
**Output schema:** `review: { number, action }` where action is one of
`"approved"`, `"changes_requested"`, `"commented"`.
---
#### pr checks
**Delegation:** `gh pr view N --json statusCheckRollup`
(avoids `gh pr checks --json` which can error on unusual check data).
**Flags:** `<number>` — required.
**Output:**
```
summary: "N passed, N failed[, N skipped][, N pending], N total"
checks: [ { name, conclusion }, ... ]
help[N]: ...
```
When no checks configured:
```
checks: "0 passed, 0 failed — this PR has no CI checks configured"
```
Check name: uses `c.name ?? c.context ?? "check"`.
Check conclusion: classified to `"pass"`, `"fail"`, `"skip"`, or `"pending"`.
---
#### pr diff
**Delegation:** `gh pr diff N` (raw text output).
**Flags:**
- `<number>` — required
- `--full` — bypass truncation
**Truncation limit:** 4000 chars (hard-coded `DIFF_TRUNCATE_LIMIT`).
Truncates from the beginning (keeps the first 4000 chars of the diff, unlike
log truncation which keeps the tail).
When truncated, output includes `truncated: true` and `original_length: N`.
When `--full` is not passed and the diff is truncated, prepends a suggestion:
`` "Run `gh-axi [-R nwo] pr diff N --full` to see the complete diff" ``.
**Output schema:** `pr_diff: { number, diff[, truncated, original_length] }`
---
#### pr checkout
**Delegation:** `gh pr checkout N`.
**Flags:** `<number>` — required.
Branch name extracted via `/Switched to branch '([^']+)'/` on stdout;
falls back to `stdout.trim()`.
**Output schema:** `checkout: { number, branch, status: "ok" }`
---
#### pr ready
**Delegation:** `gh pr view` (idempotency check) then `gh pr ready N`.
**Idempotency:** If `isDraft === false`, returns early:
`pull_request: { number, draft: "no", already: true }`.
**Output schema (success):** `ready: { number, status: "ok" }`
---
#### pr reopen
**Delegation:** `gh pr view` (idempotency check) then `gh pr reopen N`.
**Idempotency:** If state is `"OPEN"`, returns early:
`pull_request: { number, state: "open", already: true }`.
**Output schema (success):** `reopened: { number, status: "ok" }`
---
#### pr comment
**Delegation:** `gh pr comment N --body <body>`.
**Flags:**
- `<number>` — required
- `--body <text>` or `--body-file <path>` — **required**
**Output schema:** `commented: { number, status: "ok" }`
---
#### pr update-branch
**Delegation:** `gh pr update-branch N`.
**Flags:** `<number>` — required.
**Output schema:** `updated: { number, status: "ok" }`
---
#### pr revert
**Delegation:** Tries `gh pr revert N` first (may not exist in all gh versions).
If exit code is non-zero, falls back to:
`gh api repos/{owner}/{repo}/pulls/N/revert --method POST`
On failure of both, throws `UNKNOWN` `AxiError` with the first stderr line.
The revert PR number is extracted from stdout via `/\/pull\/(\d+)/`.
**Output schema:** `reverted: { number, revert_pr, [url,] status: "ok" }`
---
### label Command
**File:** `src/commands/label.ts`
**Subcommands:** list, create, edit, delete
---
#### label list
**Delegation:** `gh label list --json name --limit N`.
**Flags:** `--limit <n>` (default 500).
**Default output schema:** `labels: [ { name }, ... ]`
**Count line:** uses `formatCountLine({ count, limit })`.
---
#### label create
**Delegation:** Idempotency check via `gh label list --json name`, then
`gh label create`.
**Flags:**
- `--name <text>` — **required**
- `--color <hex>` — **required** (without `#`)
- `--description <text>`
**Idempotency:** Case-insensitive name match.
If found, returns `{ create: "already_exists", label: existingName }` without
creating.
**Output schema (success):** `{ created: "ok", label: name }`
---
#### label edit
**Delegation:** `gh label edit <name>`.
**Flags:**
- `<name>` — positional at args[1]
- `--name <new-name>`
- `--color <hex>`
- `--description <text>`
**Output schema:** `{ edit: "ok", label: newName ?? originalName }`
---
#### label delete
**Delegation:** `gh label delete <name> --yes`.
**Flags:** `<name>` — positional at args[1].
**Output schema:** `{ delete: "ok", label: name }`
---
### run Command (skimmed)
**File:** `src/commands/run.ts`
Not in gitea-axi scope; documented here for the log truncation pattern.
**Log truncation pattern (`wrapLogOutput`):**
Limit: `LOG_TRUNCATE_LIMIT = 20000` chars.
Unlike `pr diff` (which keeps the head), log output truncates to the **tail**:
`output.slice(-LOG_TRUNCATE_LIMIT)`.
Rationale: "CI logs put the failure at the end, so keep the tail when
truncating."
When truncated:
1. Attempts to save the full log to a temp file:
`mkdtemp` in system tmpdir under `gh-axi-logs-` prefix.
File name: `{run}[-job-{job}]-{mode}.log` with non-alphanumeric chars
replaced by `_`.
File permissions: `0o600`.
2. If saved, `run_log.full_log` is set to the temp file path and a hint is
added: `"Output shows the last 20000 of N chars; full log saved to PATH -
grep it for earlier context"`.
3. If save fails (best-effort), a hint without a path is added.
Output schema:
```
run_log: { run, mode, output, truncated, [original_length, full_log] }
```
`run cancel` is idempotent: checks `status === "completed"` before canceling.
If already completed, returns `{ cancel: "already_completed", run, conclusion }`.
---
## Cross-Cutting Patterns
### Idempotency Pattern
Commands that mutate state follow this pattern before calling `gh`:
1. Fetch current state via `gh ... --json state` (or `locked`, `isPinned`, etc.).
2. If already in the target state, return a structured response with
`already: true` (or `message: "Already X"`).
3. Only then call the mutating `gh` command.
Applies to: `issue close`, `issue reopen`, `issue lock`, `issue unlock`,
`issue pin`, `issue unpin`, `pr close`, `pr merge`, `pr ready`, `pr reopen`,
`run cancel`.
### Body Handling Pattern
Commands that accept body text follow this sequence:
1. Call `takeBody(args, options?)` which removes the flag from `args` and
returns the body string (or undefined if optional and not provided).
2. If body is not undefined, pass it to `gh` via `--body bodyText`.
3. `takeBody` handles both `--body "text"` and `--body-file path` forms.
4. `--body` and `--body-file` cannot both be provided; `takeBody` throws
`VALIDATION_ERROR` if both are present.
### Error Propagation Pattern
All `gh` calls that use `ghJson` or `ghExec` throw `AxiError` on failure.
These propagate up through the command handler.
The `runAxiCli` framework catches all `AxiError` instances and renders them as:
```
error: <message>
code: <ERROR_CODE>
help[N]:
<suggestions>
```
Unhandled errors (non-`AxiError`) are also caught by the framework and rendered
as `UNKNOWN` errors.
### --repo Injection Timing
`-R` / `--repo` is parsed by `parseRepoContextArgs` in `cli.ts` before any
command handler runs.
The flag is stripped from `args` before they are passed to the handler.
The resolved `RepoContext` is passed as the `ctx` parameter.
Inside handlers, `ghJson(args, ctx)` / `ghExec(args, ctx)` call `buildArgs`
which appends `--repo owner/name` when `ctx.source !== 'git'`.
**search command exception:** `--repo` is passed through to the handler (not
stripped) because `gh search` expects `--repo` as its own flag.
### Number Extraction
Most pr subcommands use `takeNumber(args, "PR")` which finds the first
all-digit positional token anywhere in `args` and removes it.
Most issue subcommands use `requireNumber(getPositional(args, 1), "issue")`
which reads position 1 (after the subcommand name) without modifying `args`.
This means issue commands use positional ordering while pr commands are more
permissive (the number can appear anywhere in the remaining args).
### True Count via GraphQL
Both `issue list` and `pr list` make a secondary GraphQL call when the number
of returned items equals the requested limit.
This provides an accurate `"count: N of T total"` line instead of the less
informative `"count: N (showing first N)"`.
The GraphQL call is best-effort: failures silently fall back to the limit-based
message.
### Paginated REST API Calls
`ghApiPaginatedArray<T>(path)` calls `gh api <path> --paginate --slurp` and
flattens the result.
Used by `pr view --reviews` to fetch all review objects and all inline review
comments.
### GraphQL via gh api graphql
Sub-issue operations and issue type resolution bypass `gh issue` subcommands
and call `gh api graphql` directly.
The `gqlRequest` helper passes `void ctx` explicitly (suppressing the lint
warning) because `gh api graphql` ignores `--repo`; the owner/name are baked
into the query string.