# Gitea Tools

A collection of Python scripts and an MCP server to automate interactions with Gitea instances.

## Supported Instances

| Remote         | Host                       | Org / Repo                          |
|----------------|----------------------------|-------------------------------------|
| `dadeschools`  | `gitea.dadeschools.net`    | `Contractor / Timesheet`            |
| `prgs`         | `gitea.prgs.cc`            | `Scaled-Tech-Consulting / Timesheet`|

## Authentication

Authentication is configured via environment variables or a local `.env` file in the repository root (uses `python-dotenv`).

Create a `.env` file in the project root:

```bash
# Option A: Gitea Personal Access Tokens (Recommended)
GITEA_TOKEN_DADESCHOOLS="your_token_here"
GITEA_TOKEN_PRGS="your_token_here"

# Option B: Gitea Username & Password (fallback)
GITEA_USER_DADESCHOOLS="username"
GITEA_PASS_DADESCHOOLS="password"
GITEA_USER_PRGS="username"
GITEA_PASS_PRGS="password"

# Optional: Fallback to macOS Keychain (via git credential fill)
# GITEA_USE_KEYCHAIN=1
```

## MCP Server (Recommended)

The Gitea-Tools MCP server exposes all functionality as structured tool calls.
Any MCP-compatible agent (Antigravity, Claude Code, etc.) can call these tools natively.

### Available Tools

| Tool | Description |
|------|-------------|
| `gitea_create_issue` | Create an issue with title, body, remote |
| `gitea_create_pr` | Open a pull request with title, head, base |
| `gitea_edit_pr` | Edit details of an existing pull request |
| `gitea_list_prs` | List pull requests with state/remote |
| `gitea_view_pr` | Get full details of a single pull request |
| `gitea_merge_pr` | Gated merge: merge/squash/rebase only after identity+profile+eligibility gates pass, explicit `confirmation="MERGE PR <n>"`, optional head-SHA and changed-files pinning (no self-merge, no force) |
| `gitea_review_pr` | Legacy wrapper for `gitea_submit_pr_review` (merging disabled) |
| `gitea_delete_branch` | Delete a remote branch |
| `gitea_close_issue` | Close an issue by number |
| `gitea_list_issues` | List issues with state/label filters |
| `gitea_view_issue` | Get full details of a single issue |
| `gitea_whoami` | Read-only: identify the authenticated Gitea account (safe metadata only) |
| `gitea_get_profile` | Read-only: describe the active runtime execution profile (safe metadata only) |
| `gitea_check_pr_eligibility` | Read-only: check if the current identity/profile may review/approve/request_changes/merge a PR |
| `gitea_assess_conflict_fix_classification` | Read-only: classify conflict-fix need from a live PR head re-fetch before creating a conflict-fix worktree |
| `gitea_submit_pr_review` | Gated review mutation: comment/approve/request_changes, only after identity+profile+eligibility gates pass (no merge, no self-approval) |
| `gitea_mark_issue` | Claim/release an issue (start/done) |
| `gitea_list_labels` | List all available labels in a repository |
| `gitea_create_label` | Create a new label with custom color |
| `gitea_set_issue_labels` | Replace all labels on an issue |
| `gitea_get_file` | Retrieve file content and SHA metadata |
| `gitea_commit_files` | Commit changes to multiple files atomically |
| `gitea_mirror_refs` | Mirror branches + tags between instances |

### Setup

#### 1. Install dependencies

```bash
cd /Users/jasonwalker/Development/Gitea-Tools
python3 -m venv venv          # skip if venv already exists
source venv/bin/activate
pip install "mcp[cli]"
```

#### 2. Configure your AI client

The MCP server uses **stdio transport** — each client starts it as a subprocess.
Add the config below to your client, then restart it.

<details>
<summary><strong>Antigravity (Google)</strong></summary>

Add to `~/.gemini/antigravity-ide/mcp_config.json` inside `"mcpServers"`:

