Reviewer and merger PR leases minted a fixed 120-minute expiry (#407 AC6/AC7)
and derived staleness from two further activity bands: stale at 30 minutes,
reclaimable at 60. A session that died — daemon crash, transport flap, client
restart — therefore kept a PR blocked for an hour before anyone could reclaim
it, and two hours before manual cleanup was sanctioned. #718 records the
resulting deadlock: a merge stalled behind a reviewer lease that had stopped
being live long before it stopped being authoritative.
The flaw was using a long fixed expiry as a proxy for "the owner is probably
still alive" instead of making the owner continuously prove liveness.
Sliding window
--------------
`LEASE_TTL_MINUTES = 10` now governs acquisition, and every write of the lease
marker re-derives `expires_at` from the moment of the write, so each heartbeat
slides the window forward. An actively heartbeating session is never evicted
and has no maximum lifetime; a dead one releases its hold within one TTL.
`LEASE_RENEWAL_MINUTES` is named separately from the acquisition TTL. Renewal
previously had no seam at all: the heartbeat slid the expiry only as a side
effect of re-defaulting the acquisition constant, so the two durations could
not be reasoned about or tuned independently. `format_lease_body` now takes an
explicit `ttl_minutes`, and the heartbeat passes the renewal window rather than
relying on that default.
Merger leases acquire through the same lease-body formatter, so they inherit
the identical window by construction rather than by a parallel constant.
Removal of the reclaim tier
---------------------------
`classify_lease_freshness` no longer returns `reclaimable`. Beyond being an
extra waiting tier, that band is unreachable under a sliding TTL: a heartbeat
stamps `last_activity` and `expires_at` together, so a lease idle for a full
TTL is necessarily already expired. Expiry is now the only takeover gate.
No reclaim path is lost. `find_active_reviewer_lease` already ignores expired
markers, so an expired foreign lease never gated acquisition; and the
`foreign_expired` classification carries the same
`NEXT_ACTION_RELEASE_EXPIRED_LEASE` the retired `foreign_reclaimable` did. The
two updated tests in `test_reviewer_pr_lease.py` assert exactly that: the
classification label changes, the sanctioned next action does not.
`STALE_WARNING_MINUTES` drops to 5 — half the window — so the warning still
fires while the owner can heartbeat and recover.
Diagnostics
-----------
Adds `lease_seconds_remaining`, and the heartbeat tool now returns
`ttl_minutes`, `expires_at`, and `seconds_remaining`, so an operator can
distinguish "held and live" from "held and dying" instead of only seeing that
a lease exists. All additions are additive; no existing key changed.
Also removes `pr_work_lease.DEFAULT_REVIEWER_LEASE_TTL_MINUTES`, a duplicate of
the reviewer TTL with no readers anywhere in the tree, which could only drift.
Legacy markers minted under the old 120-minute TTL still parse and are judged
against their own recorded `expires_at`, so no lease is retroactively expired
by this change.
Full suite: 3425 passed, 6 skipped, 2 failed. Both failures
(`test_issue_702_review_findings_f1_f6`, `test_reconciler_supersession_close`)
reproduce identically on clean master b05075fd25 and are pre-existing.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01QxXHZ7rqXtLgTusngaWgKZ
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:
# 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
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.
Antigravity (Google)
Add to ~/.gemini/antigravity-ide/mcp_config.json inside "mcpServers":
"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").
Claude Code (Anthropic)
Add to ~/.claude.json (global) or .mcp.json in the project root:
{
"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.
Any MCP-compatible client
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.
Runtime profiles (multiple env-configured entries)
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.
{
"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 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.
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_whoamireturns the profile name, andgitea_get_profilereturns 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.mdfor the full profile model, anddocs/llm-workflow-runbooks.mdfor 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. - 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 — whenGITEA_AUDIT_LOGis set. Auditing is off by default and never adds API calls or breaks the action when off. Seegitea_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,
loaded by gitea_config.py.
Canonical profile file (e.g. ~/.config/gitea-tools/profiles.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:
"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
authnames where the token lives —{ "type": "keychain", "id": "..." }(macOS keychain) or{ "type": "env", "name": "..." }(env var). Inlinetoken/passwordkeys 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_CONFIGunset, behaviour is exactly the legacy env-only mode. A missing file, invalid JSON, unsupportedversion, 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:
./scripts/gitea-config-menu
The generated launcher snippets contain only command, args,
GITEA_MCP_CONFIG, and GITEA_MCP_PROFILE — never a token or password.
Portable LLM workflow skill
Reusable LLM operating rules are packaged as a portable skill at
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.
Codex / non-MCP tools
OpenAI Codex and other tools that don't support MCP can use the CLI scripts directly. See the CLI Scripts section below.
# 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"
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
# 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
# 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.
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):
# 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.