Slice A of Issue #790, per the controller reassessment in comment 13958. Does
not close the issue: terminal retirement (Slice B) and the read-side generation
check plus the #760 renewal re-scope (Slice C) are deliberately not implemented.
The defect. `issue_lock_store.assess_lock_freshness` parsed `last_heartbeat_at`
and then never consulted it. Liveness was decided by an absolute four-hour
`expires_at` and by PID liveness, and the recorded PID is the long-lived MCP
daemon rather than the authoring task, so an abandoned claim stayed live for the
full four hours. A tree-wide search found the field written in exactly one place
and advanced by nothing. Issue #787 / PR #789 hit this; Issue #760 / PR #791 hit
it again, blocking reconciliation for over five hours after its work had landed.
A1 — central policy. New `lease_policy` declares every duration for every task
class in one place: author initial/sliding TTL 10 minutes, heartbeat cadence 2,
stale warning 5, missed-heartbeat grace 10, absolute cap 8 hours, recovery grace
10, terminal race-drain 2. It ships first so the first heartbeat and TTL
behavior to run reads from it (AC-N7). The duplicated four-hour literal is gone
from both `issue_lock_store` and `gitea_mcp_server`. Reviewer, merger, and
conflict-fix classes are declared but not rewired — Slice C moves those call
sites — and a test asserts the declaration still equals the constants #747 and
`pr_work_lease` own, so the two cannot drift apart unnoticed.
A2 — load-bearing freshness, with two deliberate asymmetries. An alive PID never
establishes freshness anywhere (AC-N2); it is recorded as evidence and no branch
returns live because of it. A dead PID still marks a lease stale, and that band
still precedes every heartbeat evaluation, so #753 dead-session recovery keys on
exactly the classification it always did. New bands `stale_missed_heartbeat` and
`stale_absolute_cap` are classified in `branch_cleanup_guard` rather than
falling through to unknown-status, and still block unless the ownership record
proves `reclaim_allowed is True`. A heartbeat lease carrying no heartbeat is
contradictory and fails closed. `assess_expired_lock_reclaim` accepts a lapsed
heartbeat as reclaim grounds for heartbeat-lifecycle leases only: under this
lifecycle the heartbeat is the liveness proof, and also requiring a dead PID
would reinstate the original defect.
A3/A4 — task-session identity and the writer. `mint_task_session_id` produces an
ownership key containing no process identifier, since the daemon PID is reused
by every task it serves and identifies none of them. `heartbeat_session_lock`
writes inside the existing per-issue flock under the #772 generation
compare-and-swap, verifying exact issue, branch, realpath-normalized worktree,
claimant username, claimant profile, and recorded session identifier. It cannot
acquire, take over, or revive: a lease past its grace is refused and must use
the reclaim path, so a session that stopped proving liveness cannot restore
ownership retroactively. New `gitea_heartbeat_issue_lock` gates on the same
authority as `lock_issue`, being strictly narrower.
A5 — legacy compatibility (AC-N8). The explicit `lifecycle_version` marker, never
a timestamp comparison, discriminates legacy from heartbeat leases: a legacy lock
has `last_heartbeat_at == created_at` forever precisely because nothing advanced
it, and a freshly minted heartbeat lease has them equal too, so the equality
carries no information in either direction. Legacy locks keep their recorded
absolute expiry and are never evaluated against the short grace, so deployment
cannot make an existing claim instantly reclaimable. They leave that state only
by terminal retirement (Slice B) or by `rebind_legacy_lock`, which re-verifies
the exact owner and mints a genuine identifier and first heartbeat while
preserving the original claim under `legacy_origin`. Rebinding a lapsed legacy
lease is refused; that belongs to #760 renewal or #601 reclaim.
A6 — native coverage. Review #499 proved assessor-level tests miss discard
points, so `tests/test_issue_790_heartbeat_mcp_path.py` drives the real tools
against a real git repository and a real durable lock: lock creation and
read-back, policy window, freshness, survival of `verify_lock_for_mutation`,
invariance of the duplicate-work and linked-open-PR gates, CAS rejection,
foreign-session and foreign-claimant refusal, alive-PID-only refusal, missed
heartbeat, legacy protection on deployment, and legacy rebinding.
Tests. New suites 55 passed. Lock and lease regression set (issue_lock_store,
lease_lifecycle, #753, #755, #760 x2, #768, #772, lock registration, worktree,
adoption, duplicate gate, branch cleanup guard, capability invariants, claim
heartbeat, worktrees) 383 passed with 98 subtests. Full suite 4295 passed, 11
failed, 6 skipped, 499 subtests passed, against a clean master baseline worktree
at 620ed6e9 that reports 11 failed and 4240 passed — the same eleven node IDs.
The 55-test delta is exactly the new suites; no new failures.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_011u6GKSJwwrrYjguPjs1aK5
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.