```json
"gitea-tools": {
  "command": "/Users/jasonwalker/Development/Gitea-Tools/venv/bin/python3",
  "args": ["/Users/jasonwalker/Development/Gitea-Tools/mcp_server.py"],
  "env": {}
}
```

Restart Antigravity to load the server. Tools appear as lazy-loaded MCP tools
(call via `call_mcp_tool` with `ServerName: "gitea-tools"`).
</details>

<details>
<summary><strong>Claude Code (Anthropic)</strong></summary>

Add to `~/.claude.json` (global) or `.mcp.json` in the project root:

```json
{
  "mcpServers": {
    "gitea-tools": {
      "command": "/Users/jasonwalker/Development/Gitea-Tools/venv/bin/python3",
      "args": ["/Users/jasonwalker/Development/Gitea-Tools/mcp_server.py"]
    }
  }
}
```

Restart Claude Code. Tools appear as `mcp__gitea-tools__gitea_create_issue`, etc.
</details>

<details>
<summary><strong>Any MCP-compatible client</strong></summary>

The server is a standard MCP stdio server. Point your client at:

- **Command:** `/Users/jasonwalker/Development/Gitea-Tools/venv/bin/python3`
- **Args:** `["/Users/jasonwalker/Development/Gitea-Tools/mcp_server.py"]`
- **Transport:** `stdio`

No environment variables needed — auth is handled via macOS keychain.
</details>

<details>
<summary><strong>Runtime profiles (multiple env-configured entries)</strong></summary>

The same server can run as **separate MCP entries**, each authenticating as its
own Gitea token and carrying its own profile name. This keeps roles task-scoped:
*the profile is the role, not the LLM.* Point each entry at a different
gitignored env file.

```json
{
  "mcpServers": {
    "gitea-tools-reviewer": {
      "command": "/Users/jasonwalker/Development/Gitea-Tools/venv/bin/python3",
      "args": ["/Users/jasonwalker/Development/Gitea-Tools/mcp_server.py"],
      "env": {
        "GITEA_PROFILE_NAME": "gitea-reviewer",
        "GITEA_ALLOWED_OPERATIONS": "read,review,approve"
      }
    },
    "gitea-tools-merger": {
      "command": "/Users/jasonwalker/Development/Gitea-Tools/venv/bin/python3",
      "args": ["/Users/jasonwalker/Development/Gitea-Tools/mcp_server.py"],
      "env": {
        "GITEA_PROFILE_NAME": "gitea-merger",
        "GITEA_ALLOWED_OPERATIONS": "read,merge"
      }
    }
  }
}
```

Recognized environment fields (see [`.env.example`](.env.example) for placeholders):

| Variable | Purpose |
|----------|---------|
| `GITEA_TOKEN` | API token for this runtime. Read only by the auth layer; **never** returned, logged, or committed. |
| `GITEA_PROFILE_NAME` | Non-secret label for the running profile (e.g. `gitea-reviewer`). Surfaced by `gitea_whoami`. |
| `GITEA_ALLOWED_OPERATIONS` | Optional, comma-separated operation categories (descriptive metadata only for now). |
| `GITEA_FORBIDDEN_OPERATIONS` | Optional, comma-separated categories this profile must not perform (descriptive). |
| `GITEA_AUDIT_LABEL` | Optional short label for this runtime, for audit purposes. |
| `GITEA_TOKEN_SOURCE` | Optional *name* of the token source (e.g. an env var name). A name only — never the token value. |
| `GITEA_BASE_URL` | Optional informational base URL. |
| `GITEA_AUDIT_LOG` | Optional path to an audit log file. When set, mutating actions append one redacted JSON record each (profile + authenticated user + outcome). Unset ⇒ auditing off (no records, no extra API calls). |
| `GITEA_MCP_CONFIG` | Optional path to a JSON file defining multiple named runtime profiles. Unset ⇒ pure env behaviour. |
| `GITEA_MCP_PROFILE` | Name of the profile (from `GITEA_MCP_CONFIG`) to activate for this runtime. |

#### External MCP Control Plane servers

Jenkins and GlitchTip are separate MCP trust boundaries, not tools inside this
Gitea MCP runtime. Register them as `jenkins-mcp` and `glitchtip-mcp` in the
client that will use them, then reconnect or reload the client and verify the
expected tools are visible before claiming readiness. See
[`docs/mcp-client-registration.md`](docs/mcp-client-registration.md).

Notes:

- This provides **one token + one profile per process**. It does not implement
  multi-token switching inside a single runtime, nor any approve/merge/eligibility
  gating — those are later roadmap items (#14–#18).
- Profile name and allowed operations are **metadata only**; the token value is
  never part of any tool output. `gitea_whoami` returns the profile name, and
  `gitea_get_profile` returns the full non-secret profile metadata so a workflow
  can inspect which runtime it is talking to before deciding to act.
- See [`docs/gitea-execution-profiles.md`](docs/gitea-execution-profiles.md) for
  the full profile model, and
  [`docs/llm-workflow-runbooks.md`](docs/llm-workflow-runbooks.md) for the
  task-scoped, profile-based runbooks (create/review/merge/close, thin
  launchers, migration, fail-closed rules).
- For the **portable** version of this workflow (issue-first, isolated
  worktrees, no self-review/merge, profile safety, cleanup, fail-closed) that
  can be copied into any project, see the reusable skill
  [`skills/llm-project-workflow/SKILL.md`](skills/llm-project-workflow/SKILL.md).
- **Audit logging (#18):** mutating actions emit a durable, redacted JSON audit
  record — timestamp, action, result (`allowed`/`blocked`/`failed`/`succeeded`),
  profile name + audit label, authenticated username, target repo/issue/PR,
  branch and head SHA where applicable — when `GITEA_AUDIT_LOG` is set. Auditing
  is off by default and never adds API calls or breaks the action when off.
  See [`gitea_audit.py`](gitea_audit.py).

**Canonical runtime profiles (#19).** Define every Gitea profile **once**, in a
canonical JSON file, and keep each LLM launcher (Claude / Gemini / Codex) a
*thin* pointer at it — no duplicated `GITEA_USER_*` / `GITEA_PASS_*` blocks and
no raw tokens in client configs. See [`gitea-mcp.example.json`](gitea-mcp.example.json),
loaded by [`gitea_config.py`](gitea_config.py).

Canonical profile file (e.g. `~/.config/gitea-tools/profiles.json`):

```json
{
  "version": 1,
  "profiles": {
    "prgs": {
      "base_url": "https://gitea.prgs.cc",
      "username": "jcwalker3",
      "auth": { "type": "keychain", "id": "prgs-gitea-token" },
      "default_owner": "Scaled-Tech-Consulting",
      "execution_profile": "personal-prgs"
    },
    "mdcps": {
      "base_url": "https://gitea.dadeschools.net",
      "username": "913443",
      "auth": { "type": "env", "name": "GITEA_TOKEN_MDCPS" },
      "execution_profile": "mdcps"
    },
    "mdcps-reviewer": {
      "base_url": "https://gitea.dadeschools.net",
      "username": "913443",
      "auth": { "type": "keychain", "id": "mdcps.gitea.reviewer.token" },
      "execution_profile": "mdcps-reviewer"
    }
  }
}
```

Thin LLM launcher (Claude / Gemini / Codex) — only two env vars, no secrets:

```json
"gitea-tools": {
  "command": "/Users/jasonwalker/Development/Gitea-Tools/venv/bin/python3",
  "args": ["/Users/jasonwalker/Development/Gitea-Tools/mcp_server.py"],
  "env": {
    "GITEA_MCP_CONFIG": "/Users/jasonwalker/.config/gitea-tools/profiles.json",
    "GITEA_MCP_PROFILE": "prgs"
  }
}
```

- **Secrets by reference only:** a profile's `auth` names *where* the token
  lives — `{ "type": "keychain", "id": "..." }` (macOS keychain) or
  `{ "type": "env", "name": "..." }` (env var). Inline `token`/`password` keys
  are rejected. The value is resolved on demand and never stored in, returned
  by, or logged as profile metadata.
- **Precedence:** explicit process env vars (`GITEA_PROFILE_NAME`,
  `GITEA_BASE_URL`, `GITEA_TOKEN`, …) **override** the JSON profile; the JSON
  profile only fills what the environment leaves unset.
- **Backwards compatible / fail-safe:** with `GITEA_MCP_CONFIG` unset, behaviour
  is exactly the legacy env-only mode. A missing file, invalid JSON, unsupported
  `version`, unknown/unset selected profile, or unresolvable secret reference
  raises a clear startup error that never prints file contents, tokens, or
  passwords. Parsing makes no network calls.

**Migrating from duplicated `GITEA_PASS_*` blocks.** Move each instance's
credentials into one canonical profile entry (referencing a keychain id or env
var for the secret), then delete the `GITEA_USER_*` / `GITEA_PASS_*` /
`GITEA_SITE_*` blocks from every LLM `mcp_config.json`, leaving only
`GITEA_MCP_CONFIG` + `GITEA_MCP_PROFILE`. Existing env-only setups keep working
unchanged until migrated.

**Interactive setup — no hand-editing JSON.** Run the menu to create/edit/
validate profiles, store a token in the macOS keychain (never echoed or written
to any config), test a profile's authentication, print the authenticated user,
check reviewer eligibility for a PR, and generate ready-to-paste launcher
snippets for Claude / Gemini / Codex:

```bash
./scripts/gitea-config-menu
```

The generated launcher snippets contain only `command`, `args`,
`GITEA_MCP_CONFIG`, and `GITEA_MCP_PROFILE` — never a token or password.
</details>

### Portable LLM workflow skill

Reusable LLM operating rules are packaged as a portable skill at
[`skills/llm-project-workflow/SKILL.md`](skills/llm-project-workflow/SKILL.md).
It documents issue-first work, isolated branch worktrees, no self-review or
self-merge, profile safety, fail-closed behavior, merge cleanup, and recovery
patterns. Copy the `skills/llm-project-workflow/` directory into other projects
that should use the same workflow.

<details>
<summary><strong>Codex / non-MCP tools</strong></summary>

OpenAI Codex and other tools that don't support MCP can use the CLI scripts
directly. See the [CLI Scripts](#cli-scripts) section below.

```bash
# Example: Codex can shell out to the scripts
python3 /Users/jasonwalker/Development/Gitea-Tools/create_issue.py \
  --remote prgs --title "Bug report" --body "Details here"
```
</details>

## CLI Scripts

The MCP tools can also be used as standalone CLI scripts:

| Script              | Description                                                        |
|---------------------|--------------------------------------------------------------------|
| `create_issue.py`   | Create an issue (`--remote`, `--title`, `--body`, `--body-file`)   |
| `create_pr.py`      | Open a Pull Request (`--remote`, `--title`, `--head`, `--base`)    |
| `edit_pr.py`        | Edit a Pull Request (`--title`, `--body`, `--body-file`, etc.)      |
| `review_pr.py`      | Review/sign-off on a pull request (`--merge` is disabled — fails closed; merge only via gated `gitea_merge_pr`) |
| `close_issue.py`    | Close a specific issue                                             |
| `mark_issue.py`     | Claim/release an issue via `status:in-progress` label              |
| `manage_labels.py`  | Create label set and apply label mappings (`--dry` to preview)     |
| `mirror_refs.sh`    | Mirror branches + tags between dadeschools ⇄ prgs                  |

### Quick Examples

```bash
# Create an issue
./create_issue.py --title "Fix PDF output" --body "Blank on Safari"

# Create an issue on the prgs instance
./create_issue.py --remote prgs --title "Add tests" --body-file description.md

# Create a PR
./create_pr.py --title "feat: add validation" --head feat/validation --body "Closes #12"

# Edit a PR's description or title
./edit_pr.py 155 --body "Updated description wording"

# Review and approve a PR (review only — CLI merge is disabled; use the
# gated gitea_merge_pr MCP workflow to merge)
./review_pr.py --pr-number 12 --event APPROVE --body "Approved"

# Close issue #5
./close_issue.py 5

# Claim an issue before working on it
./mark_issue.py 10 start

# Release when done
./mark_issue.py 10 done

# Mirror refs (dry-run by default)
./mirror_refs.sh

# Actually push the refs
./mirror_refs.sh --apply
```

Use `--help` on any Python script or shell script for full usage details.

## Architecture

```
gitea_auth.py    ← shared auth & API helpers (get_credentials, api_request)
mcp_server.py    ← MCP server (FastMCP, stdio transport)
create_issue.py  ← CLI: create issues
create_pr.py     ← CLI: create PRs
edit_pr.py       ← CLI: edit PRs
review_pr.py     ← CLI: review PRs
manage_labels.py ← CLI: label management
close_issue.py   ← CLI: close issues
mark_issue.py    ← CLI: claim/release issues
mirror_refs.sh   ← CLI: ref mirroring
```

## Tests

```bash
# Run with the venv (includes MCP SDK)
source venv/bin/activate
python3 -m pytest tests/ -v
```

| Test file                | Covers                                                  |
|--------------------------|---------------------------------------------------------|
| `test_mcp_server.py`    | All 7 MCP tools: create, list, view, close, mark, PR, mirror |
| `test_create_issue.py`  | CLI arg parsing, remote resolution, payload, auth, errors |
| `test_create_pr.py`     | CLI arg parsing, remote resolution, payload, auth, errors |
| `test_credentials.py`   | `get_credentials()`, `get_auth_header()`, `repo_api_url()` |
| `test_manage_labels.py` | Label create/skip, dry run, mapping, constant validation |
| `test_python_cli.py`    | `close_issue.py` + `mark_issue.py` CLI validation       |
| `test_mirror_refs.py`   | Flags, safety defaults, local integration tests |

(Core suites — the table is non-exhaustive; see `tests/` for the full set.)

All tests mock network and keychain access — no real API calls are made.

For how to write tests — mocking the API/auth safely, testing profile and
self-review/self-merge gates, no-secret regression expectations, and unit vs.
integration guidance — see
[`docs/developer-testing-guidelines.md`](docs/developer-testing-guidelines.md).

## Troubleshooting

### macOS: `com.apple.provenance` blocks Python execution (#3)

On macOS Sequoia and later, files written by an agent/IDE terminal receive the
`com.apple.provenance` extended attribute, and macOS blocks `Python.app` from
**executing** such files. Symptoms: newly created/restored `.py` files fail to
run (e.g. `create_issue.py` "vanishing" or refusing to execute), while shell
scripts and files created before the session are unaffected. This is a macOS
security feature, not a bug in this project's code.

Workarounds (run from a terminal with **Full Disk Access**, e.g. `Terminal.app`
— not the IDE terminal, or the removal itself may be blocked):

```bash
# Preferred: strip only com.apple.provenance under the repo (dry-run first)
./scripts/clear-provenance --dry-run
./scripts/clear-provenance

# Or a single file
./scripts/clear-provenance /path/to/file.py

# Manual equivalents
xattr -r -d com.apple.provenance /Users/jasonwalker/Development/Gitea-Tools/
xattr -cr /Users/jasonwalker/Development/Gitea-Tools/   # clears ALL xattrs
```

Alternatively, grant Full Disk Access to the terminal app in
**System Settings → Privacy & Security**. `scripts/clear-provenance` removes only
`com.apple.provenance` (leaving other extended attributes intact) and supports
`--dry-run`.
