Compare commits
60 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e0861bcb03 | |||
| 205f089c44 | |||
| ff920a6496 | |||
| fbf1bc5f5c | |||
| 255cfc87dd | |||
| 8d2eb23237 | |||
| 7fa1bb9cfb | |||
| ed3cc106aa | |||
| 472e6850fe | |||
| e63cf5b5eb | |||
| 6dbd51b2a4 | |||
| 2e2da05eab | |||
| e9c67e7292 | |||
| 65ea7514d2 | |||
| 790c2c80b1 | |||
| cdc32669c7 | |||
| 3eff8d1cb3 | |||
| 8120486109 | |||
| 02c0c2023b | |||
| 4b61e80f39 | |||
| e730c391a2 | |||
| 0e2840b76c | |||
| 31f5bf9975 | |||
| d8269fc704 | |||
| 0d6d31d341 | |||
| ba6064e51e | |||
| fac10dd6cc | |||
| 0cdbf63660 | |||
| 6f9699b51a | |||
| 58e4bcd157 | |||
| 0a61e8a92d | |||
| fd68c439b2 | |||
| 4f5b732741 | |||
| 1bc2f20623 | |||
| 007b5dad14 | |||
| 104907e311 | |||
| 74a7e8f792 | |||
| 53e061bafd | |||
| cfe3ff6755 | |||
| 093945254d | |||
| 625f835aa7 | |||
| be4cd82c37 | |||
| afa57fa65c | |||
| 2d5cb4bb29 | |||
| 1441591e74 | |||
| 848a4294ba | |||
| 496e796cdd | |||
| 642adf4705 | |||
| e842b60ad8 | |||
| 3a246ab553 | |||
| dbfa0fe188 | |||
| b3728c54ce | |||
| 4afada098c | |||
| 6089ec724a | |||
| c6c6e75af6 | |||
| 4e43347b2d | |||
| ec9ddb09a7 | |||
| f18cecc998 | |||
| 00ec883014 | |||
| 92b449f080 |
@@ -6,6 +6,7 @@ __pycache__/
|
|||||||
# Real JSON runtime-profile configs may reference private hosts; keep only the example.
|
# Real JSON runtime-profile configs may reference private hosts; keep only the example.
|
||||||
gitea-mcp*.json
|
gitea-mcp*.json
|
||||||
!gitea-mcp.example.json
|
!gitea-mcp.example.json
|
||||||
|
!gitea-mcp.v2-contexts.example.json
|
||||||
.vscode/
|
.vscode/
|
||||||
graphify-out/
|
graphify-out/
|
||||||
branches/
|
branches/
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
## [v1.1.0] - 2026-07-02
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Read-only identity and eligibility tooling: `gitea_whoami` authenticated-user lookup (#11), `gitea_get_profile` runtime-profile discovery (#13), and `gitea_check_pr_eligibility` fail-closed PR eligibility checks (#14).
|
||||||
|
- Identity lookup aliases (`gitea_get_authenticated_user` and `gitea_get_current_user`) for common MCP/LLM tool discovery (#9).
|
||||||
|
- Gated PR review actions (`gitea_submit_pr_review`) reusing the eligibility gates (#15).
|
||||||
|
- Gated PR merge workflow (`gitea_merge_pr`) with explicit `MERGE PR <n>` confirmation, head-SHA and changed-file pinning, and self-merge blocking as the only merge path (#16).
|
||||||
|
- Task-scoped Gitea MCP execution profiles: documented profile model (#12) and runtime profiles via environment config with `allowed_operations` (#19).
|
||||||
|
- Audit logging for all mutating MCP actions with execution-profile metadata and secret redaction (#18).
|
||||||
|
- Shared API pagination (`api_get_all`) and hardened failure handling in `gitea_auth.api_request`: request timeouts, clear network/DNS errors, explicit 502/503/504 upstream errors, malformed-JSON handling, and redacted error text (#67).
|
||||||
|
- `scripts/release-tag` SemVer-gated annotated-tag helper (safe-by-default, master-only, tests required) (#50).
|
||||||
|
- Automatic `status:in-progress` release on issue close and PR close/merge (#56, #58).
|
||||||
|
- `LLM-Agent-SHA` opaque agent attribution convention (Phase 0): documentation, handoff/review templates, and negative tests proving the SHA can never bypass self-review/self-merge gates (#86).
|
||||||
|
- macOS `com.apple.provenance` cleanup helper tool and documentation (#3).
|
||||||
|
- `manage_labels.py` refactored into reusable modes (`--create-labels`, `--apply-mapping`, `--add-label`) (#6).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- HTTP 429 responses now honor `Retry-After` with jittered exponential backoff (#27).
|
||||||
|
- Read-only list tools (`gitea_list_issues`, `gitea_list_prs`, `gitea_list_labels`) now paginate across pages with bounded page caps (#67).
|
||||||
|
- Automatic `status:in-progress` cleanup on issue/PR close and merge.
|
||||||
|
- Label cleanup now utilizes safe targeted label deletion behavior rather than replacing the entire label set.
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- MCP security model and trust-boundary documentation (#8).
|
||||||
|
- Developer testing guidelines (#70).
|
||||||
|
- Jenkins read-only build-status tools design (#72).
|
||||||
|
- Jenkins repo/branch/PR → job mapping design (#77).
|
||||||
|
- Safety and boundary docs updated for Jenkins/GlitchTip: `glitchtip-mcp` boundary, read-only-first policy, mutation gating (#79).
|
||||||
|
- Proposed label taxonomy for Jenkins/GlitchTip workflows (#80).
|
||||||
|
- GlitchTip read-only error/event tools design (#73).
|
||||||
|
- Multi-service MCP profile model extension (#76).
|
||||||
|
|
||||||
|
## [v1.0.1]
|
||||||
|
- Fix Recent Timesheets Remove button text clipping and copy theme/whats_new in build.
|
||||||
|
|
||||||
|
## [v1.0.0]
|
||||||
|
- Initial versioned release.
|
||||||
@@ -186,6 +186,10 @@ Notes:
|
|||||||
[`docs/llm-workflow-runbooks.md`](docs/llm-workflow-runbooks.md) for the
|
[`docs/llm-workflow-runbooks.md`](docs/llm-workflow-runbooks.md) for the
|
||||||
task-scoped, profile-based runbooks (create/review/merge/close, thin
|
task-scoped, profile-based runbooks (create/review/merge/close, thin
|
||||||
launchers, migration, fail-closed rules).
|
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
|
- **Audit logging (#18):** mutating actions emit a durable, redacted JSON audit
|
||||||
record — timestamp, action, result (`allowed`/`blocked`/`failed`/`succeeded`),
|
record — timestamp, action, result (`allowed`/`blocked`/`failed`/`succeeded`),
|
||||||
profile name + audit label, authenticated username, target repo/issue/PR,
|
profile name + audit label, authenticated username, target repo/issue/PR,
|
||||||
@@ -270,6 +274,15 @@ The generated launcher snippets contain only `command`, `args`,
|
|||||||
`GITEA_MCP_CONFIG`, and `GITEA_MCP_PROFILE` — never a token or password.
|
`GITEA_MCP_CONFIG`, and `GITEA_MCP_PROFILE` — never a token or password.
|
||||||
</details>
|
</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>
|
<details>
|
||||||
<summary><strong>Codex / non-MCP tools</strong></summary>
|
<summary><strong>Codex / non-MCP tools</strong></summary>
|
||||||
|
|
||||||
@@ -368,4 +381,43 @@ python3 -m pytest tests/ -v
|
|||||||
| `test_python_cli.py` | `close_issue.py` + `mark_issue.py` CLI validation |
|
| `test_python_cli.py` | `close_issue.py` + `mark_issue.py` CLI validation |
|
||||||
| `test_mirror_refs.py` | Flags, safety defaults, local integration tests |
|
| `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.
|
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`.
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# GlitchTip-Gitea Deduplication and Linking Design
|
||||||
|
|
||||||
|
- **Status:** Design (child of #74)
|
||||||
|
- **Issue:** #78 (parent: #74 / #75)
|
||||||
|
- **Date:** 2026-07-02
|
||||||
|
|
||||||
|
## 1. Overview and Goals
|
||||||
|
To prevent automated error-reporting from flooding the Gitea issue tracker with duplicate tickets for the same underlying GlitchTip error, the filing orchestrator must deduplicate reports. Every filed Gitea issue will be cleanly linked back to its originating GlitchTip error via structured metadata.
|
||||||
|
|
||||||
|
## 2. Structured Metadata Marker
|
||||||
|
Each Gitea issue filed by the orchestrator will contain a machine-readable, structured metadata block in its body. This metadata will contain the GlitchTip issue ID and fingerprint.
|
||||||
|
|
||||||
|
We will use a hidden HTML comment at the end of the issue body:
|
||||||
|
```markdown
|
||||||
|
<!-- glitchtip-metadata: {"issue_id": "12345", "fingerprint": "abc123xyz"} -->
|
||||||
|
```
|
||||||
|
|
||||||
|
Adding this as a hidden comment allows orchestrators to parse the metadata reliably without cluttering the user interface or affecting human readability.
|
||||||
|
|
||||||
|
## 3. Search and Duplicate Detection Strategy
|
||||||
|
Before the orchestrator files a new issue, it must search the target Gitea repository for any existing issues referencing the same GlitchTip error.
|
||||||
|
|
||||||
|
### Search Process:
|
||||||
|
1. **API Query:** Query the Gitea repository's issues endpoint using the search term `"glitchtip-metadata"`. This narrows the results down to issues filed by this workflow. The query must search **both open and closed** issues (using Gitea API `state=all`).
|
||||||
|
2. **Client-side Parsing:** Fetch the details/body of matching issues and extract the metadata block.
|
||||||
|
3. **Identity Match:** Check if the Gitea issue's `issue_id` or `fingerprint` matches the incoming GlitchTip error. If a match is found, it is flagged as a duplicate.
|
||||||
|
|
||||||
|
## 4. Handling Closed Matching Issues (Open Owner Decision)
|
||||||
|
When a matching duplicate Gitea issue is found but its status is **closed**, the workflow cannot assume a single correct behavior (e.g. reopening could cause infinite loops on flaky errors; creating new issues could cause duplicate spam).
|
||||||
|
|
||||||
|
The orchestrator must support configurable modes for this scenario:
|
||||||
|
* Mode A: **Ask Human** (Prompt for decision: reopen, file new, or ignore) - *Default Mode*.
|
||||||
|
* Mode B: **Comment-Only** (Post a comment in the closed Gitea issue noting that the error recurred, rather than reopening it).
|
||||||
|
* Mode C: **Reopen** (Reopen the closed Gitea issue and apply `status:triage` / `status:in-progress`).
|
||||||
|
* Mode D: **Create New** (Ignore the closed issue and file a new one, linking it to the previous closed issue).
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> **Open Owner Decision:** The final default behavior and Mode configuration must be confirmed by the owner prior to implementation.
|
||||||
|
|
||||||
|
## 5. Concurrency and Race Condition Mitigation
|
||||||
|
Since multiple runs of the orchestrator could occur concurrently (e.g. parallel Jenkins builds or multiple webhook deliveries), there is a risk of two runs checking for duplicates simultaneously and both creating new issues.
|
||||||
|
|
||||||
|
### Mitigation Strategies:
|
||||||
|
1. **Single-Concurrency Gate:** Limit execution of the issue filing runbook to a single-concurrency queue (e.g. GHA `concurrency` groups, Jenkins lockable resources).
|
||||||
|
2. **Double-Check Query:** Add a randomized delay/jitter (0-5 seconds) before creating the issue, and perform a final check of Gitea issues immediately prior to POSTing the new issue.
|
||||||
|
3. **Idempotency Header / Cache:** (Optional) Keep a lightweight, short-lived external state store or cache if a persistent runner is used.
|
||||||
|
|
||||||
|
## 6. Spam Prevention (Spam Cap)
|
||||||
|
To protect Gitea from an unexpected surge in errors (e.g., during a major site outage), the orchestrator must enforce a maximum spam cap per execution:
|
||||||
|
- **Default Cap:** Maximum of 5 new Gitea issues filed per execution run.
|
||||||
|
- **Exceeded Behavior:** If the cap is reached, the runbook will halt filing new issues, log a warning, and print a summary of all skipped issues to the console/audit logs.
|
||||||
|
|
||||||
|
## 7. Testing Strategy (Mocked Verification)
|
||||||
|
Unit tests for the implementing orchestrator must use mocked Gitea/GlitchTip APIs to assert:
|
||||||
|
1. **Deduplication:** A second run with a matching fingerprint does not trigger issue creation.
|
||||||
|
2. **State Search:** Both open and closed issues are queried (`state=all`).
|
||||||
|
3. **Closed Match mode:** Mode logic operates as configured (`comment`, `reopen`, `new`, `ask`).
|
||||||
|
4. **Spam Cap:** Asserts that only the capped limit of issues is created, even if more errors are fetched from GlitchTip.
|
||||||
|
5. **No Secrets/PII Leak:** Check that metadata and issue content are clean of credentials.
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
# GlitchTip Read-Only Error/Event Tools — Design Notes
|
||||||
|
|
||||||
|
- **Status:** Design (implementation-ready notes; **no implementation in this repo**)
|
||||||
|
- **Issue:** #73 (umbrella: #75; boundary decision: ADR-0001, #71)
|
||||||
|
- **Related:** #74 (GlitchTip→Gitea filing workflow — composes these read tools),
|
||||||
|
#78 (dedup/linking, child of #74), #76 (per-service profile schema)
|
||||||
|
- **Date:** 2026-07-02
|
||||||
|
|
||||||
|
## 1. Purpose and scope
|
||||||
|
|
||||||
|
Define the minimum **read-only** GlitchTip MCP tool set that lets an LLM answer:
|
||||||
|
*"What unresolved errors does project X have (by environment/release), and what
|
||||||
|
is this specific error?"* — with privacy-safe output suitable for LLM context,
|
||||||
|
issue bodies, and audit logs.
|
||||||
|
|
||||||
|
Strictly read-only, per ADR-0001:
|
||||||
|
|
||||||
|
- **No mutation tools** — no resolving/ignoring/assigning issues, no comment
|
||||||
|
posting, no project/team/key administration, no deletes.
|
||||||
|
- **No automatic GlitchTip→Gitea filing** (that is #74's *orchestrated,
|
||||||
|
explicitly-invoked* workflow; it composes these read tools and Gitea write
|
||||||
|
tools — never one dual-credential server).
|
||||||
|
- **This server never holds Gitea write credentials.**
|
||||||
|
|
||||||
|
## 2. Boundary placement (namespace pending)
|
||||||
|
|
||||||
|
These tools belong to the GlitchTip observability boundary of the MCP Control
|
||||||
|
Plane — `glitchtip-mcp` (ADR-0001's recommendation), `observability-mcp`, or
|
||||||
|
folded into `ops-mcp`. **ADR-0001 open owner decision #2 picks the name; this
|
||||||
|
design does not assume it.** Tool names below use the `glitchtip_` prefix for
|
||||||
|
readability and rename mechanically with the decision.
|
||||||
|
|
||||||
|
Fixed regardless of the name (per `tool-boundaries.md`,
|
||||||
|
`credential-isolation.md`):
|
||||||
|
|
||||||
|
- Own server process, own `.env`, GlitchTip credentials only.
|
||||||
|
- No Gitea, Jenkins, or Ops tokens in this runtime; no GlitchTip token
|
||||||
|
anywhere else.
|
||||||
|
|
||||||
|
## 3. API surface note (Sentry compatibility)
|
||||||
|
|
||||||
|
GlitchTip implements a Sentry-compatible REST API (`/api/0/...` — organizations,
|
||||||
|
projects, issues, events). The design targets **GlitchTip's documented subset**
|
||||||
|
only; Sentry-only endpoints must not be assumed. The implementation should pin
|
||||||
|
against a tested GlitchTip version and treat missing endpoints/fields as
|
||||||
|
degraded-but-safe (omit field, never crash).
|
||||||
|
|
||||||
|
## 4. Minimum read-only tool set
|
||||||
|
|
||||||
|
| Tool | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `glitchtip_whoami` | Verify authenticated identity + active profile (mirror of `gitea_whoami`; fail-closed identity proof) |
|
||||||
|
| `glitchtip_list_projects` | Projects visible to the token (org-scoped), with pagination bounds |
|
||||||
|
| `glitchtip_list_unresolved` | Unresolved issues for a project, filterable (§6), sorted by last-seen |
|
||||||
|
| `glitchtip_get_issue` | Safe detail of one issue (fields §5) |
|
||||||
|
| `glitchtip_recent_events` | Recent events for an issue (summaries only, §5) |
|
||||||
|
| `glitchtip_search` | Issue search within a project (query + filters §6) |
|
||||||
|
|
||||||
|
All tools are `GET`-only. No tool issues PUT/POST/DELETE.
|
||||||
|
|
||||||
|
## 5. Privacy: field-level allowlist (the core rule)
|
||||||
|
|
||||||
|
Error events routinely contain PII and secrets (request bodies, cookies,
|
||||||
|
headers, tokens, user emails/IPs, local variables). Therefore: **allowlist
|
||||||
|
projection only — raw event/issue payloads are never passed through.**
|
||||||
|
|
||||||
|
### Issue-level safe fields (`glitchtip_list_unresolved`, `glitchtip_get_issue`, `glitchtip_search`)
|
||||||
|
|
||||||
|
| Field | Notes |
|
||||||
|
|---|---|
|
||||||
|
| `issue_id` | GlitchTip issue ID (dedup key for #78) |
|
||||||
|
| `fingerprint` | When available (dedup key for #78) |
|
||||||
|
| `title` / `culprit` | Error type + short message/transaction — redactor-passed |
|
||||||
|
| `project` | Slug |
|
||||||
|
| `level` | error/warning/… |
|
||||||
|
| `status` | unresolved/… |
|
||||||
|
| `environment` | When filtered/available |
|
||||||
|
| `release` | Version string |
|
||||||
|
| `first_seen` / `last_seen` | ISO-8601 UTC |
|
||||||
|
| `event_count` / `user_count` | Numbers only — never user identities |
|
||||||
|
| `permalink` | GlitchTip web URL (the "link, not dump" principle) |
|
||||||
|
|
||||||
|
### Event-level safe fields (`glitchtip_recent_events`)
|
||||||
|
|
||||||
|
`event_id`, `timestamp`, `level`, `environment`, `release`, redactor-passed
|
||||||
|
`message`, and a **stack summary** only: top N (default 5) frames as
|
||||||
|
`module/filename:function:line` — in-app frames preferred.
|
||||||
|
|
||||||
|
### Redact / omit — never returned
|
||||||
|
|
||||||
|
Request headers; cookies; auth/session fields; user emails, usernames, IPs;
|
||||||
|
request/form bodies; query strings; local variables; full raw stack frames
|
||||||
|
(source context lines); SDK/device metadata beyond platform name; breadcrumbs;
|
||||||
|
any `extra`/`context` blobs.
|
||||||
|
|
||||||
|
Full raw frames or request context require a **separate, explicitly approved**
|
||||||
|
operation (`glitchtip.event.read_raw`) that is absent from default profiles —
|
||||||
|
same pattern as `jenkins.console.read` in the #72 design. Even then, output
|
||||||
|
passes the shared secret redactor; redaction failure ⇒ error, never raw text.
|
||||||
|
|
||||||
|
**Default output = fingerprint / release / summary + permalink.** The
|
||||||
|
permalink carries the human to the full data in GlitchTip's own UI, where its
|
||||||
|
access control applies — the MCP layer does not re-serve raw payloads.
|
||||||
|
|
||||||
|
## 6. Filtering and pagination
|
||||||
|
|
||||||
|
Filters (all optional, combinable): `project` (required for issue/event
|
||||||
|
queries), `environment`, `release`, `fingerprint`, free-text `query`
|
||||||
|
(GlitchTip search syntax, e.g. `is:unresolved`).
|
||||||
|
|
||||||
|
Pagination: cursor-based per the API. Bounds: per-page cap 50; default overall
|
||||||
|
cap 100 items; hard cap `max_pages` (default 10) against runaway loops —
|
||||||
|
mirroring `gitea_auth.api_get_all`. Truncation is **explicit** in the return
|
||||||
|
(`"truncated": true`) — never silent.
|
||||||
|
|
||||||
|
## 7. Credentials and profile requirements
|
||||||
|
|
||||||
|
Per-service profile model (`gitea-execution-profiles.md`, extended by #76):
|
||||||
|
|
||||||
|
- Env/config: `GLITCHTIP_URL`, `GLITCHTIP_ORG`, `GLITCHTIP_TOKEN_SOURCE_NAME`
|
||||||
|
(secret **name** only; value resolved at runtime, never logged/committed).
|
||||||
|
- Profile: e.g. `glitchtip-readonly` with namespaced
|
||||||
|
`allowed_operations: ["glitchtip.read", "glitchtip.event.read"]`
|
||||||
|
(+ `glitchtip.event.read_raw` only with explicit approval);
|
||||||
|
`forbidden_operations: ["glitchtip.issue.mutate", "glitchtip.admin"]`
|
||||||
|
belt-and-braces though no mutating tool exists.
|
||||||
|
- Missing URL/org/token/profile ⇒ **fail closed** before any network call.
|
||||||
|
- Read-only ⇒ no confirmation gates; identity (`glitchtip_whoami`) must work so
|
||||||
|
workflows can prove which account they read as.
|
||||||
|
|
||||||
|
## 8. Failure behavior (fail closed, clear, safe)
|
||||||
|
|
||||||
|
| Condition | Behavior |
|
||||||
|
|---|---|
|
||||||
|
| Unknown project/issue | Explicit `{"found": false, ...}` — no fuzzy matching |
|
||||||
|
| GlitchTip unreachable (DNS/timeout) | `"network error contacting GlitchTip: <redacted reason>"` — mirror `gitea_auth.api_request` conversion |
|
||||||
|
| 502/503/504 | "GlitchTip upstream unavailable" |
|
||||||
|
| 401/403 | "GlitchTip auth failed / insufficient permissions" — no credential echo |
|
||||||
|
| 429 | Honor Retry-After with capped jittered backoff (as `gitea_auth`) |
|
||||||
|
| Malformed JSON | "malformed JSON response from GlitchTip" — no raw-body dump |
|
||||||
|
| Missing profile/creds | Fail closed before any network call (§7) |
|
||||||
|
|
||||||
|
All error text passes the shared secret redactor.
|
||||||
|
|
||||||
|
## 9. Testing strategy (mocked; for the implementing package)
|
||||||
|
|
||||||
|
Mocked-GlitchTip unit tests only, per `docs/developer-testing-guidelines.md`:
|
||||||
|
|
||||||
|
- Assert method is always `GET`; URL/filter/cursor shape correct.
|
||||||
|
- **Projection tests:** response fixtures containing emails, IPs, cookies,
|
||||||
|
headers, request bodies, locals, full frames ⇒ none appear in output
|
||||||
|
(explicit negative assertions per §5's redact list).
|
||||||
|
- Stack summary: top-N frame cap enforced; source-context lines absent.
|
||||||
|
- Pagination: per-page/overall/max-pages caps; explicit `truncated` flag.
|
||||||
|
- Filters: environment/release/fingerprint/query passed through correctly.
|
||||||
|
- Failure matrix of §8 incl. no-token-in-error assertions.
|
||||||
|
- Profile gate: missing/insufficient profile ⇒ no network call
|
||||||
|
(`mock_api.assert_not_called()` pattern).
|
||||||
|
- `read_raw` op absent ⇒ raw-frame request refused without an API call.
|
||||||
|
|
||||||
|
## 10. Implementation-readiness checklist
|
||||||
|
|
||||||
|
Ready to implement once:
|
||||||
|
|
||||||
|
1. ADR-0001 owner decision #2 (namespace/placement) is made — mechanical
|
||||||
|
rename of the `glitchtip_` prefix if needed.
|
||||||
|
2. ADR-0001 owner decision #1 (repo home) is made.
|
||||||
|
3. #76 profile schema exists (or a minimal `glitchtip-readonly` profile is
|
||||||
|
hand-rolled to the same rules).
|
||||||
|
4. A pinned GlitchTip version is chosen for API-subset testing (§3).
|
||||||
|
|
||||||
|
Explicitly **not** unlocked by this document: any GlitchTip mutation, any
|
||||||
|
automatic Gitea filing (#74 designs that as a gated, explicitly-invoked
|
||||||
|
orchestrated workflow), any Gitea credentials in this boundary.
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# GlitchTip-to-Gitea Issue Filing Workflow Design
|
||||||
|
|
||||||
|
- **Status:** Design (no implementation in this repo)
|
||||||
|
- **Issue:** #74 (parent umbrella: #75)
|
||||||
|
- **Related:** #78 (deduplication design, child of #74)
|
||||||
|
- **Date:** 2026-07-02
|
||||||
|
|
||||||
|
## 1. Boundary and Orchestration
|
||||||
|
|
||||||
|
* **GlitchTip-to-Gitea filing is NOT a GlitchTip MCP capability.** The `glitchtip-mcp` boundary remains strictly read-only per ADR-0001.
|
||||||
|
* The filing capability lives in an **orchestrator / runbook / release workflow**. It **composes** separate GlitchTip **read** tools (from the `glitchtip-mcp` server) and Gitea **issue** tools (from the `gitea-mcp` server).
|
||||||
|
* The orchestrator **must not centralize credentials** into a single server. The GlitchTip MCP holds only GlitchTip tokens, and the Gitea MCP holds only Gitea tokens.
|
||||||
|
|
||||||
|
## 2. Invocation and Safety
|
||||||
|
|
||||||
|
* **Explicit invocation only:** There is no automatic, unsupervised filing in phase 1. A human or an explicitly-triggered automation must initiate the workflow.
|
||||||
|
* **Dry-run / Preview required:** The orchestrator must present a preview of the drafted Gitea issue (title, body, labels) and obtain explicit confirmation before calling the Gitea mutation tool to file the issue.
|
||||||
|
* **Gitea Profile Checks & Audit Logging:** The actual Gitea issue creation relies on `gitea-mcp`, and therefore inherently subjects the mutation to Gitea profile checks and audit logging as standard.
|
||||||
|
|
||||||
|
## 3. Gitea Issue Format
|
||||||
|
|
||||||
|
The workflow generates a Gitea issue using the following format and fields:
|
||||||
|
|
||||||
|
### Required Fields
|
||||||
|
The issue body MUST include the following extracted fields from GlitchTip:
|
||||||
|
- **Project**
|
||||||
|
- **Environment**
|
||||||
|
- **Release**
|
||||||
|
- **First seen / Last seen**
|
||||||
|
- **Event count / User count**
|
||||||
|
- **Stack summary** (Truncated/summarized, no raw frames)
|
||||||
|
- **GlitchTip URL / linkback:** A permalink back to the GlitchTip web UI so users can view the full unredacted data securely.
|
||||||
|
|
||||||
|
### Title Format
|
||||||
|
`[GlitchTip] {Project} - {Error Type}: {Short Message}`
|
||||||
|
|
||||||
|
### Labels
|
||||||
|
The orchestrator must apply the following labels upon creation:
|
||||||
|
* `source:glitchtip`
|
||||||
|
* `bug`
|
||||||
|
* `status:triage`
|
||||||
|
|
||||||
|
## 4. Redaction Rules
|
||||||
|
|
||||||
|
To prevent PII or secret leakage into Gitea, the orchestrator and the underlying `glitchtip-mcp` read tools strictly omit and redact the following from the Gitea issue body:
|
||||||
|
* Request bodies
|
||||||
|
* Cookies and headers
|
||||||
|
* Authentication tokens / Session IDs
|
||||||
|
* PII (User emails, usernames, IPs)
|
||||||
|
* Full raw stack traces (source code lines)
|
||||||
|
|
||||||
|
The principle is: **"Link, don't dump"**. The generated issue acts as an alert/pointer, while the raw context remains protected inside GlitchTip.
|
||||||
|
|
||||||
|
## 5. Deduplication and Linking (Deferred)
|
||||||
|
|
||||||
|
Deduplication logic (e.g. searching existing Gitea issues, managing GlitchTip issue IDs, and race condition handling) is specifically handled by **Issue #78** and will augment this design.
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
# Jenkins Repo/Branch/PR → Job Mapping — Design Notes
|
||||||
|
|
||||||
|
- **Status:** Design (implementation-ready notes; **no implementation in this repo**)
|
||||||
|
- **Issue:** #77 (parent: #72 read-only tools design; umbrella: #75; boundary: ADR-0001, #71)
|
||||||
|
- **Related docs:** [`jenkins-readonly-build-status-design.md`](jenkins-readonly-build-status-design.md)
|
||||||
|
- **Date:** 2026-07-02
|
||||||
|
|
||||||
|
## 1. Purpose
|
||||||
|
|
||||||
|
The #72 tool set addresses Jenkins jobs by **explicit fully-qualified job
|
||||||
|
path**. This document designs the layer above it: how a *(repository, branch,
|
||||||
|
PR)* tuple — the vocabulary of Gitea workflows — resolves deterministically to
|
||||||
|
a Jenkins job path, so an LLM can ask "did the build for `Gitea-Tools`
|
||||||
|
`master` pass?" without knowing Jenkins internals.
|
||||||
|
|
||||||
|
Hard constraints inherited from #72 / ADR-0001:
|
||||||
|
|
||||||
|
- **No silent guessing of job names.** Unmapped input returns an explicit
|
||||||
|
"no mapping" result — never a fuzzy match, never a constructed-and-probed
|
||||||
|
name.
|
||||||
|
- **Read-only.** Mapping introduces no Jenkins write actions.
|
||||||
|
- Lives in the **`jenkins-mcp`** boundary; no Gitea credentials involved.
|
||||||
|
|
||||||
|
## 2. Mapping format
|
||||||
|
|
||||||
|
Declarative, versioned config (TOML or JSON — match whatever config format
|
||||||
|
`jenkins-mcp` adopts; illustrated here as TOML):
|
||||||
|
|
||||||
|
```toml
|
||||||
|
version = 1
|
||||||
|
|
||||||
|
[[mapping]]
|
||||||
|
# Source side (what the caller supplies)
|
||||||
|
repo = "Scaled-Tech-Consulting/Gitea-Tools" # org/repo, exact
|
||||||
|
# Target side (where it lives in Jenkins)
|
||||||
|
job = "scaled-tech/gitea-tools" # foldered job path
|
||||||
|
type = "multibranch" # "multibranch" | "single" | "parameterized-view"
|
||||||
|
|
||||||
|
[[mapping]]
|
||||||
|
repo = "Scaled-Tech-Consulting/Timesheet"
|
||||||
|
branch = "master" # optional: branch-specific override
|
||||||
|
job = "scaled-tech/timesheet-master"
|
||||||
|
type = "single"
|
||||||
|
```
|
||||||
|
|
||||||
|
Field semantics:
|
||||||
|
|
||||||
|
| Field | Required | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `repo` | yes | Exact `org/repo` (case-insensitive compare, stored canonical) |
|
||||||
|
| `branch` | no | Exact branch name this entry pins; absent = all branches |
|
||||||
|
| `job` | yes | Fully-qualified Jenkins job path, folders `/`-joined |
|
||||||
|
| `type` | yes | How branch/PR resolves under the job (§3) |
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- **Exact matching only** on `repo` and `branch`. No globs in v1 (globs invite
|
||||||
|
accidental over-matching; add later behind an explicit `pattern = true` flag
|
||||||
|
if ever needed).
|
||||||
|
- Unknown `type` or malformed entry ⇒ config load fails closed with a clear
|
||||||
|
error naming the entry — a broken mapping file must not half-load.
|
||||||
|
- Duplicate `(repo, branch)` keys ⇒ load error (ambiguity is refused, not
|
||||||
|
resolved).
|
||||||
|
|
||||||
|
## 3. Resolution semantics by job type
|
||||||
|
|
||||||
|
Given caller input `(repo, branch?, pr?)`:
|
||||||
|
|
||||||
|
- **`multibranch`** — branch job addressed as `<job>/<url-encoded-branch>`
|
||||||
|
(e.g. `feature/x` → `feature%2Fx`). PRs addressed as `<job>/PR-<number>`
|
||||||
|
(Jenkins multibranch PR-discovery naming). Both per #72 §8.
|
||||||
|
- **`single`** — the job path is used as-is; `branch`/`pr` input beyond the
|
||||||
|
entry's pinned branch is a **no-mapping** result (a single job cannot answer
|
||||||
|
for arbitrary branches).
|
||||||
|
- **`parameterized-view`** — read-only variant for jobs that encode branch as
|
||||||
|
a build parameter: resolution returns the base job path plus a
|
||||||
|
`branch_param` filter hint the status tools may apply client-side when
|
||||||
|
scanning recent builds. It never triggers anything (read-only rule).
|
||||||
|
|
||||||
|
## 4. Precedence
|
||||||
|
|
||||||
|
Most-specific entry wins, evaluated in this order:
|
||||||
|
|
||||||
|
1. `(repo, branch)` exact entry — branch-pinned override.
|
||||||
|
2. `(repo)` entry — repo-wide (multibranch typical).
|
||||||
|
3. Nothing → **no mapping** (§5).
|
||||||
|
|
||||||
|
PR input resolves through the same chain: a PR belongs to its **base repo**'s
|
||||||
|
mapping; forks never introduce their own mapping (a fork's head repo is not
|
||||||
|
consulted — CI runs live under the base repo's job). If the base repo is
|
||||||
|
unmapped, the PR is unmapped.
|
||||||
|
|
||||||
|
Ties are impossible by construction (duplicate keys refused at load).
|
||||||
|
|
||||||
|
## 5. No-match behavior
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mapped": false,
|
||||||
|
"repo": "org/unknown-repo",
|
||||||
|
"branch": "master",
|
||||||
|
"error": "no Jenkins job mapping for this repo/branch",
|
||||||
|
"hint": "add an entry to the jenkins-mcp mapping config"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Deterministic, explicit, machine-checkable (`mapped: false`).
|
||||||
|
- **Never** falls back to name construction ("repo name probably equals job
|
||||||
|
name"), never probes Jenkins for candidates, never string-similarity ranks.
|
||||||
|
- The hint names the config, not a guessed job.
|
||||||
|
|
||||||
|
## 6. Where the mapping config lives
|
||||||
|
|
||||||
|
- **In the `jenkins-mcp` package/deployment** (e.g. `jenkins-mcp/mapping.toml`),
|
||||||
|
version-controlled next to the server that consumes it — *not* in Gitea-Tools
|
||||||
|
and *not* in per-user env vars (mappings are shared team facts, not
|
||||||
|
credentials).
|
||||||
|
- Path overridable via env (`JENKINS_MCP_MAPPING_FILE`) for tests/containers.
|
||||||
|
- Contains **no secrets** — job paths and repo names only — so it is safe to
|
||||||
|
commit and review like any other config.
|
||||||
|
- Reloaded at server start; a hot-reload tool is out of scope (restart is the
|
||||||
|
documented path).
|
||||||
|
|
||||||
|
## 7. Exposed tool surface (read-only)
|
||||||
|
|
||||||
|
One addition to the #72 tool set:
|
||||||
|
|
||||||
|
| Tool | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `jenkins_resolve_job` | `(repo, branch?, pr?)` → `{mapped, job, addressed_path, type}` or the §5 no-match result. Pure config lookup — **no Jenkins API call at all.** |
|
||||||
|
|
||||||
|
Status tools (`jenkins_latest_build` etc.) accept either an explicit job path
|
||||||
|
(as designed in #72) **or** `(repo, branch)` which they resolve via the same
|
||||||
|
mapping layer first. Resolution failure surfaces the §5 payload rather than
|
||||||
|
querying Jenkins.
|
||||||
|
|
||||||
|
## 8. Testing strategy (mocked; for the implementing package)
|
||||||
|
|
||||||
|
Config-layer tests (no network at all):
|
||||||
|
|
||||||
|
- Exact-match hit: repo-wide and branch-pinned entries.
|
||||||
|
- Precedence: branch-pinned beats repo-wide.
|
||||||
|
- Multibranch encoding: `feature/x` → `<job>/feature%2Fx`; PR → `<job>/PR-7`.
|
||||||
|
- `single` type with non-pinned branch ⇒ no-mapping.
|
||||||
|
- Fork PR resolves through base repo; unmapped base ⇒ no-mapping.
|
||||||
|
- Unknown repo/branch ⇒ §5 payload, and **no Jenkins client call**
|
||||||
|
(`mock_api.assert_not_called()`).
|
||||||
|
- Malformed config / duplicate keys / unknown type ⇒ load fails closed with
|
||||||
|
entry-naming error.
|
||||||
|
- No-secret check: mapping load/error paths never touch or print credentials.
|
||||||
|
|
||||||
|
Integration with mocked Jenkins API (per #72 §9): resolved path is used
|
||||||
|
verbatim in the GET URL; no write verbs anywhere.
|
||||||
|
|
||||||
|
## 9. Standalone-worthiness and readiness
|
||||||
|
|
||||||
|
#77 was split from #72 on the condition it stays "standalone only if mapping
|
||||||
|
is nontrivial." The precedence rules, fork/PR semantics, three job types, and
|
||||||
|
fail-closed config loading above are the nontrivial part; this document is the
|
||||||
|
justification.
|
||||||
|
|
||||||
|
Ready to implement in `jenkins-mcp` when #72's readiness checklist clears
|
||||||
|
(ADR-0001 owner decision #1; profile schema per #76 or hand-rolled
|
||||||
|
`jenkins-readonly`). Nothing here unlocks build triggers, deploys, or
|
||||||
|
parameterized launches.
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
# Jenkins Read-Only Build Status Tools — Design Notes
|
||||||
|
|
||||||
|
- **Status:** Design (implementation-ready notes; **no implementation in this repo**)
|
||||||
|
- **Issue:** #72 (parent umbrella: #75; boundary decision: ADR-0001, #71)
|
||||||
|
- **Related:** #77 (repo/branch/PR → job mapping, designed separately)
|
||||||
|
- **Date:** 2026-07-02
|
||||||
|
|
||||||
|
## 1. Purpose and scope
|
||||||
|
|
||||||
|
Define the minimum **read-only** Jenkins MCP tool set that lets an LLM answer:
|
||||||
|
*"Did the latest build for this project/branch succeed or fail?"* — plus enough
|
||||||
|
detail (build URL, number, timing, result) to report or investigate.
|
||||||
|
|
||||||
|
Phase 1 is **strictly read-only**, per ADR-0001
|
||||||
|
([`adr-0001-mcp-control-plane-boundaries.md`](adr-0001-mcp-control-plane-boundaries.md)):
|
||||||
|
|
||||||
|
- **Excluded: build triggers.**
|
||||||
|
- **Excluded: deploy triggers.**
|
||||||
|
- **Excluded: parameterized job launches.**
|
||||||
|
- Excluded: job creation/deletion/config changes, queue manipulation, node
|
||||||
|
management — any Jenkins mutation whatsoever.
|
||||||
|
|
||||||
|
## 2. Boundary placement
|
||||||
|
|
||||||
|
These tools belong to the **`jenkins-mcp`** package/server of the MCP Control
|
||||||
|
Plane — **never** inside `gitea-mcp` (`mcp_server.py` in this repo).
|
||||||
|
Consequences (from `tool-boundaries.md`, `credential-isolation.md`, ADR-0001):
|
||||||
|
|
||||||
|
- `jenkins-mcp` runs as its own server process with its own `.env`.
|
||||||
|
- **Jenkins credentials never enter the Gitea MCP runtime**, and Gitea
|
||||||
|
credentials never enter `jenkins-mcp`.
|
||||||
|
- This document lands in this repo only because the repo currently hosts the
|
||||||
|
Control Plane's architecture docs; the code ships elsewhere (owner decision
|
||||||
|
#1 of ADR-0001).
|
||||||
|
|
||||||
|
## 3. Minimum read-only tool set
|
||||||
|
|
||||||
|
| Tool | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `jenkins_whoami` | Verify authenticated Jenkins identity + active profile (mirror of `gitea_whoami`; fail-closed identity proof before anything else) |
|
||||||
|
| `jenkins_list_jobs` | List visible jobs (supports folder paths), with pagination bounds |
|
||||||
|
| `jenkins_latest_build` | The primary question: latest build of a job (or job+branch for multibranch) → status summary |
|
||||||
|
| `jenkins_build_status` | Status of a specific build number (job, number) |
|
||||||
|
| `jenkins_get_build` | Full safe detail of a build (fields in §4) |
|
||||||
|
| `jenkins_console_tail` | Bounded, redacted tail of a build's console log (§6) — optional, approval-gated addition |
|
||||||
|
|
||||||
|
All tools are `GET`-only against the Jenkins JSON API (`/api/json`,
|
||||||
|
`.../lastBuild/api/json`, `.../consoleText`). No tool issues POST/PUT/DELETE.
|
||||||
|
|
||||||
|
## 4. Return payloads (safe fields)
|
||||||
|
|
||||||
|
`jenkins_latest_build` / `jenkins_build_status` / `jenkins_get_build` return:
|
||||||
|
|
||||||
|
| Field | Source | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `job` | request | Fully-qualified job path (folders joined with `/`) |
|
||||||
|
| `build_number` | `number` | int |
|
||||||
|
| `result` | `result` | `SUCCESS` / `FAILURE` / `UNSTABLE` / `ABORTED` / `NOT_BUILT`; `null` → `IN_PROGRESS` when `building=true` |
|
||||||
|
| `building` | `building` | bool |
|
||||||
|
| `url` | `url` | Build URL |
|
||||||
|
| `branch` | multibranch job name / SCM action | Best-effort; omitted when unknown |
|
||||||
|
| `timestamp` | `timestamp` | ISO-8601 UTC (converted from epoch ms) |
|
||||||
|
| `duration_seconds` | `duration` | 0/omitted while building |
|
||||||
|
| `commit_sha` | SCM build action | Best-effort; omitted when unknown |
|
||||||
|
|
||||||
|
Rules: no raw Jenkins payload passthrough (allowlist projection only); no
|
||||||
|
`Authorization` header, token, or crumb material in any output or error
|
||||||
|
(reuse the shared redaction approach of `safety-model.md` §3 / `gitea_audit`).
|
||||||
|
|
||||||
|
## 5. Failure behavior (fail closed, clear, safe)
|
||||||
|
|
||||||
|
| Condition | Behavior |
|
||||||
|
|---|---|
|
||||||
|
| Unknown job | Explicit `{"found": false, "job": "<path>", "error": "job not found"}` — never guess or fuzzy-match a job name (hard rule; see also #77) |
|
||||||
|
| Jenkins unreachable (DNS/timeout/conn refused) | Clear `"network error contacting Jenkins: <redacted reason>"`; no retry storm — mirror `gitea_auth.api_request` timeout + failure conversion |
|
||||||
|
| 502/503/504 | Explicit "Jenkins upstream unavailable" |
|
||||||
|
| 401/403 | "Jenkins auth failed / insufficient permissions" — **without** echoing credentials or the request's auth material |
|
||||||
|
| Malformed JSON | "malformed JSON response from Jenkins" (no raw-body dump) |
|
||||||
|
| Missing profile/creds | Fail closed before any network call (§7) |
|
||||||
|
|
||||||
|
## 6. Console tail safety (`jenkins_console_tail`)
|
||||||
|
|
||||||
|
Console logs are the highest-risk surface (secrets, tokens, internal hosts
|
||||||
|
routinely leak into build logs). If included at all (owner may defer it):
|
||||||
|
|
||||||
|
- **Bounded:** hard server-side cap (default: last 200 lines AND ≤ 64 KiB,
|
||||||
|
whichever is smaller; caller may request less, never more).
|
||||||
|
- **Redacted:** pass through the shared secret redactor (token/`Basic`/`Bearer`/
|
||||||
|
password/key-value patterns) before returning; redaction failure ⇒ return an
|
||||||
|
error, never the raw text.
|
||||||
|
- **Default off:** summary fields (`result`, failing stage if cheaply available)
|
||||||
|
are preferred; the tail requires an explicit `allowed_operations` entry
|
||||||
|
(`jenkins.console.read`) distinct from plain `jenkins.build.read`.
|
||||||
|
|
||||||
|
## 7. Credentials and profile requirements
|
||||||
|
|
||||||
|
Follows the per-service profile model (`gitea-execution-profiles.md`, extended
|
||||||
|
by #76):
|
||||||
|
|
||||||
|
- Env/config: `JENKINS_URL`, `JENKINS_USER`, `JENKINS_TOKEN_SOURCE_NAME`
|
||||||
|
(name-of-secret only — value resolved at runtime, never logged/committed).
|
||||||
|
- Profile: e.g. `jenkins-readonly` with namespaced
|
||||||
|
`allowed_operations: ["jenkins.read", "jenkins.build.read"]`
|
||||||
|
(+ `jenkins.console.read` only if the tail tool is approved);
|
||||||
|
`forbidden_operations: ["jenkins.build.trigger", "jenkins.deploy", "jenkins.job.configure"]`
|
||||||
|
as belt-and-braces even though no mutating tool exists.
|
||||||
|
- Missing URL/user/token/profile ⇒ **fail closed** with a clear message.
|
||||||
|
- Since every tool is read-only, no confirmation gates are needed — but
|
||||||
|
identity (`jenkins_whoami`) must still work so workflows can prove which
|
||||||
|
Jenkins account they act as.
|
||||||
|
|
||||||
|
## 8. Job addressing and mapping
|
||||||
|
|
||||||
|
Tools accept an explicit fully-qualified job path (folder-aware:
|
||||||
|
`folder/subfolder/job`). How a *repo/branch/PR* resolves to that job path is
|
||||||
|
**out of scope here** and designed in **#77**, with these fixed constraints:
|
||||||
|
|
||||||
|
- No silent guessing of job names — unmapped input returns an explicit
|
||||||
|
"no mapping" result.
|
||||||
|
- Multibranch pipelines address a branch job as `<job>/<branch>` with proper
|
||||||
|
URL-encoding of branch names (e.g. `feature%2Fx`).
|
||||||
|
|
||||||
|
## 9. Testing strategy (for the implementing package)
|
||||||
|
|
||||||
|
Mocked-Jenkins unit tests only (no live Jenkins in unit CI), mirroring this
|
||||||
|
repo's conventions (`docs/developer-testing-guidelines.md`):
|
||||||
|
|
||||||
|
- Patch the HTTP client; assert method is always `GET` and URL shape is correct
|
||||||
|
(folders, multibranch encoding).
|
||||||
|
- Success projections: field allowlist exactly as §4; unknown fields dropped.
|
||||||
|
- `result=null + building=true` ⇒ `IN_PROGRESS`.
|
||||||
|
- Unknown job ⇒ found:false, no fuzzy match, no API retry.
|
||||||
|
- Timeout/DNS/5xx/malformed-JSON ⇒ safe errors, no secret/credential leakage
|
||||||
|
(explicit no-token-in-error assertions).
|
||||||
|
- Console tail: cap enforcement (lines and bytes), redaction applied, redaction
|
||||||
|
failure ⇒ error not raw text, gated behind `jenkins.console.read`.
|
||||||
|
- Profile gate: missing/insufficient profile ⇒ no network call
|
||||||
|
(`mock_api.assert_not_called()` pattern).
|
||||||
|
|
||||||
|
## 10. Implementation-readiness checklist
|
||||||
|
|
||||||
|
Ready to implement in `jenkins-mcp` once:
|
||||||
|
|
||||||
|
1. ADR-0001 owner decision #1 (where `jenkins-mcp` lives) is made.
|
||||||
|
2. #76 profile schema exists (or a minimal `jenkins-readonly` profile is
|
||||||
|
hand-rolled to the same rules).
|
||||||
|
3. #77 mapping design is accepted (or tools ship path-addressed only, mapping
|
||||||
|
deferred).
|
||||||
|
|
||||||
|
Explicitly **not** unlocked by this document: build triggers, deploys,
|
||||||
|
parameterized launches, any Jenkins code in `mcp_server.py`.
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# MCP Gitea Server Refactor: Compatibility Matrix & Staged Plan
|
||||||
|
|
||||||
|
- **Status:** Staging/Design (First phase of #65)
|
||||||
|
- **Issue:** #65 (Staged refactor of `mcp_server.py` into a modular package)
|
||||||
|
- **Date:** 2026-07-02
|
||||||
|
|
||||||
|
## 1. Overview and Refactoring Contract
|
||||||
|
The goal of this refactor is to split the monolith `mcp_server.py` (~1689 lines) into a clean, maintainable, and modular Python package (`gitea_tools`).
|
||||||
|
|
||||||
|
To ensure complete backward compatibility, we establish a strict contract:
|
||||||
|
* **No functional changes:** Code behaviour, API endpoint targets, parameter sets, and return formats must remain identical.
|
||||||
|
* **No gate bypasses:** Allowed operations, forbidden operations, identity resolving, and audit logging must continue to execute exactly as they do in the monolith.
|
||||||
|
* **Independent testing:** The full pytest suite must pass with 100% success after every single stage.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Compatibility Matrix
|
||||||
|
|
||||||
|
The following table documents every MCP tool's expected signature, parameters, return payload shape, and error behavior that must be preserved.
|
||||||
|
|
||||||
|
### 2.1 Issue & Label Management Tools
|
||||||
|
|
||||||
|
| Tool Name | Parameters | Return Payload Shape | Error Behavior / Edge Cases |
|
||||||
|
| :--- | :--- | :--- | :--- |
|
||||||
|
| `gitea_create_issue` | `title: str`, `body: str`, `remote: str`, `host: str \| None`, `org: str \| None`, `repo: str \| None` | Dict containing issue details (`number`, `title`, `body`, `state`, `labels`, `assignee`, `url`) | Raises error on auth failure, missing parameters, or Gitea API validation error. |
|
||||||
|
| `gitea_close_issue` | `issue_number: int`, `remote: str`, `host: str \| None`, `org: str \| None`, `repo: str \| None` | Dict detailing the closed issue. | Raises 404 if issue doesn't exist; fails closed if user has insufficient permission. |
|
||||||
|
| `gitea_list_issues` | `state: str`, `label: str \| None`, `limit: int`, `remote: str`, `host: str \| None`, `org: str \| None`, `repo: str \| None` | List of dicts representing matched issues. | Limits pagination per page and overall maximum caps. |
|
||||||
|
| `gitea_view_issue` | `issue_number: int`, `remote: str`, `host: str \| None`, `org: str \| None`, `repo: str \| None` | Dict of detailed issue attributes. | Returns clear 404 error if not found. |
|
||||||
|
| `gitea_mark_issue` | `issue_number: int`, `action: str`, `remote: str`, `host: str \| None`, `org: str \| None`, `repo: str \| None` | Dict indicating current label states (presence of `status:in-progress`). | Rejects unknown actions; fails if label doesn't exist on Gitea. |
|
||||||
|
| `gitea_list_labels` | `remote: str`, `host: str \| None`, `org: str \| None`, `repo: str \| None` | List of dicts representing labels. | Basic auth error fallback behavior. |
|
||||||
|
| `gitea_create_label` | `name: str`, `color: str`, `description: str`, `remote: str`, `host: str \| None`, `org: str \| None`, `repo: str \| None` | Dict of created label properties. | Fails on duplicate names or invalid color hex formats. |
|
||||||
|
| `gitea_set_issue_labels` | `issue_number: int`, `labels: list[str]`, `remote: str`, `host: str \| None`, `org: str \| None`, `repo: str \| None` | List of all labels currently applied to the issue. | Fails closed if any label name does not exist. |
|
||||||
|
|
||||||
|
### 2.2 PR & Review Management Tools
|
||||||
|
|
||||||
|
| Tool Name | Parameters | Return Payload Shape | Error Behavior / Edge Cases |
|
||||||
|
| :--- | :--- | :--- | :--- |
|
||||||
|
| `gitea_create_pr` | `title: str`, `head: str`, `base: str`, `body: str`, `remote: str`, `host: str \| None`, `org: str \| None`, `repo: str \| None` | Dict detailing the created PR. | Fails on missing branches, existing duplicate PR, or invalid base branch. |
|
||||||
|
| `gitea_list_prs` | `state: str`, `remote: str`, `host: str \| None`, `org: str \| None`, `repo: str \| None` | List of dicts representing open/closed PRs. | Standard limits apply. |
|
||||||
|
| `gitea_view_pr` | `pr_number: int`, `remote: str`, `host: str \| None`, `org: str \| None`, `repo: str \| None` | Dict of detailed PR attributes. | Fails if PR does not exist. |
|
||||||
|
| `gitea_check_pr_eligibility` | `pr_number: int`, `action: str`, `remote: str`, `host: str \| None`, `org: str \| None`, `repo: str \| None` | Dict: `{"eligible": bool, "reasons": list[str]}` | Non-gated, safe, read-only. Fails on invalid actions. |
|
||||||
|
| `gitea_submit_pr_review` | `pr_number: int`, `action: str`, `body: str`, `expected_head_sha: str \| None`, `remote: str`, `host: str \| None`, `org: str \| None`, `repo: str \| None` | Dict of submitted review properties. | Rejects self-review; fails if head SHA has changed in the meantime. |
|
||||||
|
| `gitea_edit_pr` | `pr_number: int`, `title: str \| None`, `body: str \| None`, `state: str \| None`, `base: str \| None`, `remote: str`, `host: str \| None`, `org: str \| None`, `repo: str \| None` | Dict of updated PR attributes. | Fails on invalid fields or if PR state transition is blocked. |
|
||||||
|
| `gitea_merge_pr` | `pr_number: int`, `confirmation: str`, `expected_head_sha: str \| None`, `expected_changed_files: list[str] \| None`, `do: str`, `title: str \| None`, `message: str \| None`, `remote: str`, `host: str \| None`, `org: str \| None`, `repo: str \| None` | Dict of merge result details. | Fails if any gating eligibility checks fail (e.g. self-merge, wrong confirmation, SHA mismatch). |
|
||||||
|
| `gitea_review_pr` | `pr_number: int`, `event: str`, `body: str`, `merge: bool`, `merge_method: str`, `remote: str`, `host: str \| None`, `org: str \| None`, `repo: str \| None` | Dict representing legacy review output. | Backward compatibility wrapper; delegates to review/merge logic. |
|
||||||
|
| `gitea_delete_branch` | `branch: str`, `remote: str`, `host: str \| None`, `org: str \| None`, `repo: str \| None` | Dict indicating branch deletion status. | Fails on protected branches or non-existent refs. |
|
||||||
|
|
||||||
|
### 2.3 File, Identity, and Utility Tools
|
||||||
|
|
||||||
|
| Tool Name | Parameters | Return Payload Shape | Error Behavior / Edge Cases |
|
||||||
|
| :--- | :--- | :--- | :--- |
|
||||||
|
| `gitea_get_file` | `filepath: str`, `ref: str`, `remote: str`, `host: str \| None`, `org: str \| None`, `repo: str \| None` | Dict containing metadata and Base64 content of the target file. | Fails if path or reference branch does not exist. |
|
||||||
|
| `gitea_commit_files` | `files: list[dict]`, `message: str`, `branch: str \| None`, `new_branch: str \| None`, `remote: str`, `host: str \| None`, `org: str \| None`, `repo: str \| None` | Dict describing commit hash and ref state. | Fails on file path conflicts or commit collisions. |
|
||||||
|
| `gitea_whoami` | `remote: str`, `host: str \| None` | Dict detailing verified login user (e.g., `sysadmin`). | Alias targets: `gitea_get_authenticated_user`, `gitea_get_current_user` must be preserved. |
|
||||||
|
| `gitea_get_profile` | `remote: str`, `host: str \| None`, `resolve_identity: bool` | Dict of loaded profile constraints and active configuration details. | Fails closed on invalid/missing profile specs. |
|
||||||
|
| `gitea_mirror_refs` | `apply: bool`, `force: bool` | Dict summarizing mirrored branch/tag logs. | Fails on Git CLI mirror action exceptions. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Staged Refactoring Plan
|
||||||
|
|
||||||
|
We will perform the refactoring in five discrete stages. Each stage will land as its own independent PR to master, verifying that the codebase compiles and passes the complete test suite at each step.
|
||||||
|
|
||||||
|
### Stage 1: API and Client Core Extraction
|
||||||
|
* **Goal:** Extract common network request wrappers, pagination handlers, and HTTP exception conversions.
|
||||||
|
* **Target File:** `gitea_tools/client.py`
|
||||||
|
* **Contents:** `api_request`, `api_get_all`, HTTP error maps, and token/credential redaction helper `_redact`.
|
||||||
|
|
||||||
|
### Stage 2: Auth and Configuration Extraction
|
||||||
|
* **Goal:** Extract Gitea profile parsers, credential loading logic, and helper scripts.
|
||||||
|
* **Target File:** `gitea_tools/config.py`
|
||||||
|
* **Contents:** `get_auth_header`, `get_profile`, `repo_api_url`, and profile config schemas.
|
||||||
|
|
||||||
|
### Stage 3: Audit Logging and Security Gates
|
||||||
|
* **Goal:** Extract security filters, audit logging mechanisms, and metadata decorators.
|
||||||
|
* **Target File:** `gitea_tools/audit.py`
|
||||||
|
* **Contents:** `AuditSink`, `_audited`, and audit message templates.
|
||||||
|
|
||||||
|
### Stage 4: Tool Implementations (Domain-Driven Modules)
|
||||||
|
* **Goal:** Group and move the core implementation logic of the 24 tools out of `mcp_server.py`.
|
||||||
|
* **Target Files:**
|
||||||
|
* `gitea_tools/issues.py` — Issues, labels, and mark status tools.
|
||||||
|
* `gitea_tools/prs.py` — PRs, reviews, merge gating, and branch delete.
|
||||||
|
* `gitea_tools/files.py` — File retrieval and atomic commits.
|
||||||
|
* `gitea_tools/identity.py` — whoami and runtime profile descriptions.
|
||||||
|
* `gitea_tools/utilities.py` — Mirroring scripts and miscellaneous tasks.
|
||||||
|
|
||||||
|
### Stage 5: Final Tool Registration Layer
|
||||||
|
* **Goal:** Clean up the root `mcp_server.py` to be a pure registration layer.
|
||||||
|
* **Contents:** Imports the modular functions from the `gitea_tools` package and wraps them inside the standard FastMCP `@mcp.tool()` decorators.
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# Multi-Service MCP Profile and Configuration Model
|
||||||
|
|
||||||
|
- **Status:** Design (no implementation in this repo yet)
|
||||||
|
- **Issue:** #76 (parent umbrella: #75; boundary decision: ADR-0001, #71)
|
||||||
|
- **Date:** 2026-07-02
|
||||||
|
|
||||||
|
## 1. Purpose and Scope
|
||||||
|
|
||||||
|
Extend the existing Gitea execution-profile model (`docs/gitea-execution-profiles.md`) into a generic **per-service** MCP profile/config model. This supports integrating Jenkins and GlitchTip into the MCP Control Plane while strictly preserving isolation and fail-closed safety.
|
||||||
|
|
||||||
|
**Crucial Constraints:**
|
||||||
|
* The shared profile/config model is a **schema / library**, **not a shared credential pool**.
|
||||||
|
* Tokens remain **service-local**; profiles are **per service**.
|
||||||
|
* Orchestrators **must not** directly hold every service credential.
|
||||||
|
|
||||||
|
## 2. Profile Schema (Per Service)
|
||||||
|
|
||||||
|
The schema reuses the proven Gitea field model, adapted per service.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"profile_name": "readonly-metrics",
|
||||||
|
"service": "glitchtip",
|
||||||
|
"token_source_name": "GLITCHTIP_API_TOKEN_READONLY",
|
||||||
|
"allowed_operations": [
|
||||||
|
"glitchtip.event.read",
|
||||||
|
"glitchtip.issue.read"
|
||||||
|
],
|
||||||
|
"forbidden_operations": [
|
||||||
|
"glitchtip.issue.resolve",
|
||||||
|
"glitchtip.issue.delete"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Schema Rules
|
||||||
|
|
||||||
|
* `allowed_operations` are **namespaced** (e.g., `gitea.issue.create`, `jenkins.build.read`, `glitchtip.event.read`).
|
||||||
|
* `forbidden_operations`, if present, **always override** `allowed_operations`.
|
||||||
|
* `token_source_name` records the source **name only, never the value**. Tokens must never be printed, logged, or included in telemetry.
|
||||||
|
|
||||||
|
## 3. Fail-Closed Behavior
|
||||||
|
|
||||||
|
The model enforces strict fail-closed constraints before any network call occurs:
|
||||||
|
* **Missing Profile:** If a requested profile is undefined for the target service, the operation fails immediately.
|
||||||
|
* **Missing Credentials:** If the `token_source_name` cannot be resolved to a valid token at runtime, the operation fails immediately without retrying or prompting.
|
||||||
|
|
||||||
|
## 4. Environment Overrides
|
||||||
|
|
||||||
|
Profiles can be dynamically overridden or injected via environment variables, following the established hierarchy:
|
||||||
|
|
||||||
|
1. **Explicit Environment Variable:** (Highest precedence) e.g., `MCP_GLITCHTIP_TOKEN` overrides any JSON profile.
|
||||||
|
2. **Profile Mapping in JSON:** Resolved via `token_source_name` (e.g., `GLITCHTIP_API_TOKEN_READONLY`) mapping to an environment variable or secret store.
|
||||||
|
3. **No Auth:** Fails closed.
|
||||||
|
|
||||||
|
## 5. Audit Logging
|
||||||
|
|
||||||
|
To maintain accountability across multi-service workflows, all mutating actions must include the audit identity and source:
|
||||||
|
* The audit log must record the `profile_name`, the orchestrator source (e.g., `sysadmin`, `jenkins-mcp`), and the action taken.
|
||||||
|
* The audit system must sanitize all output to ensure tokens are stripped (see `safety-model.md`).
|
||||||
|
|
||||||
|
## 6. Backward Compatibility
|
||||||
|
|
||||||
|
The existing Gitea profile behavior (`gitea_whoami`, etc.) remains strictly backward compatible. The generic profile library will parse existing Gitea profile objects without requiring them to migrate their schemas, defaulting the `service` attribute to `gitea`.
|
||||||
|
|
||||||
|
## 7. Implementation Boundary
|
||||||
|
|
||||||
|
Per the namespace decisions in #71 and #75, this generic model belongs in the `common` package or library. It will be imported by `gitea-mcp` (this repo), `jenkins-mcp`, and `glitchtip-mcp` without forcing a monolithic architecture.
|
||||||
@@ -5,5 +5,5 @@ This document describes how credentials and sensitive environment variables are
|
|||||||
## Separate Credentials
|
## Separate Credentials
|
||||||
Even though multiple MCP servers share the same monorepo, they **must** have separate credentials and runtimes.
|
Even though multiple MCP servers share the same monorepo, they **must** have separate credentials and runtimes.
|
||||||
|
|
||||||
- **No Shared Environments**: Each MCP server (`gitea-mcp`, `jenkins-mcp`, `ops-mcp`, etc.) must be instantiated as an independent service with its own dedicated `.env` configuration file.
|
- **No Shared Environments**: Each MCP server (`gitea-mcp`, `jenkins-mcp`, `glitchtip-mcp`, `ops-mcp`, etc.) must be instantiated as an independent service with its own dedicated `.env` configuration file.
|
||||||
- **Strict Isolation**: A server will only have access to the credentials required for its specific trust boundary. For instance, `gitea-mcp` has no access to Jenkins or Ops authentication tokens.
|
- **Strict Isolation**: A server will only have access to the credentials required for its specific trust boundary. For instance, `gitea-mcp` has no access to Jenkins or Ops authentication tokens.
|
||||||
|
|||||||
@@ -0,0 +1,241 @@
|
|||||||
|
# Developer Testing Guidelines
|
||||||
|
|
||||||
|
How to write and run tests for Gitea-Tools. This guide reflects the current
|
||||||
|
repository behavior and the safety model documented in
|
||||||
|
[`safety-model.md`](safety-model.md),
|
||||||
|
[`credential-isolation.md`](credential-isolation.md), and
|
||||||
|
[`gitea-execution-profiles.md`](gitea-execution-profiles.md).
|
||||||
|
|
||||||
|
Core principle: **tests never make real network calls and never touch real
|
||||||
|
credentials.** Every test mocks the HTTP client and the keychain/auth lookup.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Standard test commands
|
||||||
|
|
||||||
|
The test suite needs the project virtualenv (it provides the MCP SDK):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From the repository root
|
||||||
|
source venv/bin/activate
|
||||||
|
python3 -m pytest tests/ -q
|
||||||
|
```
|
||||||
|
|
||||||
|
Or invoke the venv interpreter directly without activating:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./venv/bin/python -m pytest tests/ -q
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `-q` for a compact summary and `-v` to see individual test names.
|
||||||
|
|
||||||
|
### Run the full suite
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./venv/bin/python -m pytest tests/ -q
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run targeted tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# One file
|
||||||
|
./venv/bin/python -m pytest tests/test_mcp_server.py -q
|
||||||
|
|
||||||
|
# One class
|
||||||
|
./venv/bin/python -m pytest tests/test_merge_pr.py::TestMergeDisabled -q
|
||||||
|
|
||||||
|
# One test, by node id
|
||||||
|
./venv/bin/python -m pytest tests/test_review_pr.py::TestAPIPayload::test_payload_fields_and_workflow -q
|
||||||
|
|
||||||
|
# By keyword expression
|
||||||
|
./venv/bin/python -m pytest tests/ -q -k "merge and fails_closed"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Syntax and formatting checks
|
||||||
|
|
||||||
|
These are fast and belong in any pre-PR loop:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Byte-compile the main modules (catches syntax errors)
|
||||||
|
python3 -m py_compile mcp_server.py
|
||||||
|
python3 -m py_compile manage_labels.py
|
||||||
|
|
||||||
|
# Lint shell scripts without executing them
|
||||||
|
bash -n scripts/clear-provenance
|
||||||
|
|
||||||
|
# Detect stray conflict markers and whitespace errors in the diff
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
Run `git diff --check` before every commit; it flags leftover merge-conflict
|
||||||
|
markers and trailing-whitespace/whitespace-error lines.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. How to add an MCP tool test
|
||||||
|
|
||||||
|
MCP tools live in `mcp_server.py` and are exercised in
|
||||||
|
`tests/test_mcp_server.py`. Tests call the underlying tool function directly
|
||||||
|
with the network layer and auth mocked. The established pattern:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
|
||||||
|
FAKE_AUTH = "Basic ZmFrZTpmYWtl" # not a real credential
|
||||||
|
|
||||||
|
class TestCreateIssue(unittest.TestCase):
|
||||||
|
@patch("mcp_server.api_request")
|
||||||
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
|
def test_creates_issue(self, _auth, mock_api):
|
||||||
|
mock_api.return_value = {"number": 1, "html_url": "https://gitea.example.com/issues/1"}
|
||||||
|
|
||||||
|
result = mcp_server.gitea_create_issue(title="Add tests", remote="prgs")
|
||||||
|
|
||||||
|
# Assert on the request the tool would have made
|
||||||
|
mock_api.assert_called_once()
|
||||||
|
method, url = mock_api.call_args[0][0], mock_api.call_args[0][1]
|
||||||
|
self.assertEqual(method, "POST")
|
||||||
|
self.assertIn("/issues", url)
|
||||||
|
```
|
||||||
|
|
||||||
|
Checklist when adding a tool test:
|
||||||
|
|
||||||
|
* Patch `mcp_server.api_request` — never hit the network.
|
||||||
|
* Patch `mcp_server.get_auth_header` to return a fake header — never read the
|
||||||
|
keychain.
|
||||||
|
* Assert on the **method, URL, and payload** the tool builds, and on the shape
|
||||||
|
of the returned payload.
|
||||||
|
* Cover both `dadeschools` and `prgs` remotes when the tool takes `remote`, and
|
||||||
|
confirm the correct host/org/repo are used.
|
||||||
|
* Cover the error path (e.g. `api_request` raising) and confirm the tool
|
||||||
|
surfaces a clear message without leaking secrets.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. How to mock API requests safely
|
||||||
|
|
||||||
|
* **Always patch `mcp_server.api_request`** (or `gitea_auth.api_request` for the
|
||||||
|
CLI/auth-level tests). No test should open a socket.
|
||||||
|
* **Always patch the auth lookup** (`get_auth_header` / `get_credentials`) and
|
||||||
|
return an obviously fake value. Do not put a real token or password in a test,
|
||||||
|
a fixture, or an environment file.
|
||||||
|
* Prefer asserting on `mock_api.call_args` (method/URL/payload) over asserting on
|
||||||
|
a real response body.
|
||||||
|
* For keychain behavior specifically, see `tests/test_credentials.py`, which
|
||||||
|
mocks the `git credential fill` subprocess (`Popen`) and the environment.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. How to test profile / allowed-operation failures
|
||||||
|
|
||||||
|
The execution-profile model (see
|
||||||
|
[`gitea-execution-profiles.md`](gitea-execution-profiles.md)) enforces that a
|
||||||
|
tool may only perform operations in its profile's `allowed_operations`, and that
|
||||||
|
`forbidden_operations` always override `allowed_operations`. Mutating tools must
|
||||||
|
**fail closed** when the active profile does not permit the operation.
|
||||||
|
|
||||||
|
When adding or changing a gated tool, add tests that:
|
||||||
|
|
||||||
|
* Configure a profile whose `allowed_operations` does **not** include the
|
||||||
|
requested operation, and assert the tool refuses **without** calling
|
||||||
|
`api_request` (assert `mock_api.assert_not_called()`).
|
||||||
|
* Configure a profile where the operation is in **both** allowed and forbidden,
|
||||||
|
and assert forbidden wins (still refused).
|
||||||
|
* Confirm the refusal message names the missing operation and does not include
|
||||||
|
any secret material.
|
||||||
|
* Confirm the happy path (operation allowed) still reaches `api_request`.
|
||||||
|
|
||||||
|
The guiding assertion is: **no mutation path may reach `api_request` unless the
|
||||||
|
profile/allowed-operation check passed first.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. How to test self-review / self-merge gates
|
||||||
|
|
||||||
|
Author-cannot-review and author-cannot-merge are hard safety gates. The merge
|
||||||
|
path is gated (`gitea_merge_pr`), the legacy review wrapper fails closed on
|
||||||
|
`merge=True`, and `gitea_submit_pr_review` never merges. Existing coverage lives
|
||||||
|
in `tests/test_merge_pr.py` and `tests/test_review_pr.py`.
|
||||||
|
|
||||||
|
Patterns to follow (see those files for concrete examples):
|
||||||
|
|
||||||
|
* **Self-merge blocked:** authenticated user == PR author → the tool returns a
|
||||||
|
refusal and **never calls the merge endpoint** (`mock_api.assert_not_called()`
|
||||||
|
or assert no `POST .../merge`).
|
||||||
|
* **Fail-closed inputs:** missing confirmation string, or an unexpected
|
||||||
|
`expected_head_sha`/changed-file set → refuse before any API call.
|
||||||
|
* **Legacy wrapper:** `merge=True` on the review wrapper fails closed and points
|
||||||
|
to the gated workflow, with no API call
|
||||||
|
(`test_merge_flag_fails_closed_without_api_call`).
|
||||||
|
* **Self-approval blocked:** authenticated user == PR author → `approve` /
|
||||||
|
`request_changes` refused.
|
||||||
|
|
||||||
|
Every new gate should have a test proving the mutating endpoint is **not**
|
||||||
|
reached when the gate should block.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. No-secret / no-token regression expectations
|
||||||
|
|
||||||
|
Secrets must never appear in logs, tool return values, audit records, or test
|
||||||
|
output (see [`safety-model.md`](safety-model.md) §3). The audit module
|
||||||
|
(`gitea_audit.py`) redacts secret-like keys and value prefixes; see
|
||||||
|
`tests/test_audit.py`.
|
||||||
|
|
||||||
|
Expectations for new tests:
|
||||||
|
|
||||||
|
* Assert that token/authorization/password fields are replaced with
|
||||||
|
`gitea_audit.REDACTED` in any structured output or audit record
|
||||||
|
(`test_redacts_secret_keys`, `test_redacts_nested_and_lists`).
|
||||||
|
* Assert that credential-looking substrings in free-text (error messages,
|
||||||
|
reasons) are redacted (`test_redacts_credential_value_prefixes`,
|
||||||
|
`test_metadata_and_reason_redacted`).
|
||||||
|
* Never commit a real token/password, even in a fixture. Use obviously fake
|
||||||
|
values (e.g. `FAKE_AUTH` above).
|
||||||
|
* When a tool returns identity/profile metadata, assert it contains the
|
||||||
|
non-secret fields (username, profile name) and **not** the token.
|
||||||
|
|
||||||
|
There is no third-party secret scanner wired into this repo today; secret safety
|
||||||
|
is enforced by `gitea_audit.redact` plus the regression tests above. A quick
|
||||||
|
manual sweep before a PR:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Look for accidentally committed credentials in the diff
|
||||||
|
git diff --cached | grep -nEi "authorization: (basic|bearer)|password|token=[A-Za-z0-9]" || echo "clean"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Unit tests vs. Docker integration tests
|
||||||
|
|
||||||
|
* **Unit tests (default):** fast, fully mocked, no network, no keychain.
|
||||||
|
This is where the vast majority of coverage lives and where new tests should
|
||||||
|
go. They must stay fast and must not require credentials.
|
||||||
|
* **Docker/local-Gitea integration tests (#66, `tests/integration/`):** opt-in
|
||||||
|
and skipped by default — enabled only by `GITEA_INTEGRATION=1` and run
|
||||||
|
against a pinned, disposable Gitea container
|
||||||
|
(`tests/integration/gitea-integration up|token|down`). They validate real
|
||||||
|
API behavior (pagination, permissions, label endpoints, error payloads) that
|
||||||
|
mocks cannot prove. They must not use production credentials and must not
|
||||||
|
leak tokens. See [`../tests/integration/README.md`](../tests/integration/README.md).
|
||||||
|
|
||||||
|
Rule of thumb: prove **logic and request-shaping** with unit tests; reserve
|
||||||
|
integration tests for **real-server compatibility**. Do not convert unit tests
|
||||||
|
into network tests.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Read-only vs. mutating tool expectations
|
||||||
|
|
||||||
|
* **Read-only tools** (e.g. `gitea_whoami`, `gitea_view_*`, `gitea_list_*`,
|
||||||
|
`gitea_get_profile`): test that they never issue a mutating HTTP method and
|
||||||
|
never require a mutation gate. Assert the request method is `GET`.
|
||||||
|
* **Mutating tools** (create/edit/close/label, review, merge, mirror): test that
|
||||||
|
they (a) pass the profile/allowed-operation gate, (b) honor confirmation and
|
||||||
|
self-action gates, (c) emit an audit record with the authenticated identity
|
||||||
|
and outcome, and (d) fail closed — no `api_request` call — when any gate fails.
|
||||||
|
|
||||||
|
Keep this split explicit in test names and assertions so a reviewer can see, per
|
||||||
|
tool, which category it belongs to and which gates it must respect.
|
||||||
@@ -134,8 +134,50 @@ Rules:
|
|||||||
appears in both, it is forbidden.
|
appears in both, it is forbidden.
|
||||||
- An operation not present in `allowed_operations` is treated as **not
|
- An operation not present in `allowed_operations` is treated as **not
|
||||||
allowed** (deny by default).
|
allowed** (deny by default).
|
||||||
- These categories are descriptive for this issue. Their runtime enforcement is
|
|
||||||
out of scope here (see roadmap links).
|
## Operation-name normalization (#106)
|
||||||
|
|
||||||
|
Canonical operation names are namespaced: `{service}.{area}.{verb}` (e.g.
|
||||||
|
`gitea.pr.merge`, `jenkins.build.read`). Legacy unqualified spellings are
|
||||||
|
accepted **only** through the explicit alias table below (the code of record
|
||||||
|
is `GITEA_OPERATION_ALIASES` in `gitea_config.py`; the enforcement matrix is
|
||||||
|
`tests/test_op_normalization.py`).
|
||||||
|
|
||||||
|
| Legacy spelling | Canonical operation |
|
||||||
|
|-------------------|----------------------------|
|
||||||
|
| `read` | `gitea.read` |
|
||||||
|
| `review` | `gitea.pr.review` |
|
||||||
|
| `comment` | `gitea.pr.comment` |
|
||||||
|
| `approve` | `gitea.pr.approve` |
|
||||||
|
| `request_changes` | `gitea.pr.request_changes` |
|
||||||
|
| `merge` | `gitea.pr.merge` |
|
||||||
|
| `pr.create` | `gitea.pr.create` |
|
||||||
|
| `branch.push` | `gitea.branch.push` |
|
||||||
|
| `branch` | `gitea.branch.create` |
|
||||||
|
| `commit` | `gitea.repo.commit` |
|
||||||
|
| `push` | `gitea.branch.push` |
|
||||||
|
| `open_pr` | `gitea.pr.create` |
|
||||||
|
|
||||||
|
For non-Gitea services, a single unqualified word namespaces to the checked
|
||||||
|
service (`read` → `jenkins.read` when checking Jenkins); names already
|
||||||
|
prefixed with that service pass through unchanged.
|
||||||
|
|
||||||
|
Enforcement rules (`gitea_config.check_operation`, run **before** any
|
||||||
|
allowed/forbidden membership check):
|
||||||
|
|
||||||
|
- Unknown operation names fail closed (denied).
|
||||||
|
- Ambiguous names — dotted names that are neither service-prefixed nor in the
|
||||||
|
alias table — fail closed.
|
||||||
|
- Cross-service names are never accepted by the wrong service
|
||||||
|
(`jenkins.read` never matches a Gitea check, and a Gitea alias is never
|
||||||
|
applied to another service).
|
||||||
|
- `forbidden_operations` overrides `allowed_operations` after both sides are
|
||||||
|
normalized, so a legacy spelling can never bypass a canonical forbidden
|
||||||
|
entry (or vice versa).
|
||||||
|
- An allowed entry that cannot be normalized grants nothing; a forbidden
|
||||||
|
entry that cannot be normalized denies the request. Normalization can
|
||||||
|
therefore never silently widen permissions.
|
||||||
|
- An empty or missing `allowed_operations` list denies everything.
|
||||||
|
|
||||||
## Identity and fail-closed rules
|
## Identity and fail-closed rules
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Label Taxonomy
|
||||||
|
|
||||||
|
This document catalogs the issue labels used for MCP workflows, including Jenkins and GlitchTip (observability).
|
||||||
|
|
||||||
|
> **Approval Required:** Do not create or apply new labels in `manage_labels.py` without explicit owner approval of this document.
|
||||||
|
|
||||||
|
## Existing Labels
|
||||||
|
|
||||||
|
* **`jenkins`**
|
||||||
|
* Description: Jenkins integration
|
||||||
|
* Color: `d93f0b`
|
||||||
|
* Use: Used to mark issues, PRs, or tasks that involve the `jenkins-mcp` boundaries, CI/CD designs, or build failures.
|
||||||
|
|
||||||
|
* **`glitchtip`**
|
||||||
|
* Description: GlitchTip integration
|
||||||
|
* Color: `b60205`
|
||||||
|
* Use: Used to mark issues related to the `glitchtip-mcp` boundary and observability integration.
|
||||||
|
|
||||||
|
## Proposed / Missing Labels
|
||||||
|
|
||||||
|
* **`observability`**
|
||||||
|
* Proposed Description: Observability, metrics, and monitoring tasks
|
||||||
|
* Proposed Color: `5319e7`
|
||||||
|
* Use: Broader than GlitchTip alone; covers logging, metrics, traces, and general observability pipeline improvements.
|
||||||
|
|
||||||
|
* **`source:glitchtip`**
|
||||||
|
* Proposed Description: Issue filed automatically by GlitchTip orchestration
|
||||||
|
* Proposed Color: `b60205`
|
||||||
|
* Use: Applied automatically by the orchestrator when a GlitchTip error event is converted into a Gitea issue.
|
||||||
|
|
||||||
|
* **`status:triage`**
|
||||||
|
* Proposed Description: Issue needs human or orchestrator triage
|
||||||
|
* Proposed Color: `fbca04`
|
||||||
|
* Use: Used for incoming issues (especially automated ones like `source:glitchtip`) that have not yet been evaluated for priority or resolution.
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
# LLM-Agent-SHA — Opaque Agent Attribution (Phase 0)
|
||||||
|
|
||||||
|
Convention for attributing work to a specific LLM session/workstream across
|
||||||
|
issues, branches, PRs, and review handoffs, without exposing a human or model
|
||||||
|
identity. Approved by the owner decision on issue #86
|
||||||
|
(`#issuecomment-1354`); this document implements **Phase 0 only**.
|
||||||
|
|
||||||
|
## The one rule that matters
|
||||||
|
|
||||||
|
`LLM-Agent-SHA` is **informational attribution metadata only**. It must never
|
||||||
|
be used for authentication, authorization, review eligibility, merge
|
||||||
|
eligibility, profile permissions, or any other security decision.
|
||||||
|
|
||||||
|
The security gates remain, unchanged:
|
||||||
|
|
||||||
|
- the **authenticated Gitea user** (self-review/self-merge protection),
|
||||||
|
- the **active MCP profile** and its `allowed_operations`
|
||||||
|
(see [`gitea-execution-profiles.md`](gitea-execution-profiles.md)),
|
||||||
|
- the fail-closed eligibility checks in `gitea_check_pr_eligibility`.
|
||||||
|
|
||||||
|
Two sessions with different `LLM-Agent-SHA` values that authenticate as the
|
||||||
|
same Gitea user are **the same actor** for review/merge safety. A different
|
||||||
|
SHA never unlocks self-review or self-merge. `tests/test_llm_agent_sha.py`
|
||||||
|
proves the eligibility logic never consults the SHA.
|
||||||
|
|
||||||
|
## Format
|
||||||
|
|
||||||
|
```text
|
||||||
|
LLM-Agent-SHA: llm-<12 lowercase hex chars>
|
||||||
|
```
|
||||||
|
|
||||||
|
Validation regex:
|
||||||
|
|
||||||
|
```text
|
||||||
|
^llm-[0-9a-f]{12}$
|
||||||
|
```
|
||||||
|
|
||||||
|
Examples: `llm-8f3a9c2d6b41`, `llm-41d0e7aa9f2c`, `llm-b7c93d441a08`.
|
||||||
|
|
||||||
|
### Generation
|
||||||
|
|
||||||
|
Generate 48 random bits, e.g. `python3 -c "import secrets; print('llm-' +
|
||||||
|
secrets.token_hex(6))"`, or hash a non-secret session UUID. An
|
||||||
|
operator-provided opaque ID is also fine.
|
||||||
|
|
||||||
|
Do **not** derive the value from any of:
|
||||||
|
|
||||||
|
- a Gitea token or other secret,
|
||||||
|
- an email address or username,
|
||||||
|
- a machine hostname or private filesystem path,
|
||||||
|
- a model or provider name,
|
||||||
|
- conversation contents.
|
||||||
|
|
||||||
|
The SHA must contain no model name, provider name, human name, email,
|
||||||
|
hostname, token, private path, or conversation-derived content. It is safe to
|
||||||
|
include in PR bodies, issue comments, and audit logs — and only there.
|
||||||
|
|
||||||
|
## Lifetime
|
||||||
|
|
||||||
|
Canonical lifetime is **per PR/workstream**: pick one SHA when starting an
|
||||||
|
issue and keep it through the branch, PR, and handoff for that workstream. A
|
||||||
|
per-session SHA is acceptable when the session maps cleanly to one
|
||||||
|
workstream. Do not reuse a SHA across unrelated workstreams.
|
||||||
|
|
||||||
|
## Placement
|
||||||
|
|
||||||
|
Phase 0 uses **visible markdown metadata blocks** (not hidden HTML
|
||||||
|
comments). Include the block in PR bodies and review handoffs; keep it out of
|
||||||
|
ordinary comments unless attribution is genuinely useful there.
|
||||||
|
|
||||||
|
**Never put the SHA in branch or worktree names.** Branches stay
|
||||||
|
issue-linked and human-readable (`docs/issue-86-llm-agent-sha-phase0`), per
|
||||||
|
the branch standard.
|
||||||
|
|
||||||
|
### Handoff metadata block (implementer → PR body / handoff report)
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
LLM Handoff Metadata:
|
||||||
|
- LLM-Agent-SHA: llm-8f3a9c2d6b41
|
||||||
|
- LLM-Role: implementer
|
||||||
|
- Authenticated-Gitea-User: jcwalker3
|
||||||
|
- MCP-Profile: gitea-default
|
||||||
|
- Branch: docs/example-branch
|
||||||
|
- Worktree: branches/docs-example-branch
|
||||||
|
- Self-review allowed: no
|
||||||
|
```
|
||||||
|
|
||||||
|
### Review metadata block (reviewer → review comment)
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
Review Metadata:
|
||||||
|
- LLM-Agent-SHA: llm-41d0e7aa9f2c
|
||||||
|
- LLM-Role: reviewer
|
||||||
|
- Authenticated-Gitea-User: sysadmin
|
||||||
|
- MCP-Profile: prgs-reviewer
|
||||||
|
- Eligibility: passed
|
||||||
|
```
|
||||||
|
|
||||||
|
## Same SHA vs same user vs same profile
|
||||||
|
|
||||||
|
Reviewers and operators must keep three distinct identities straight:
|
||||||
|
|
||||||
|
| Comparison | Meaning | Effect on eligibility |
|
||||||
|
|---|---|---|
|
||||||
|
| same `LLM-Agent-SHA` | same LLM session/workstream wrote both artifacts | **none — attribution only** |
|
||||||
|
| same authenticated Gitea user | same Gitea actor | **blocks** self-review / self-merge, regardless of SHA |
|
||||||
|
| same MCP profile | same capability set | governs `allowed_operations` (what actions are permitted at all) |
|
||||||
|
|
||||||
|
Concretely: an implementer session (`llm-8f3a…`, user `jcwalker3`) and a
|
||||||
|
would-be reviewer session (`llm-41d0…`, also user `jcwalker3`) have different
|
||||||
|
SHAs but the **same Gitea user** — the reviewer session is still the PR
|
||||||
|
author to Gitea and must not review, approve, or merge. Review handoffs
|
||||||
|
require a genuinely different authenticated user (e.g. `sysadmin` /
|
||||||
|
`prgs-reviewer`).
|
||||||
|
|
||||||
|
## Phase 0 scope (and what is deferred)
|
||||||
|
|
||||||
|
Phase 0 is documentation, handoff/review templates, and negative tests only.
|
||||||
|
Deferred to later owner-approved phases; none of this exists yet:
|
||||||
|
|
||||||
|
- launcher-enforced SHA generation,
|
||||||
|
- `LLM_AGENT_SHA` / `LLM_AGENT_ROLE` environment injection,
|
||||||
|
- `gitea_whoami` returning SHA/role,
|
||||||
|
- automatic PR body injection by MCP tools,
|
||||||
|
- audit schema changes requiring the SHA,
|
||||||
|
- release/orchestrator lineage tracking.
|
||||||
|
|
||||||
|
MCP tools neither read nor emit the SHA. Setting an `LLM_AGENT_SHA`
|
||||||
|
environment variable has no effect on any tool; the negative tests assert
|
||||||
|
eligibility results are byte-identical with and without it.
|
||||||
|
|
||||||
|
## Related documents
|
||||||
|
|
||||||
|
- [`llm-workflow-runbooks.md`](llm-workflow-runbooks.md) — the runbooks whose
|
||||||
|
handoffs carry these blocks
|
||||||
|
- [`gitea-execution-profiles.md`](gitea-execution-profiles.md) — profiles and
|
||||||
|
`allowed_operations` (the real permission gate)
|
||||||
|
- [`safety-model.md`](safety-model.md) — audit, redaction, confirmation gates
|
||||||
+165
-11
@@ -7,11 +7,23 @@ package of the MCP Control Plane: creating issues, implementing them, opening
|
|||||||
and reviewing pull requests, merging, and closing out — safely and
|
and reviewing pull requests, merging, and closing out — safely and
|
||||||
reproducibly.
|
reproducibly.
|
||||||
|
|
||||||
|
> For the **project-agnostic** version of these operating rules (issue-first,
|
||||||
|
> isolated worktrees, no self-review/merge, profile safety, cleanup, fail-closed)
|
||||||
|
> that can be copied into any repository, see the reusable skill
|
||||||
|
> [`skills/llm-project-workflow/SKILL.md`](../skills/llm-project-workflow/SKILL.md)
|
||||||
|
> and its `templates/`. This runbook is the Gitea-specific application of it.
|
||||||
|
|
||||||
These runbooks are **operational guidance only**. They add no tooling; the
|
These runbooks are **operational guidance only**. They add no tooling; the
|
||||||
behavior they rely on already exists (canonical runtime profiles, the
|
behavior they rely on already exists (canonical runtime profiles, the
|
||||||
interactive setup menu, identity/eligibility checks, gated review/merge, and
|
interactive setup menu, identity/eligibility checks, gated review/merge, and
|
||||||
audit logging). See [Related documents](#related-documents).
|
audit logging). See [Related documents](#related-documents).
|
||||||
|
|
||||||
|
For cross-project use, copy the portable workflow skill at
|
||||||
|
[`../skills/llm-project-workflow/SKILL.md`](../skills/llm-project-workflow/SKILL.md).
|
||||||
|
It extracts the issue-first, isolated-worktree, no-self-review, profile-safety,
|
||||||
|
merge-cleanup, fail-closed, and recovery rules into a reusable package that can
|
||||||
|
be adapted to other repositories.
|
||||||
|
|
||||||
## Principle: the profile is the role, not the LLM
|
## Principle: the profile is the role, not the LLM
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -33,6 +45,18 @@ Use any eligible reviewer profile to review PR #N.
|
|||||||
Use any eligible merger profile to merge PR #N if checks pass.
|
Use any eligible merger profile to merge PR #N if checks pass.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Attribution: `LLM-Agent-SHA` (metadata only)
|
||||||
|
|
||||||
|
Sessions may attribute their work with an opaque `LLM-Agent-SHA`
|
||||||
|
(`llm-<12 lowercase hex>`, e.g. `llm-8f3a9c2d6b41`) in PR-body and
|
||||||
|
review-handoff metadata blocks — see
|
||||||
|
[`llm-agent-sha.md`](llm-agent-sha.md) for the full convention. It is
|
||||||
|
**attribution only**: eligibility is decided solely by the authenticated
|
||||||
|
Gitea user and the profile's allowed operations. Two sessions with different
|
||||||
|
SHAs under the same Gitea user are the same actor — a different SHA never
|
||||||
|
permits self-review or self-merge. Keep the SHA out of branch and worktree
|
||||||
|
names.
|
||||||
|
|
||||||
## Prerequisites: canonical config + thin launchers
|
## Prerequisites: canonical config + thin launchers
|
||||||
|
|
||||||
Runtime profiles live in **one canonical JSON file**, referenced by every LLM
|
Runtime profiles live in **one canonical JSON file**, referenced by every LLM
|
||||||
@@ -100,8 +124,35 @@ and the two `GITEA_MCP_*` variables — never a token or password:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Run the same server as several launcher entries (e.g. `-author`, `-reviewer`,
|
### Dual-profile MCP launcher pattern (Recommended)
|
||||||
`-merger`), each pointing at a different `GITEA_MCP_PROFILE`.
|
|
||||||
|
To avoid the bottleneck of relaunching/restarting the MCP server to switch between author and reviewer roles, the client should register **both** profiles concurrently as separate server instances in the client's MCP configuration:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"gitea-author": {
|
||||||
|
"command": "/path/to/Gitea-Tools/venv/bin/python3",
|
||||||
|
"args": ["/path/to/Gitea-Tools/mcp_server.py"],
|
||||||
|
"env": {
|
||||||
|
"GITEA_MCP_CONFIG": "/path/to/.config/gitea-tools/profiles.json",
|
||||||
|
"GITEA_MCP_PROFILE": "prgs-author"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"gitea-reviewer": {
|
||||||
|
"command": "/path/to/Gitea-Tools/venv/bin/python3",
|
||||||
|
"args": ["/path/to/Gitea-Tools/mcp_server.py"],
|
||||||
|
"env": {
|
||||||
|
"GITEA_MCP_CONFIG": "/path/to/.config/gitea-tools/profiles.json",
|
||||||
|
"GITEA_MCP_PROFILE": "prgs-reviewer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
* **Tool Namespaces:** Tool calls become distinct and identity-scoped in the client UI:
|
||||||
|
* `mcp__gitea-author__*` (for creating issues, pushing branches, creating PRs)
|
||||||
|
* `mcp__gitea-reviewer__*` (for reviewing PRs, approving, requesting changes, merging)
|
||||||
|
* **Trust Model:** Separate tokens remain separate in the keychain/environment. Each instance operates under its own `GITEA_MCP_PROFILE` and enforces its own `allowed_operations`. A runtime `whoami` identity check is still performed independently, and self-review/self-merge checks remain strictly mandatory. The dual-server pattern is a operational convenience and never a security bypass.
|
||||||
|
* **Reviewer-Identity PR Creation Deadlock:** Reviewer/merge identities must not create PRs or push branches. Doing so makes the reviewer identity the PR author in Gitea, blocking subsequent independent review and causing a review deadlock. Normally, PRs must be created by the author/work identity (`gitea-author`), leaving the reviewer identity (`gitea-reviewer`) clean and available for independent review and merge.
|
||||||
|
* **Fallback:** If the dual-profile MCP launcher pattern is not supported or configured in the client, the LLM must relaunch or restart the client/MCP with the correct profile environment variable before claiming or working on any tasks.
|
||||||
|
|
||||||
## Setup runbook — interactive menu
|
## Setup runbook — interactive menu
|
||||||
|
|
||||||
@@ -168,6 +219,24 @@ under `branches/`. The main repository checkout is an orchestration checkout:
|
|||||||
use it for status checks, issue creation/claiming, and creating worktrees, but
|
use it for status checks, issue creation/claiming, and creating worktrees, but
|
||||||
do not edit tracked repository files there.
|
do not edit tracked repository files there.
|
||||||
|
|
||||||
|
**Issue → branch → worktree → PR → cleanup.** Every implementation branch is
|
||||||
|
tied to an issue number so the work is traceable end to end:
|
||||||
|
|
||||||
|
| Stage | Form |
|
||||||
|
|-------|------|
|
||||||
|
| Issue | `#123` (claimed with `status:in-progress`) |
|
||||||
|
| Branch | `(fix\|feat\|docs\|chore)/issue-123-<slug>` (review: `review/pr-456-<slug>`) |
|
||||||
|
| Worktree | `branches/fix-issue-123-<slug>` (slashes → hyphens) |
|
||||||
|
| PR | body says `Closes #123` or `Fixes #123` (closes issue); `Implements #123` or `Refs #123` (does NOT close) |
|
||||||
|
| Cleanup | remove remote+local branch + worktree folder; drop `status:in-progress` |
|
||||||
|
|
||||||
|
`scripts/worktree-start` **rejects** implementation branches that are not
|
||||||
|
issue-linked (use `--allow-unlinked` only for genuine exceptions). When claiming,
|
||||||
|
post a comment like
|
||||||
|
`Claimed. Branch: fix/issue-123-<slug>. Worktree: branches/fix-issue-123-<slug>.`
|
||||||
|
Gitea has no native issue→branch API field (only a PR's head branch), so this
|
||||||
|
linkage is enforced by branch name + claim comment + PR body + cleanup.
|
||||||
|
|
||||||
Branch folders are ignored by git via `branches/`, so dirty work in one issue
|
Branch folders are ignored by git via `branches/`, so dirty work in one issue
|
||||||
does not block starting an unrelated issue in a separate branch folder. No LLM
|
does not block starting an unrelated issue in a separate branch folder. No LLM
|
||||||
may edit another issue's branch folder unless explicitly assigned to that issue.
|
may edit another issue's branch folder unless explicitly assigned to that issue.
|
||||||
@@ -244,7 +313,8 @@ touching anything.
|
|||||||
`fix/...` / `docs/...`); `cd` into that worktree; implement narrowly; add or
|
`fix/...` / `docs/...`); `cd` into that worktree; implement narrowly; add or
|
||||||
update tests if behavior changes; run the full suite; commit with an
|
update tests if behavior changes; run the full suite; commit with an
|
||||||
issue-linked message; open a PR to `master`. **Do not** review or merge your
|
issue-linked message; open a PR to `master`. **Do not** review or merge your
|
||||||
own PR.
|
own PR. Include an `LLM Handoff Metadata` block (with `LLM-Agent-SHA`) in
|
||||||
|
the PR body — see [`llm-agent-sha.md`](llm-agent-sha.md).
|
||||||
- **Prompt:** `Use an author profile to implement issue #N and open a PR to
|
- **Prompt:** `Use an author profile to implement issue #N and open a PR to
|
||||||
master. Do not self-review or self-merge.`
|
master. Do not self-review or self-merge.`
|
||||||
|
|
||||||
@@ -255,7 +325,11 @@ touching anything.
|
|||||||
- **Steps:** confirm identity + eligibility (menu eligibility check or
|
- **Steps:** confirm identity + eligibility (menu eligibility check or
|
||||||
`gitea_check_pr_eligibility`); read the diff; confirm scope matches the linked
|
`gitea_check_pr_eligibility`); read the diff; confirm scope matches the linked
|
||||||
issue; post the review (`comment` / `request_changes` / `approve`) via the
|
issue; post the review (`comment` / `request_changes` / `approve`) via the
|
||||||
gated review tool. Pin the reviewed head SHA where supported.
|
gated review tool. Pin the reviewed head SHA where supported. Include a
|
||||||
|
`Review Metadata` block (with your own `LLM-Agent-SHA`) in the review —
|
||||||
|
and remember: a different `LLM-Agent-SHA` does **not** make you a different
|
||||||
|
actor; only a different authenticated Gitea user does
|
||||||
|
([`llm-agent-sha.md`](llm-agent-sha.md)).
|
||||||
- **Prompt:** `Use any eligible reviewer profile to review PR #N. Approve only
|
- **Prompt:** `Use any eligible reviewer profile to review PR #N. Approve only
|
||||||
if scope matches issue #M and checks pass; otherwise request changes.`
|
if scope matches issue #M and checks pass; otherwise request changes.`
|
||||||
|
|
||||||
@@ -265,18 +339,26 @@ touching anything.
|
|||||||
- **Steps:** confirm eligibility; require explicit confirmation
|
- **Steps:** confirm eligibility; require explicit confirmation
|
||||||
(`MERGE PR <n>`); optionally pin head SHA / changed-file set; merge only when
|
(`MERGE PR <n>`); optionally pin head SHA / changed-file set; merge only when
|
||||||
Gitea reports the PR mergeable (branch-protection checks satisfied). No force,
|
Gitea reports the PR mergeable (branch-protection checks satisfied). No force,
|
||||||
no ignore-checks.
|
no ignore-checks. Verify that remote master contains the merge commit or the expected squashed changes (do not assume a "closed" PR succeeded without verifying the actual landed changes).
|
||||||
- **Prompt:** `Use any eligible merger profile to merge PR #N if checks pass and
|
- **Prompt:** `Use any eligible merger profile to merge PR #N if checks pass and
|
||||||
it is mergeable. Confirm with "MERGE PR N". Do not force-merge.`
|
it is mergeable. Confirm with "MERGE PR N". Do not force-merge.`
|
||||||
|
|
||||||
### Close the issue after merge
|
### Close the issue after merge / Reconciliation
|
||||||
|
|
||||||
- **Profile:** issue-manager or merger.
|
- **Profile:** issue-manager or merger.
|
||||||
- **Steps:** verify remote `master` actually contains the merge; close the
|
- **Steps:** Verify remote `master` actually contains the merge (post-merge file-presence verification):
|
||||||
issue (or rely on a `Closes #N` keyword); release `status:in-progress`;
|
- Run: `git fetch <remote> --prune; git checkout master; git pull <remote> master --ff-only`
|
||||||
clean up merged branches.
|
- Verify that expected files added/modified in the PR are present on `master` (or absent if deleted).
|
||||||
- **Prompt:** `After confirming master contains the merge of PR #N, close issue
|
- Alternatively, verify with: `git log --oneline -- <expected-file>` or `git merge-base --is-ancestor <pr-head-sha> master`
|
||||||
#M and delete the merged branch.`
|
- Close the issue; release `status:in-progress` (if it cannot be removed, report why).
|
||||||
|
- **If closed but not merged (`merged=false`):** Stop normal flow. Do not delete worktrees. Compare PR content to remote `master`.
|
||||||
|
- **fully landed:** comment it landed, remove `status:in-progress`, clean up.
|
||||||
|
- **partially landed:** reopen issue, create corrective PR for missing pieces.
|
||||||
|
- **not landed:** reopen issue/PR, do not clean up.
|
||||||
|
- **Direct push to master:** is forbidden except as a documented recovery exception. Final reports must include why, commits, PR metadata, and repaired labels.
|
||||||
|
- **Final reports:** must include both PR metadata (state, merged flag, merge commit) and Git content (remote master hash, expected content present, verification method used & results).
|
||||||
|
- **Prompt (normal):** `After verifying master contains the merge of PR #N using post-merge file-presence verification, close issue #M and delete the merged branch. Include verification details in the report.`
|
||||||
|
- **Prompt (reconcile):** `Reconcile closed-not-merged PR #N by verifying if its content landed on master.`
|
||||||
|
|
||||||
### Stop on blocker
|
### Stop on blocker
|
||||||
|
|
||||||
@@ -285,6 +367,38 @@ touching anything.
|
|||||||
files, detected secret, or any production/deploy behavior — **stop, report the
|
files, detected secret, or any production/deploy behavior — **stop, report the
|
||||||
blocker, and take no mutating action.** Fail closed; never work around a gate.
|
blocker, and take no mutating action.** Fail closed; never work around a gate.
|
||||||
|
|
||||||
|
## Controller Handoff (required, every task)
|
||||||
|
|
||||||
|
Every task — implementation, review, merge, triage, documentation,
|
||||||
|
discussion-only, or blocked planning — **must end with a
|
||||||
|
`Controller Handoff`** so a controller LLM can pick up the state
|
||||||
|
without rereading the conversation. The canonical formats and rules live in
|
||||||
|
the portable skill:
|
||||||
|
[`../skills/llm-project-workflow/SKILL.md`](../skills/llm-project-workflow/SKILL.md) §K.
|
||||||
|
|
||||||
|
**Compact format is the default** — nine lines (`Task / Repo/state /
|
||||||
|
Issues/PRs / Changed / Validation / Blockers / Review / Next / Safety`),
|
||||||
|
written for controller-LLM readability, not a full human status report. The
|
||||||
|
`Safety:` line is never omitted (usually
|
||||||
|
`no self-review; no self-merge; no tags; no secrets; no prod`). PR bodies
|
||||||
|
still carry the full review detail — the handoff never replaces PR
|
||||||
|
documentation.
|
||||||
|
|
||||||
|
**The long form** (Work performed · Current state · Files changed ·
|
||||||
|
Validation · Issues encountered · Review needed? · Next recommended action ·
|
||||||
|
Safety confirmations) **is reserved for high-risk or complex tasks**: a
|
||||||
|
merge/tag/release happened, validation failed, permissions/profile gates
|
||||||
|
blocked work, secrets or production access were involved, an owner decision
|
||||||
|
is complicated, the task spanned multiple repos or cross-issue state, or the
|
||||||
|
owner explicitly asks for it.
|
||||||
|
|
||||||
|
Hard rules: never omit it; never bury blockers earlier only; an opened PR
|
||||||
|
means "Review needed — PR is open"; a blocked merge names the exact gate;
|
||||||
|
discussion-only comments need owner/design feedback, not code review; any
|
||||||
|
touched release state names the exact tag/commit and why. Design debates
|
||||||
|
belong in **discussion/RFC issues** (e.g. #100 `profiles.json v2`) — comment
|
||||||
|
on the issue, create no branches/PRs, and end the comment with this handoff.
|
||||||
|
|
||||||
## Fail-closed behavior
|
## Fail-closed behavior
|
||||||
|
|
||||||
Before any mutating action the workflow verifies identity, active profile,
|
Before any mutating action the workflow verifies identity, active profile,
|
||||||
@@ -307,6 +421,44 @@ All mutating attempts — allowed, blocked, failed, or succeeded — are audit-l
|
|||||||
with the profile and authenticated user when `GITEA_AUDIT_LOG` is set (see
|
with the profile and authenticated user when `GITEA_AUDIT_LOG` is set (see
|
||||||
[`safety-model.md`](safety-model.md)).
|
[`safety-model.md`](safety-model.md)).
|
||||||
|
|
||||||
|
## Releases and version tags
|
||||||
|
|
||||||
|
Versions follow SemVer — **`vMAJOR.MINOR.PATCH`**, using **`v0.x.y`** while
|
||||||
|
unstable. Pick the bump by the largest change since the last tag:
|
||||||
|
|
||||||
|
- **PATCH** — bug fixes, docs, tests, wrappers, non-breaking workflow polish.
|
||||||
|
- **MINOR** — new MCP tools, new workflow helpers, new config features;
|
||||||
|
backward-compatible behavior.
|
||||||
|
- **MAJOR** — breaking config/schema/API behavior or a changed MCP contract.
|
||||||
|
|
||||||
|
Tags are **annotated** (`git tag -a`), created **only from the exact commit on
|
||||||
|
remote `master`**, **only after the full suite passes**, and carry release notes
|
||||||
|
referencing the merged PRs/issues. **Never tag** feature branches, dirty
|
||||||
|
worktrees, unreviewed or self-authored work, or commits not on remote `master`.
|
||||||
|
|
||||||
|
Release runbook (see [`../skills/llm-project-workflow/templates/release-tag.md`](../skills/llm-project-workflow/templates/release-tag.md)):
|
||||||
|
|
||||||
|
1. `git fetch prgs --prune`.
|
||||||
|
2. Confirm local `master` equals `prgs/master` (`0 0`) and the tree is clean.
|
||||||
|
3. Run the full test suite; stop on failure.
|
||||||
|
4. Review merged issues/PRs since the last tag
|
||||||
|
(`git log --oneline <last-tag>..prgs/master`).
|
||||||
|
5. Choose the version bump.
|
||||||
|
6. `git tag -a <vX.Y.Z> prgs/master -m "<notes referencing #issues / PRs>"`.
|
||||||
|
7. `git push prgs <vX.Y.Z>`; add release notes if the forge supports it.
|
||||||
|
|
||||||
|
`scripts/release-tag` automates steps 1–7 with these gates built in (SemVer
|
||||||
|
check, fetch/prune, on-master, clean tree, local==remote master, HEAD on remote
|
||||||
|
master, no duplicate tag, tests run unless `--skip-tests`, annotated tag only).
|
||||||
|
It is **safe by default** — no push unless `--push`, and `--dry-run` changes
|
||||||
|
nothing:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/release-tag --dry-run v0.4.0
|
||||||
|
scripts/release-tag v0.4.0 --notes-file /tmp/release-notes.md
|
||||||
|
scripts/release-tag v0.4.0 --notes-file /tmp/release-notes.md --push
|
||||||
|
```
|
||||||
|
|
||||||
## Safety notes
|
## Safety notes
|
||||||
|
|
||||||
- Never place raw tokens or passwords in any LLM MCP config; reference secrets
|
- Never place raw tokens or passwords in any LLM MCP config; reference secrets
|
||||||
@@ -316,7 +468,9 @@ with the profile and authenticated user when `GITEA_AUDIT_LOG` is set (see
|
|||||||
|
|
||||||
## Related documents
|
## Related documents
|
||||||
|
|
||||||
|
- [`../skills/llm-project-workflow/SKILL.md`](../skills/llm-project-workflow/SKILL.md) — portable cross-project LLM workflow skill.
|
||||||
- [`gitea-execution-profiles.md`](gitea-execution-profiles.md) — the profile model.
|
- [`gitea-execution-profiles.md`](gitea-execution-profiles.md) — the profile model.
|
||||||
|
- [`llm-agent-sha.md`](llm-agent-sha.md) — opaque agent attribution metadata (never an eligibility input).
|
||||||
- [`safety-model.md`](safety-model.md) — trust boundaries and audit logging.
|
- [`safety-model.md`](safety-model.md) — trust boundaries and audit logging.
|
||||||
- [`tool-boundaries.md`](tool-boundaries.md) — per-tool allowed operations.
|
- [`tool-boundaries.md`](tool-boundaries.md) — per-tool allowed operations.
|
||||||
- [`credential-isolation.md`](credential-isolation.md) — credential handling.
|
- [`credential-isolation.md`](credential-isolation.md) — credential handling.
|
||||||
|
|||||||
@@ -13,3 +13,11 @@ To maintain a secure environment, all secrets, tokens, passwords, and sensitive
|
|||||||
- System and application logs
|
- System and application logs
|
||||||
- Tool return values/outputs
|
- Tool return values/outputs
|
||||||
- Any form of persistent storage or console output
|
- Any form of persistent storage or console output
|
||||||
|
|
||||||
|
## 4. Read-Only First Policy
|
||||||
|
By default, MCP servers (such as `jenkins-mcp` and `ops-mcp`) operate in a **read-only** mode. Mutation capabilities are deny-by-default and fail-closed.
|
||||||
|
|
||||||
|
## 5. Mutation Gating
|
||||||
|
Any mutating action (e.g., Gitea issue creation from GlitchTip, or Jenkins builds) must be explicitly allowed by the execution profile.
|
||||||
|
- **Jenkins build triggers** are explicitly deferred for phase 1.
|
||||||
|
- **GlitchTip to Gitea issue filing** is documented as a gated, orchestrated workflow, not a direct unprompted automatic action.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
This document defines the strict boundaries between the different MCP server packages within the monorepo.
|
This document defines the strict boundaries between the different MCP server packages within the monorepo.
|
||||||
|
|
||||||
The project is named **MCP Control Plane** and lives in the `mcp-control-plane` repository. It groups the following packages: `common`, `gitea-mcp`, `jenkins-mcp`, `ops-mcp`, and `release-mcp`.
|
The project is named **MCP Control Plane** and lives in the `mcp-control-plane` repository. It groups the following packages: `common`, `gitea-mcp`, `jenkins-mcp`, `glitchtip-mcp`, `ops-mcp`, and `release-mcp`.
|
||||||
|
|
||||||
## 1. Architectural Philosophy
|
## 1. Architectural Philosophy
|
||||||
- **One MCP Server per Trust Boundary**: While the packages share a monorepo, their runtime services must remain entirely separate. There is no single "everything" server.
|
- **One MCP Server per Trust Boundary**: While the packages share a monorepo, their runtime services must remain entirely separate. There is no single "everything" server.
|
||||||
@@ -10,4 +10,5 @@ The project is named **MCP Control Plane** and lives in the `mcp-control-plane`
|
|||||||
## 2. Package-Specific Boundaries
|
## 2. Package-Specific Boundaries
|
||||||
- **gitea-mcp**: Restricted to source-control and work-item capabilities (issues, PRs, comments). This package **must not** have Jenkins or Ops credentials, nor can it execute deploy operations.
|
- **gitea-mcp**: Restricted to source-control and work-item capabilities (issues, PRs, comments). This package **must not** have Jenkins or Ops credentials, nor can it execute deploy operations.
|
||||||
- **jenkins-mcp**: Focused on CI/CD capabilities. This package **must not** have Ops credentials unless explicitly configured for a specific, isolated pipeline later.
|
- **jenkins-mcp**: Focused on CI/CD capabilities. This package **must not** have Ops credentials unless explicitly configured for a specific, isolated pipeline later.
|
||||||
|
- **glitchtip-mcp**: Dedicated to observability and error reporting. This package **must not** have Gitea write credentials or Jenkins deploy capabilities.
|
||||||
- **ops-mcp**: Dedicated to live environment and host checks. In its initial state, this package starts as strictly read-only (e.g., health checks, status checks, log reading).
|
- **ops-mcp**: Dedicated to live environment and host checks. In its initial state, this package starts as strictly read-only (e.g., health checks, status checks, log reading).
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
{
|
||||||
|
"version": 2,
|
||||||
|
"contexts": {
|
||||||
|
"example-context": {
|
||||||
|
"enabled": true,
|
||||||
|
"label": "Example environment",
|
||||||
|
"description": "One deployment environment: its Gitea plus non-Gitea services.",
|
||||||
|
"default_owner": "Example-Org",
|
||||||
|
"gitea": {
|
||||||
|
"enabled": true,
|
||||||
|
"kind": "gitea",
|
||||||
|
"base_url": "https://gitea.example.invalid"
|
||||||
|
},
|
||||||
|
"services": {
|
||||||
|
"jenkins": {
|
||||||
|
"enabled": true,
|
||||||
|
"kind": "jenkins",
|
||||||
|
"label": "Example Jenkins",
|
||||||
|
"base_url": "https://jenkins.example.invalid",
|
||||||
|
"auth": { "type": "keychain", "id": "example-jenkins-token" },
|
||||||
|
"capabilities": ["read"]
|
||||||
|
},
|
||||||
|
"glitchtip": {
|
||||||
|
"enabled": false,
|
||||||
|
"kind": "glitchtip",
|
||||||
|
"label": "Example GlitchTip (disabled: defined but unavailable)",
|
||||||
|
"base_url": "",
|
||||||
|
"auth": { "type": "keychain", "id": "example-glitchtip-token" },
|
||||||
|
"capabilities": ["read"],
|
||||||
|
"allow_raw_events": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"profiles": {
|
||||||
|
"example-author": {
|
||||||
|
"enabled": true,
|
||||||
|
"context": "example-context",
|
||||||
|
"role": "author",
|
||||||
|
"username": "author-user",
|
||||||
|
"execution_profile": "example-author",
|
||||||
|
"audit_label": "example-author",
|
||||||
|
"auth": { "type": "keychain", "id": "example-gitea-author-token" },
|
||||||
|
"allowed_operations": ["read", "branch", "commit", "push", "open_pr", "comment"],
|
||||||
|
"forbidden_operations": ["approve", "request_changes", "merge"]
|
||||||
|
},
|
||||||
|
"example-reviewer": {
|
||||||
|
"enabled": true,
|
||||||
|
"context": "example-context",
|
||||||
|
"role": "reviewer",
|
||||||
|
"username": "reviewer-user",
|
||||||
|
"execution_profile": "example-reviewer",
|
||||||
|
"audit_label": "example-reviewer",
|
||||||
|
"auth": { "type": "keychain", "id": "example-gitea-reviewer-token" },
|
||||||
|
"allowed_operations": ["read", "review", "comment", "approve", "request_changes", "merge"],
|
||||||
|
"forbidden_operations": ["branch", "commit", "push", "open_pr"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"projects": {
|
||||||
|
"/absolute/path/to/local/repo": {
|
||||||
|
"enabled": true,
|
||||||
|
"context": "example-context",
|
||||||
|
"default_owner": "Example-Org",
|
||||||
|
"default_repo": "Example-Repo",
|
||||||
|
"default_author_profile": "example-author",
|
||||||
|
"default_reviewer_profile": "example-reviewer"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"rules": {
|
||||||
|
"disabled_behavior": "Defined but unavailable for action. MCP tools may report disabled entries during audits, but must not use them automatically.",
|
||||||
|
"no_silent_fallback": true,
|
||||||
|
"tokens_in_json": false,
|
||||||
|
"token_storage": "keychain",
|
||||||
|
"identity_must_match_task": true,
|
||||||
|
"same_username_cannot_review_own_pr": true,
|
||||||
|
"hide_service_urls_from_llm": true,
|
||||||
|
"hide_keychain_ids_from_llm": true,
|
||||||
|
"mcp_resolves_endpoints": true
|
||||||
|
}
|
||||||
|
}
|
||||||
+127
-10
@@ -14,6 +14,7 @@ import datetime
|
|||||||
import subprocess
|
import subprocess
|
||||||
import urllib.request
|
import urllib.request
|
||||||
import urllib.error
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
from email.utils import parsedate_to_datetime
|
from email.utils import parsedate_to_datetime
|
||||||
from dotenv import dotenv_values, load_dotenv
|
from dotenv import dotenv_values, load_dotenv
|
||||||
|
|
||||||
@@ -122,13 +123,17 @@ def get_auth_header(host):
|
|||||||
token = os.environ.get("GITEA_TOKEN")
|
token = os.environ.get("GITEA_TOKEN")
|
||||||
|
|
||||||
# 3. Fall back to a JSON runtime-profile token reference (token_env).
|
# 3. Fall back to a JSON runtime-profile token reference (token_env).
|
||||||
# Explicit env tokens above take precedence. A broken config never breaks
|
# Explicit env tokens above take precedence. When GITEA_MCP_CONFIG is
|
||||||
# auth here — it fails closed to "no token"; the clear error surfaces via
|
# configured, a broken config or unresolvable profile/credential fails
|
||||||
# get_profile() / startup instead.
|
# closed here (no silent fallback to Basic auth or another source,
|
||||||
|
# #120). Without a configured JSON layer, env-only behaviour is
|
||||||
|
# unchanged.
|
||||||
if not token:
|
if not token:
|
||||||
try:
|
try:
|
||||||
token = gitea_config.resolve_token(gitea_config.resolve_profile())
|
token = gitea_config.resolve_token(gitea_config.resolve_profile())
|
||||||
except gitea_config.ConfigError:
|
except gitea_config.ConfigError:
|
||||||
|
if gitea_config.config_path():
|
||||||
|
raise
|
||||||
token = None
|
token = None
|
||||||
|
|
||||||
if token:
|
if token:
|
||||||
@@ -188,6 +193,39 @@ DEFAULT_MAX_RETRIES = _env_int("GITEA_MAX_RETRIES", 3)
|
|||||||
DEFAULT_BASE_DELAY = _env_float("GITEA_RETRY_BASE_DELAY", 1.0) # seconds
|
DEFAULT_BASE_DELAY = _env_float("GITEA_RETRY_BASE_DELAY", 1.0) # seconds
|
||||||
DEFAULT_MAX_DELAY = _env_float("GITEA_RETRY_MAX_DELAY", 60.0) # seconds
|
DEFAULT_MAX_DELAY = _env_float("GITEA_RETRY_MAX_DELAY", 60.0) # seconds
|
||||||
|
|
||||||
|
# Per-request socket timeout (seconds). Overridable via environment.
|
||||||
|
DEFAULT_HTTP_TIMEOUT = _env_float("GITEA_HTTP_TIMEOUT", 30.0)
|
||||||
|
|
||||||
|
|
||||||
|
def _redact(text):
|
||||||
|
"""Best-effort strip of credential-like substrings from error text.
|
||||||
|
|
||||||
|
Reuses the audit module's redactor so error messages never surface tokens,
|
||||||
|
Basic/Bearer headers, or password-like values. Falls back to the plain
|
||||||
|
string if the audit helper is unavailable.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from gitea_audit import _redact_str
|
||||||
|
return _redact_str(str(text))
|
||||||
|
except Exception:
|
||||||
|
return str(text)
|
||||||
|
|
||||||
|
|
||||||
|
def _add_query(url, **params):
|
||||||
|
"""Return *url* with the given query parameters added or overridden.
|
||||||
|
|
||||||
|
Preserves any existing query string on *url* (e.g. ``?state=open``) so
|
||||||
|
pagination params can be layered on top of an already-filtered endpoint.
|
||||||
|
"""
|
||||||
|
parts = urllib.parse.urlsplit(url)
|
||||||
|
query = dict(urllib.parse.parse_qsl(parts.query, keep_blank_values=True))
|
||||||
|
for key, value in params.items():
|
||||||
|
query[str(key)] = str(value)
|
||||||
|
new_query = urllib.parse.urlencode(query)
|
||||||
|
return urllib.parse.urlunsplit(
|
||||||
|
(parts.scheme, parts.netloc, parts.path, new_query, parts.fragment)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def parse_retry_after(value, now=None):
|
def parse_retry_after(value, now=None):
|
||||||
"""Parse a ``Retry-After`` header into a non-negative delay in seconds.
|
"""Parse a ``Retry-After`` header into a non-negative delay in seconds.
|
||||||
@@ -239,16 +277,31 @@ def backoff_delay(attempt, base=DEFAULT_BASE_DELAY, cap=DEFAULT_MAX_DELAY, rand=
|
|||||||
|
|
||||||
def api_request(method, url, auth_header, payload=None, *,
|
def api_request(method, url, auth_header, payload=None, *,
|
||||||
max_retries=None, base_delay=None, max_delay=None,
|
max_retries=None, base_delay=None, max_delay=None,
|
||||||
|
timeout=None,
|
||||||
sleep_func=time.sleep, rand_func=random.random,
|
sleep_func=time.sleep, rand_func=random.random,
|
||||||
now_func=time.time):
|
now_func=time.time):
|
||||||
"""Make an authenticated JSON request to the Gitea API.
|
"""Make an authenticated JSON request to the Gitea API.
|
||||||
|
|
||||||
Returns parsed JSON on success, raises ``RuntimeError`` on HTTP errors.
|
Returns parsed JSON on success (or ``None`` for an empty body), and raises
|
||||||
|
``RuntimeError`` on failure.
|
||||||
|
|
||||||
On HTTP 429 the request is retried up to *max_retries* times: honoring a
|
On HTTP 429 the request is retried up to *max_retries* times: honoring a
|
||||||
valid ``Retry-After`` header (seconds or HTTP-date) when present, otherwise
|
valid ``Retry-After`` header (seconds or HTTP-date) when present, otherwise
|
||||||
using capped jittered exponential backoff. Non-429 errors and successful
|
using capped jittered exponential backoff. Successful responses are
|
||||||
responses are unchanged. The ``*_func`` parameters are injection points for
|
unchanged.
|
||||||
|
|
||||||
|
All failures are converted to a ``RuntimeError`` with a clear, secret
|
||||||
|
-redacted message (no raw stack traces or credential material):
|
||||||
|
|
||||||
|
- Non-429 HTTP errors surface the status code and a redacted response body.
|
||||||
|
502/503/504 upstream errors get an explicit "Gitea upstream unavailable"
|
||||||
|
message.
|
||||||
|
- Timeouts and network/DNS failures (``URLError`` / ``TimeoutError``) surface
|
||||||
|
a generic "network error contacting Gitea" message.
|
||||||
|
- A malformed (non-JSON) success body surfaces a "malformed JSON response"
|
||||||
|
message rather than a raw decode error.
|
||||||
|
|
||||||
|
The ``*_func`` parameters and ``timeout`` are injection points for
|
||||||
deterministic testing.
|
deterministic testing.
|
||||||
"""
|
"""
|
||||||
if max_retries is None:
|
if max_retries is None:
|
||||||
@@ -257,6 +310,8 @@ def api_request(method, url, auth_header, payload=None, *,
|
|||||||
base_delay = DEFAULT_BASE_DELAY
|
base_delay = DEFAULT_BASE_DELAY
|
||||||
if max_delay is None:
|
if max_delay is None:
|
||||||
max_delay = DEFAULT_MAX_DELAY
|
max_delay = DEFAULT_MAX_DELAY
|
||||||
|
if timeout is None:
|
||||||
|
timeout = DEFAULT_HTTP_TIMEOUT
|
||||||
|
|
||||||
data = json.dumps(payload).encode("utf-8") if payload is not None else None
|
data = json.dumps(payload).encode("utf-8") if payload is not None else None
|
||||||
req = urllib.request.Request(url, data=data, method=method)
|
req = urllib.request.Request(url, data=data, method=method)
|
||||||
@@ -267,9 +322,8 @@ def api_request(method, url, auth_header, payload=None, *,
|
|||||||
attempt = 0
|
attempt = 0
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(req) as resp:
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||||
body = resp.read().decode("utf-8")
|
body = resp.read().decode("utf-8")
|
||||||
return json.loads(body) if body else None
|
|
||||||
except urllib.error.HTTPError as e:
|
except urllib.error.HTTPError as e:
|
||||||
if e.code == 429 and attempt < max_retries:
|
if e.code == 429 and attempt < max_retries:
|
||||||
header = e.headers.get("Retry-After") if e.headers else None
|
header = e.headers.get("Retry-After") if e.headers else None
|
||||||
@@ -279,8 +333,71 @@ def api_request(method, url, auth_header, payload=None, *,
|
|||||||
attempt += 1
|
attempt += 1
|
||||||
sleep_func(delay)
|
sleep_func(delay)
|
||||||
continue
|
continue
|
||||||
error_body = e.read().decode("utf-8", errors="replace")
|
try:
|
||||||
raise RuntimeError(f"HTTP {e.code}: {error_body}") from e
|
error_body = e.read().decode("utf-8", errors="replace")
|
||||||
|
except Exception:
|
||||||
|
error_body = ""
|
||||||
|
detail = _redact(error_body).strip()
|
||||||
|
if e.code in (502, 503, 504):
|
||||||
|
msg = f"HTTP {e.code}: Gitea upstream unavailable"
|
||||||
|
raise RuntimeError(f"{msg}: {detail}" if detail else msg) from e
|
||||||
|
raise RuntimeError(f"HTTP {e.code}: {detail}") from e
|
||||||
|
except (urllib.error.URLError, TimeoutError) as e:
|
||||||
|
reason = getattr(e, "reason", e)
|
||||||
|
raise RuntimeError(
|
||||||
|
f"network error contacting Gitea: {_redact(reason)}"
|
||||||
|
) from e
|
||||||
|
|
||||||
|
if not body:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return json.loads(body)
|
||||||
|
except ValueError as e:
|
||||||
|
raise RuntimeError("malformed JSON response from Gitea") from e
|
||||||
|
|
||||||
|
|
||||||
|
def api_get_all(url, auth_header, *, limit=None, page_size=50, max_pages=100,
|
||||||
|
**kwargs):
|
||||||
|
"""Fetch a paginated Gitea collection, following page-based pagination.
|
||||||
|
|
||||||
|
Issues successive ``GET`` requests with ``page`` and ``limit`` (per-page)
|
||||||
|
query parameters, accumulating list items until one of:
|
||||||
|
|
||||||
|
- a page returns fewer items than the page size (the last page),
|
||||||
|
- an empty or ``None`` page is returned (also treated as the end — this is
|
||||||
|
how missing/malformed pagination metadata degrades safely),
|
||||||
|
- *limit* total items have been collected, or
|
||||||
|
- *max_pages* pages have been fetched (a safety cap against runaway loops).
|
||||||
|
|
||||||
|
Pagination relies on the *length of each returned page*, not on
|
||||||
|
``X-Total-Count`` / ``Link`` headers, so it tolerates missing or malformed
|
||||||
|
pagination metadata. Returns a list (possibly empty). Raises ``RuntimeError``
|
||||||
|
(via :func:`api_request`) on network/HTTP/malformed failures, or if a page is
|
||||||
|
not a JSON list. Extra ``kwargs`` pass through to :func:`api_request`.
|
||||||
|
"""
|
||||||
|
if page_size < 1:
|
||||||
|
page_size = 1
|
||||||
|
if page_size > 50:
|
||||||
|
page_size = 50 # Gitea caps per-page results at 50
|
||||||
|
if limit is not None and limit < page_size:
|
||||||
|
page_size = max(1, limit)
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for page in range(1, max_pages + 1):
|
||||||
|
page_url = _add_query(url, page=page, limit=page_size)
|
||||||
|
data = api_request("GET", page_url, auth_header, **kwargs)
|
||||||
|
if data is None:
|
||||||
|
break
|
||||||
|
if not isinstance(data, list):
|
||||||
|
raise RuntimeError(
|
||||||
|
f"expected a list page from Gitea, got {type(data).__name__}"
|
||||||
|
)
|
||||||
|
results.extend(data)
|
||||||
|
if limit is not None and len(results) >= limit:
|
||||||
|
return results[:limit]
|
||||||
|
if len(data) < page_size:
|
||||||
|
break
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
def repo_api_url(host, org, repo):
|
def repo_api_url(host, org, repo):
|
||||||
|
|||||||
+692
-10
@@ -54,11 +54,125 @@ ENV_CONFIG_PATH = "GITEA_MCP_CONFIG"
|
|||||||
ENV_PROFILE = "GITEA_MCP_PROFILE"
|
ENV_PROFILE = "GITEA_MCP_PROFILE"
|
||||||
|
|
||||||
SUPPORTED_VERSION = 1
|
SUPPORTED_VERSION = 1
|
||||||
|
SUPPORTED_VERSIONS = (1, 2)
|
||||||
_AUTH_TYPES = ("keychain", "env")
|
_AUTH_TYPES = ("keychain", "env")
|
||||||
|
|
||||||
# Profile names go into env vars, keychain ids, and JSON keys — keep them tame.
|
# Profile names go into env vars, keychain ids, and JSON keys — keep them tame.
|
||||||
_PROFILE_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
_PROFILE_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
||||||
|
|
||||||
|
# v2 address segments (environment / service / identity) must be dot-free so
|
||||||
|
# the dotted profile address {env}.{service}.{identity} stays unambiguous.
|
||||||
|
_SEGMENT_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]*$")
|
||||||
|
|
||||||
|
# Placeholder usernames must never activate (fail closed until provisioned).
|
||||||
|
_TBD_RE = re.compile(r"(?i)^tbd(-|$)")
|
||||||
|
|
||||||
|
# Keys that would mean an inline secret wherever they appear.
|
||||||
|
_INLINE_SECRET_KEYS = ("token", "password", "secret")
|
||||||
|
|
||||||
|
# ── Operation-name normalization table (#106; minimal subset landed in #103) ───
|
||||||
|
# Canonical operations are namespaced ({service}.{area}.{verb}). Legacy
|
||||||
|
# unqualified spellings are accepted ONLY through this explicit table — never
|
||||||
|
# by guessing. The same table is the documentation of record (see
|
||||||
|
# docs/gitea-execution-profiles.md) and is exercised by
|
||||||
|
# tests/test_op_normalization.py.
|
||||||
|
GITEA_OPERATION_ALIASES = {
|
||||||
|
"read": "gitea.read",
|
||||||
|
"review": "gitea.pr.review",
|
||||||
|
"comment": "gitea.pr.comment",
|
||||||
|
"approve": "gitea.pr.approve",
|
||||||
|
"request_changes": "gitea.pr.request_changes",
|
||||||
|
"merge": "gitea.pr.merge",
|
||||||
|
"pr.create": "gitea.pr.create",
|
||||||
|
"branch.push": "gitea.branch.push",
|
||||||
|
# Contexts-shape author verbs (#120) — the invariant checks below depend on
|
||||||
|
# "push"/"open_pr" normalizing to the two author-only ops.
|
||||||
|
"branch": "gitea.branch.create",
|
||||||
|
"commit": "gitea.repo.commit",
|
||||||
|
"push": "gitea.branch.push",
|
||||||
|
"open_pr": "gitea.pr.create",
|
||||||
|
}
|
||||||
|
_REVIEW_MERGE_OPS = frozenset({"gitea.pr.approve", "gitea.pr.merge"})
|
||||||
|
_AUTHOR_ONLY_OPS = frozenset({"gitea.pr.create", "gitea.branch.push"})
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_operation(op, service="gitea"):
|
||||||
|
"""Return the canonical namespaced name for *op*, or fail closed (#106).
|
||||||
|
|
||||||
|
- already namespaced for this service (``{service}.*``) → unchanged
|
||||||
|
- known unqualified Gitea ops → mapped via ``GITEA_OPERATION_ALIASES``
|
||||||
|
- unqualified single-word ops on non-Gitea services → ``{service}.{op}``
|
||||||
|
- anything else — foreign service prefixes, dotted names outside the
|
||||||
|
table, unknown unqualified names — is unknown or ambiguous → ConfigError
|
||||||
|
|
||||||
|
Normalization never crosses services (a Gitea alias is never applied to
|
||||||
|
another service) and never widens permissions: an operation that cannot
|
||||||
|
be normalized grants and matches nothing.
|
||||||
|
"""
|
||||||
|
if not isinstance(op, str) or not op:
|
||||||
|
raise ConfigError("operation must be a non-empty string (fail closed)")
|
||||||
|
if op.startswith(service + "."):
|
||||||
|
return op
|
||||||
|
if service == "gitea" and op in GITEA_OPERATION_ALIASES:
|
||||||
|
return GITEA_OPERATION_ALIASES[op]
|
||||||
|
if service != "gitea" and "." not in op:
|
||||||
|
return f"{service}.{op}"
|
||||||
|
raise ConfigError(
|
||||||
|
f"operation {op!r} cannot be normalized safely for service "
|
||||||
|
f"'{service}' (unknown, ambiguous, or cross-service; fail closed)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def check_operation(op, allowed, forbidden=(), service="gitea"):
|
||||||
|
"""Decide whether *op* is permitted. Returns ``(bool, reason)`` (#106).
|
||||||
|
|
||||||
|
Everything is normalized via :func:`normalize_operation` BEFORE any
|
||||||
|
membership check, so legacy and canonical spellings always compare equal.
|
||||||
|
Reasons: ``allowed``, ``invalid-operation``, ``invalid-forbidden-entry``,
|
||||||
|
``forbidden``, ``no-allowed-operations``, ``not-allowed``.
|
||||||
|
|
||||||
|
Fail-closed rules:
|
||||||
|
- an *op* that cannot be normalized is denied (``invalid-operation``)
|
||||||
|
- a forbidden entry that cannot be normalized denies the request
|
||||||
|
(``invalid-forbidden-entry``) — dropping it would silently narrow the
|
||||||
|
forbidden set, i.e. widen permissions
|
||||||
|
- an allowed entry that cannot be normalized is ignored — it grants
|
||||||
|
nothing, so permissions never widen
|
||||||
|
- ``forbidden`` always overrides ``allowed``
|
||||||
|
- an empty or missing allowed list denies everything
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
op_n = normalize_operation(op, service)
|
||||||
|
except ConfigError:
|
||||||
|
return (False, "invalid-operation")
|
||||||
|
forbidden_n = set()
|
||||||
|
for entry in (forbidden or ()):
|
||||||
|
try:
|
||||||
|
forbidden_n.add(normalize_operation(entry, service))
|
||||||
|
except ConfigError:
|
||||||
|
return (False, "invalid-forbidden-entry")
|
||||||
|
if op_n in forbidden_n:
|
||||||
|
return (False, "forbidden")
|
||||||
|
if not allowed:
|
||||||
|
return (False, "no-allowed-operations")
|
||||||
|
allowed_n = set()
|
||||||
|
for entry in allowed:
|
||||||
|
try:
|
||||||
|
allowed_n.add(normalize_operation(entry, service))
|
||||||
|
except ConfigError:
|
||||||
|
continue
|
||||||
|
if op_n in allowed_n:
|
||||||
|
return (True, "allowed")
|
||||||
|
return (False, "not-allowed")
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_op(service, op, addr):
|
||||||
|
"""Normalize *op* for identity *addr*, or fail closed with context."""
|
||||||
|
try:
|
||||||
|
return normalize_operation(op, service)
|
||||||
|
except ConfigError as exc:
|
||||||
|
raise ConfigError(f"identity '{addr}': {exc}") from None
|
||||||
|
|
||||||
# Default canonical config location (one file shared by all LLM launchers).
|
# Default canonical config location (one file shared by all LLM launchers).
|
||||||
DEFAULT_CONFIG_PATH = os.path.join(
|
DEFAULT_CONFIG_PATH = os.path.join(
|
||||||
os.path.expanduser("~"), ".config", "gitea-tools", "profiles.json"
|
os.path.expanduser("~"), ".config", "gitea-tools", "profiles.json"
|
||||||
@@ -108,16 +222,550 @@ def load_config(path=None):
|
|||||||
) from None
|
) from None
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
raise ConfigError(f"could not read {path}: {exc.strerror}") from None
|
raise ConfigError(f"could not read {path}: {exc.strerror}") from None
|
||||||
if not isinstance(data, dict) or not isinstance(data.get("profiles"), dict):
|
if not isinstance(data, dict):
|
||||||
raise ConfigError(f"{path} must be a JSON object with a 'profiles' object")
|
raise ConfigError(f"{path} must be a JSON object")
|
||||||
version = data.get("version", SUPPORTED_VERSION)
|
version = data.get("version")
|
||||||
|
if version is None:
|
||||||
|
# Fail closed (#103): an unversioned config is ambiguous between v1 and
|
||||||
|
# v2 shapes, so it is refused rather than guessed.
|
||||||
|
raise ConfigError(
|
||||||
|
f"{path} is missing the required 'version' field; "
|
||||||
|
f"expected one of {list(SUPPORTED_VERSIONS)}"
|
||||||
|
)
|
||||||
|
if version == 2:
|
||||||
|
return _load_v2_any(data, path)
|
||||||
if version != SUPPORTED_VERSION:
|
if version != SUPPORTED_VERSION:
|
||||||
raise ConfigError(
|
raise ConfigError(
|
||||||
f"{path} has unsupported version {version!r}; expected {SUPPORTED_VERSION}"
|
f"{path} has unsupported version {version!r}; "
|
||||||
|
f"expected one of {list(SUPPORTED_VERSIONS)}"
|
||||||
)
|
)
|
||||||
|
if not isinstance(data.get("profiles"), dict):
|
||||||
|
raise ConfigError(f"{path} must be a JSON object with a 'profiles' object")
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
# ── profiles.json version 2 (#103): environment → service → identity ──────────
|
||||||
|
# v2 files are validated and *flattened* at load time into the same
|
||||||
|
# {"profiles": {...}} shape v1 consumers already understand, keyed by the
|
||||||
|
# canonical dotted address {environment}.{service}.{identity}. Two extra
|
||||||
|
# top-level keys are carried: "aliases" (exact-name compatibility selectors)
|
||||||
|
# and "unavailable" (addresses that fail closed at selection, e.g. TBD users).
|
||||||
|
|
||||||
|
def _validate_identity_auth(addr, auth):
|
||||||
|
"""Require and validate an identity 'auth' reference. Rejects inline secrets."""
|
||||||
|
if auth is None:
|
||||||
|
raise ConfigError(f"identity '{addr}' is missing an 'auth' reference")
|
||||||
|
if not isinstance(auth, dict):
|
||||||
|
raise ConfigError(f"identity '{addr}' has a non-object 'auth'")
|
||||||
|
for key in _INLINE_SECRET_KEYS:
|
||||||
|
if key in auth:
|
||||||
|
raise ConfigError(
|
||||||
|
f"identity '{addr}' auth must not contain an inline '{key}'; "
|
||||||
|
"store secrets in the keychain and reference them by id"
|
||||||
|
)
|
||||||
|
_validate_auth(addr, auth)
|
||||||
|
|
||||||
|
|
||||||
|
def _flatten_identity(env_name, svc_name, svc, ident_name, ident):
|
||||||
|
"""Validate one v2 identity and return (addr, flattened_profile).
|
||||||
|
|
||||||
|
The flattened profile is v1-shaped (base_url/auth/username/defaults) plus
|
||||||
|
v2 metadata (profile_path, environment, service, identity, role) and
|
||||||
|
normalized operation lists. Raises ConfigError on any invariant violation.
|
||||||
|
"""
|
||||||
|
addr = f"{env_name}.{svc_name}.{ident_name}"
|
||||||
|
if not isinstance(ident, dict):
|
||||||
|
raise ConfigError(f"identity '{addr}' must be a JSON object")
|
||||||
|
for key in _INLINE_SECRET_KEYS:
|
||||||
|
if key in ident:
|
||||||
|
raise ConfigError(
|
||||||
|
f"identity '{addr}' must not contain an inline '{key}'; "
|
||||||
|
"use an 'auth' reference instead"
|
||||||
|
)
|
||||||
|
_validate_identity_auth(addr, ident.get("auth"))
|
||||||
|
|
||||||
|
base_url = ident.get("base_url") or svc.get("base_url")
|
||||||
|
if not base_url:
|
||||||
|
raise ConfigError(
|
||||||
|
f"identity '{addr}' has no 'base_url' at identity or service level"
|
||||||
|
)
|
||||||
|
|
||||||
|
allowed = ident.get("allowed_operations") or []
|
||||||
|
forbidden = ident.get("forbidden_operations") or []
|
||||||
|
if not isinstance(allowed, list) or not isinstance(forbidden, list):
|
||||||
|
raise ConfigError(f"identity '{addr}' operation fields must be lists")
|
||||||
|
allowed_n = {_normalize_op(svc_name, op, addr) for op in allowed}
|
||||||
|
forbidden_n = {_normalize_op(svc_name, op, addr) for op in forbidden}
|
||||||
|
|
||||||
|
# Reviewer-identity deadlock rule (#100/#103): an identity that may approve
|
||||||
|
# or merge PRs must explicitly forbid creating PRs and pushing branches,
|
||||||
|
# so the reviewer identity can never author the PR it must review.
|
||||||
|
if allowed_n & _REVIEW_MERGE_OPS:
|
||||||
|
missing = sorted(_AUTHOR_ONLY_OPS - forbidden_n)
|
||||||
|
if missing:
|
||||||
|
raise ConfigError(
|
||||||
|
f"identity '{addr}' allows PR approve/merge but does not forbid "
|
||||||
|
f"{missing}; reviewer identities must forbid gitea.pr.create and "
|
||||||
|
"gitea.branch.push (reviewer-identity deadlock rule)"
|
||||||
|
)
|
||||||
|
|
||||||
|
profile = {
|
||||||
|
"profile_path": addr,
|
||||||
|
"environment": env_name,
|
||||||
|
"service": svc_name,
|
||||||
|
"identity": ident_name,
|
||||||
|
"base_url": base_url,
|
||||||
|
"auth": ident["auth"],
|
||||||
|
"allowed_operations": sorted(allowed_n),
|
||||||
|
"forbidden_operations": sorted(forbidden_n),
|
||||||
|
}
|
||||||
|
# Service-level defaults inherit unless the identity overrides them.
|
||||||
|
for key in ("default_owner", "default_repo", "default_org"):
|
||||||
|
value = ident.get(key, svc.get(key))
|
||||||
|
if value:
|
||||||
|
profile[key] = value
|
||||||
|
for key in ("role", "username", "execution_profile", "audit_label"):
|
||||||
|
if ident.get(key):
|
||||||
|
profile[key] = ident[key]
|
||||||
|
return addr, profile
|
||||||
|
|
||||||
|
|
||||||
|
def _load_v2(data, path):
|
||||||
|
"""Validate a v2 config and return the flattened, resolvable structure."""
|
||||||
|
environments = data.get("environments")
|
||||||
|
if not isinstance(environments, dict) or not environments:
|
||||||
|
raise ConfigError(
|
||||||
|
f"{path} version 2 config requires a non-empty 'environments' object"
|
||||||
|
)
|
||||||
|
profiles = {}
|
||||||
|
unavailable = {}
|
||||||
|
for env_name, env in environments.items():
|
||||||
|
if not _SEGMENT_RE.match(env_name or ""):
|
||||||
|
raise ConfigError(f"invalid environment name {env_name!r} (no dots)")
|
||||||
|
if not isinstance(env, dict):
|
||||||
|
raise ConfigError(f"environment '{env_name}' must be a JSON object")
|
||||||
|
services = env.get("services")
|
||||||
|
if not isinstance(services, dict) or not services:
|
||||||
|
raise ConfigError(
|
||||||
|
f"environment '{env_name}' requires a non-empty 'services' object"
|
||||||
|
)
|
||||||
|
for svc_name, svc in services.items():
|
||||||
|
if not _SEGMENT_RE.match(svc_name or ""):
|
||||||
|
raise ConfigError(
|
||||||
|
f"invalid service name {svc_name!r} in '{env_name}' (no dots)"
|
||||||
|
)
|
||||||
|
if not isinstance(svc, dict):
|
||||||
|
raise ConfigError(
|
||||||
|
f"service '{env_name}.{svc_name}' must be a JSON object"
|
||||||
|
)
|
||||||
|
identities = svc.get("identities")
|
||||||
|
if not isinstance(identities, dict) or not identities:
|
||||||
|
raise ConfigError(
|
||||||
|
f"service '{env_name}.{svc_name}' requires a non-empty "
|
||||||
|
"'identities' object"
|
||||||
|
)
|
||||||
|
for ident_name, ident in identities.items():
|
||||||
|
if not _SEGMENT_RE.match(ident_name or ""):
|
||||||
|
raise ConfigError(
|
||||||
|
f"invalid identity name {ident_name!r} in "
|
||||||
|
f"'{env_name}.{svc_name}' (no dots)"
|
||||||
|
)
|
||||||
|
addr, profile = _flatten_identity(
|
||||||
|
env_name, svc_name, svc, ident_name, ident
|
||||||
|
)
|
||||||
|
username = profile.get("username") or ""
|
||||||
|
if _TBD_RE.match(username):
|
||||||
|
# Fail closed at selection, without blocking every other
|
||||||
|
# identity in the file (see #103 acceptance criteria).
|
||||||
|
unavailable[addr] = (
|
||||||
|
f"identity '{addr}' username {username!r} is a TBD "
|
||||||
|
"placeholder; provision the account before use "
|
||||||
|
"(fail closed)"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
profiles[addr] = profile
|
||||||
|
|
||||||
|
aliases = data.get("aliases") or {}
|
||||||
|
if not isinstance(aliases, dict):
|
||||||
|
raise ConfigError(f"{path} 'aliases' must be a JSON object")
|
||||||
|
known = set(profiles) | set(unavailable)
|
||||||
|
for alias, target in aliases.items():
|
||||||
|
if not isinstance(target, str) or not target:
|
||||||
|
raise ConfigError(f"alias '{alias}' target must be a non-empty string")
|
||||||
|
if alias in known and alias != target:
|
||||||
|
raise ConfigError(
|
||||||
|
f"selector '{alias}' is both an alias and a profile address "
|
||||||
|
"with a different target (conflicting selector; fail closed)"
|
||||||
|
)
|
||||||
|
if target not in known:
|
||||||
|
raise ConfigError(
|
||||||
|
f"alias '{alias}' points to unknown profile '{target}'"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"version": 2,
|
||||||
|
"profiles": profiles,
|
||||||
|
"aliases": dict(aliases),
|
||||||
|
"unavailable": unavailable,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── profiles.json version 2 *contexts* shape (#120) ───────────────────────────
|
||||||
|
# The canonical machine config groups everything by context: top-level
|
||||||
|
# "contexts" (each with a gitea block and non-Gitea "services"), flat
|
||||||
|
# "profiles" (Gitea identities pointing at a context), "projects" (local repo
|
||||||
|
# paths mapped to a context), and "rules". Every context/profile/service/
|
||||||
|
# project carries a required boolean "enabled": disabled entries are surfaced
|
||||||
|
# in audits but fail closed at selection — never a silent fallback. Loading
|
||||||
|
# flattens profiles into the same {"profiles": {...}, "unavailable": {...}}
|
||||||
|
# model v1 consumers and select_profile() already understand, and carries the
|
||||||
|
# validated "contexts"/"projects"/"rules" through for service resolution.
|
||||||
|
|
||||||
|
def _load_v2_any(data, path):
|
||||||
|
"""Dispatch a version-2 file to its shape loader; ambiguity fails closed."""
|
||||||
|
has_contexts = "contexts" in data
|
||||||
|
has_environments = "environments" in data
|
||||||
|
if has_contexts and has_environments:
|
||||||
|
raise ConfigError(
|
||||||
|
f"{path} version 2 config must not mix 'contexts' and "
|
||||||
|
"'environments' shapes (ambiguous; fail closed)"
|
||||||
|
)
|
||||||
|
if has_contexts:
|
||||||
|
return _load_v2_contexts(data, path)
|
||||||
|
return _load_v2(data, path)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_enabled(kind, name, obj):
|
||||||
|
"""Return the required boolean ``enabled`` flag, failing closed."""
|
||||||
|
enabled = obj.get("enabled")
|
||||||
|
if not isinstance(enabled, bool):
|
||||||
|
raise ConfigError(
|
||||||
|
f"{kind} '{name}' requires a boolean 'enabled' flag (fail closed)"
|
||||||
|
)
|
||||||
|
return enabled
|
||||||
|
|
||||||
|
|
||||||
|
def _reject_inline_secrets(kind, name, obj):
|
||||||
|
for key in _INLINE_SECRET_KEYS:
|
||||||
|
if key in obj:
|
||||||
|
raise ConfigError(
|
||||||
|
f"{kind} '{name}' must not contain an inline '{key}'; "
|
||||||
|
"store secrets in the keychain and reference them by id"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_context_service(ctx_name, svc_name, svc):
|
||||||
|
"""Validate one context service entry (auth reference only, no secrets)."""
|
||||||
|
addr = f"{ctx_name}.{svc_name}"
|
||||||
|
if not isinstance(svc, dict):
|
||||||
|
raise ConfigError(f"service '{addr}' must be a JSON object")
|
||||||
|
_require_enabled("service", addr, svc)
|
||||||
|
_reject_inline_secrets("service", addr, svc)
|
||||||
|
if "auth" in svc:
|
||||||
|
_validate_auth(addr, svc["auth"])
|
||||||
|
|
||||||
|
|
||||||
|
def _load_v2_contexts(data, path):
|
||||||
|
"""Validate a v2 contexts-shape config and return the resolvable structure."""
|
||||||
|
contexts = data.get("contexts")
|
||||||
|
if not isinstance(contexts, dict) or not contexts:
|
||||||
|
raise ConfigError(
|
||||||
|
f"{path} version 2 contexts config requires a non-empty "
|
||||||
|
"'contexts' object"
|
||||||
|
)
|
||||||
|
for ctx_name, ctx in contexts.items():
|
||||||
|
if not _PROFILE_NAME_RE.match(ctx_name or ""):
|
||||||
|
raise ConfigError(f"invalid context name {ctx_name!r}")
|
||||||
|
if not isinstance(ctx, dict):
|
||||||
|
raise ConfigError(f"context '{ctx_name}' must be a JSON object")
|
||||||
|
_require_enabled("context", ctx_name, ctx)
|
||||||
|
gitea = ctx.get("gitea")
|
||||||
|
if gitea is not None:
|
||||||
|
if not isinstance(gitea, dict):
|
||||||
|
raise ConfigError(
|
||||||
|
f"context '{ctx_name}' has a non-object 'gitea' block")
|
||||||
|
_require_enabled("service", f"{ctx_name}.gitea", gitea)
|
||||||
|
_reject_inline_secrets("service", f"{ctx_name}.gitea", gitea)
|
||||||
|
services = ctx.get("services") or {}
|
||||||
|
if not isinstance(services, dict):
|
||||||
|
raise ConfigError(
|
||||||
|
f"context '{ctx_name}' has a non-object 'services' block")
|
||||||
|
for svc_name, svc in services.items():
|
||||||
|
_validate_context_service(ctx_name, svc_name, svc)
|
||||||
|
|
||||||
|
raw_profiles = data.get("profiles")
|
||||||
|
if not isinstance(raw_profiles, dict) or not raw_profiles:
|
||||||
|
raise ConfigError(
|
||||||
|
f"{path} version 2 contexts config requires a non-empty "
|
||||||
|
"'profiles' object"
|
||||||
|
)
|
||||||
|
profiles = {}
|
||||||
|
unavailable = {}
|
||||||
|
for name, raw in raw_profiles.items():
|
||||||
|
if not is_valid_profile_name(name):
|
||||||
|
raise ConfigError(f"invalid profile name {name!r}")
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
raise ConfigError(f"profile '{name}' must be a JSON object")
|
||||||
|
enabled = _require_enabled("profile", name, raw)
|
||||||
|
_reject_inline_secrets("profile", name, raw)
|
||||||
|
_validate_identity_auth(name, raw.get("auth"))
|
||||||
|
ctx_name = raw.get("context")
|
||||||
|
if ctx_name not in contexts:
|
||||||
|
raise ConfigError(
|
||||||
|
f"profile '{name}' references unknown context {ctx_name!r}")
|
||||||
|
context = contexts[ctx_name]
|
||||||
|
|
||||||
|
allowed = raw.get("allowed_operations") or []
|
||||||
|
forbidden = raw.get("forbidden_operations") or []
|
||||||
|
if not isinstance(allowed, list) or not isinstance(forbidden, list):
|
||||||
|
raise ConfigError(f"profile '{name}' operation fields must be lists")
|
||||||
|
allowed_n = {_normalize_op("gitea", op, name) for op in allowed}
|
||||||
|
forbidden_n = {_normalize_op("gitea", op, name) for op in forbidden}
|
||||||
|
# Reviewer-identity deadlock rule (#100/#103) applies here unchanged.
|
||||||
|
if allowed_n & _REVIEW_MERGE_OPS:
|
||||||
|
missing = sorted(_AUTHOR_ONLY_OPS - forbidden_n)
|
||||||
|
if missing:
|
||||||
|
raise ConfigError(
|
||||||
|
f"profile '{name}' allows PR approve/merge but does not "
|
||||||
|
f"forbid {missing}; reviewer identities must forbid "
|
||||||
|
"gitea.pr.create and gitea.branch.push "
|
||||||
|
"(reviewer-identity deadlock rule)"
|
||||||
|
)
|
||||||
|
|
||||||
|
profile = dict(raw)
|
||||||
|
profile["allowed_operations"] = sorted(allowed_n)
|
||||||
|
profile["forbidden_operations"] = sorted(forbidden_n)
|
||||||
|
gitea = context.get("gitea") or {}
|
||||||
|
if not profile.get("base_url") and gitea.get("enabled"):
|
||||||
|
profile["base_url"] = gitea.get("base_url")
|
||||||
|
|
||||||
|
username = profile.get("username") or ""
|
||||||
|
if not enabled:
|
||||||
|
unavailable[name] = (
|
||||||
|
f"profile '{name}' is disabled (enabled: false); defined but "
|
||||||
|
"unavailable for action — refusing, no fallback"
|
||||||
|
)
|
||||||
|
elif not context.get("enabled"):
|
||||||
|
unavailable[name] = (
|
||||||
|
f"profile '{name}' belongs to context '{ctx_name}' which is "
|
||||||
|
"disabled (enabled: false); refusing, no fallback"
|
||||||
|
)
|
||||||
|
elif not profile.get("base_url"):
|
||||||
|
unavailable[name] = (
|
||||||
|
f"profile '{name}' has no usable base_url (none set and the "
|
||||||
|
f"context '{ctx_name}' gitea service is disabled or has none); "
|
||||||
|
"fail closed"
|
||||||
|
)
|
||||||
|
elif _TBD_RE.match(username):
|
||||||
|
unavailable[name] = (
|
||||||
|
f"profile '{name}' username {username!r} is a TBD placeholder; "
|
||||||
|
"provision the account before use (fail closed)"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
profiles[name] = profile
|
||||||
|
continue
|
||||||
|
# Unavailable profiles keep their (secret-free) body for audits only.
|
||||||
|
profile["_unavailable_reason"] = unavailable[name]
|
||||||
|
profiles.setdefault("_audit_only", {})
|
||||||
|
profiles["_audit_only"][name] = profile
|
||||||
|
|
||||||
|
projects = data.get("projects") or {}
|
||||||
|
if not isinstance(projects, dict):
|
||||||
|
raise ConfigError(f"{path} 'projects' must be a JSON object")
|
||||||
|
for proj_path, proj in projects.items():
|
||||||
|
if not isinstance(proj, dict):
|
||||||
|
raise ConfigError(f"project '{proj_path}' must be a JSON object")
|
||||||
|
_require_enabled("project", proj_path, proj)
|
||||||
|
if proj.get("context") not in contexts:
|
||||||
|
raise ConfigError(
|
||||||
|
f"project '{proj_path}' references unknown context "
|
||||||
|
f"{proj.get('context')!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
rules = data.get("rules") or {}
|
||||||
|
if not isinstance(rules, dict):
|
||||||
|
raise ConfigError(f"{path} 'rules' must be a JSON object")
|
||||||
|
|
||||||
|
audit_only = profiles.pop("_audit_only", {})
|
||||||
|
return {
|
||||||
|
"version": 2,
|
||||||
|
"shape": "contexts",
|
||||||
|
"profiles": profiles,
|
||||||
|
"unavailable": unavailable,
|
||||||
|
"audit_only_profiles": audit_only,
|
||||||
|
"contexts": contexts,
|
||||||
|
"projects": projects,
|
||||||
|
"rules": rules,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_service(config, context_name, service_name):
|
||||||
|
"""Return one context service's config for *internal* MCP use.
|
||||||
|
|
||||||
|
The returned dict includes the endpoint base_url and the keychain auth
|
||||||
|
*reference* — both are for MCP-internal resolution only and must never be
|
||||||
|
echoed into normal LLM-facing output (see audit_config/service_summaries).
|
||||||
|
Fails closed on an unknown or disabled context/service; never falls back
|
||||||
|
to another service.
|
||||||
|
"""
|
||||||
|
contexts = (config or {}).get("contexts")
|
||||||
|
if not isinstance(contexts, dict):
|
||||||
|
raise ConfigError(
|
||||||
|
"service resolution requires a version 2 contexts config")
|
||||||
|
ctx = contexts.get(context_name)
|
||||||
|
if ctx is None:
|
||||||
|
raise ConfigError(
|
||||||
|
f"unknown context '{context_name}' (fail closed, no fallback)")
|
||||||
|
if not ctx.get("enabled"):
|
||||||
|
raise ConfigError(
|
||||||
|
f"context '{context_name}' is disabled; its services are defined "
|
||||||
|
"but unavailable for action (no fallback)"
|
||||||
|
)
|
||||||
|
if service_name == "gitea":
|
||||||
|
service = ctx.get("gitea")
|
||||||
|
else:
|
||||||
|
service = (ctx.get("services") or {}).get(service_name)
|
||||||
|
if service is None:
|
||||||
|
raise ConfigError(
|
||||||
|
f"unknown service '{service_name}' in context '{context_name}' "
|
||||||
|
"(fail closed, no fallback)"
|
||||||
|
)
|
||||||
|
if not service.get("enabled"):
|
||||||
|
raise ConfigError(
|
||||||
|
f"service '{context_name}.{service_name}' is disabled; defined "
|
||||||
|
"but unavailable for action — refusing, no fallback"
|
||||||
|
)
|
||||||
|
return dict(service)
|
||||||
|
|
||||||
|
|
||||||
|
def project_for_path(config, path):
|
||||||
|
"""Map a local project *path* to its context entry, failing closed.
|
||||||
|
|
||||||
|
Returns None when the path is not configured (feature off for that repo).
|
||||||
|
Raises :class:`ConfigError` when the project or its context is disabled —
|
||||||
|
a configured-but-disabled project must never be acted on.
|
||||||
|
"""
|
||||||
|
projects = (config or {}).get("projects") or {}
|
||||||
|
project = projects.get(path)
|
||||||
|
if project is None:
|
||||||
|
return None
|
||||||
|
if not project.get("enabled"):
|
||||||
|
raise ConfigError(
|
||||||
|
f"project '{path}' is disabled (enabled: false); refusing, "
|
||||||
|
"no fallback"
|
||||||
|
)
|
||||||
|
contexts = (config or {}).get("contexts") or {}
|
||||||
|
ctx = contexts.get(project.get("context")) or {}
|
||||||
|
if not ctx.get("enabled"):
|
||||||
|
raise ConfigError(
|
||||||
|
f"project '{path}' maps to context '{project.get('context')}' "
|
||||||
|
"which is disabled; refusing, no fallback"
|
||||||
|
)
|
||||||
|
return dict(project)
|
||||||
|
|
||||||
|
|
||||||
|
def _audit_profile_entry(name, profile, enabled, reveal_endpoints):
|
||||||
|
"""One LLM-safe audit row: no endpoint URLs, no keychain ids, no tokens."""
|
||||||
|
auth = profile.get("auth") if isinstance(profile, dict) else None
|
||||||
|
entry = {
|
||||||
|
"name": name,
|
||||||
|
"enabled": enabled,
|
||||||
|
"context": profile.get("context") or profile.get("environment"),
|
||||||
|
"role": profile.get("role"),
|
||||||
|
"username": profile.get("username"),
|
||||||
|
"auth": (auth or {}).get("type") if isinstance(auth, dict) else None,
|
||||||
|
}
|
||||||
|
reason = profile.get("_unavailable_reason")
|
||||||
|
if reason:
|
||||||
|
entry["reason"] = reason
|
||||||
|
if reveal_endpoints:
|
||||||
|
entry["base_url"] = profile.get("base_url")
|
||||||
|
entry["auth_source"] = auth_source_name(profile)
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
def audit_config(config, reveal_endpoints=False):
|
||||||
|
"""Report enabled/disabled profiles and services without secrets.
|
||||||
|
|
||||||
|
Default output is LLM-safe: names, contexts, enabled state, capability
|
||||||
|
labels, and the auth *type* only — never endpoint URLs, keychain ids,
|
||||||
|
token values, or auth source names. ``reveal_endpoints=True`` is the
|
||||||
|
explicit admin/debug opt-in for local diagnostics: it adds base URLs and
|
||||||
|
non-secret auth source names (``keychain:<id>`` / env var name). Token
|
||||||
|
values are never included on any path.
|
||||||
|
"""
|
||||||
|
if config is None:
|
||||||
|
return {"version": None, "profiles": [], "services": []}
|
||||||
|
report = {
|
||||||
|
"version": config.get("version"),
|
||||||
|
"shape": config.get("shape") or ("environments"
|
||||||
|
if config.get("aliases") is not None
|
||||||
|
else "profiles"),
|
||||||
|
"profiles": [],
|
||||||
|
"services": [],
|
||||||
|
}
|
||||||
|
for name, profile in (config.get("profiles") or {}).items():
|
||||||
|
if not isinstance(profile, dict):
|
||||||
|
continue
|
||||||
|
report["profiles"].append(_audit_profile_entry(
|
||||||
|
name, profile, True, reveal_endpoints))
|
||||||
|
for name, profile in (config.get("audit_only_profiles") or {}).items():
|
||||||
|
report["profiles"].append(_audit_profile_entry(
|
||||||
|
name, profile, False, reveal_endpoints))
|
||||||
|
|
||||||
|
for ctx_name, ctx in (config.get("contexts") or {}).items():
|
||||||
|
ctx_enabled = bool(ctx.get("enabled"))
|
||||||
|
for svc_name, svc in (ctx.get("services") or {}).items():
|
||||||
|
entry = {
|
||||||
|
"context": ctx_name,
|
||||||
|
"name": svc_name,
|
||||||
|
"kind": svc.get("kind"),
|
||||||
|
"label": svc.get("label"),
|
||||||
|
"enabled": ctx_enabled and bool(svc.get("enabled")),
|
||||||
|
"capabilities": list(svc.get("capabilities") or []),
|
||||||
|
"auth": (svc.get("auth") or {}).get("type"),
|
||||||
|
}
|
||||||
|
if reveal_endpoints:
|
||||||
|
entry["base_url"] = svc.get("base_url")
|
||||||
|
entry["auth_source"] = auth_source_name(svc)
|
||||||
|
report["services"].append(entry)
|
||||||
|
return report
|
||||||
|
|
||||||
|
|
||||||
|
def service_summaries(config, auth_check=None):
|
||||||
|
"""Safe one-line service summaries for LLM sessions.
|
||||||
|
|
||||||
|
Each line reports label + state only (e.g. ``PRGS Jenkins: enabled,
|
||||||
|
read-only, authenticated`` / ``PRGS Sentry: disabled``) — never endpoint
|
||||||
|
URLs, keychain ids, or token values. *auth_check* is a callable taking the
|
||||||
|
service dict and returning True when its credential resolves; it defaults
|
||||||
|
to a local keychain presence check and its result is reported only as
|
||||||
|
``authenticated`` / ``no credential``.
|
||||||
|
"""
|
||||||
|
if auth_check is None:
|
||||||
|
def auth_check(service):
|
||||||
|
auth = service.get("auth") or {}
|
||||||
|
if auth.get("type") == "keychain":
|
||||||
|
return _keychain_token(auth.get("id")) is not None
|
||||||
|
if auth.get("type") == "env":
|
||||||
|
return bool(os.environ.get(auth.get("name") or ""))
|
||||||
|
return False
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
for ctx_name, ctx in (config.get("contexts") or {}).items():
|
||||||
|
ctx_enabled = bool(ctx.get("enabled"))
|
||||||
|
for svc_name, svc in (ctx.get("services") or {}).items():
|
||||||
|
label = svc.get("label") or f"{ctx_name} {svc_name}"
|
||||||
|
if not (ctx_enabled and svc.get("enabled")):
|
||||||
|
lines.append(f"{label}: disabled")
|
||||||
|
continue
|
||||||
|
caps = list(svc.get("capabilities") or [])
|
||||||
|
cap_part = "read-only" if caps == ["read"] else ", ".join(caps)
|
||||||
|
auth_part = "authenticated" if auth_check(svc) else "no credential"
|
||||||
|
parts = ["enabled"] + ([cap_part] if cap_part else []) + [auth_part]
|
||||||
|
lines.append(f"{label}: " + ", ".join(parts))
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
def _validate_auth(name, auth):
|
def _validate_auth(name, auth):
|
||||||
"""Validate a profile's optional ``auth`` reference. Never echoes secrets."""
|
"""Validate a profile's optional ``auth`` reference. Never echoes secrets."""
|
||||||
if auth is None:
|
if auth is None:
|
||||||
@@ -147,18 +795,25 @@ def select_profile(config, name=None):
|
|||||||
if config is None:
|
if config is None:
|
||||||
return None
|
return None
|
||||||
profiles = config.get("profiles", {})
|
profiles = config.get("profiles", {})
|
||||||
|
aliases = config.get("aliases") or {}
|
||||||
|
unavailable = config.get("unavailable") or {}
|
||||||
name = name or selected_profile_name()
|
name = name or selected_profile_name()
|
||||||
available = sorted(profiles)
|
available = sorted(set(profiles) | set(aliases))
|
||||||
if not name:
|
if not name:
|
||||||
raise ConfigError(
|
raise ConfigError(
|
||||||
f"{ENV_CONFIG_PATH} is set but {ENV_PROFILE} is not; "
|
f"{ENV_CONFIG_PATH} is set but {ENV_PROFILE} is not; "
|
||||||
f"available profiles: {available}"
|
f"available profiles: {available}"
|
||||||
)
|
)
|
||||||
if name not in profiles:
|
# Strict resolution order (#103): exact alias → exact profile address →
|
||||||
|
# fail closed. No fuzzy matching, no partial matches, no defaults.
|
||||||
|
resolved = aliases.get(name, name)
|
||||||
|
if resolved in unavailable:
|
||||||
|
raise ConfigError(unavailable[resolved])
|
||||||
|
if resolved not in profiles:
|
||||||
raise ConfigError(
|
raise ConfigError(
|
||||||
f"profile '{name}' not found in config; available profiles: {available}"
|
f"profile '{name}' not found in config; available profiles: {available}"
|
||||||
)
|
)
|
||||||
profile = profiles[name]
|
profile = profiles[resolved]
|
||||||
if not isinstance(profile, dict):
|
if not isinstance(profile, dict):
|
||||||
raise ConfigError(f"profile '{name}' must be a JSON object")
|
raise ConfigError(f"profile '{name}' must be a JSON object")
|
||||||
for secret_key in ("token", "password"):
|
for secret_key in ("token", "password"):
|
||||||
@@ -292,9 +947,21 @@ def validate_config(config):
|
|||||||
problems = []
|
problems = []
|
||||||
if not isinstance(config, dict):
|
if not isinstance(config, dict):
|
||||||
return ["config is not a JSON object"]
|
return ["config is not a JSON object"]
|
||||||
if config.get("version", SUPPORTED_VERSION) != SUPPORTED_VERSION:
|
version = config.get("version")
|
||||||
|
if version is None:
|
||||||
problems.append(
|
problems.append(
|
||||||
f"unsupported version {config.get('version')!r} (expected {SUPPORTED_VERSION})"
|
f"missing required 'version' (expected one of {list(SUPPORTED_VERSIONS)})"
|
||||||
|
)
|
||||||
|
elif version == 2:
|
||||||
|
# v2 validation is all-or-nothing via the loader's invariants.
|
||||||
|
try:
|
||||||
|
_load_v2_any(config, "<config>")
|
||||||
|
except ConfigError as exc:
|
||||||
|
problems.append(str(exc))
|
||||||
|
return problems
|
||||||
|
elif version != SUPPORTED_VERSION:
|
||||||
|
problems.append(
|
||||||
|
f"unsupported version {version!r} (expected one of {list(SUPPORTED_VERSIONS)})"
|
||||||
)
|
)
|
||||||
profiles = config.get("profiles")
|
profiles = config.get("profiles")
|
||||||
if not isinstance(profiles, dict):
|
if not isinstance(profiles, dict):
|
||||||
@@ -445,5 +1112,20 @@ if __name__ == "__main__": # pragma: no cover - thin CLI dispatch
|
|||||||
if len(sys.argv) > 1 and sys.argv[1] == "menu":
|
if len(sys.argv) > 1 and sys.argv[1] == "menu":
|
||||||
import gitea_config_menu
|
import gitea_config_menu
|
||||||
raise SystemExit(gitea_config_menu.main(sys.argv[2:]))
|
raise SystemExit(gitea_config_menu.main(sys.argv[2:]))
|
||||||
print("usage: python gitea_config.py menu", file=sys.stderr)
|
if len(sys.argv) > 1 and sys.argv[1] == "audit":
|
||||||
|
# Local admin/debug diagnostics (#120). --reveal-endpoints is the
|
||||||
|
# explicit opt-in that adds base URLs and non-secret auth source
|
||||||
|
# names; token values are never printed on any path.
|
||||||
|
try:
|
||||||
|
config = load_config(config_path() or DEFAULT_CONFIG_PATH)
|
||||||
|
report = audit_config(
|
||||||
|
config, reveal_endpoints="--reveal-endpoints" in sys.argv[2:])
|
||||||
|
report["summaries"] = service_summaries(config)
|
||||||
|
except ConfigError as exc:
|
||||||
|
print(f"config error: {exc}", file=sys.stderr)
|
||||||
|
raise SystemExit(1)
|
||||||
|
print(json.dumps(report, indent=2))
|
||||||
|
raise SystemExit(0)
|
||||||
|
print("usage: python gitea_config.py menu | audit [--reveal-endpoints]",
|
||||||
|
file=sys.stderr)
|
||||||
raise SystemExit(2)
|
raise SystemExit(2)
|
||||||
|
|||||||
+78
-17
@@ -4,9 +4,14 @@
|
|||||||
Auth follows the project convention: credentials are pulled from the macOS
|
Auth follows the project convention: credentials are pulled from the macOS
|
||||||
keychain via `git credential fill` (HTTPS), then sent as Basic auth.
|
keychain via `git credential fill` (HTTPS), then sent as Basic auth.
|
||||||
|
|
||||||
Usage:
|
Modes (default = create labels then apply the one-off MAPPING, preserving the
|
||||||
./manage_labels.py # create labels, then apply the mapping below
|
original behavior):
|
||||||
./manage_labels.py --dry # print actions without writing
|
|
||||||
|
./manage_labels.py # create labels + apply MAPPING
|
||||||
|
./manage_labels.py --create-labels # idempotent label creation only
|
||||||
|
./manage_labels.py --apply-mapping # one-off MAPPING labeling only
|
||||||
|
./manage_labels.py --add-label 42 chore # add one label to one issue
|
||||||
|
./manage_labels.py --dry ... # print actions without writing
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
@@ -34,7 +39,7 @@ LABELS = [
|
|||||||
"description": "Issue is being worked on"},
|
"description": "Issue is being worked on"},
|
||||||
]
|
]
|
||||||
|
|
||||||
# issue number -> label names to apply
|
# issue number -> label names to apply (one-off backfill)
|
||||||
MAPPING = {
|
MAPPING = {
|
||||||
23: ["chore"],
|
23: ["chore"],
|
||||||
22: ["chore"],
|
22: ["chore"],
|
||||||
@@ -56,6 +61,11 @@ MAPPING = {
|
|||||||
|
|
||||||
BASE_URL = repo_api_url(HOST, ORG, REPO)
|
BASE_URL = repo_api_url(HOST, ORG, REPO)
|
||||||
|
|
||||||
|
USAGE = (
|
||||||
|
"usage: manage_labels.py [--dry] "
|
||||||
|
"[--create-labels | --apply-mapping | --add-label <issue> <label>]"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def api(method, path, auth, payload=None):
|
def api(method, path, auth, payload=None):
|
||||||
"""Thin wrapper around auth.api_request that prepends BASE_URL and
|
"""Thin wrapper around auth.api_request that prepends BASE_URL and
|
||||||
@@ -68,19 +78,15 @@ def api(method, path, auth, payload=None):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def _labels_by_name(auth):
|
||||||
dry = "--dry" in sys.argv
|
"""Return {label name: id} for the repo's existing labels."""
|
||||||
auth = get_auth_header(HOST)
|
|
||||||
if auth is None:
|
|
||||||
print("Could not get credentials from git credential fill",
|
|
||||||
file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# 1. Existing labels -> name:id
|
|
||||||
existing = api("GET", "/labels?limit=100", auth) or []
|
existing = api("GET", "/labels?limit=100", auth) or []
|
||||||
by_name = {l["name"]: l["id"] for l in existing}
|
return {lb["name"]: lb["id"] for lb in existing}
|
||||||
|
|
||||||
# 2. Create missing labels
|
|
||||||
|
def create_labels(auth, dry=False):
|
||||||
|
"""Idempotently create the LABELS set; return the resulting name->id map."""
|
||||||
|
by_name = _labels_by_name(auth)
|
||||||
for spec in LABELS:
|
for spec in LABELS:
|
||||||
if spec["name"] in by_name:
|
if spec["name"] in by_name:
|
||||||
print(f"label exists: {spec['name']}")
|
print(f"label exists: {spec['name']}")
|
||||||
@@ -92,8 +98,13 @@ def main():
|
|||||||
if created:
|
if created:
|
||||||
by_name[created["name"]] = created["id"]
|
by_name[created["name"]] = created["id"]
|
||||||
print(f"created label: {created['name']} (id {created['id']})")
|
print(f"created label: {created['name']} (id {created['id']})")
|
||||||
|
return by_name
|
||||||
|
|
||||||
# 3. Apply mapping
|
|
||||||
|
def apply_mapping(auth, by_name=None, dry=False):
|
||||||
|
"""Apply the one-off MAPPING (PUT replaces each issue's label set)."""
|
||||||
|
if by_name is None:
|
||||||
|
by_name = _labels_by_name(auth)
|
||||||
for issue, names in sorted(MAPPING.items(), reverse=True):
|
for issue, names in sorted(MAPPING.items(), reverse=True):
|
||||||
ids = [by_name[n] for n in names if n in by_name]
|
ids = [by_name[n] for n in names if n in by_name]
|
||||||
missing = [n for n in names if n not in by_name]
|
missing = [n for n in names if n not in by_name]
|
||||||
@@ -105,9 +116,59 @@ def main():
|
|||||||
# PUT replaces the issue's labels with exactly this set (idempotent).
|
# PUT replaces the issue's labels with exactly this set (idempotent).
|
||||||
res = api("PUT", f"/issues/{issue}/labels", auth, {"labels": ids})
|
res = api("PUT", f"/issues/{issue}/labels", auth, {"labels": ids})
|
||||||
if res is not None:
|
if res is not None:
|
||||||
applied = [l["name"] for l in res]
|
applied = [lb["name"] for lb in res]
|
||||||
print(f"#{issue} labeled: {applied}")
|
print(f"#{issue} labeled: {applied}")
|
||||||
|
|
||||||
|
|
||||||
|
def add_label(auth, issue, label, dry=False):
|
||||||
|
"""Ad-hoc: ADD a single existing label to one issue (append, not replace)."""
|
||||||
|
by_name = _labels_by_name(auth)
|
||||||
|
if label not in by_name:
|
||||||
|
print(f" unknown label '{label}'; create it first (--create-labels)",
|
||||||
|
file=sys.stderr)
|
||||||
|
return False
|
||||||
|
if dry:
|
||||||
|
print(f"[dry] #{issue} += {label}")
|
||||||
|
return True
|
||||||
|
# POST appends to the issue's existing labels (does not replace).
|
||||||
|
res = api("POST", f"/issues/{issue}/labels", auth, {"labels": [by_name[label]]})
|
||||||
|
if res is not None:
|
||||||
|
print(f"#{issue} += {label}")
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None):
|
||||||
|
argv = list(sys.argv[1:] if argv is None else argv)
|
||||||
|
dry = "--dry" in argv or "--dry-run" in argv
|
||||||
|
|
||||||
|
auth = get_auth_header(HOST)
|
||||||
|
if auth is None:
|
||||||
|
print("Could not get credentials from git credential fill",
|
||||||
|
file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if "--create-labels" in argv:
|
||||||
|
create_labels(auth, dry=dry)
|
||||||
|
elif "--apply-mapping" in argv:
|
||||||
|
apply_mapping(auth, dry=dry)
|
||||||
|
elif "--add-label" in argv:
|
||||||
|
i = argv.index("--add-label")
|
||||||
|
if i + 2 >= len(argv):
|
||||||
|
print(USAGE, file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
|
try:
|
||||||
|
issue = int(argv[i + 1])
|
||||||
|
except ValueError:
|
||||||
|
print(f"--add-label: issue must be a number, got '{argv[i + 1]}'",
|
||||||
|
file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
|
add_label(auth, issue, argv[i + 2], dry=dry)
|
||||||
|
else:
|
||||||
|
# Default (backward compatible): create labels, then apply the mapping.
|
||||||
|
by_name = create_labels(auth, dry=dry)
|
||||||
|
apply_mapping(auth, by_name, dry=dry)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
+208
-22
@@ -14,6 +14,7 @@ Configuration (mcp_config.json):
|
|||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import sys
|
import sys
|
||||||
import functools
|
import functools
|
||||||
import contextlib
|
import contextlib
|
||||||
@@ -37,10 +38,21 @@ from gitea_auth import ( # noqa: E402
|
|||||||
get_credentials,
|
get_credentials,
|
||||||
get_auth_header,
|
get_auth_header,
|
||||||
api_request,
|
api_request,
|
||||||
|
api_get_all,
|
||||||
repo_api_url,
|
repo_api_url,
|
||||||
get_profile,
|
get_profile,
|
||||||
)
|
)
|
||||||
import gitea_audit # noqa: E402
|
import gitea_audit # noqa: E402
|
||||||
|
import gitea_config # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def _reveal_endpoints() -> bool:
|
||||||
|
"""Admin/debug opt-in (#120): include endpoint URLs and token source
|
||||||
|
names in tool output. Off by default so normal LLM-facing responses
|
||||||
|
expose only logical names and status. Never affects token values, which
|
||||||
|
are excluded on every path."""
|
||||||
|
return (os.environ.get("GITEA_MCP_REVEAL_ENDPOINTS") or "").strip().lower() \
|
||||||
|
in ("1", "true", "yes")
|
||||||
|
|
||||||
mcp = FastMCP("gitea-tools", instructions=(
|
mcp = FastMCP("gitea-tools", instructions=(
|
||||||
"Gitea issue tracker and PR management for dadeschools and prgs instances. "
|
"Gitea issue tracker and PR management for dadeschools and prgs instances. "
|
||||||
@@ -48,6 +60,71 @@ mcp = FastMCP("gitea-tools", instructions=(
|
|||||||
))
|
))
|
||||||
|
|
||||||
|
|
||||||
|
def extract_linked_issue_numbers(text: str | None, branch_name: str | None = None) -> list[int]:
|
||||||
|
issues = set()
|
||||||
|
if text:
|
||||||
|
pattern = re.compile(r'(?i)(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?|implement[s]?|implemented)\s+#(\d+)')
|
||||||
|
issues.update(int(m) for m in pattern.findall(text))
|
||||||
|
if branch_name:
|
||||||
|
pattern = re.compile(r'(?i)issue-(\d+)')
|
||||||
|
issues.update(int(m) for m in pattern.findall(branch_name))
|
||||||
|
return sorted(list(issues))
|
||||||
|
|
||||||
|
def release_in_progress_label(issue_numbers: list[int], remote: str, host: str | None, org: str | None, repo: str | None) -> dict:
|
||||||
|
if not issue_numbers:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
h, o, r = _resolve(remote, host, org, repo)
|
||||||
|
auth = _auth(h)
|
||||||
|
base = repo_api_url(h, o, r)
|
||||||
|
|
||||||
|
try:
|
||||||
|
labels = api_request("GET", f"{base}/labels?limit=100", auth)
|
||||||
|
label_id = None
|
||||||
|
for lb in labels:
|
||||||
|
if lb["name"] == "status:in-progress":
|
||||||
|
label_id = lb["id"]
|
||||||
|
break
|
||||||
|
except Exception as exc:
|
||||||
|
return {num: f"error fetching repo labels: {_redact(str(exc))}" for num in issue_numbers}
|
||||||
|
|
||||||
|
results = {}
|
||||||
|
if label_id is None:
|
||||||
|
for num in issue_numbers:
|
||||||
|
results[num] = "not present"
|
||||||
|
return results
|
||||||
|
|
||||||
|
for num in issue_numbers:
|
||||||
|
try:
|
||||||
|
url = f"{base}/issues/{num}"
|
||||||
|
issue_data = api_request("GET", url, auth)
|
||||||
|
issue_labels = [lb["name"] for lb in issue_data.get("labels", [])]
|
||||||
|
|
||||||
|
if "status:in-progress" in issue_labels:
|
||||||
|
with _audited("release_in_progress_label", host=h, remote=remote, org=o, repo=r, issue_number=num, request_metadata={"action": "remove status:in-progress"}):
|
||||||
|
api_request("DELETE", f"{url}/labels/{label_id}", auth)
|
||||||
|
results[num] = "released"
|
||||||
|
else:
|
||||||
|
results[num] = "not present"
|
||||||
|
except Exception as exc:
|
||||||
|
results[num] = f"error: {_redact(str(exc))}"
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
def cleanup_in_progress_for_pr(pr_payload: dict, remote: str, host: str | None, org: str | None, repo: str | None) -> dict:
|
||||||
|
body = pr_payload.get("body") or ""
|
||||||
|
title = pr_payload.get("title") or ""
|
||||||
|
branch = pr_payload.get("head", {}).get("ref") or ""
|
||||||
|
|
||||||
|
text = f"{title}\n{body}"
|
||||||
|
issues = extract_linked_issue_numbers(text, branch)
|
||||||
|
|
||||||
|
if not issues:
|
||||||
|
return {"cleanup_status": "no linked issue found"}
|
||||||
|
|
||||||
|
results = release_in_progress_label(issues, remote, host, org, repo)
|
||||||
|
return {"cleanup_status": results}
|
||||||
|
|
||||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _resolve(remote: str, host: str | None, org: str | None, repo: str | None):
|
def _resolve(remote: str, host: str | None, org: str | None, repo: str | None):
|
||||||
@@ -318,7 +395,7 @@ def gitea_list_prs(
|
|||||||
h, o, r = _resolve(remote, host, org, repo)
|
h, o, r = _resolve(remote, host, org, repo)
|
||||||
auth = _auth(h)
|
auth = _auth(h)
|
||||||
url = f"{repo_api_url(h, o, r)}/pulls?state={state}"
|
url = f"{repo_api_url(h, o, r)}/pulls?state={state}"
|
||||||
prs = api_request("GET", url, auth) or []
|
prs = api_get_all(url, auth)
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"number": pr["number"],
|
"number": pr["number"],
|
||||||
@@ -444,14 +521,24 @@ def gitea_check_pr_eligibility(
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
# Profile capability check (metadata only; not enforcement of the action).
|
# Profile capability check (metadata only; not enforcement of the action).
|
||||||
|
# Both the action and the profile lists are normalized before comparison
|
||||||
|
# (#106), so legacy spellings ("merge") and canonical namespaced ops
|
||||||
|
# ("gitea.pr.merge") always match each other and never cross services.
|
||||||
allowed = profile["allowed_operations"]
|
allowed = profile["allowed_operations"]
|
||||||
forbidden = profile["forbidden_operations"]
|
forbidden = profile["forbidden_operations"]
|
||||||
if not allowed:
|
op_ok, op_reason = gitea_config.check_operation(action, allowed, forbidden)
|
||||||
reasons.append("profile has no configured allowed operations (fail closed)")
|
if not op_ok:
|
||||||
if action in forbidden:
|
if op_reason == "no-allowed-operations":
|
||||||
reasons.append(f"profile forbids '{action}'")
|
reasons.append(
|
||||||
elif action not in allowed:
|
"profile has no configured allowed operations (fail closed)")
|
||||||
reasons.append(f"profile is not allowed to {action}")
|
elif op_reason == "forbidden":
|
||||||
|
reasons.append(f"profile forbids '{action}'")
|
||||||
|
elif op_reason == "invalid-forbidden-entry":
|
||||||
|
reasons.append(
|
||||||
|
"profile has an unrecognized forbidden operation entry "
|
||||||
|
"(fail closed)")
|
||||||
|
else:
|
||||||
|
reasons.append(f"profile is not allowed to {action}")
|
||||||
|
|
||||||
h, o, r = _resolve(remote, host, org, repo)
|
h, o, r = _resolve(remote, host, org, repo)
|
||||||
|
|
||||||
@@ -743,6 +830,20 @@ def gitea_edit_pr(
|
|||||||
with _audited("edit_pr", host=h, remote=remote, org=o, repo=r,
|
with _audited("edit_pr", host=h, remote=remote, org=o, repo=r,
|
||||||
pr_number=pr_number, request_metadata={"fields": sorted(payload)}):
|
pr_number=pr_number, request_metadata={"fields": sorted(payload)}):
|
||||||
data = api_request("PATCH", url, auth, payload)
|
data = api_request("PATCH", url, auth, payload)
|
||||||
|
|
||||||
|
cleanup_status = None
|
||||||
|
if state == "closed":
|
||||||
|
cleanup = cleanup_in_progress_for_pr(data, remote, host, org, repo)
|
||||||
|
cleanup_status = cleanup.get("cleanup_status")
|
||||||
|
if isinstance(cleanup_status, dict):
|
||||||
|
for issue_num, st in cleanup_status.items():
|
||||||
|
if st == "released":
|
||||||
|
try:
|
||||||
|
comment_url = f"{repo_api_url(h, o, r)}/issues/{issue_num}/comments"
|
||||||
|
api_request("POST", comment_url, auth, {"body": f"Tracker cleanup: removed `status:in-progress` from this issue because linked PR #{pr_number} was closed."})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"number": data["number"],
|
"number": data["number"],
|
||||||
@@ -750,6 +851,7 @@ def gitea_edit_pr(
|
|||||||
"body": data.get("body", ""),
|
"body": data.get("body", ""),
|
||||||
"state": data["state"],
|
"state": data["state"],
|
||||||
"url": data["html_url"],
|
"url": data["html_url"],
|
||||||
|
"cleanup_status": cleanup_status,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1016,6 +1118,7 @@ def gitea_merge_pr(
|
|||||||
payload["MergeMessageField"] = message
|
payload["MergeMessageField"] = message
|
||||||
api_request("POST", merge_url, auth, payload)
|
api_request("POST", merge_url, auth, payload)
|
||||||
# Best-effort read-back of the merge commit SHA (redacted on error).
|
# Best-effort read-back of the merge commit SHA (redacted on error).
|
||||||
|
merged = None
|
||||||
try:
|
try:
|
||||||
merged = api_request(
|
merged = api_request(
|
||||||
"GET", f"{repo_api_url(h, o, r)}/pulls/{pr_number}", auth
|
"GET", f"{repo_api_url(h, o, r)}/pulls/{pr_number}", auth
|
||||||
@@ -1023,6 +1126,22 @@ def gitea_merge_pr(
|
|||||||
result["merge_commit"] = (merged or {}).get("merged_commit_sha")
|
result["merge_commit"] = (merged or {}).get("merged_commit_sha")
|
||||||
except Exception:
|
except Exception:
|
||||||
result["merge_commit"] = None
|
result["merge_commit"] = None
|
||||||
|
|
||||||
|
# Tracker cleanup (#98): never blocks the merge, and always surfaces an
|
||||||
|
# explicit cleanup_status once the merge mutation has happened. Cleanup
|
||||||
|
# needs the PR title/body/branch, which only the read-back payload
|
||||||
|
# carries here — so a failed read-back is an explicit skip, not a
|
||||||
|
# silent one.
|
||||||
|
if merged is None:
|
||||||
|
result["cleanup_status"] = "skipped (merge read-back failed)"
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
cleanup = cleanup_in_progress_for_pr(merged, remote, host, org, repo)
|
||||||
|
result["cleanup_status"] = cleanup.get("cleanup_status")
|
||||||
|
except Exception as cleanup_exc: # noqa: BLE001 — redact before surfacing
|
||||||
|
result["cleanup_status"] = (
|
||||||
|
f"skipped (cleanup error: {_redact(str(cleanup_exc))})"
|
||||||
|
)
|
||||||
except Exception as exc: # noqa: BLE001 — redact before surfacing
|
except Exception as exc: # noqa: BLE001 — redact before surfacing
|
||||||
reasons.append(f"merge failed: {_redact(str(exc))}")
|
reasons.append(f"merge failed: {_redact(str(exc))}")
|
||||||
return result
|
return result
|
||||||
@@ -1157,7 +1276,14 @@ def gitea_close_issue(
|
|||||||
with _audited("close_issue", host=h, remote=remote, org=o, repo=r,
|
with _audited("close_issue", host=h, remote=remote, org=o, repo=r,
|
||||||
issue_number=issue_number, request_metadata={"state": "closed"}):
|
issue_number=issue_number, request_metadata={"state": "closed"}):
|
||||||
api_request("PATCH", url, auth, {"state": "closed"})
|
api_request("PATCH", url, auth, {"state": "closed"})
|
||||||
return {"success": True, "message": f"Issue #{issue_number} closed."}
|
|
||||||
|
cleanup_result = release_in_progress_label([issue_number], remote, host, org, repo)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": f"Issue #{issue_number} closed.",
|
||||||
|
"cleanup_status": cleanup_result
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@@ -1175,7 +1301,7 @@ def gitea_list_issues(
|
|||||||
Args:
|
Args:
|
||||||
state: Filter by state — 'open', 'closed', or 'all'.
|
state: Filter by state — 'open', 'closed', or 'all'.
|
||||||
label: Filter by label name (e.g. 'important').
|
label: Filter by label name (e.g. 'important').
|
||||||
limit: Max number of issues to return (default: 50).
|
limit: Max number of issues to return across all pages (default: 50).
|
||||||
remote: Known instance — 'dadeschools' or 'prgs'.
|
remote: Known instance — 'dadeschools' or 'prgs'.
|
||||||
host: Override the Gitea host.
|
host: Override the Gitea host.
|
||||||
org: Override the owner/organization.
|
org: Override the owner/organization.
|
||||||
@@ -1186,11 +1312,11 @@ def gitea_list_issues(
|
|||||||
"""
|
"""
|
||||||
h, o, r = _resolve(remote, host, org, repo)
|
h, o, r = _resolve(remote, host, org, repo)
|
||||||
auth = _auth(h)
|
auth = _auth(h)
|
||||||
params = f"state={state}&limit={limit}&type=issues"
|
params = f"state={state}&type=issues"
|
||||||
if label:
|
if label:
|
||||||
params += f"&labels={label}"
|
params += f"&labels={label}"
|
||||||
url = f"{repo_api_url(h, o, r)}/issues?{params}"
|
url = f"{repo_api_url(h, o, r)}/issues?{params}"
|
||||||
issues = api_request("GET", url, auth)
|
issues = api_get_all(url, auth, limit=limit)
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"number": i["number"],
|
"number": i["number"],
|
||||||
@@ -1276,21 +1402,44 @@ def gitea_whoami(
|
|||||||
"Verify the configured token is valid for this instance."
|
"Verify the configured token is valid for this instance."
|
||||||
)
|
)
|
||||||
# Runtime profile metadata is non-secret (name + allowed op categories).
|
# Runtime profile metadata is non-secret (name + allowed op categories).
|
||||||
# The token is resolved separately and is never included here.
|
# The token is resolved separately and is never included here. Endpoint
|
||||||
|
# URLs stay out of normal LLM-facing output (#120): the logical remote
|
||||||
|
# name is the addressing surface; 'server' appears only under the
|
||||||
|
# GITEA_MCP_REVEAL_ENDPOINTS admin opt-in.
|
||||||
profile = get_profile()
|
profile = get_profile()
|
||||||
return {
|
result = {
|
||||||
"authenticated": True,
|
"authenticated": True,
|
||||||
"username": data.get("login"),
|
"username": data.get("login"),
|
||||||
"display_name": data.get("full_name") or None,
|
"display_name": data.get("full_name") or None,
|
||||||
"user_id": data.get("id"),
|
"user_id": data.get("id"),
|
||||||
"email": data.get("email") or None,
|
"email": data.get("email") or None,
|
||||||
"server": f"https://{h}",
|
|
||||||
"remote": remote,
|
"remote": remote,
|
||||||
"profile": {
|
"profile": {
|
||||||
"profile_name": profile["profile_name"],
|
"profile_name": profile["profile_name"],
|
||||||
"allowed_operations": profile["allowed_operations"],
|
"allowed_operations": profile["allowed_operations"],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
if _reveal_endpoints():
|
||||||
|
result["server"] = f"https://{h}"
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def gitea_get_authenticated_user(
|
||||||
|
remote: str = "dadeschools",
|
||||||
|
host: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Alias for gitea_whoami. Look up the authenticated Gitea account."""
|
||||||
|
return gitea_whoami(remote=remote, host=host)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def gitea_get_current_user(
|
||||||
|
remote: str = "dadeschools",
|
||||||
|
host: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Alias for gitea_whoami. Look up the authenticated Gitea account."""
|
||||||
|
return gitea_whoami(remote=remote, host=host)
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@@ -1303,9 +1452,11 @@ def gitea_get_profile(
|
|||||||
|
|
||||||
Read-only. Reports the non-secret configuration of the running MCP
|
Read-only. Reports the non-secret configuration of the running MCP
|
||||||
process (profile name, allowed/forbidden operation categories, audit
|
process (profile name, allowed/forbidden operation categories, audit
|
||||||
label, token *source name*, base URL) plus the resolved server for the
|
label, auth *status*). Endpoint URLs and token source names are hidden
|
||||||
given remote. Optionally resolves the authenticated username via
|
from normal output (#120) and appear only under the
|
||||||
``gitea_whoami``'s endpoint so an LLM can see who this runtime acts as.
|
GITEA_MCP_REVEAL_ENDPOINTS admin opt-in. Optionally resolves the
|
||||||
|
authenticated username via ``gitea_whoami``'s endpoint so an LLM can see
|
||||||
|
who this runtime acts as.
|
||||||
|
|
||||||
This tool never mutates Gitea and never approves, merges, comments, or
|
This tool never mutates Gitea and never approves, merges, comments, or
|
||||||
creates anything. It never returns the token value, Authorization header,
|
creates anything. It never returns the token value, Authorization header,
|
||||||
@@ -1323,18 +1474,25 @@ def gitea_get_profile(
|
|||||||
'verified', 'unknown', 'unavailable', or 'not_resolved'.
|
'verified', 'unknown', 'unavailable', or 'not_resolved'.
|
||||||
"""
|
"""
|
||||||
profile = get_profile()
|
profile = get_profile()
|
||||||
|
reveal = _reveal_endpoints()
|
||||||
result = {
|
result = {
|
||||||
"profile_name": profile["profile_name"],
|
"profile_name": profile["profile_name"],
|
||||||
"allowed_operations": profile["allowed_operations"],
|
"allowed_operations": profile["allowed_operations"],
|
||||||
"forbidden_operations": profile["forbidden_operations"],
|
"forbidden_operations": profile["forbidden_operations"],
|
||||||
"audit_label": profile["audit_label"],
|
"audit_label": profile["audit_label"],
|
||||||
"token_source_name": profile["token_source_name"],
|
# Auth is reported as a status only (#120): the token source *name*
|
||||||
"base_url": profile["base_url"],
|
# (env var name / keychain id) joins endpoint URLs behind the
|
||||||
|
# GITEA_MCP_REVEAL_ENDPOINTS admin opt-in. Token values never appear.
|
||||||
|
"auth_status": ("configured" if profile["token_source_name"]
|
||||||
|
else "unconfigured"),
|
||||||
"remote": remote if remote in REMOTES else None,
|
"remote": remote if remote in REMOTES else None,
|
||||||
"server": None,
|
|
||||||
"authenticated_username": None,
|
"authenticated_username": None,
|
||||||
"identity_status": "not_resolved",
|
"identity_status": "not_resolved",
|
||||||
}
|
}
|
||||||
|
if reveal:
|
||||||
|
result["token_source_name"] = profile["token_source_name"]
|
||||||
|
result["base_url"] = profile["base_url"]
|
||||||
|
result["server"] = None
|
||||||
|
|
||||||
if remote not in REMOTES:
|
if remote not in REMOTES:
|
||||||
# Mark ambiguity rather than raising: the tool stays inspectable.
|
# Mark ambiguity rather than raising: the tool stays inspectable.
|
||||||
@@ -1343,7 +1501,8 @@ def gitea_get_profile(
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
h = host or REMOTES[remote]["host"]
|
h = host or REMOTES[remote]["host"]
|
||||||
result["server"] = f"https://{h}"
|
if reveal:
|
||||||
|
result["server"] = f"https://{h}"
|
||||||
|
|
||||||
if resolve_identity:
|
if resolve_identity:
|
||||||
try:
|
try:
|
||||||
@@ -1363,6 +1522,33 @@ def gitea_get_profile(
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def gitea_audit_config() -> dict:
|
||||||
|
"""Audit the configured profiles/services: enabled state, no secrets.
|
||||||
|
|
||||||
|
Read-only and local-only: loads the canonical profiles.json named by
|
||||||
|
GITEA_MCP_CONFIG and reports profile/service names, contexts, enabled
|
||||||
|
state, capabilities, auth *status*, and one-line service summaries (e.g.
|
||||||
|
``PRGS Jenkins: enabled, read-only, authenticated``). Disabled entries
|
||||||
|
are listed so they can be audited, but the server refuses to act with
|
||||||
|
them and never falls back to another profile or service.
|
||||||
|
|
||||||
|
Never includes endpoint URLs, keychain ids, token source names, or token
|
||||||
|
values. Endpoint-revealing diagnostics exist only in the local admin CLI
|
||||||
|
(``python3 gitea_config.py audit --reveal-endpoints``), never over MCP.
|
||||||
|
"""
|
||||||
|
config = gitea_config.load_config()
|
||||||
|
if config is None:
|
||||||
|
return {
|
||||||
|
"configured": False,
|
||||||
|
"message": "No GITEA_MCP_CONFIG configured; env-only mode.",
|
||||||
|
}
|
||||||
|
report = gitea_config.audit_config(config)
|
||||||
|
report["configured"] = True
|
||||||
|
report["summaries"] = gitea_config.service_summaries(config)
|
||||||
|
return report
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def gitea_mark_issue(
|
def gitea_mark_issue(
|
||||||
issue_number: int,
|
issue_number: int,
|
||||||
@@ -1445,7 +1631,7 @@ def gitea_list_labels(
|
|||||||
h, o, r = _resolve(remote, host, org, repo)
|
h, o, r = _resolve(remote, host, org, repo)
|
||||||
auth = _auth(h)
|
auth = _auth(h)
|
||||||
base = repo_api_url(h, o, r)
|
base = repo_api_url(h, o, r)
|
||||||
return api_request("GET", f"{base}/labels?limit=100", auth)
|
return api_get_all(f"{base}/labels", auth)
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
|
|||||||
Executable
+61
@@ -0,0 +1,61 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# clear-provenance — strip the macOS com.apple.provenance extended attribute so
|
||||||
|
# Python.app can execute .py files created by agent/IDE terminals (issue #3).
|
||||||
|
#
|
||||||
|
# macOS Sequoia+ blocks Python.app from executing files carrying
|
||||||
|
# com.apple.provenance. Files written by the agent terminal get it; shell
|
||||||
|
# scripts are unaffected. This is a macOS security feature, not a bug in our
|
||||||
|
# code — see the Troubleshooting section of the README.
|
||||||
|
#
|
||||||
|
# Run from a terminal with Full Disk Access (e.g. Terminal.app), not the IDE
|
||||||
|
# terminal, or the removal itself may be blocked.
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
usage: scripts/clear-provenance [--dry-run] [path]
|
||||||
|
|
||||||
|
Recursively remove the com.apple.provenance extended attribute under <path>
|
||||||
|
(default: the repository root). macOS only. Only that attribute is removed;
|
||||||
|
other extended attributes are left intact.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
scripts/clear-provenance --dry-run
|
||||||
|
scripts/clear-provenance
|
||||||
|
scripts/clear-provenance /path/to/file.py
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
dry_run=0
|
||||||
|
while [[ "${1:-}" == --* ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--dry-run) dry_run=1 ;;
|
||||||
|
--help) usage; exit 0 ;;
|
||||||
|
*) usage >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ $# -gt 1 ]]; then
|
||||||
|
usage >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
repo_root="$(cd "$script_dir/.." && pwd)"
|
||||||
|
target="${1:-$repo_root}"
|
||||||
|
|
||||||
|
if [[ ! -e "$target" ]]; then
|
||||||
|
printf 'clear-provenance: no such path: %s\n' "$target" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Remove only com.apple.provenance; tolerate files that do not carry it.
|
||||||
|
if [[ "$dry_run" -eq 1 ]]; then
|
||||||
|
printf 'clear-provenance: [dry-run] would run: xattr -r -d com.apple.provenance %q\n' "$target"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
xattr -r -d com.apple.provenance "$target" 2>/dev/null || true
|
||||||
|
printf 'clear-provenance: removed com.apple.provenance recursively under: %s\n' "$target"
|
||||||
Executable
+139
@@ -0,0 +1,139 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# release-tag — create an annotated release tag safely from remote master.
|
||||||
|
# Enforces the documented tagging policy (see docs/llm-workflow-runbooks.md and
|
||||||
|
# skills/llm-project-workflow/SKILL.md). Never pushes unless --push is given.
|
||||||
|
#
|
||||||
|
# Test/CI injection points (env):
|
||||||
|
# RELEASE_TAG_REMOTE git remote name (default: prgs)
|
||||||
|
# RELEASE_TAG_TEST_CMD test command run before tagging
|
||||||
|
# (default: ./venv/bin/python -m pytest tests/ -q)
|
||||||
|
|
||||||
|
REMOTE="${RELEASE_TAG_REMOTE:-prgs}"
|
||||||
|
TEST_CMD="${RELEASE_TAG_TEST_CMD:-./venv/bin/python -m pytest tests/ -q}"
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
usage: scripts/release-tag [--dry-run] [--skip-tests] [--push]
|
||||||
|
[--notes-file <path>] <vMAJOR.MINOR.PATCH>
|
||||||
|
|
||||||
|
Create an annotated release tag from remote master, only when the tree/branch
|
||||||
|
are clean and tests pass. Safe by default: no push unless --push; --dry-run
|
||||||
|
changes nothing.
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--dry-run Print planned actions; create/push nothing.
|
||||||
|
--skip-tests Skip the test suite (explicit opt-out; prints a warning).
|
||||||
|
--push Push the tag to the remote after creating it.
|
||||||
|
--notes-file <path> Use this file's contents as the annotated-tag message.
|
||||||
|
--help Show this help.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
scripts/release-tag --dry-run v0.4.0
|
||||||
|
scripts/release-tag v0.4.0 --notes-file /tmp/release-notes.md
|
||||||
|
scripts/release-tag v0.4.0 --notes-file /tmp/release-notes.md --push
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
fail() { printf 'release-tag: %s\n' "$1" >&2; exit "${2:-1}"; }
|
||||||
|
|
||||||
|
dry_run=0
|
||||||
|
skip_tests=0
|
||||||
|
push=0
|
||||||
|
notes_file=""
|
||||||
|
version=""
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--dry-run) dry_run=1 ;;
|
||||||
|
--skip-tests) skip_tests=1 ;;
|
||||||
|
--push) push=1 ;;
|
||||||
|
--notes-file) shift; notes_file="${1:-}"; [[ -n "$notes_file" ]] || fail "--notes-file needs a path" 2 ;;
|
||||||
|
--help) usage; exit 0 ;;
|
||||||
|
-*) usage >&2; exit 2 ;;
|
||||||
|
*) if [[ -z "$version" ]]; then version="$1"; else usage >&2; exit 2; fi ;;
|
||||||
|
esac
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
|
||||||
|
[[ -n "$version" ]] || { usage >&2; exit 2; }
|
||||||
|
|
||||||
|
# 1. SemVer validation (before any git/network work).
|
||||||
|
if [[ ! "$version" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||||
|
fail "invalid version '$version' (expected vMAJOR.MINOR.PATCH, e.g. v0.4.0)" 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "$notes_file" && ! -f "$notes_file" ]]; then
|
||||||
|
fail "notes file not found: $notes_file" 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
repo_root="$(cd "$script_dir/.." && pwd)"
|
||||||
|
git_c=(git -C "$repo_root")
|
||||||
|
|
||||||
|
# 2. Fetch/prune first.
|
||||||
|
"${git_c[@]}" fetch "$REMOTE" --prune
|
||||||
|
|
||||||
|
# 3. Must be on master.
|
||||||
|
current_branch="$("${git_c[@]}" symbolic-ref --quiet --short HEAD || echo DETACHED)"
|
||||||
|
[[ "$current_branch" == "master" ]] || fail "not on master (on '$current_branch'); tag only from master"
|
||||||
|
|
||||||
|
# 4. Clean worktree.
|
||||||
|
[[ -z "$("${git_c[@]}" status --porcelain)" ]] || fail "worktree is dirty; commit/stash before tagging"
|
||||||
|
|
||||||
|
# 5. Local master must equal remote master.
|
||||||
|
local_sha="$("${git_c[@]}" rev-parse master)"
|
||||||
|
remote_sha="$("${git_c[@]}" rev-parse "$REMOTE/master")"
|
||||||
|
[[ "$local_sha" == "$remote_sha" ]] || fail "local master ($local_sha) != $REMOTE/master ($remote_sha)"
|
||||||
|
|
||||||
|
# 6. HEAD must be that same commit (present on remote master).
|
||||||
|
head_sha="$("${git_c[@]}" rev-parse HEAD)"
|
||||||
|
[[ "$head_sha" == "$remote_sha" ]] || fail "HEAD ($head_sha) is not $REMOTE/master; tag only commits on remote master"
|
||||||
|
|
||||||
|
# 7. Tag must not already exist locally or on the remote.
|
||||||
|
if "${git_c[@]}" rev-parse -q --verify "refs/tags/$version" >/dev/null 2>&1; then
|
||||||
|
fail "tag $version already exists locally"
|
||||||
|
fi
|
||||||
|
if "${git_c[@]}" ls-remote --tags "$REMOTE" "refs/tags/$version" | grep -q .; then
|
||||||
|
fail "tag $version already exists on $REMOTE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Annotation message: notes file, or a minimal default.
|
||||||
|
if [[ -n "$notes_file" ]]; then
|
||||||
|
notes_arg=(-F "$notes_file")
|
||||||
|
else
|
||||||
|
notes_arg=(-m "$version")
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Tests (default on; explicit --skip-tests warns). Not executed in dry-run.
|
||||||
|
tests_run="no"
|
||||||
|
if [[ "$skip_tests" -eq 1 ]]; then
|
||||||
|
printf 'release-tag: WARNING --skip-tests set; NOT running the test suite before tagging.\n' >&2
|
||||||
|
elif [[ "$dry_run" -eq 1 ]]; then
|
||||||
|
printf 'release-tag: [dry-run] would run tests: %s\n' "$TEST_CMD"
|
||||||
|
else
|
||||||
|
tests_run="yes"
|
||||||
|
( cd "$repo_root" && eval "$TEST_CMD" ) || fail "tests failed; refusing to tag"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create (and optionally push) the annotated tag.
|
||||||
|
tag_created="no"
|
||||||
|
tag_pushed="no"
|
||||||
|
if [[ "$dry_run" -eq 1 ]]; then
|
||||||
|
printf 'release-tag: [dry-run] would create annotated tag %s at %s\n' "$version" "$head_sha"
|
||||||
|
[[ "$push" -eq 1 ]] && printf 'release-tag: [dry-run] would push %s to %s\n' "$version" "$REMOTE"
|
||||||
|
else
|
||||||
|
"${git_c[@]}" tag -a "$version" "$head_sha" "${notes_arg[@]}"
|
||||||
|
tag_created="yes"
|
||||||
|
if [[ "$push" -eq 1 ]]; then
|
||||||
|
"${git_c[@]}" push "$REMOTE" "$version"
|
||||||
|
tag_pushed="yes"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf 'commit: %s\n' "$head_sha"
|
||||||
|
printf 'tag: %s\n' "$version"
|
||||||
|
printf 'tests_run: %s\n' "$tests_run"
|
||||||
|
printf 'tag_created: %s\n' "$tag_created"
|
||||||
|
printf 'tag_pushed: %s\n' "$tag_pushed"
|
||||||
+37
-6
@@ -3,21 +3,32 @@ set -euo pipefail
|
|||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<'EOF'
|
cat <<'EOF'
|
||||||
usage: scripts/worktree-start [--dry-run] <branch-name> [start-ref]
|
usage: scripts/worktree-start [--dry-run] [--allow-unlinked] <branch-name> [start-ref]
|
||||||
|
|
||||||
Create an issue-specific git worktree under branches/<branch-name-with-slashes-replaced>.
|
Create an issue-linked git worktree under branches/<branch-name-with-slashes-replaced>.
|
||||||
|
|
||||||
|
Branch names must be traceable to an issue (or a PR, for review branches):
|
||||||
|
implementation: (fix|feat|docs|chore)/issue-<number>-<short-description>
|
||||||
|
review: review/pr-<number>-<short-description>
|
||||||
|
Use --allow-unlinked to bypass the check (discouraged).
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
scripts/worktree-start fix/issue-123-example
|
scripts/worktree-start fix/issue-123-example
|
||||||
scripts/worktree-start --dry-run review/pr-123-scope-check prgs/master
|
scripts/worktree-start --dry-run review/pr-456-scope-check prgs/master
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
dry_run=0
|
dry_run=0
|
||||||
if [[ "${1:-}" == "--dry-run" ]]; then
|
allow_unlinked=0
|
||||||
dry_run=1
|
while [[ "${1:-}" == --* ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--dry-run) dry_run=1 ;;
|
||||||
|
--allow-unlinked) allow_unlinked=1 ;;
|
||||||
|
--help) usage; exit 0 ;;
|
||||||
|
*) usage >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
shift
|
shift
|
||||||
fi
|
done
|
||||||
|
|
||||||
if [[ $# -lt 1 || $# -gt 2 ]]; then
|
if [[ $# -lt 1 || $# -gt 2 ]]; then
|
||||||
usage >&2
|
usage >&2
|
||||||
@@ -27,6 +38,26 @@ fi
|
|||||||
branch="$1"
|
branch="$1"
|
||||||
start_ref="${2:-prgs/master}"
|
start_ref="${2:-prgs/master}"
|
||||||
|
|
||||||
|
# Enforce issue-linked, traceable branch names (issue → branch → worktree → PR).
|
||||||
|
if [[ "$allow_unlinked" -eq 0 ]]; then
|
||||||
|
if [[ "$branch" =~ ^(fix|feat|docs|chore)/issue-[0-9]+-.+ ]] \
|
||||||
|
|| [[ "$branch" =~ ^review/pr-[0-9]+-.+ ]]; then
|
||||||
|
:
|
||||||
|
else
|
||||||
|
cat >&2 <<EOF
|
||||||
|
Untraceable branch name: $branch
|
||||||
|
|
||||||
|
Implementation branches must be issue-linked:
|
||||||
|
(fix|feat|docs|chore)/issue-<number>-<short-description>
|
||||||
|
Review branches:
|
||||||
|
review/pr-<number>-<short-description>
|
||||||
|
|
||||||
|
Fix the branch name, or pass --allow-unlinked to override (discouraged).
|
||||||
|
EOF
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
repo_root="$(cd "$script_dir/.." && pwd)"
|
repo_root="$(cd "$script_dir/.." && pwd)"
|
||||||
worktree_name="${branch//\//-}"
|
worktree_name="${branch//\//-}"
|
||||||
|
|||||||
@@ -0,0 +1,471 @@
|
|||||||
|
---
|
||||||
|
name: llm-project-workflow
|
||||||
|
description: >-
|
||||||
|
Portable, safe operating workflow for LLMs working on any Git/forge project:
|
||||||
|
issue-first, isolated branch worktrees, no self-review/self-merge, distinct
|
||||||
|
author/reviewer profiles, cleanup after merge, and fail-closed behavior.
|
||||||
|
Use at the start of any implementation, review, or merge task on a repo.
|
||||||
|
---
|
||||||
|
|
||||||
|
# LLM Project Workflow
|
||||||
|
|
||||||
|
A reusable workflow any LLM can follow to work on any repository safely. Copy
|
||||||
|
this `skills/llm-project-workflow/` directory into another project unchanged;
|
||||||
|
adapt only the forge-specific names in [Adapting to a project](#adapting-to-a-project).
|
||||||
|
|
||||||
|
The core promise: **an LLM never does unsafe or untracked work.** Every change
|
||||||
|
is tracked by an issue, isolated in its own worktree, reviewed by a different
|
||||||
|
identity, and cleaned up only after a real merge.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## Definitions
|
||||||
|
|
||||||
|
- **Merged**: Gitea PR metadata says `merged=true`.
|
||||||
|
- **Landed**: Equivalent content is present on remote `master`, but PR metadata may not say merged.
|
||||||
|
- **Closed-not-merged**: PR state is closed and `merged=false`.
|
||||||
|
- **Reconciled**: A human/LLM verified whether closed-not-merged content landed, partially landed, or was lost, and repaired issue/label/tracker state.
|
||||||
|
|
||||||
|
## A. Issue-first rule
|
||||||
|
|
||||||
|
**No repository change without a tracking issue.** This includes creating,
|
||||||
|
editing, deleting, or `chmod`-ing files; docs; scripts; commits; pushes; and PRs.
|
||||||
|
|
||||||
|
1. Before any change, confirm a tracking issue exists.
|
||||||
|
2. If none exists, create one first (title + problem + scope + acceptance).
|
||||||
|
3. Claim it (assign yourself or apply the `status:in-progress` label) and comment
|
||||||
|
that work is starting, including the planned branch name.
|
||||||
|
4. **If the issue cannot be created or claimed, stop.** Do not touch files.
|
||||||
|
|
||||||
|
Reading the repo, running read-only status/`git log`, and creating/claiming the
|
||||||
|
issue itself are allowed from the orchestration checkout without a prior issue.
|
||||||
|
|
||||||
|
Additional issue-first rules:
|
||||||
|
|
||||||
|
- Do not implement code without an issue unless explicitly authorized.
|
||||||
|
- **Design-only work uses a discussion/RFC issue** — create one or comment on
|
||||||
|
the existing one. Design debates belong on the issue, where other LLMs
|
||||||
|
comment directly. Discussion-only tasks must **not** create branches or PRs;
|
||||||
|
their comments should include recommendations, risks, open questions, and a
|
||||||
|
Controller Handoff (§K; compact format unless high-risk).
|
||||||
|
- **If the repo/tracker home for the work is unclear, stop and ask for an
|
||||||
|
owner decision.** Do not create a new repository or a new tracker unless
|
||||||
|
explicitly approved by the owner.
|
||||||
|
|
||||||
|
## B. Isolated worktree rule
|
||||||
|
|
||||||
|
**Never implement or review in the main checkout.** The main checkout is for
|
||||||
|
orchestration and status only (issue creation, `git status`, creating worktrees).
|
||||||
|
|
||||||
|
- Each issue gets its own branch worktree under an ignored `branches/` directory.
|
||||||
|
- Review work uses a **separate** review worktree, never the author's folder.
|
||||||
|
- Dirty work in one branch folder must not block starting another issue.
|
||||||
|
- No LLM may edit another issue's worktree unless explicitly assigned to it.
|
||||||
|
- Branch folders are removed only after the PR is merged/closed **and** cleanup
|
||||||
|
is explicitly part of the task.
|
||||||
|
|
||||||
|
Every implementation branch **must include its issue number** so it is
|
||||||
|
traceable end to end: **issue → branch → worktree folder → PR → cleanup.**
|
||||||
|
|
||||||
|
Allowed implementation patterns:
|
||||||
|
|
||||||
|
- `fix/issue-123-short-description`
|
||||||
|
- `feat/issue-123-short-description`
|
||||||
|
- `docs/issue-123-short-description`
|
||||||
|
- `chore/issue-123-short-description`
|
||||||
|
|
||||||
|
Review-only branches:
|
||||||
|
|
||||||
|
- `review/pr-456-short-description`
|
||||||
|
|
||||||
|
Use a filesystem-safe folder under `branches/` by replacing slashes with
|
||||||
|
hyphens, for example `branches/fix-issue-123-short-description`.
|
||||||
|
|
||||||
|
`scripts/worktree-start` **enforces** this: it rejects an implementation branch
|
||||||
|
that does not match `(fix|feat|docs|chore)/issue-<number>-…` (or a
|
||||||
|
`review/pr-<number>-…` branch), unless `--allow-unlinked` is passed. Traceability
|
||||||
|
is maintained by:
|
||||||
|
|
||||||
|
- the branch name (contains the issue number),
|
||||||
|
- a claim comment on the issue, e.g.
|
||||||
|
`Claimed. Branch: fix/issue-123-short-description. Worktree: branches/fix-issue-123-short-description.`,
|
||||||
|
- the PR body — `Closes #123` or `Fixes #123` when the PR should close the issue
|
||||||
|
(do NOT use `Implements #123` or `Refs #123` to close, as Gitea will not auto-close),
|
||||||
|
- cleanup after merge — remove the remote branch, local branch, and the issue
|
||||||
|
worktree folder, and drop `status:in-progress`.
|
||||||
|
|
||||||
|
For projects using `Gitea-Tools` helpers:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/worktree-start fix/issue-123-example # → branches/fix-issue-123-example
|
||||||
|
scripts/worktree-review fix/issue-123-example # → branches/review-fix-issue-123-example (detached)
|
||||||
|
scripts/worktree-clean --delete-branch fix/issue-123-example
|
||||||
|
```
|
||||||
|
|
||||||
|
Manual equivalent:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git fetch <remote> --prune
|
||||||
|
git worktree add -b fix/issue-123-example branches/fix-issue-123-example <remote>/master
|
||||||
|
cd branches/fix-issue-123-example
|
||||||
|
```
|
||||||
|
|
||||||
|
`venv/` and similar are not copied into new worktrees — run checks with a known
|
||||||
|
interpreter path, or create a venv inside the branch folder.
|
||||||
|
|
||||||
|
## C. Identity and profile safety
|
||||||
|
|
||||||
|
- Use canonical execution profiles where available; the profile is the role, not the LLM. A task selects a profile; a profile is not permanently assigned.
|
||||||
|
- **Author and reviewer identities must be distinct.**
|
||||||
|
- Never place raw tokens/passwords in an LLM/MCP client config. Reference secrets by keychain id or environment variable name only. Prefer a single canonical config file selected by two env vars, e.g.:
|
||||||
|
- `GITEA_MCP_CONFIG` — path to the canonical profiles file
|
||||||
|
- `GITEA_MCP_PROFILE` — the profile to activate
|
||||||
|
- **Dual-Profile MCP Launcher Pattern (Recommended):** To avoid relaunch bottlenecks and PR-author deadlocks, register multiple instances of the same MCP server in the client's configuration simultaneously (e.g., `gitea-author` and `gitea-reviewer`), each pointing to its respective `GITEA_MCP_PROFILE`.
|
||||||
|
- Tool calls become namespace-scoped: `mcp__gitea-author__*` and `mcp__gitea-reviewer__*`.
|
||||||
|
- **Trust Model:** Separate tokens remain separate. Profile gates enforce allowed operations, `whoami` is still checked, and self-review/self-merge prevention remains mandatory. This pattern is for convenience and does not bypass security gates.
|
||||||
|
- **Deadlock Warning:** Reviewer/merge identities must not be used to create PRs, as this makes the reviewer the PR author in Gitea and blocks independent review. PRs should normally be created by the author/work identity, keeping the reviewer identity available for reviews.
|
||||||
|
- **Fallback:** If a dual-server launcher is not available in the client, relaunch or restart the client with the correct profile environment variable before claiming work.
|
||||||
|
- **If the authenticated user equals the PR author, stop** — no self-review, no self-merge.
|
||||||
|
|
||||||
|
## D. Branch naming
|
||||||
|
|
||||||
|
```text
|
||||||
|
fix/issue-123-short-description
|
||||||
|
feat/issue-123-short-description
|
||||||
|
docs/issue-123-short-description
|
||||||
|
review/pr-456-scope-check
|
||||||
|
```
|
||||||
|
|
||||||
|
Worktree folder = branch with `/` replaced by `-`
|
||||||
|
(`branches/fix-issue-123-short-description`).
|
||||||
|
|
||||||
|
## E. Start-work workflow
|
||||||
|
|
||||||
|
1. Verify the orchestration checkout (right repo, clean tree).
|
||||||
|
2. Fetch/prune: `git fetch <remote> --prune`.
|
||||||
|
3. Confirm local `master` equals remote `master` (`git rev-list --left-right --count <remote>/master...master` → `0 0`).
|
||||||
|
4. Create/claim the issue (§A).
|
||||||
|
5. Create the isolated worktree (§B) from latest remote `master`.
|
||||||
|
6. Implement the narrow scope only — no unrelated refactors or formatting churn.
|
||||||
|
7. Add/update focused tests when behavior changes.
|
||||||
|
8. Run the checks (tests, compile/lint, `git diff --check`, secret scan).
|
||||||
|
9. Commit with an issue-linked message.
|
||||||
|
10. Push the branch.
|
||||||
|
11. Open a PR to `master`.
|
||||||
|
12. **If you are the author, stop before review/merge.**
|
||||||
|
13. **Normal issue work must not directly push to `master`.** PR content should be merged through the forge PR merge mechanism.
|
||||||
|
14. Direct push to `master` is allowed only as a documented recovery exception. If used, the final report must include:
|
||||||
|
- why the PR merge path could not be used
|
||||||
|
- exact commits pushed
|
||||||
|
- PR metadata state
|
||||||
|
- issue labels/state repaired
|
||||||
|
- whether the PR is closed-not-merged
|
||||||
|
|
||||||
|
|
||||||
|
## F. Review workflow
|
||||||
|
|
||||||
|
1. Use a separate review worktree (`scripts/worktree-review <branch>`), detached.
|
||||||
|
2. Verify your authenticated identity.
|
||||||
|
3. Verify the PR author — **you must not be the author.**
|
||||||
|
4. Verify the worktree is clean.
|
||||||
|
5. Inspect the full diff; confirm scope matches the linked issue; flag unrelated files.
|
||||||
|
6. Run the tests.
|
||||||
|
7. **Do not merge if checks fail. Do not merge if the reviewer is the author.**
|
||||||
|
|
||||||
|
## G. Merge / cleanup workflow
|
||||||
|
|
||||||
|
Only an eligible (non-author) reviewer merges. Before merging: always verify
|
||||||
|
the authenticated identity **and** the PR author; respect runtime profile
|
||||||
|
gates; run independent validation (do not trust the author's reported
|
||||||
|
results); and merge with a **pinned head SHA** and, where supported, the
|
||||||
|
**expected changed-file set**, so a moved head or widened diff refuses the
|
||||||
|
merge. After a real merge:
|
||||||
|
|
||||||
|
1. Confirm remote `master` actually contains the merge commit or expected squashed changes via post-merge file-presence verification (A PR is not done just because `master` moved or is marked "closed". Verify that expected files added/modified in the PR are actually present on `master` using `git pull`, `git log --oneline -- <file>`, or `git merge-base --is-ancestor`; linked issues are closed; `status:in-progress` is removed).
|
||||||
|
2. Close/release the issue.
|
||||||
|
3. Whenever an issue is closed, check for `status:in-progress`: remove it, or report why it could not be removed.
|
||||||
|
4. Do not delete the remote source branch until: PR `merged=true`, or reconciliation confirms content is safely landed, or the issue owner explicitly abandons the work.
|
||||||
|
5. Remove the local branch.
|
||||||
|
6. Remove the branch worktree folder (`scripts/worktree-clean --delete-branch <branch>`). Branches/worktrees are cleaned only after the above is verified.
|
||||||
|
7. Fetch/prune.
|
||||||
|
8. Confirm the main checkout is clean and current (`0 0` vs remote).
|
||||||
|
9. Final merge/reconciliation reports must include: PR metadata (state, merged flag, merge commit/hash), Git content (remote master hash, expected content present or not), and the exact post-merge verification method used & results.
|
||||||
|
|
||||||
|
Never run cleanup before the merge is confirmed on remote `master`.
|
||||||
|
|
||||||
|
## H. Fail-closed cases
|
||||||
|
|
||||||
|
**Stop and report — take no mutating action — if:**
|
||||||
|
|
||||||
|
- No issue exists and one cannot be created.
|
||||||
|
- Worktree state is unclear or unexpected.
|
||||||
|
- Branch/PR state conflicts with the prompt (e.g. prompt says "merged" but it is not).
|
||||||
|
- A PR is closed but not merged (closed with `merged=false`). In this case:
|
||||||
|
- stop normal review/merge
|
||||||
|
- do not delete branches/worktrees
|
||||||
|
- do not start dependent work
|
||||||
|
- run reconciliation
|
||||||
|
- Local `master` is ahead of remote unexpectedly.
|
||||||
|
- The authenticated user is the PR author (for review/merge).
|
||||||
|
- Secrets/tokens appear in the diff.
|
||||||
|
- Tests fail.
|
||||||
|
- A cleanup step would delete unmerged work.
|
||||||
|
|
||||||
|
When in doubt, stop and surface the discrepancy; do not guess or work around a gate.
|
||||||
|
|
||||||
|
## I. Recovery patterns
|
||||||
|
|
||||||
|
- **Dirty worktree from another issue:** do not touch it. Start your issue in its
|
||||||
|
own new worktree; unrelated dirty work must not block you.
|
||||||
|
- **Local `master` ahead of remote unexpectedly:** do not push `master`. Confirm
|
||||||
|
the commits are preserved on a feature branch (local + remote) first, then
|
||||||
|
`git reset --hard <remote>/master` to realign. Never discard commits that are
|
||||||
|
not safely pushed elsewhere.
|
||||||
|
- **PR closed but not merged (`merged=false`):** do not merge. Run reconciliation: compare PR content to remote `master` and decide:
|
||||||
|
- **fully landed:** comment that content is present on `master`, remove `status:in-progress`, keep/close issue as appropriate, clean up only after content equivalence is confirmed.
|
||||||
|
- **partially landed:** do not clean up, reopen issue if needed, create corrective issue/PR for missing pieces.
|
||||||
|
- **not landed:** reopen issue if needed, reopen PR or create replacement PR, do not clean up source branch/worktree.
|
||||||
|
- **Branch deleted before merge:** if the commits still exist locally (a branch or
|
||||||
|
reflog), re-push them and reopen the PR; otherwise recover via
|
||||||
|
`git fsck --lost-found`. Preserve first, then proceed.
|
||||||
|
- **Unauthorized/untracked file created:** do not commit it. Leave pre-existing
|
||||||
|
untracked artifacts (e.g. editor/agent dirs, reports) alone; stage only the
|
||||||
|
files your issue names (`git add <files>`, never blind `git add -A`).
|
||||||
|
- **Preserve commits before a reset:** confirm the target commits are reachable
|
||||||
|
from a branch that is pushed to the remote, then reset. Verify with
|
||||||
|
`git branch --contains <sha>` and `git log <remote>/<branch>`.
|
||||||
|
|
||||||
|
## J. Prompt snippets
|
||||||
|
|
||||||
|
Ready-to-copy templates live in [`templates/`](templates/):
|
||||||
|
|
||||||
|
- [`start-issue.md`](templates/start-issue.md) — start a new issue.
|
||||||
|
- [`review-pr.md`](templates/review-pr.md) — review a PR.
|
||||||
|
- [`merge-pr.md`](templates/merge-pr.md) — merge a PR (eligible reviewer only).
|
||||||
|
- [`recover-bad-state.md`](templates/recover-bad-state.md) — recover from bad state.
|
||||||
|
- [`reconcile-closed-not-merged-pr.md`](templates/reconcile-closed-not-merged-pr.md) — reconcile a closed-not-merged PR.
|
||||||
|
- [`worktree-cleanup.md`](templates/worktree-cleanup.md) — clean up after merge.
|
||||||
|
- [`release-tag.md`](templates/release-tag.md) — create a release tag.
|
||||||
|
|
||||||
|
## K. Controller Handoff (required, every task)
|
||||||
|
|
||||||
|
Every LLM task **must end with a `Controller Handoff`** — whether the
|
||||||
|
task was implementation, review, merge, issue triage, documentation,
|
||||||
|
discussion-only, or blocked planning. It lets a controller LLM understand the
|
||||||
|
current state immediately, without rereading the conversation.
|
||||||
|
|
||||||
|
**The compact format is the default.** It is written for controller-LLM
|
||||||
|
readability, not as a full human status report. PR bodies still carry the
|
||||||
|
full review detail — the handoff never replaces PR documentation.
|
||||||
|
|
||||||
|
Compact format (default):
|
||||||
|
|
||||||
|
```md
|
||||||
|
## Controller Handoff
|
||||||
|
|
||||||
|
- Task:
|
||||||
|
- Repo/state:
|
||||||
|
- Issues/PRs:
|
||||||
|
- Changed:
|
||||||
|
- Validation:
|
||||||
|
- Blockers:
|
||||||
|
- Review:
|
||||||
|
- Next:
|
||||||
|
- Safety:
|
||||||
|
```
|
||||||
|
|
||||||
|
The `Safety:` line is never omitted; it is usually:
|
||||||
|
|
||||||
|
```text
|
||||||
|
no self-review; no self-merge; no tags; no secrets; no prod
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules (both formats):
|
||||||
|
|
||||||
|
- Never omit the handoff, and never omit the safety confirmations.
|
||||||
|
- Never bury blockers in earlier text only — they must appear here.
|
||||||
|
- If you opened a PR, state clearly that review is needed.
|
||||||
|
- If you reviewed but could not merge, name the exact gate that blocked it.
|
||||||
|
- If you only commented on a discussion issue, say no code review is needed
|
||||||
|
but owner/design feedback may be needed.
|
||||||
|
- If release state was touched, state exactly which tag/commit changed and why.
|
||||||
|
- If blocked (permissions, missing repo, missing second reviewer identity,
|
||||||
|
stale dependency, unclear tracker home): stop and report clearly; **never
|
||||||
|
bypass classifiers, profile gates, missing permissions, or live-consent
|
||||||
|
requirements**; give the owner concrete options.
|
||||||
|
|
||||||
|
**Use the long format below instead of the compact one only when the task was
|
||||||
|
high-risk or complex** — i.e. when any of these happened:
|
||||||
|
|
||||||
|
- a merge, tag, or release
|
||||||
|
- failed validation
|
||||||
|
- permissions/profile gates blocked work
|
||||||
|
- secrets or production access were involved
|
||||||
|
- a complicated owner decision
|
||||||
|
- multiple repos or cross-issue state
|
||||||
|
- the owner explicitly asks for the full format
|
||||||
|
|
||||||
|
Long format (high-risk/complex tasks only):
|
||||||
|
|
||||||
|
```md
|
||||||
|
## Controller Handoff Summary
|
||||||
|
|
||||||
|
### Work performed
|
||||||
|
|
||||||
|
Briefly state what was done.
|
||||||
|
|
||||||
|
### Current state
|
||||||
|
|
||||||
|
Include:
|
||||||
|
- current repo
|
||||||
|
- current branch or master commit
|
||||||
|
- issue number(s)
|
||||||
|
- PR number(s), if any
|
||||||
|
- whether work is complete, blocked, ready for review, or discussion-only
|
||||||
|
|
||||||
|
### Files changed
|
||||||
|
|
||||||
|
List files changed, or say `None`.
|
||||||
|
|
||||||
|
### Validation
|
||||||
|
|
||||||
|
List commands run and results, or say `Not applicable — discussion only`.
|
||||||
|
|
||||||
|
### Issues encountered
|
||||||
|
|
||||||
|
List errors, confusing state, permission/profile problems, stale branches,
|
||||||
|
failing tests, missing labels, or blocked decisions.
|
||||||
|
|
||||||
|
### Review needed?
|
||||||
|
|
||||||
|
Say one of:
|
||||||
|
- `No review needed — discussion/comment only`
|
||||||
|
- `Review needed — PR is open`
|
||||||
|
- `Independent non-author review needed`
|
||||||
|
- `Owner decision needed`
|
||||||
|
- `Blocked`
|
||||||
|
|
||||||
|
### Next recommended action
|
||||||
|
|
||||||
|
State exactly what should happen next.
|
||||||
|
|
||||||
|
### Safety confirmations
|
||||||
|
|
||||||
|
Confirm:
|
||||||
|
- no self-review
|
||||||
|
- no self-merge
|
||||||
|
- no release/tag changes unless explicitly requested
|
||||||
|
- no secrets committed
|
||||||
|
- no production access used unless explicitly authorized
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example blocked handoff
|
||||||
|
|
||||||
|
```md
|
||||||
|
## Example blocked handoff
|
||||||
|
|
||||||
|
### Work performed
|
||||||
|
|
||||||
|
Audited phase-2 MCP Control Plane planning. Found target repo
|
||||||
|
`mcp-control-plane` does not exist. Prepared issue pack but did not file it.
|
||||||
|
|
||||||
|
### Current state
|
||||||
|
|
||||||
|
- Repo: `Scaled-Tech-Consulting/Gitea-Tools`, unmodified
|
||||||
|
- Target repo: `mcp-control-plane`, missing
|
||||||
|
- Issues: none open in Gitea-Tools
|
||||||
|
- PRs: none open
|
||||||
|
- Status: blocked pending owner decision
|
||||||
|
|
||||||
|
### Files changed
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
### Validation
|
||||||
|
|
||||||
|
Tracker/repo audit only. No code validation required.
|
||||||
|
|
||||||
|
### Issues encountered
|
||||||
|
|
||||||
|
Repo creation was denied by permission/classifier because it would be scope
|
||||||
|
escalation without live consent.
|
||||||
|
|
||||||
|
### Review needed?
|
||||||
|
|
||||||
|
Owner decision needed.
|
||||||
|
|
||||||
|
### Next recommended action
|
||||||
|
|
||||||
|
Owner must choose:
|
||||||
|
1. create `Scaled-Tech-Consulting/mcp-control-plane`
|
||||||
|
2. authorize repo creation while present
|
||||||
|
3. file phase-2 issues in Gitea-Tools instead
|
||||||
|
|
||||||
|
### Safety confirmations
|
||||||
|
|
||||||
|
- no self-review
|
||||||
|
- no self-merge
|
||||||
|
- no release/tag changes
|
||||||
|
- no secrets committed
|
||||||
|
- no production access used
|
||||||
|
```
|
||||||
|
|
||||||
|
## Adapting to a project
|
||||||
|
|
||||||
|
Replace these project-specific names when copying the skill elsewhere:
|
||||||
|
|
||||||
|
| Placeholder | Meaning | Example here |
|
||||||
|
|-------------|---------|--------------|
|
||||||
|
| `<remote>` | Git remote for the forge | `prgs` |
|
||||||
|
| default branch | Integration branch | `master` |
|
||||||
|
| profile env vars | Canonical config + profile selectors | `GITEA_MCP_CONFIG`, `GITEA_MCP_PROFILE` |
|
||||||
|
| `branches/` | Ignored worktree directory | `branches/` |
|
||||||
|
| helper scripts | Worktree helpers | `scripts/worktree-start` / `-review` / `-clean` |
|
||||||
|
|
||||||
|
The rules in §A–§K are project-agnostic and should not change.
|
||||||
|
|
||||||
|
## Versioning And Tagging
|
||||||
|
|
||||||
|
Releases follow SemVer: **`vMAJOR.MINOR.PATCH`** (use **`v0.x.y`** while
|
||||||
|
unstable). Choose the bump by the largest change since the last tag:
|
||||||
|
|
||||||
|
- **PATCH** — bug fixes, docs, tests, wrappers, non-breaking workflow polish.
|
||||||
|
- **MINOR** — new tools/helpers/config features; backward-compatible behavior.
|
||||||
|
- **MAJOR** — breaking config/schema/API behavior or a changed MCP contract.
|
||||||
|
|
||||||
|
Tags must:
|
||||||
|
|
||||||
|
- be created **only from `master`** (the exact commit on remote `master`),
|
||||||
|
- be created **only after the full test suite passes**,
|
||||||
|
- be **annotated** tags (`git tag -a`), never lightweight,
|
||||||
|
- include release notes / a changelog summary referencing the merged PRs/issues.
|
||||||
|
|
||||||
|
**Never tag** feature branches, dirty worktrees, unreviewed or self-authored
|
||||||
|
work, or commits not present on remote `master`.
|
||||||
|
|
||||||
|
Additional tag rules:
|
||||||
|
|
||||||
|
- Do **not** create, move, delete, or push tags unless explicitly instructed.
|
||||||
|
- Tag only **after** the intended PR is merged, and tag only the **verified
|
||||||
|
final master merge commit** (never the PR branch head unless the merge
|
||||||
|
commit is exactly that commit).
|
||||||
|
- Always **report the tag target commit** in the final report / handoff.
|
||||||
|
|
||||||
|
Release process (see [`templates/release-tag.md`](templates/release-tag.md)):
|
||||||
|
|
||||||
|
1. `git fetch <remote> --prune`.
|
||||||
|
2. Verify local `master` equals remote `master` (`0 0`) and the tree is clean.
|
||||||
|
3. Run the full test suite; stop on any failure.
|
||||||
|
4. Inspect merged issues/PRs since the last tag
|
||||||
|
(`git log --oneline <last-tag>..<remote>/master`).
|
||||||
|
5. Choose the version bump.
|
||||||
|
6. Create the annotated tag on remote `master` with release notes.
|
||||||
|
7. Push the tag.
|
||||||
|
8. Create/update release notes if the forge supports it.
|
||||||
|
|
||||||
|
Where present, `scripts/release-tag` automates this with all gates built in
|
||||||
|
(SemVer, fetch/prune, on-master, clean tree, local==remote master, HEAD on
|
||||||
|
remote master, no duplicate tag, tests, annotated-only). Safe by default: no
|
||||||
|
push without `--push`; `--dry-run` changes nothing; `--skip-tests` must be
|
||||||
|
explicit and warns.
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Template: merge a PR (eligible reviewer only)
|
||||||
|
|
||||||
|
Copy, fill the `<...>` fields, and paste as the task prompt.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Task: merge PR #<pr> for issue #<n> if it is eligible and checks pass.
|
||||||
|
|
||||||
|
Rules (llm-project-workflow):
|
||||||
|
- Only an eligible, NON-author reviewer merges. If authenticated user == PR
|
||||||
|
author → STOP.
|
||||||
|
- Do not merge unless the PR is open, mergeable, and its checks/review pass.
|
||||||
|
- No force-merge, no bypassing branch protections.
|
||||||
|
- If the PR is closed but `merged=false`, STOP and run reconciliation. Do not clean up.
|
||||||
|
|
||||||
|
Steps:
|
||||||
|
1. Identity Checklist: Before claiming/working on merge, verify and state:
|
||||||
|
- Required identity/profile for this task: merger (allowed to merge PRs)
|
||||||
|
- Current authenticated identity (from whoami): <username>
|
||||||
|
- Target task role: merger identity (must NOT be the PR author)
|
||||||
|
*If the current identity does not match the required role (or is the PR author), STOP. Relaunch/switch to the correct profile first.*
|
||||||
|
2. Verify authenticated identity + active profile.
|
||||||
|
3. Confirm PR #<pr>: author (not you), state open, mergeable, review approved. Check if PR body uses `Closes #N` or `Fixes #N`; if it uses `Implements #N` or `Refs #N`, manual closing will be needed in step 29.
|
||||||
|
4. If any gate fails → STOP and report.
|
||||||
|
4. Merge with explicit confirmation (e.g. confirmation="MERGE PR <pr>"),
|
||||||
|
optionally pinning the reviewed head SHA / changed-file set.
|
||||||
|
5. Confirm remote master now contains the merge commit (or the expected changes if squash merged).
|
||||||
|
*Note: Gitea PR "closed" state is NOT equivalent to "merged". Do not assume a closed PR succeeded without verifying the actual landed changes.*
|
||||||
|
|
||||||
|
Then run the cleanup template (worktree-cleanup.md):
|
||||||
|
- Verify expected file/commit presence on master (post-merge file-presence verification):
|
||||||
|
- Run: git fetch <remote> --prune; git checkout master; git pull <remote> master --ff-only
|
||||||
|
- Verify that the expected files added/modified in the PR are present on master (or absent if deleted).
|
||||||
|
- Alternatively, verify with: git log --oneline -- <expected-file> or git merge-base --is-ancestor <pr-head-sha> master
|
||||||
|
- close/release issue #<n>, remove status:in-progress (if it cannot be removed, report why)
|
||||||
|
- delete remote branch, remove local branch + worktree folder
|
||||||
|
- fetch/prune; confirm main checkout is clean and current (0 0).
|
||||||
|
|
||||||
|
Handoff: reviewer identity, merge result + commit, cleanup done, issue closed, PR metadata state/merged flag/hash, remote master hash, post-merge verification method used & verification results.
|
||||||
|
```
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Reconcile Closed-Not-Merged PR Prompt
|
||||||
|
|
||||||
|
You are reconciling PR `<pr-number>` in `<repo-name>` which is closed but `merged=false`.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- Do not delete branches or worktrees before reconciliation is complete.
|
||||||
|
- Compare the PR's exact content to remote `<default-branch>`.
|
||||||
|
- Determine if the content is fully landed, partially landed, or not landed.
|
||||||
|
|
||||||
|
Workflow:
|
||||||
|
|
||||||
|
1. Verify the PR metadata says `state=closed` and `merged=false`.
|
||||||
|
2. Fetch/prune and inspect remote `<default-branch>`.
|
||||||
|
3. If fully landed: comment that it landed, remove `status:in-progress`, close issue, and clean up.
|
||||||
|
4. If partially landed: reopen issue if needed, create corrective PR for missing pieces, do not clean up.
|
||||||
|
5. If not landed: reopen issue/PR, do not clean up.
|
||||||
|
|
||||||
|
Final handoff:
|
||||||
|
|
||||||
|
- PR metadata (state, merged flag, hash)
|
||||||
|
- Git content verification (remote master hash, expected content present or not)
|
||||||
|
- reconciliation decision (fully/partially/not landed)
|
||||||
|
- issue/label state repaired
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Template: recover from bad state
|
||||||
|
|
||||||
|
Copy, fill the `<...>` fields, paste as the task prompt. Recovery is read-then-
|
||||||
|
act: gather facts first, never discard unmerged work.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Task: recover repo state for <situation>. Do not lose unmerged work.
|
||||||
|
|
||||||
|
Rules (llm-project-workflow):
|
||||||
|
- Fail closed: if state is unclear or a step would delete unmerged work, STOP.
|
||||||
|
- Never push master. Never discard commits not safely pushed to <remote>.
|
||||||
|
|
||||||
|
Diagnose first:
|
||||||
|
1. git fetch <remote> --prune
|
||||||
|
2. git status --short; git worktree list
|
||||||
|
3. git rev-list --left-right --count <remote>/master...master # ahead/behind
|
||||||
|
4. For any PR involved: confirm state (open/closed/merged) AND whether
|
||||||
|
<remote>/master actually contains its commits ("closed" != "merged").
|
||||||
|
|
||||||
|
Act per case:
|
||||||
|
- Dirty worktree from another issue: leave it; start yours in a new worktree.
|
||||||
|
- Local master ahead of remote: confirm the extra commits live on a branch
|
||||||
|
pushed to <remote>, THEN git reset --hard <remote>/master. Verify with
|
||||||
|
`git branch --contains <sha>` first.
|
||||||
|
- PR closed but not merged (`merged=false`): stop normal flow and use reconcile-closed-not-merged-pr.md instead.
|
||||||
|
- Branch deleted before merge: recover commits from a local branch/reflog (or
|
||||||
|
git fsck --lost-found), re-push, reopen the PR.
|
||||||
|
- Unauthorized untracked file: do not commit it; leave pre-existing artifacts.
|
||||||
|
|
||||||
|
Handoff: what was wrong, evidence, action taken, current state, what remains.
|
||||||
|
```
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# Template: cut a release tag
|
||||||
|
|
||||||
|
Copy, fill the `<...>` fields, and paste as the task prompt. Tagging is
|
||||||
|
irreversible-ish and outward-facing — fail closed on any doubt.
|
||||||
|
|
||||||
|
> If the project ships `scripts/release-tag`, prefer it — it enforces every gate
|
||||||
|
> below automatically and is safe by default (no push without `--push`,
|
||||||
|
> `--dry-run` changes nothing):
|
||||||
|
>
|
||||||
|
> ```bash
|
||||||
|
> scripts/release-tag --dry-run <vX.Y.Z>
|
||||||
|
> scripts/release-tag <vX.Y.Z> --notes-file <path>
|
||||||
|
> scripts/release-tag <vX.Y.Z> --notes-file <path> --push
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> The manual steps below are the fallback / what the script does.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Task: cut release <vX.Y.Z> from master.
|
||||||
|
|
||||||
|
Rules (llm-project-workflow — versioning & tagging):
|
||||||
|
- SemVer: vMAJOR.MINOR.PATCH (v0.x.y while unstable).
|
||||||
|
- PATCH: bug fixes, docs, tests, wrappers, non-breaking workflow polish.
|
||||||
|
- MINOR: new tools/helpers/config features, backward-compatible behavior.
|
||||||
|
- MAJOR: breaking config/schema/API or changed MCP contract.
|
||||||
|
- Tag ONLY from clean, tested remote master. Annotated tags only (git tag -a).
|
||||||
|
- NEVER tag: feature branches, dirty worktrees, unreviewed/self-authored work,
|
||||||
|
or commits not present on remote master.
|
||||||
|
|
||||||
|
Steps:
|
||||||
|
1. git fetch <remote> --prune
|
||||||
|
2. Confirm local master == <remote>/master (git rev-list --left-right --count
|
||||||
|
<remote>/master...master → 0 0) and the tree is clean.
|
||||||
|
3. Run the FULL test suite; it must pass. STOP on any failure.
|
||||||
|
4. Inspect merged issues/PRs since the last tag:
|
||||||
|
git log --oneline <last-tag>..<remote>/master
|
||||||
|
5. Choose the bump (PATCH/MINOR/MAJOR) per the rules above; set <vX.Y.Z>.
|
||||||
|
6. Create an ANNOTATED tag on <remote>/master with release notes that reference
|
||||||
|
the merged PRs/issues:
|
||||||
|
git tag -a <vX.Y.Z> <remote>/master -m "<vX.Y.Z>: <summary>
|
||||||
|
|
||||||
|
- #<n> <title> (PR #<pr>)
|
||||||
|
- ..."
|
||||||
|
7. Push the tag: git push <remote> <vX.Y.Z>
|
||||||
|
8. Create/update the release notes / changelog entry if the forge supports it.
|
||||||
|
|
||||||
|
Fail-closed: STOP if tests fail, the tree/worktree is dirty, master != remote,
|
||||||
|
the target commit is not on remote master, or the work was self-authored/
|
||||||
|
unreviewed. Never tag to "fix" a failing state.
|
||||||
|
|
||||||
|
Handoff: version, bump rationale, commit tagged, tests result, tag pushed,
|
||||||
|
release notes link.
|
||||||
|
```
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Template: review a PR
|
||||||
|
|
||||||
|
Copy, fill the `<...>` fields, and paste as the task prompt.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Task: review PR #<pr> for issue #<n>.
|
||||||
|
|
||||||
|
Rules (llm-project-workflow):
|
||||||
|
- Review in a SEPARATE detached review worktree, never the author's folder.
|
||||||
|
- You must NOT be the PR author. If the authenticated user == PR author, stop.
|
||||||
|
A different LLM-Agent-SHA does NOT make you a different actor — only a
|
||||||
|
different authenticated Gitea user does (docs/llm-agent-sha.md).
|
||||||
|
- Do not merge if any check fails.
|
||||||
|
|
||||||
|
Steps:
|
||||||
|
1. Identity Checklist: Before claiming/working on review, verify and state:
|
||||||
|
- Required identity/profile for this task: reviewer (allowed to review/approve/request_changes)
|
||||||
|
- Current authenticated identity (from whoami): <username>
|
||||||
|
- Target task role: reviewer identity (must NOT be the PR author)
|
||||||
|
*If the current identity does not match the required role (or is the PR author), STOP. Relaunch/switch to the correct profile first.*
|
||||||
|
2. Verify your authenticated identity (whoami) and the active profile.
|
||||||
|
3. Fetch the PR facts: PR author, head SHA, state (must be open), base branch.
|
||||||
|
4. If authenticated user == PR author → STOP (no self-review).
|
||||||
|
4. scripts/worktree-review <pr-head-branch> # detached, branches/review-*
|
||||||
|
cd branches/review-<pr-head-branch-slug>
|
||||||
|
5. Confirm the worktree is clean. Inspect the FULL diff; confirm scope matches
|
||||||
|
issue #<n>; flag any unrelated files, secrets, or formatting churn. Check that the PR body correctly uses Gitea-closing keywords (`Closes #N` or `Fixes #N`) instead of non-closing ones (`Implements #N`, `Refs #N`).
|
||||||
|
6. Run the test suite; note results.
|
||||||
|
7. Post the review verdict: approve only if scope is clean and checks pass;
|
||||||
|
otherwise request changes with specifics. Never merge from this review step.
|
||||||
|
Include a "Review Metadata" block (attribution only — docs/llm-agent-sha.md):
|
||||||
|
|
||||||
|
Review Metadata:
|
||||||
|
- LLM-Agent-SHA: llm-<12 lowercase hex, e.g. llm-41d0e7aa9f2c>
|
||||||
|
- LLM-Role: reviewer
|
||||||
|
- Authenticated-Gitea-User: <whoami result>
|
||||||
|
- MCP-Profile: <profile name>
|
||||||
|
- Eligibility: passed/failed
|
||||||
|
|
||||||
|
Handoff: reviewer identity, PR author, scope verdict, checks + results, decision —
|
||||||
|
formatted per SKILL.md §K (compact by default; long form if a merge happened
|
||||||
|
or a gate blocked you); if you could not merge, name the exact gate.
|
||||||
|
```
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# Template: start a new issue
|
||||||
|
|
||||||
|
Copy, fill the `<...>` fields, and paste as the task prompt.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Task: implement <issue title / one-line goal>.
|
||||||
|
|
||||||
|
Rules (llm-project-workflow):
|
||||||
|
- No repo changes without a tracking issue. If none exists, create one first;
|
||||||
|
if it can't be created, stop.
|
||||||
|
- Work only in an isolated branch worktree under branches/. The main checkout
|
||||||
|
is orchestration/status only.
|
||||||
|
- Do not self-review or self-merge.
|
||||||
|
|
||||||
|
Steps:
|
||||||
|
1. Identity Checklist: Before claiming work, verify and state:
|
||||||
|
- Required identity/profile for this task: author (allowed to push branches / create PRs)
|
||||||
|
- Current authenticated identity (from whoami): <username>
|
||||||
|
- Target task role: author/work identity
|
||||||
|
*If the current identity does not match the required role (or lacks push/PR permissions), STOP before claiming the issue. Relaunch/switch to the correct profile first.*
|
||||||
|
2. Verify the orchestration checkout is the right repo and clean.
|
||||||
|
3. git fetch <remote> --prune; confirm local master == <remote>/master (0 0).
|
||||||
|
4. Create the issue "<title>" (problem, scope, acceptance) and claim it
|
||||||
|
(status:in-progress + a "starting work" comment naming the branch).
|
||||||
|
5. scripts/worktree-start <type>/issue-<n>-<slug> # type = fix|feat|docs
|
||||||
|
cd branches/<type>-issue-<n>-<slug>
|
||||||
|
6. Implement the narrow scope only; add/update focused tests if behavior changes.
|
||||||
|
7. Checks: run the test suite, compile/lint changed files, git diff --check,
|
||||||
|
and scan the diff for secrets.
|
||||||
|
8. Commit (issue-linked message), push the branch, open a PR to master.
|
||||||
|
*The PR body MUST use closing keywords like `Closes #N` or `Fixes #N` to close the issue; do NOT use `Implements #N` or `Refs #N` for closing, as Gitea will not auto-close it.*
|
||||||
|
Include an "LLM Handoff Metadata" block in the PR body (attribution only;
|
||||||
|
never an eligibility input — docs/llm-agent-sha.md):
|
||||||
|
|
||||||
|
LLM Handoff Metadata:
|
||||||
|
- LLM-Agent-SHA: llm-<12 lowercase hex, e.g. llm-8f3a9c2d6b41>
|
||||||
|
- LLM-Role: implementer
|
||||||
|
- Authenticated-Gitea-User: <whoami result>
|
||||||
|
- MCP-Profile: <profile name>
|
||||||
|
- Branch: <branch>
|
||||||
|
- Worktree: <worktree path>
|
||||||
|
- Self-review allowed: no
|
||||||
|
9. Stop before review/merge — you are the author.
|
||||||
|
|
||||||
|
Handoff: issue #, branch, worktree path, files changed, checks + results, PR URL —
|
||||||
|
formatted as the compact Controller Handoff (SKILL.md §K; long form only on
|
||||||
|
the high-risk triggers); Review line: "Review needed — PR is open".
|
||||||
|
```
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Template: clean up after merge
|
||||||
|
|
||||||
|
Copy, fill the `<...>` fields, and paste as the task prompt. Only run AFTER a
|
||||||
|
real merge is confirmed on remote master.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Task: clean up branch/worktree for PR #<pr> / issue #<n> after merge.
|
||||||
|
|
||||||
|
Rules (llm-project-workflow):
|
||||||
|
- Do NOT clean up until the merge is confirmed on <remote>/master.
|
||||||
|
- Cleanup would-delete-unmerged-work → STOP. Never --force-remove a dirty tree.
|
||||||
|
|
||||||
|
Steps:
|
||||||
|
1. git fetch <remote> --prune
|
||||||
|
2. Confirm <remote>/master contains the merge of PR #<pr>
|
||||||
|
(git log <remote>/master | grep the merge, or git branch -r --contains <sha>).
|
||||||
|
If not merged → STOP; run the recovery template instead.
|
||||||
|
3. Close issue #<n> if not auto-closed; remove the status:in-progress label.
|
||||||
|
4. scripts/worktree-clean --delete-branch <type>/issue-<n>-<slug>
|
||||||
|
(removes branches/<type>-issue-<n>-<slug>; refuses if dirty; git branch -d is
|
||||||
|
safe-delete only — fails on unmerged.)
|
||||||
|
5. Delete the remote branch if the merge did not already remove it.
|
||||||
|
6. From the main checkout: git fetch <remote> --prune; git checkout master;
|
||||||
|
git reset --hard <remote>/master ONLY if local master safely matches remote.
|
||||||
|
7. Confirm main checkout clean and current (git status; 0 0 vs <remote>/master).
|
||||||
|
|
||||||
|
Handoff: merge confirmed, issue closed, branch+worktree removed, checkout clean.
|
||||||
|
```
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# Opt-in Gitea Integration Tests (#66)
|
||||||
|
|
||||||
|
Real-Gitea integration tests for the shared API client
|
||||||
|
(`gitea_auth.api_request` / `api_get_all`). **Skipped by default** — the unit
|
||||||
|
suite (`pytest tests/ -q`) stays fast, mocked, and network-free.
|
||||||
|
|
||||||
|
## What they prove
|
||||||
|
|
||||||
|
Against a real, disposable Gitea instance:
|
||||||
|
|
||||||
|
- issue listing + pagination (multi-page walk, overall `limit`)
|
||||||
|
- PR listing through the same paginated client
|
||||||
|
- targeted label add/remove (one label removed, others untouched)
|
||||||
|
- permission denial fails closed (bad token → clear `401` error, token never echoed)
|
||||||
|
- real Gitea error payloads surface as safe, redacted `RuntimeError`s
|
||||||
|
|
||||||
|
## Environment variables
|
||||||
|
|
||||||
|
| Variable | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `GITEA_INTEGRATION=1` | opt-in switch — the suite is skipped without it |
|
||||||
|
| `GITEA_INTEGRATION_URL` | base URL (default `http://localhost:3003`) |
|
||||||
|
| `GITEA_INTEGRATION_TOKEN` | API token for the **local test** instance |
|
||||||
|
|
||||||
|
Never point these at a production Gitea and never use production tokens. The
|
||||||
|
token is used only in the `Authorization` header; tests assert it does not
|
||||||
|
appear in any error output.
|
||||||
|
|
||||||
|
## Quick start (Docker)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tests/integration/gitea-integration up
|
||||||
|
export GITEA_INTEGRATION_TOKEN="$(tests/integration/gitea-integration token)"
|
||||||
|
GITEA_INTEGRATION=1 ./venv/bin/python -m pytest tests/integration/ -q
|
||||||
|
tests/integration/gitea-integration down
|
||||||
|
```
|
||||||
|
|
||||||
|
- Image is **pinned** (`gitea/gitea:1.22.6` in `docker-compose.yml`); bump the
|
||||||
|
pin deliberately, never `:latest`.
|
||||||
|
- `up` waits until `/api/v1/version` answers (60s timeout).
|
||||||
|
- `token` idempotently creates a TEST-ONLY admin (`inttest-admin`) inside the
|
||||||
|
container and prints a fresh API token to stdout — nothing is written to disk.
|
||||||
|
- `down` removes the container **and its volume** (full teardown).
|
||||||
|
|
||||||
|
An existing local Gitea works too: set `GITEA_INTEGRATION_URL` and a token for
|
||||||
|
any account allowed to create/delete its own repos.
|
||||||
|
|
||||||
|
## Seed data and cleanup
|
||||||
|
|
||||||
|
- Each session creates one uniquely-named repo (`inttest-<8 hex>`), seeds
|
||||||
|
issues/labels/one PR inside it, and deletes the repo on teardown
|
||||||
|
(best-effort; a leaked repo is disposable and obvious).
|
||||||
|
- Nothing outside the seed repo is touched; the suite never mutates
|
||||||
|
pre-existing repos, users, or instance settings.
|
||||||
|
|
||||||
|
## Relationship to the unit suite
|
||||||
|
|
||||||
|
Unit tests remain the default and must stay mocked. Add an integration test
|
||||||
|
only for behavior that genuinely depends on a real server (pagination
|
||||||
|
metadata, permission semantics, live error payloads).
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""Fixtures for the opt-in Docker/local Gitea integration suite (#66).
|
||||||
|
|
||||||
|
Everything here is inert unless GITEA_INTEGRATION=1 — the test modules carry
|
||||||
|
a module-level skipif, so a default ``pytest tests/ -q`` run never touches the
|
||||||
|
network and never needs Docker.
|
||||||
|
|
||||||
|
Required environment (see tests/integration/README.md):
|
||||||
|
- GITEA_INTEGRATION=1 opt-in switch
|
||||||
|
- GITEA_INTEGRATION_URL base URL (default http://localhost:3003)
|
||||||
|
- GITEA_INTEGRATION_TOKEN API token for the *local test* instance
|
||||||
|
|
||||||
|
The token is a throwaway credential for the disposable container. It is never
|
||||||
|
printed, logged, or asserted on. Production credentials must never be used.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
|
||||||
|
|
||||||
|
from gitea_auth import api_request # noqa: E402
|
||||||
|
|
||||||
|
ENABLED = os.environ.get("GITEA_INTEGRATION") == "1"
|
||||||
|
|
||||||
|
|
||||||
|
def _base_url():
|
||||||
|
return os.environ.get("GITEA_INTEGRATION_URL", "http://localhost:3003").rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def gitea():
|
||||||
|
"""Session facts: base URL, auth header string, authenticated login."""
|
||||||
|
token = os.environ.get("GITEA_INTEGRATION_TOKEN")
|
||||||
|
if not token:
|
||||||
|
pytest.fail(
|
||||||
|
"GITEA_INTEGRATION=1 but GITEA_INTEGRATION_TOKEN is unset; "
|
||||||
|
"run tests/integration/gitea-integration token"
|
||||||
|
)
|
||||||
|
base = _base_url()
|
||||||
|
auth = f"token {token}"
|
||||||
|
me = api_request("GET", f"{base}/api/v1/user", auth)
|
||||||
|
return {"base": base, "auth": auth, "login": me["login"]}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def seed_repo(gitea):
|
||||||
|
"""Create a disposable, uniquely-named repo; delete it on teardown."""
|
||||||
|
name = f"inttest-{uuid.uuid4().hex[:8]}"
|
||||||
|
repo = api_request(
|
||||||
|
"POST", f"{gitea['base']}/api/v1/user/repos", gitea["auth"],
|
||||||
|
payload={"name": name, "auto_init": True,
|
||||||
|
"description": "gitea-tools #66 integration seed (disposable)"},
|
||||||
|
)
|
||||||
|
owner = repo["owner"]["login"]
|
||||||
|
yield {"owner": owner, "name": name,
|
||||||
|
"api": f"{gitea['base']}/api/v1/repos/{owner}/{name}"}
|
||||||
|
# Teardown: best-effort delete; a leaked repo is visible and disposable.
|
||||||
|
try:
|
||||||
|
api_request("DELETE", f"{gitea['base']}/api/v1/repos/{owner}/{name}",
|
||||||
|
gitea["auth"])
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Disposable Gitea for the opt-in integration suite (#66).
|
||||||
|
# Pinned image for reproducibility — bump deliberately, never use :latest.
|
||||||
|
# Usage: tests/integration/gitea-integration up|token|down
|
||||||
|
services:
|
||||||
|
gitea:
|
||||||
|
image: gitea/gitea:1.22.6
|
||||||
|
container_name: gitea-tools-integration
|
||||||
|
environment:
|
||||||
|
- GITEA__security__INSTALL_LOCK=true
|
||||||
|
- GITEA__server__ROOT_URL=http://localhost:3003/
|
||||||
|
- GITEA__server__HTTP_PORT=3000
|
||||||
|
- GITEA__service__DISABLE_REGISTRATION=true
|
||||||
|
- GITEA__log__LEVEL=Warn
|
||||||
|
ports:
|
||||||
|
- "3003:3000"
|
||||||
|
volumes:
|
||||||
|
- gitea-integration-data:/data
|
||||||
|
volumes:
|
||||||
|
gitea-integration-data:
|
||||||
Executable
+59
@@ -0,0 +1,59 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# gitea-integration — manage the disposable Gitea container for the opt-in
|
||||||
|
# integration suite (#66). See tests/integration/README.md.
|
||||||
|
#
|
||||||
|
# tests/integration/gitea-integration up start pinned Gitea, wait ready
|
||||||
|
# tests/integration/gitea-integration token create test admin + print token
|
||||||
|
# tests/integration/gitea-integration down stop container, delete volume
|
||||||
|
#
|
||||||
|
# The admin credentials below are TEST-ONLY, for the throwaway local container
|
||||||
|
# started by this script. They are not secrets and must never be reused for a
|
||||||
|
# real instance. The generated API token is printed ONCE to stdout (for
|
||||||
|
# `export GITEA_INTEGRATION_TOKEN=$(...)`) and is never written to any file.
|
||||||
|
|
||||||
|
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
compose() { docker compose -f "$here/docker-compose.yml" "$@"; }
|
||||||
|
|
||||||
|
base_url="${GITEA_INTEGRATION_URL:-http://localhost:3003}"
|
||||||
|
admin_user="inttest-admin"
|
||||||
|
admin_pass="inttest-local-only-pass" # TEST-ONLY, disposable container
|
||||||
|
admin_email="inttest-admin@example.invalid"
|
||||||
|
|
||||||
|
case "${1:-}" in
|
||||||
|
up)
|
||||||
|
compose up -d
|
||||||
|
printf 'gitea-integration: waiting for %s ' "$base_url"
|
||||||
|
for _ in $(seq 1 60); do
|
||||||
|
if curl -fsS "$base_url/api/v1/version" >/dev/null 2>&1; then
|
||||||
|
printf '\ngitea-integration: ready\n'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
printf '.'
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
printf '\ngitea-integration: Gitea did not become ready in 60s\n' >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
token)
|
||||||
|
# Idempotent admin creation (ignore "already exists").
|
||||||
|
compose exec -T -u git gitea gitea admin user create \
|
||||||
|
--username "$admin_user" --password "$admin_pass" \
|
||||||
|
--email "$admin_email" --admin --must-change-password=false \
|
||||||
|
>/dev/null 2>&1 || true
|
||||||
|
# Unique token name per call; Gitea prints the token as the last field.
|
||||||
|
tok_name="inttest-$(date +%s)"
|
||||||
|
out="$(compose exec -T -u git gitea gitea admin user generate-access-token \
|
||||||
|
--username "$admin_user" --scopes all --token-name "$tok_name")"
|
||||||
|
printf '%s\n' "$out" | sed -n 's/.*successfully created[:!]* *//p' | tr -d '[:space:]'
|
||||||
|
printf '\n'
|
||||||
|
;;
|
||||||
|
down)
|
||||||
|
compose down -v
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
printf 'usage: tests/integration/gitea-integration up|token|down\n' >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
"""Opt-in integration tests against a real (containerized) Gitea (#66).
|
||||||
|
|
||||||
|
Skipped by default. Enable with GITEA_INTEGRATION=1 plus a local instance and
|
||||||
|
token — see tests/integration/README.md. These prove real Gitea behavior that
|
||||||
|
the mocked unit suite can only simulate: pagination, targeted label edits,
|
||||||
|
permission fail-closed, and error payload surfacing — all through the shared
|
||||||
|
client (``gitea_auth.api_request`` / ``api_get_all``, #65/#67 shape).
|
||||||
|
|
||||||
|
No production credentials; tokens never appear in output or assertions.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
|
||||||
|
|
||||||
|
from gitea_auth import api_request, api_get_all # noqa: E402
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.skipif(
|
||||||
|
os.environ.get("GITEA_INTEGRATION") != "1",
|
||||||
|
reason="integration tests are opt-in: set GITEA_INTEGRATION=1 (see tests/integration/README.md)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- issue listing + pagination --------------------------------------------
|
||||||
|
|
||||||
|
N_ISSUES = 7 # > page_size below, forcing a real multi-page walk
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def issues(gitea, seed_repo):
|
||||||
|
created = []
|
||||||
|
for i in range(N_ISSUES):
|
||||||
|
created.append(api_request(
|
||||||
|
"POST", f"{seed_repo['api']}/issues", gitea["auth"],
|
||||||
|
payload={"title": f"pagination seed {i}"}))
|
||||||
|
return created
|
||||||
|
|
||||||
|
|
||||||
|
def test_issue_pagination_walks_all_pages(gitea, seed_repo, issues):
|
||||||
|
got = api_get_all(f"{seed_repo['api']}/issues?state=open", gitea["auth"],
|
||||||
|
page_size=3)
|
||||||
|
titles = {i["title"] for i in got}
|
||||||
|
assert {f"pagination seed {i}" for i in range(N_ISSUES)} <= titles
|
||||||
|
assert len(got) >= N_ISSUES
|
||||||
|
|
||||||
|
|
||||||
|
def test_issue_pagination_honors_overall_limit(gitea, seed_repo, issues):
|
||||||
|
got = api_get_all(f"{seed_repo['api']}/issues?state=open", gitea["auth"],
|
||||||
|
page_size=3, limit=4)
|
||||||
|
assert len(got) == 4
|
||||||
|
|
||||||
|
|
||||||
|
# --- PR listing --------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_pr_listing_via_shared_client(gitea, seed_repo, issues):
|
||||||
|
# Create a branch (via the contents API) and a real PR, then list.
|
||||||
|
api_request(
|
||||||
|
"POST", f"{seed_repo['api']}/contents/inttest.txt", gitea["auth"],
|
||||||
|
payload={"content": "aW50ZWdyYXRpb24gc2VlZAo=", # "integration seed"
|
||||||
|
"message": "seed PR branch", "new_branch": "inttest-pr"})
|
||||||
|
pr = api_request(
|
||||||
|
"POST", f"{seed_repo['api']}/pulls", gitea["auth"],
|
||||||
|
payload={"title": "integration seed PR", "head": "inttest-pr",
|
||||||
|
"base": "main"})
|
||||||
|
got = api_get_all(f"{seed_repo['api']}/pulls?state=open", gitea["auth"],
|
||||||
|
page_size=1)
|
||||||
|
assert any(p["number"] == pr["number"] for p in got)
|
||||||
|
|
||||||
|
|
||||||
|
# --- targeted label add/remove ----------------------------------------------
|
||||||
|
|
||||||
|
def test_targeted_label_add_and_remove(gitea, seed_repo, issues):
|
||||||
|
labels = {}
|
||||||
|
for name, color in (("keep-me", "#00ff00"), ("drop-me", "#ff0000")):
|
||||||
|
labels[name] = api_request(
|
||||||
|
"POST", f"{seed_repo['api']}/labels", gitea["auth"],
|
||||||
|
payload={"name": name, "color": color})
|
||||||
|
issue_no = issues[0]["number"]
|
||||||
|
issue_labels_url = f"{seed_repo['api']}/issues/{issue_no}/labels"
|
||||||
|
api_request("POST", issue_labels_url, gitea["auth"],
|
||||||
|
payload={"labels": [labels["keep-me"]["id"],
|
||||||
|
labels["drop-me"]["id"]]})
|
||||||
|
# Targeted removal of one label must not disturb the other.
|
||||||
|
api_request("DELETE", f"{issue_labels_url}/{labels['drop-me']['id']}",
|
||||||
|
gitea["auth"])
|
||||||
|
remaining = {l["name"] for l in api_request("GET", issue_labels_url,
|
||||||
|
gitea["auth"])}
|
||||||
|
assert "keep-me" in remaining
|
||||||
|
assert "drop-me" not in remaining
|
||||||
|
|
||||||
|
|
||||||
|
# --- permission denial / fail-closed -----------------------------------------
|
||||||
|
|
||||||
|
def test_bad_token_fails_closed_without_leaking_it(gitea):
|
||||||
|
bogus = "token 0123456789abcdef0123456789abcdef01234567"
|
||||||
|
with pytest.raises(RuntimeError) as exc:
|
||||||
|
api_request("GET", f"{gitea['base']}/api/v1/user", bogus)
|
||||||
|
msg = str(exc.value)
|
||||||
|
assert "401" in msg
|
||||||
|
assert "0123456789abcdef" not in msg # credential never echoed
|
||||||
|
|
||||||
|
|
||||||
|
def test_real_error_payload_surfaces_safely(gitea, seed_repo):
|
||||||
|
# A genuinely missing resource: Gitea's real 404 error payload must become
|
||||||
|
# a clear RuntimeError, not a stack trace, and must not echo the token.
|
||||||
|
with pytest.raises(RuntimeError) as exc:
|
||||||
|
api_request("GET", f"{seed_repo['api']}/issues/999999", gitea["auth"])
|
||||||
|
msg = str(exc.value)
|
||||||
|
assert "404" in msg
|
||||||
|
token = os.environ.get("GITEA_INTEGRATION_TOKEN", "")
|
||||||
|
if token:
|
||||||
|
assert token not in msg
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
"""Unit coverage for shared API pagination and failure handling (#67).
|
||||||
|
|
||||||
|
Covers gitea_auth.api_request failure conversion (timeouts, DNS/network,
|
||||||
|
502/503, malformed error payloads, malformed success JSON, no-secret leakage,
|
||||||
|
preserved success + 429 behavior) and gitea_auth.api_get_all pagination
|
||||||
|
(single/multi page, missing/malformed metadata, limit cap, max_pages, query
|
||||||
|
handling). Everything is mocked — no real network calls are made.
|
||||||
|
"""
|
||||||
|
import io
|
||||||
|
import unittest
|
||||||
|
import urllib.error
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import gitea_auth
|
||||||
|
import gitea_audit
|
||||||
|
|
||||||
|
FAKE_AUTH = "Basic ZmFrZTpmYWtl" # not a real credential
|
||||||
|
URL = "https://gitea.example.com/api/v1/repos/o/r/issues"
|
||||||
|
|
||||||
|
|
||||||
|
class FakeResp:
|
||||||
|
"""Minimal context-manager stand-in for a urlopen response."""
|
||||||
|
|
||||||
|
def __init__(self, body):
|
||||||
|
self._body = body.encode("utf-8") if isinstance(body, str) else body
|
||||||
|
|
||||||
|
def read(self):
|
||||||
|
return self._body
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *exc):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def http_error(code, body="", headers=None):
|
||||||
|
return urllib.error.HTTPError(
|
||||||
|
url=URL, code=code, msg="err",
|
||||||
|
hdrs=headers or {}, fp=io.BytesIO(body.encode("utf-8")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# api_request — success path preserved
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
class TestApiRequestSuccess(unittest.TestCase):
|
||||||
|
|
||||||
|
@patch("gitea_auth.urllib.request.urlopen")
|
||||||
|
def test_success_returns_parsed_json(self, mock_open):
|
||||||
|
mock_open.return_value = FakeResp('{"number": 1, "title": "ok"}')
|
||||||
|
self.assertEqual(
|
||||||
|
gitea_auth.api_request("GET", URL, FAKE_AUTH),
|
||||||
|
{"number": 1, "title": "ok"},
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch("gitea_auth.urllib.request.urlopen")
|
||||||
|
def test_empty_body_returns_none(self, mock_open):
|
||||||
|
mock_open.return_value = FakeResp("")
|
||||||
|
self.assertIsNone(gitea_auth.api_request("GET", URL, FAKE_AUTH))
|
||||||
|
|
||||||
|
@patch("gitea_auth.urllib.request.urlopen")
|
||||||
|
def test_429_then_success_still_retries(self, mock_open):
|
||||||
|
mock_open.side_effect = [http_error(429), FakeResp('{"ok": true}')]
|
||||||
|
calls = []
|
||||||
|
result = gitea_auth.api_request(
|
||||||
|
"GET", URL, FAKE_AUTH,
|
||||||
|
sleep_func=lambda d: calls.append(d), rand_func=lambda: 0.0,
|
||||||
|
)
|
||||||
|
self.assertEqual(result, {"ok": True})
|
||||||
|
self.assertEqual(len(calls), 1) # slept once between the two attempts
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# api_request — failure handling
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
class TestApiRequestFailures(unittest.TestCase):
|
||||||
|
|
||||||
|
@patch("gitea_auth.urllib.request.urlopen")
|
||||||
|
def test_timeout_converted_to_runtimeerror(self, mock_open):
|
||||||
|
mock_open.side_effect = TimeoutError("timed out")
|
||||||
|
with self.assertRaises(RuntimeError) as ctx:
|
||||||
|
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
||||||
|
self.assertIn("network error contacting Gitea", str(ctx.exception))
|
||||||
|
|
||||||
|
@patch("gitea_auth.urllib.request.urlopen")
|
||||||
|
def test_dns_network_failure_converted(self, mock_open):
|
||||||
|
mock_open.side_effect = urllib.error.URLError("Name or service not known")
|
||||||
|
with self.assertRaises(RuntimeError) as ctx:
|
||||||
|
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
||||||
|
self.assertIn("network error contacting Gitea", str(ctx.exception))
|
||||||
|
|
||||||
|
@patch("gitea_auth.urllib.request.urlopen")
|
||||||
|
def test_502_upstream_message(self, mock_open):
|
||||||
|
mock_open.side_effect = http_error(502, "bad gateway")
|
||||||
|
with self.assertRaises(RuntimeError) as ctx:
|
||||||
|
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
||||||
|
msg = str(ctx.exception)
|
||||||
|
self.assertIn("HTTP 502", msg)
|
||||||
|
self.assertIn("upstream unavailable", msg)
|
||||||
|
|
||||||
|
@patch("gitea_auth.urllib.request.urlopen")
|
||||||
|
def test_503_upstream_message(self, mock_open):
|
||||||
|
mock_open.side_effect = http_error(503, "")
|
||||||
|
with self.assertRaises(RuntimeError) as ctx:
|
||||||
|
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
||||||
|
self.assertIn("HTTP 503", str(ctx.exception))
|
||||||
|
self.assertIn("upstream unavailable", str(ctx.exception))
|
||||||
|
|
||||||
|
@patch("gitea_auth.urllib.request.urlopen")
|
||||||
|
def test_malformed_error_payload_does_not_crash(self, mock_open):
|
||||||
|
# Non-JSON garbage error body must still yield a clean RuntimeError.
|
||||||
|
mock_open.side_effect = http_error(500, "<html>garbage</html>")
|
||||||
|
with self.assertRaises(RuntimeError) as ctx:
|
||||||
|
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
||||||
|
self.assertIn("HTTP 500", str(ctx.exception))
|
||||||
|
|
||||||
|
@patch("gitea_auth.urllib.request.urlopen")
|
||||||
|
def test_malformed_success_json_raises_clean_error(self, mock_open):
|
||||||
|
mock_open.return_value = FakeResp("not json{")
|
||||||
|
with self.assertRaises(RuntimeError) as ctx:
|
||||||
|
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
||||||
|
self.assertIn("malformed JSON response", str(ctx.exception))
|
||||||
|
|
||||||
|
@patch("gitea_auth.urllib.request.urlopen")
|
||||||
|
def test_no_secret_leak_in_error_body(self, mock_open):
|
||||||
|
mock_open.side_effect = http_error(
|
||||||
|
400, "failed: token supersecret123 rejected")
|
||||||
|
with self.assertRaises(RuntimeError) as ctx:
|
||||||
|
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
||||||
|
msg = str(ctx.exception)
|
||||||
|
self.assertNotIn("supersecret123", msg)
|
||||||
|
self.assertIn(gitea_audit.REDACTED, msg)
|
||||||
|
|
||||||
|
@patch("gitea_auth.urllib.request.urlopen")
|
||||||
|
def test_auth_header_never_in_error(self, mock_open):
|
||||||
|
mock_open.side_effect = http_error(400, "bad request")
|
||||||
|
with self.assertRaises(RuntimeError) as ctx:
|
||||||
|
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
||||||
|
self.assertNotIn(FAKE_AUTH, str(ctx.exception))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# api_get_all — pagination
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
class TestApiGetAll(unittest.TestCase):
|
||||||
|
|
||||||
|
@patch("gitea_auth.api_request")
|
||||||
|
def test_single_page(self, mock_req):
|
||||||
|
mock_req.return_value = [{"id": 1}, {"id": 2}] # short page (< page_size)
|
||||||
|
result = gitea_auth.api_get_all(URL, FAKE_AUTH, page_size=50)
|
||||||
|
self.assertEqual(result, [{"id": 1}, {"id": 2}])
|
||||||
|
self.assertEqual(mock_req.call_count, 1)
|
||||||
|
|
||||||
|
@patch("gitea_auth.api_request")
|
||||||
|
def test_multi_page(self, mock_req):
|
||||||
|
mock_req.side_effect = [
|
||||||
|
[{"id": 1}, {"id": 2}], # full page
|
||||||
|
[{"id": 3}, {"id": 4}], # full page
|
||||||
|
[{"id": 5}], # short page -> stop
|
||||||
|
]
|
||||||
|
result = gitea_auth.api_get_all(URL, FAKE_AUTH, page_size=2)
|
||||||
|
self.assertEqual([r["id"] for r in result], [1, 2, 3, 4, 5])
|
||||||
|
self.assertEqual(mock_req.call_count, 3)
|
||||||
|
|
||||||
|
@patch("gitea_auth.api_request")
|
||||||
|
def test_missing_metadata_none_page_ends(self, mock_req):
|
||||||
|
mock_req.return_value = None # empty/malformed metadata -> treated as end
|
||||||
|
self.assertEqual(gitea_auth.api_get_all(URL, FAKE_AUTH), [])
|
||||||
|
self.assertEqual(mock_req.call_count, 1)
|
||||||
|
|
||||||
|
@patch("gitea_auth.api_request")
|
||||||
|
def test_malformed_metadata_non_list_raises(self, mock_req):
|
||||||
|
mock_req.return_value = {"message": "not a list"}
|
||||||
|
with self.assertRaises(RuntimeError) as ctx:
|
||||||
|
gitea_auth.api_get_all(URL, FAKE_AUTH)
|
||||||
|
self.assertIn("expected a list page", str(ctx.exception))
|
||||||
|
|
||||||
|
@patch("gitea_auth.api_request")
|
||||||
|
def test_limit_caps_results(self, mock_req):
|
||||||
|
mock_req.side_effect = [[{"id": 1}, {"id": 2}], [{"id": 3}, {"id": 4}]]
|
||||||
|
result = gitea_auth.api_get_all(URL, FAKE_AUTH, page_size=2, limit=3)
|
||||||
|
self.assertEqual([r["id"] for r in result], [1, 2, 3])
|
||||||
|
|
||||||
|
@patch("gitea_auth.api_request")
|
||||||
|
def test_max_pages_safety_cap(self, mock_req):
|
||||||
|
mock_req.side_effect = [
|
||||||
|
[{"id": 1}, {"id": 2}], [{"id": 3}, {"id": 4}], [{"id": 5}, {"id": 6}],
|
||||||
|
]
|
||||||
|
result = gitea_auth.api_get_all(URL, FAKE_AUTH, page_size=2, max_pages=2)
|
||||||
|
self.assertEqual(len(result), 4)
|
||||||
|
self.assertEqual(mock_req.call_count, 2)
|
||||||
|
|
||||||
|
@patch("gitea_auth.api_request")
|
||||||
|
def test_query_params_appended_and_preserved(self, mock_req):
|
||||||
|
mock_req.return_value = [] # first (empty) page ends immediately
|
||||||
|
gitea_auth.api_get_all(URL + "?state=open", FAKE_AUTH, page_size=2)
|
||||||
|
called_url = mock_req.call_args[0][1]
|
||||||
|
self.assertIn("state=open", called_url)
|
||||||
|
self.assertIn("page=1", called_url)
|
||||||
|
self.assertIn("limit=2", called_url)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""Tests for scripts/clear-provenance (#3).
|
||||||
|
|
||||||
|
Exercises argument handling and the inert --dry-run path only — no real xattr
|
||||||
|
mutation, no network. (Actually removing com.apple.provenance is macOS-only and
|
||||||
|
has real side effects, so it is not exercised here.)
|
||||||
|
"""
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO = Path(__file__).resolve().parent.parent
|
||||||
|
SCRIPT = REPO / "scripts" / "clear-provenance"
|
||||||
|
|
||||||
|
|
||||||
|
def run(*args):
|
||||||
|
proc = subprocess.run(["bash", str(SCRIPT), *args],
|
||||||
|
capture_output=True, text=True, cwd=str(REPO))
|
||||||
|
return proc.returncode, proc.stdout, proc.stderr
|
||||||
|
|
||||||
|
|
||||||
|
class TestClearProvenance(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_dry_run_defaults_to_repo_root(self):
|
||||||
|
rc, out, _ = run("--dry-run")
|
||||||
|
self.assertEqual(rc, 0)
|
||||||
|
self.assertIn("would run: xattr -r -d com.apple.provenance", out)
|
||||||
|
self.assertIn(str(REPO), out)
|
||||||
|
|
||||||
|
def test_dry_run_explicit_path(self):
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
f = Path(d) / "x.py"
|
||||||
|
f.write_text("print('hi')\n")
|
||||||
|
rc, out, _ = run("--dry-run", str(f))
|
||||||
|
self.assertEqual(rc, 0)
|
||||||
|
self.assertIn(str(f), out)
|
||||||
|
|
||||||
|
def test_missing_path_errors(self):
|
||||||
|
rc, _, err = run("--dry-run", "/no/such/path-xyz")
|
||||||
|
self.assertEqual(rc, 1)
|
||||||
|
self.assertIn("no such path", err)
|
||||||
|
|
||||||
|
def test_bad_flag_exit_2(self):
|
||||||
|
rc, _, _ = run("--bogus")
|
||||||
|
self.assertEqual(rc, 2)
|
||||||
|
|
||||||
|
def test_too_many_args_exit_2(self):
|
||||||
|
rc, _, _ = run("a", "b")
|
||||||
|
self.assertEqual(rc, 2)
|
||||||
|
|
||||||
|
def test_only_targets_provenance_attribute(self):
|
||||||
|
# The command removes only com.apple.provenance, not all xattrs.
|
||||||
|
rc, out, _ = run("--dry-run")
|
||||||
|
self.assertIn("com.apple.provenance", out)
|
||||||
|
self.assertNotIn("xattr -rc", out) # not a blanket "clear all"
|
||||||
|
self.assertNotIn("-c ", out)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
+11
-6
@@ -127,11 +127,14 @@ class TestLoadSelect(_ConfigBase):
|
|||||||
gitea_config.resolve_profile()
|
gitea_config.resolve_profile()
|
||||||
self.assertIn("version", str(ctx.exception))
|
self.assertIn("version", str(ctx.exception))
|
||||||
|
|
||||||
def test_missing_version_defaults_ok(self):
|
def test_missing_version_fails_closed(self):
|
||||||
|
# Changed by #103: an unversioned config is ambiguous between the v1
|
||||||
|
# and v2 shapes, so the loader now refuses to guess.
|
||||||
self._write({"profiles": {"prgs": {"base_url": "https://x"}}})
|
self._write({"profiles": {"prgs": {"base_url": "https://x"}}})
|
||||||
with patch.dict(os.environ, self._env("prgs"), clear=True):
|
with patch.dict(os.environ, self._env("prgs"), clear=True):
|
||||||
self.assertEqual(
|
with self.assertRaises(gitea_config.ConfigError) as ctx:
|
||||||
gitea_config.resolve_profile()["base_url"], "https://x")
|
gitea_config.resolve_profile()
|
||||||
|
self.assertIn("version", str(ctx.exception))
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -281,11 +284,13 @@ class TestAuthIntegration(_ConfigBase):
|
|||||||
self.assertEqual(header, "token process-token")
|
self.assertEqual(header, "token process-token")
|
||||||
|
|
||||||
def test_auth_header_unresolvable_ref_fails_closed(self):
|
def test_auth_header_unresolvable_ref_fails_closed(self):
|
||||||
# env token ref points at an unset var -> ConfigError inside resolve is
|
# env token ref points at an unset var -> with GITEA_MCP_CONFIG set the
|
||||||
# swallowed to "no token"; auth falls through to (mocked-empty) basic.
|
# ConfigError propagates (fail closed, #120): no silent fallback to
|
||||||
|
# Basic auth or another credential source.
|
||||||
with patch.dict(os.environ, self._env("mdcps-env"), clear=True):
|
with patch.dict(os.environ, self._env("mdcps-env"), clear=True):
|
||||||
with patch("gitea_auth.get_credentials", return_value=("", "")):
|
with patch("gitea_auth.get_credentials", return_value=("", "")):
|
||||||
self.assertIsNone(gitea_auth.get_auth_header("gitea.example.com"))
|
with self.assertRaises(gitea_config.ConfigError):
|
||||||
|
gitea_auth.get_auth_header("gitea.example.com")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,367 @@
|
|||||||
|
"""Tests for profiles.json version 2 (#103): environment → service → identity.
|
||||||
|
|
||||||
|
Covers: v2 loading + flattening, dotted-path and alias resolution with strict
|
||||||
|
order (exact alias → exact address → fail closed), legacy v1 names via aliases,
|
||||||
|
fail-closed validation (missing/unknown version, malformed hierarchy, ambiguous
|
||||||
|
selectors, TBD-* usernames, reviewer-identity deadlock rule, inline secrets,
|
||||||
|
missing auth, unnormalizable operations), service-default inheritance, and that
|
||||||
|
flattened v2 profiles still work with resolve_token. No network, no secrets.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import copy
|
||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
import gitea_config # noqa: E402
|
||||||
|
|
||||||
|
FAKE_TOKEN = "fake-token-for-tests" # not a real credential
|
||||||
|
|
||||||
|
|
||||||
|
def v2_config():
|
||||||
|
"""A fresh, valid v2 config exercising both environments."""
|
||||||
|
return {
|
||||||
|
"version": 2,
|
||||||
|
"environments": {
|
||||||
|
"prgs": {
|
||||||
|
"services": {
|
||||||
|
"gitea": {
|
||||||
|
"base_url": "https://gitea.prgs.cc",
|
||||||
|
"default_owner": "Scaled-Tech-Consulting",
|
||||||
|
"identities": {
|
||||||
|
"author": {
|
||||||
|
"role": "author",
|
||||||
|
"username": "jcwalker3",
|
||||||
|
"auth": {"type": "keychain",
|
||||||
|
"id": "prgs.gitea.author.token"},
|
||||||
|
"execution_profile": "prgs-author",
|
||||||
|
"audit_label": "prgs-author",
|
||||||
|
"allowed_operations": [
|
||||||
|
"gitea.read", "gitea.issue.create",
|
||||||
|
"gitea.branch.push", "gitea.pr.create",
|
||||||
|
],
|
||||||
|
"forbidden_operations": [
|
||||||
|
"gitea.pr.approve", "gitea.pr.merge",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"reviewer": {
|
||||||
|
"role": "reviewer",
|
||||||
|
"username": "sysadmin",
|
||||||
|
"auth": {"type": "env",
|
||||||
|
"name": "PRGS_REVIEWER_TOKEN"},
|
||||||
|
"execution_profile": "prgs-reviewer",
|
||||||
|
"audit_label": "prgs-reviewer",
|
||||||
|
"default_repo": "Gitea-Tools",
|
||||||
|
"allowed_operations": [
|
||||||
|
"read", "review", "comment", "approve",
|
||||||
|
"request_changes", "merge",
|
||||||
|
],
|
||||||
|
"forbidden_operations": [
|
||||||
|
"gitea.pr.create", "gitea.branch.push",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"mdcps": {
|
||||||
|
"services": {
|
||||||
|
"gitea": {
|
||||||
|
"base_url": "https://gitea.dadeschools.net",
|
||||||
|
"identities": {
|
||||||
|
"author": {
|
||||||
|
"role": "author",
|
||||||
|
"username": "913443",
|
||||||
|
"auth": {"type": "keychain",
|
||||||
|
"id": "mdcps.gitea.author.token"},
|
||||||
|
"allowed_operations": ["gitea.read"],
|
||||||
|
"forbidden_operations": [
|
||||||
|
"gitea.pr.approve", "gitea.pr.merge",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"reviewer": {
|
||||||
|
"role": "reviewer",
|
||||||
|
"username": "TBD-second-mdcps-user",
|
||||||
|
"auth": {"type": "keychain",
|
||||||
|
"id": "mdcps.gitea.reviewer.token"},
|
||||||
|
"allowed_operations": [
|
||||||
|
"gitea.read", "gitea.pr.approve",
|
||||||
|
"gitea.pr.merge",
|
||||||
|
],
|
||||||
|
"forbidden_operations": [
|
||||||
|
"gitea.pr.create", "gitea.branch.push",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"jenkins": {
|
||||||
|
"base_url": "https://jenkins.dadeschools.net",
|
||||||
|
"identities": {
|
||||||
|
"reader": {
|
||||||
|
"role": "reader",
|
||||||
|
"username": "svc-jenkins-read",
|
||||||
|
"auth": {"type": "keychain",
|
||||||
|
"id": "mdcps.jenkins.reader.token"},
|
||||||
|
"allowed_operations": ["read", "jenkins.build.read"],
|
||||||
|
"forbidden_operations": ["jenkins.build.trigger"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"mdcps": "mdcps.gitea.author",
|
||||||
|
"prgs-author": "prgs.gitea.author",
|
||||||
|
"prgs-reviewer": "prgs.gitea.reviewer",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class _V2Base(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self._dir = tempfile.TemporaryDirectory()
|
||||||
|
self.path = os.path.join(self._dir.name, "profiles.json")
|
||||||
|
self._write(v2_config())
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self._dir.cleanup()
|
||||||
|
|
||||||
|
def _write(self, obj):
|
||||||
|
with open(self.path, "w", encoding="utf-8") as fh:
|
||||||
|
fh.write(obj if isinstance(obj, str) else json.dumps(obj))
|
||||||
|
|
||||||
|
def _env(self, profile, **extra):
|
||||||
|
env = {"GITEA_MCP_CONFIG": self.path, "GITEA_MCP_PROFILE": profile}
|
||||||
|
env.update(extra)
|
||||||
|
return env
|
||||||
|
|
||||||
|
def _resolve(self, profile):
|
||||||
|
with patch.dict(os.environ, self._env(profile), clear=True):
|
||||||
|
return gitea_config.resolve_profile()
|
||||||
|
|
||||||
|
def _load_raises(self, mutate, needle):
|
||||||
|
cfg = v2_config()
|
||||||
|
mutate(cfg)
|
||||||
|
self._write(cfg)
|
||||||
|
with patch.dict(os.environ, self._env("prgs.gitea.author"), clear=True):
|
||||||
|
with self.assertRaises(gitea_config.ConfigError) as ctx:
|
||||||
|
gitea_config.resolve_profile()
|
||||||
|
self.assertIn(needle, str(ctx.exception))
|
||||||
|
return str(ctx.exception)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Happy path: loading, dotted paths, aliases, inheritance
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
class TestV2Loads(_V2Base):
|
||||||
|
|
||||||
|
def test_dotted_path_resolution(self):
|
||||||
|
p = self._resolve("prgs.gitea.author")
|
||||||
|
self.assertEqual(p["base_url"], "https://gitea.prgs.cc")
|
||||||
|
self.assertEqual(p["username"], "jcwalker3")
|
||||||
|
self.assertEqual(p["profile_path"], "prgs.gitea.author")
|
||||||
|
self.assertEqual(p["environment"], "prgs")
|
||||||
|
self.assertEqual(p["service"], "gitea")
|
||||||
|
self.assertEqual(p["identity"], "author")
|
||||||
|
self.assertEqual(p["role"], "author")
|
||||||
|
|
||||||
|
def test_alias_resolution_legacy_names(self):
|
||||||
|
for legacy, addr in (
|
||||||
|
("mdcps", "mdcps.gitea.author"),
|
||||||
|
("prgs-author", "prgs.gitea.author"),
|
||||||
|
("prgs-reviewer", "prgs.gitea.reviewer"),
|
||||||
|
):
|
||||||
|
p = self._resolve(legacy)
|
||||||
|
self.assertEqual(p["profile_path"], addr, legacy)
|
||||||
|
|
||||||
|
def test_service_defaults_inherit_and_identity_overrides(self):
|
||||||
|
author = self._resolve("prgs.gitea.author")
|
||||||
|
self.assertEqual(author["default_owner"], "Scaled-Tech-Consulting")
|
||||||
|
self.assertNotIn("default_repo", author)
|
||||||
|
reviewer = self._resolve("prgs.gitea.reviewer")
|
||||||
|
self.assertEqual(reviewer["default_owner"], "Scaled-Tech-Consulting")
|
||||||
|
self.assertEqual(reviewer["default_repo"], "Gitea-Tools")
|
||||||
|
|
||||||
|
def test_unqualified_ops_normalized_minimally(self):
|
||||||
|
reviewer = self._resolve("prgs.gitea.reviewer")
|
||||||
|
self.assertIn("gitea.pr.merge", reviewer["allowed_operations"])
|
||||||
|
self.assertIn("gitea.read", reviewer["allowed_operations"])
|
||||||
|
self.assertNotIn("merge", reviewer["allowed_operations"])
|
||||||
|
jenkins = self._resolve("mdcps.jenkins.reader")
|
||||||
|
self.assertIn("jenkins.read", jenkins["allowed_operations"])
|
||||||
|
self.assertIn("jenkins.build.read", jenkins["allowed_operations"])
|
||||||
|
|
||||||
|
def test_resolve_token_works_on_flattened_profile(self):
|
||||||
|
with patch.dict(
|
||||||
|
os.environ,
|
||||||
|
self._env("prgs.gitea.reviewer", PRGS_REVIEWER_TOKEN=FAKE_TOKEN),
|
||||||
|
clear=True,
|
||||||
|
):
|
||||||
|
profile = gitea_config.resolve_profile()
|
||||||
|
self.assertEqual(gitea_config.resolve_token(profile), FAKE_TOKEN)
|
||||||
|
|
||||||
|
def test_auth_source_name_on_flattened_profile(self):
|
||||||
|
p = self._resolve("mdcps.gitea.author")
|
||||||
|
self.assertEqual(
|
||||||
|
gitea_config.auth_source_name(p), "keychain:mdcps.gitea.author.token"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_v1_config_still_loads(self):
|
||||||
|
self._write({
|
||||||
|
"version": 1,
|
||||||
|
"profiles": {"prgs": {
|
||||||
|
"base_url": "https://gitea.prgs.cc",
|
||||||
|
"auth": {"type": "keychain", "id": "prgs-gitea-token"},
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
p = self._resolve("prgs")
|
||||||
|
self.assertEqual(p["base_url"], "https://gitea.prgs.cc")
|
||||||
|
|
||||||
|
def test_validate_config_accepts_valid_v2(self):
|
||||||
|
self.assertEqual(gitea_config.validate_config(v2_config()), [])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fail-closed: selectors
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
class TestV2Selectors(_V2Base):
|
||||||
|
|
||||||
|
def test_unknown_selector_fails_closed(self):
|
||||||
|
with self.assertRaises(gitea_config.ConfigError) as ctx:
|
||||||
|
self._resolve("prgs.gitea") # partial address — no fuzzy matching
|
||||||
|
self.assertIn("not found", str(ctx.exception))
|
||||||
|
|
||||||
|
def test_no_fuzzy_matching_on_near_miss(self):
|
||||||
|
with self.assertRaises(gitea_config.ConfigError):
|
||||||
|
self._resolve("prgs-reviewers")
|
||||||
|
|
||||||
|
def test_conflicting_alias_and_address_fails_closed(self):
|
||||||
|
def mutate(cfg):
|
||||||
|
cfg["aliases"]["prgs.gitea.author"] = "prgs.gitea.reviewer"
|
||||||
|
self._load_raises(mutate, "conflicting selector")
|
||||||
|
|
||||||
|
def test_alias_to_unknown_target_fails_closed(self):
|
||||||
|
def mutate(cfg):
|
||||||
|
cfg["aliases"]["ghost"] = "prgs.gitea.nope"
|
||||||
|
self._load_raises(mutate, "unknown profile")
|
||||||
|
|
||||||
|
def test_tbd_username_fails_closed_on_selection(self):
|
||||||
|
with self.assertRaises(gitea_config.ConfigError) as ctx:
|
||||||
|
self._resolve("mdcps.gitea.reviewer")
|
||||||
|
msg = str(ctx.exception)
|
||||||
|
self.assertIn("TBD", msg)
|
||||||
|
self.assertIn("provision", msg)
|
||||||
|
|
||||||
|
def test_tbd_identity_does_not_block_other_identities(self):
|
||||||
|
# Same file contains the TBD reviewer; author still resolves.
|
||||||
|
p = self._resolve("mdcps.gitea.author")
|
||||||
|
self.assertEqual(p["username"], "913443")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fail-closed: structure and versions
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
class TestV2Structure(_V2Base):
|
||||||
|
|
||||||
|
def test_missing_version_fails_closed(self):
|
||||||
|
def mutate(cfg):
|
||||||
|
del cfg["version"]
|
||||||
|
self._load_raises(mutate, "version")
|
||||||
|
|
||||||
|
def test_unknown_version_fails_closed(self):
|
||||||
|
def mutate(cfg):
|
||||||
|
cfg["version"] = 3
|
||||||
|
self._load_raises(mutate, "unsupported version")
|
||||||
|
|
||||||
|
def test_missing_environments_fails_closed(self):
|
||||||
|
def mutate(cfg):
|
||||||
|
del cfg["environments"]
|
||||||
|
self._load_raises(mutate, "environments")
|
||||||
|
|
||||||
|
def test_malformed_environment_fails_closed(self):
|
||||||
|
def mutate(cfg):
|
||||||
|
cfg["environments"]["prgs"] = "not-an-object"
|
||||||
|
self._load_raises(mutate, "must be a JSON object")
|
||||||
|
|
||||||
|
def test_missing_services_fails_closed(self):
|
||||||
|
def mutate(cfg):
|
||||||
|
cfg["environments"]["prgs"]["services"] = {}
|
||||||
|
self._load_raises(mutate, "services")
|
||||||
|
|
||||||
|
def test_missing_identities_fails_closed(self):
|
||||||
|
def mutate(cfg):
|
||||||
|
cfg["environments"]["prgs"]["services"]["gitea"]["identities"] = {}
|
||||||
|
self._load_raises(mutate, "identities")
|
||||||
|
|
||||||
|
def test_dotted_segment_name_fails_closed(self):
|
||||||
|
def mutate(cfg):
|
||||||
|
envs = cfg["environments"]
|
||||||
|
envs["bad.env"] = copy.deepcopy(envs["prgs"])
|
||||||
|
self._load_raises(mutate, "invalid environment name")
|
||||||
|
|
||||||
|
def test_missing_base_url_fails_closed(self):
|
||||||
|
def mutate(cfg):
|
||||||
|
svc = cfg["environments"]["prgs"]["services"]["gitea"]
|
||||||
|
del svc["base_url"]
|
||||||
|
self._load_raises(mutate, "base_url")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fail-closed: identity invariants
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
class TestV2IdentityInvariants(_V2Base):
|
||||||
|
|
||||||
|
def _ident(self, cfg, addr="prgs.gitea.author"):
|
||||||
|
env, svc, ident = addr.split(".")
|
||||||
|
return cfg["environments"][env]["services"][svc]["identities"][ident]
|
||||||
|
|
||||||
|
def test_missing_auth_fails_closed(self):
|
||||||
|
def mutate(cfg):
|
||||||
|
del self._ident(cfg)["auth"]
|
||||||
|
self._load_raises(mutate, "missing an 'auth' reference")
|
||||||
|
|
||||||
|
def test_inline_secret_in_identity_rejected(self):
|
||||||
|
def mutate(cfg):
|
||||||
|
self._ident(cfg)["token"] = "oops-not-a-real-secret"
|
||||||
|
msg = self._load_raises(mutate, "inline 'token'")
|
||||||
|
self.assertNotIn("oops-not-a-real-secret", msg)
|
||||||
|
|
||||||
|
def test_inline_secret_in_auth_rejected(self):
|
||||||
|
def mutate(cfg):
|
||||||
|
self._ident(cfg)["auth"]["password"] = "oops-not-a-real-secret"
|
||||||
|
msg = self._load_raises(mutate, "inline 'password'")
|
||||||
|
self.assertNotIn("oops-not-a-real-secret", msg)
|
||||||
|
|
||||||
|
def test_reviewer_deadlock_invariant_enforced(self):
|
||||||
|
def mutate(cfg):
|
||||||
|
reviewer = self._ident(cfg, "prgs.gitea.reviewer")
|
||||||
|
reviewer["forbidden_operations"] = [] # can approve/merge AND create
|
||||||
|
msg = self._load_raises(mutate, "deadlock")
|
||||||
|
self.assertIn("gitea.pr.create", msg)
|
||||||
|
|
||||||
|
def test_reviewer_deadlock_applies_to_unqualified_merge(self):
|
||||||
|
def mutate(cfg):
|
||||||
|
author = self._ident(cfg)
|
||||||
|
author["allowed_operations"] = ["merge"] # normalized to gitea.pr.merge
|
||||||
|
author["forbidden_operations"] = []
|
||||||
|
self._load_raises(mutate, "deadlock")
|
||||||
|
|
||||||
|
def test_unnormalizable_operation_fails_closed(self):
|
||||||
|
def mutate(cfg):
|
||||||
|
self._ident(cfg)["allowed_operations"] = ["frobnicate"]
|
||||||
|
self._load_raises(mutate, "cannot be normalized")
|
||||||
|
|
||||||
|
def test_foreign_namespace_operation_fails_closed(self):
|
||||||
|
def mutate(cfg):
|
||||||
|
reader = self._ident(cfg, "mdcps.jenkins.reader")
|
||||||
|
reader["allowed_operations"] = ["gitea.pr.merge"]
|
||||||
|
self._load_raises(mutate, "cannot be normalized")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,446 @@
|
|||||||
|
"""Tests for profiles.json version 2 *contexts* shape (#120).
|
||||||
|
|
||||||
|
The canonical machine config uses ``contexts`` / ``profiles`` / ``projects`` /
|
||||||
|
``rules`` with explicit ``enabled`` flags. Covers: loading + active-profile
|
||||||
|
resolution via GITEA_MCP_PROFILE, fail-closed refusal of disabled profiles /
|
||||||
|
contexts / services / projects, project-to-context mapping, base-URL fallback
|
||||||
|
from the context's gitea block, keychain-only auth references, LLM-safe audit
|
||||||
|
output (no endpoint URLs, no keychain ids, no tokens) with an explicit
|
||||||
|
admin/debug opt-in, v1 compatibility, and the no-silent-fallback rule in
|
||||||
|
gitea_auth.get_auth_header. No network, no real secrets.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
import gitea_config # noqa: E402
|
||||||
|
import gitea_auth # noqa: E402
|
||||||
|
|
||||||
|
FAKE_TOKEN = "fake-token-for-tests" # not a real credential
|
||||||
|
|
||||||
|
|
||||||
|
def contexts_config():
|
||||||
|
"""A fresh, valid v2 contexts-shape config with enabled/disabled entries."""
|
||||||
|
return {
|
||||||
|
"version": 2,
|
||||||
|
"contexts": {
|
||||||
|
"prgs": {
|
||||||
|
"enabled": True,
|
||||||
|
"label": "Local / PRGS",
|
||||||
|
"default_owner": "Scaled-Tech-Consulting",
|
||||||
|
"gitea": {
|
||||||
|
"enabled": True,
|
||||||
|
"kind": "gitea",
|
||||||
|
"base_url": "https://gitea.prgs.cc",
|
||||||
|
},
|
||||||
|
"services": {
|
||||||
|
"jenkins": {
|
||||||
|
"enabled": True,
|
||||||
|
"kind": "jenkins",
|
||||||
|
"label": "PRGS Jenkins",
|
||||||
|
"base_url": "https://jenkins.prgs.cc",
|
||||||
|
"auth": {"type": "keychain", "id": "prgs-jenkins-token"},
|
||||||
|
"capabilities": ["read"],
|
||||||
|
},
|
||||||
|
"sentry": {
|
||||||
|
"enabled": False,
|
||||||
|
"kind": "sentry",
|
||||||
|
"label": "PRGS Sentry",
|
||||||
|
"base_url": "",
|
||||||
|
"auth": {"type": "keychain", "id": "prgs-sentry-token"},
|
||||||
|
"capabilities": ["read"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"lab": {
|
||||||
|
"enabled": False,
|
||||||
|
"gitea": {"enabled": False, "kind": "gitea", "base_url": ""},
|
||||||
|
"services": {
|
||||||
|
"jenkins": {
|
||||||
|
"enabled": False,
|
||||||
|
"kind": "jenkins",
|
||||||
|
"label": "Lab Jenkins",
|
||||||
|
"base_url": "http://localhost:8080",
|
||||||
|
"auth": {"type": "keychain", "id": "lab-jenkins-token"},
|
||||||
|
"capabilities": ["read"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"profiles": {
|
||||||
|
"prgs-author": {
|
||||||
|
"enabled": True,
|
||||||
|
"context": "prgs",
|
||||||
|
"role": "author",
|
||||||
|
"username": "jcwalker3",
|
||||||
|
"execution_profile": "prgs-author",
|
||||||
|
"audit_label": "prgs-author",
|
||||||
|
"base_url": "https://gitea.prgs.cc",
|
||||||
|
"auth": {"type": "keychain", "id": "prgs-gitea-author-token"},
|
||||||
|
"allowed_operations": [
|
||||||
|
"read", "branch", "commit", "push", "open_pr", "comment",
|
||||||
|
],
|
||||||
|
"forbidden_operations": [
|
||||||
|
"approve", "request_changes", "merge",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"prgs-reviewer": {
|
||||||
|
"enabled": True,
|
||||||
|
"context": "prgs",
|
||||||
|
"role": "reviewer",
|
||||||
|
"username": "sysadmin",
|
||||||
|
"execution_profile": "prgs-reviewer",
|
||||||
|
"audit_label": "prgs-reviewer",
|
||||||
|
# no base_url on purpose: must fall back to context gitea
|
||||||
|
"auth": {"type": "keychain", "id": "prgs-gitea-reviewer-token"},
|
||||||
|
"allowed_operations": [
|
||||||
|
"read", "review", "comment", "approve",
|
||||||
|
"request_changes", "merge",
|
||||||
|
],
|
||||||
|
"forbidden_operations": [
|
||||||
|
"branch", "commit", "push", "open_pr",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"retired-author": {
|
||||||
|
"enabled": False,
|
||||||
|
"context": "prgs",
|
||||||
|
"role": "author",
|
||||||
|
"username": "jcwalker3",
|
||||||
|
"base_url": "https://gitea.prgs.cc",
|
||||||
|
"auth": {"type": "keychain", "id": "retired-token-ref"},
|
||||||
|
"allowed_operations": ["read"],
|
||||||
|
"forbidden_operations": [],
|
||||||
|
},
|
||||||
|
"lab-author": {
|
||||||
|
"enabled": True,
|
||||||
|
"context": "lab",
|
||||||
|
"role": "author",
|
||||||
|
"username": "jcwalker3",
|
||||||
|
"base_url": "http://localhost:3000",
|
||||||
|
"auth": {"type": "keychain", "id": "lab-gitea-author-token"},
|
||||||
|
"allowed_operations": ["read"],
|
||||||
|
"forbidden_operations": [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"projects": {
|
||||||
|
"/repo/one": {
|
||||||
|
"enabled": True,
|
||||||
|
"context": "prgs",
|
||||||
|
"default_owner": "Scaled-Tech-Consulting",
|
||||||
|
"default_repo": "One",
|
||||||
|
"default_author_profile": "prgs-author",
|
||||||
|
"default_reviewer_profile": "prgs-reviewer",
|
||||||
|
},
|
||||||
|
"/repo/lab": {
|
||||||
|
"enabled": False,
|
||||||
|
"context": "lab",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"rules": {
|
||||||
|
"disabled_behavior": "report in audits, never act",
|
||||||
|
"no_silent_fallback": True,
|
||||||
|
"tokens_in_json": False,
|
||||||
|
"token_storage": "keychain",
|
||||||
|
"hide_service_urls_from_llm": True,
|
||||||
|
"hide_keychain_ids_from_llm": True,
|
||||||
|
"mcp_resolves_endpoints": True,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def write_config(data):
|
||||||
|
"""Write *data* to a temp JSON file and return its path."""
|
||||||
|
fd, path = tempfile.mkstemp(suffix=".json")
|
||||||
|
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
||||||
|
json.dump(data, fh)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def load(data):
|
||||||
|
"""Load *data* through gitea_config via a temp file, then clean up."""
|
||||||
|
path = write_config(data)
|
||||||
|
try:
|
||||||
|
return gitea_config.load_config(path)
|
||||||
|
finally:
|
||||||
|
os.unlink(path)
|
||||||
|
|
||||||
|
|
||||||
|
class LoadContextsShapeTests(unittest.TestCase):
|
||||||
|
def test_contexts_shape_loads(self):
|
||||||
|
config = load(contexts_config())
|
||||||
|
self.assertEqual(config["version"], 2)
|
||||||
|
self.assertIn("prgs-author", config["profiles"])
|
||||||
|
self.assertIn("prgs-reviewer", config["profiles"])
|
||||||
|
|
||||||
|
def test_active_profile_resolved_from_env(self):
|
||||||
|
path = write_config(contexts_config())
|
||||||
|
try:
|
||||||
|
with patch.dict(os.environ, {
|
||||||
|
gitea_config.ENV_CONFIG_PATH: path,
|
||||||
|
gitea_config.ENV_PROFILE: "prgs-author",
|
||||||
|
}):
|
||||||
|
profile = gitea_config.resolve_profile()
|
||||||
|
finally:
|
||||||
|
os.unlink(path)
|
||||||
|
self.assertEqual(profile["username"], "jcwalker3")
|
||||||
|
self.assertEqual(profile["base_url"], "https://gitea.prgs.cc")
|
||||||
|
self.assertEqual(profile["context"], "prgs")
|
||||||
|
|
||||||
|
def test_base_url_falls_back_to_context_gitea(self):
|
||||||
|
profile = gitea_config.select_profile(load(contexts_config()),
|
||||||
|
"prgs-reviewer")
|
||||||
|
self.assertEqual(profile["base_url"], "https://gitea.prgs.cc")
|
||||||
|
|
||||||
|
def test_profile_without_any_base_url_is_refused(self):
|
||||||
|
data = contexts_config()
|
||||||
|
del data["profiles"]["prgs-author"]["base_url"]
|
||||||
|
data["contexts"]["prgs"]["gitea"]["enabled"] = False
|
||||||
|
config = load(data)
|
||||||
|
with self.assertRaises(gitea_config.ConfigError):
|
||||||
|
gitea_config.select_profile(config, "prgs-author")
|
||||||
|
|
||||||
|
def test_v1_config_still_loads(self):
|
||||||
|
config = load({
|
||||||
|
"version": 1,
|
||||||
|
"profiles": {
|
||||||
|
"prgs": {
|
||||||
|
"base_url": "https://gitea.prgs.cc",
|
||||||
|
"auth": {"type": "keychain", "id": "prgs-gitea-token"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
profile = gitea_config.select_profile(config, "prgs")
|
||||||
|
self.assertEqual(profile["base_url"], "https://gitea.prgs.cc")
|
||||||
|
|
||||||
|
def test_mixed_contexts_and_environments_rejected(self):
|
||||||
|
data = contexts_config()
|
||||||
|
data["environments"] = {"x": {"services": {}}}
|
||||||
|
with self.assertRaises(gitea_config.ConfigError):
|
||||||
|
load(data)
|
||||||
|
|
||||||
|
def test_missing_enabled_flag_is_refused(self):
|
||||||
|
data = contexts_config()
|
||||||
|
del data["profiles"]["prgs-author"]["enabled"]
|
||||||
|
with self.assertRaises(gitea_config.ConfigError) as ctx:
|
||||||
|
load(data)
|
||||||
|
self.assertIn("enabled", str(ctx.exception))
|
||||||
|
|
||||||
|
|
||||||
|
class DisabledRefusalTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.config = load(contexts_config())
|
||||||
|
|
||||||
|
def test_disabled_profile_refused(self):
|
||||||
|
with self.assertRaises(gitea_config.ConfigError) as ctx:
|
||||||
|
gitea_config.select_profile(self.config, "retired-author")
|
||||||
|
self.assertIn("disabled", str(ctx.exception))
|
||||||
|
|
||||||
|
def test_profile_in_disabled_context_refused(self):
|
||||||
|
with self.assertRaises(gitea_config.ConfigError) as ctx:
|
||||||
|
gitea_config.select_profile(self.config, "lab-author")
|
||||||
|
self.assertIn("disabled", str(ctx.exception))
|
||||||
|
|
||||||
|
def test_enabled_profile_still_selectable(self):
|
||||||
|
profile = gitea_config.select_profile(self.config, "prgs-author")
|
||||||
|
self.assertEqual(profile["context"], "prgs")
|
||||||
|
|
||||||
|
def test_disabled_service_refused(self):
|
||||||
|
with self.assertRaises(gitea_config.ConfigError) as ctx:
|
||||||
|
gitea_config.resolve_service(self.config, "prgs", "sentry")
|
||||||
|
self.assertIn("disabled", str(ctx.exception))
|
||||||
|
|
||||||
|
def test_enabled_service_resolves_internally_with_auth_reference(self):
|
||||||
|
# Internal resolution keeps the URL + auth reference for MCP's own use;
|
||||||
|
# they must never appear in LLM-facing (audit/summary) output.
|
||||||
|
service = gitea_config.resolve_service(self.config, "prgs", "jenkins")
|
||||||
|
self.assertEqual(service["base_url"], "https://jenkins.prgs.cc")
|
||||||
|
self.assertEqual(service["auth"], {"type": "keychain",
|
||||||
|
"id": "prgs-jenkins-token"})
|
||||||
|
self.assertNotIn("token", service)
|
||||||
|
|
||||||
|
def test_service_in_disabled_context_refused(self):
|
||||||
|
with self.assertRaises(gitea_config.ConfigError) as ctx:
|
||||||
|
gitea_config.resolve_service(self.config, "lab", "jenkins")
|
||||||
|
self.assertIn("disabled", str(ctx.exception))
|
||||||
|
|
||||||
|
def test_unknown_service_fails_closed(self):
|
||||||
|
with self.assertRaises(gitea_config.ConfigError):
|
||||||
|
gitea_config.resolve_service(self.config, "prgs", "nope")
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectMappingTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.config = load(contexts_config())
|
||||||
|
|
||||||
|
def test_project_maps_to_context(self):
|
||||||
|
project = gitea_config.project_for_path(self.config, "/repo/one")
|
||||||
|
self.assertEqual(project["context"], "prgs")
|
||||||
|
self.assertEqual(project["default_reviewer_profile"], "prgs-reviewer")
|
||||||
|
|
||||||
|
def test_unknown_project_returns_none(self):
|
||||||
|
self.assertIsNone(
|
||||||
|
gitea_config.project_for_path(self.config, "/repo/unknown"))
|
||||||
|
|
||||||
|
def test_disabled_project_refused(self):
|
||||||
|
with self.assertRaises(gitea_config.ConfigError) as ctx:
|
||||||
|
gitea_config.project_for_path(self.config, "/repo/lab")
|
||||||
|
self.assertIn("disabled", str(ctx.exception))
|
||||||
|
|
||||||
|
|
||||||
|
class SecretHandlingTests(unittest.TestCase):
|
||||||
|
def test_inline_profile_token_rejected(self):
|
||||||
|
data = contexts_config()
|
||||||
|
data["profiles"]["prgs-author"]["token"] = FAKE_TOKEN
|
||||||
|
with self.assertRaises(gitea_config.ConfigError) as ctx:
|
||||||
|
load(data)
|
||||||
|
self.assertNotIn(FAKE_TOKEN, str(ctx.exception))
|
||||||
|
|
||||||
|
def test_inline_service_token_rejected(self):
|
||||||
|
data = contexts_config()
|
||||||
|
data["contexts"]["prgs"]["services"]["jenkins"]["token"] = FAKE_TOKEN
|
||||||
|
with self.assertRaises(gitea_config.ConfigError) as ctx:
|
||||||
|
load(data)
|
||||||
|
self.assertNotIn(FAKE_TOKEN, str(ctx.exception))
|
||||||
|
|
||||||
|
def test_selected_profile_resolves_token_via_keychain(self):
|
||||||
|
profile = gitea_config.select_profile(load(contexts_config()),
|
||||||
|
"prgs-author")
|
||||||
|
token = gitea_config.resolve_token(
|
||||||
|
profile, keychain_lookup=lambda item_id: FAKE_TOKEN
|
||||||
|
if item_id == "prgs-gitea-author-token" else None)
|
||||||
|
self.assertEqual(token, FAKE_TOKEN)
|
||||||
|
|
||||||
|
|
||||||
|
class AuditTests(unittest.TestCase):
|
||||||
|
"""LLM-facing audit output: enabled/disabled state only — no endpoint
|
||||||
|
URLs, no keychain ids, no token values. Admin opt-in reveals endpoints
|
||||||
|
and auth source names (never token values)."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.config = load(contexts_config())
|
||||||
|
|
||||||
|
def test_audit_reports_enabled_and_disabled(self):
|
||||||
|
report = gitea_config.audit_config(self.config)
|
||||||
|
profiles = {p["name"]: p for p in report["profiles"]}
|
||||||
|
self.assertTrue(profiles["prgs-author"]["enabled"])
|
||||||
|
self.assertFalse(profiles["retired-author"]["enabled"])
|
||||||
|
services = {(s["context"], s["name"]): s for s in report["services"]}
|
||||||
|
self.assertTrue(services[("prgs", "jenkins")]["enabled"])
|
||||||
|
self.assertFalse(services[("prgs", "sentry")]["enabled"])
|
||||||
|
self.assertFalse(services[("lab", "jenkins")]["enabled"])
|
||||||
|
|
||||||
|
def test_audit_hides_urls_keychain_ids_and_tokens_by_default(self):
|
||||||
|
rendered = json.dumps(gitea_config.audit_config(self.config))
|
||||||
|
for leaked in ("https://", "http://", "prgs-gitea-author-token",
|
||||||
|
"prgs-jenkins-token", "base_url", FAKE_TOKEN):
|
||||||
|
self.assertNotIn(leaked, rendered)
|
||||||
|
# Auth is reported as a status, not a reference.
|
||||||
|
report = gitea_config.audit_config(self.config)
|
||||||
|
profiles = {p["name"]: p for p in report["profiles"]}
|
||||||
|
self.assertEqual(profiles["prgs-author"]["auth"], "keychain")
|
||||||
|
|
||||||
|
def test_audit_admin_optin_reveals_endpoints_but_never_tokens(self):
|
||||||
|
report = gitea_config.audit_config(self.config, reveal_endpoints=True)
|
||||||
|
rendered = json.dumps(report)
|
||||||
|
self.assertIn("https://jenkins.prgs.cc", rendered)
|
||||||
|
self.assertIn("keychain:prgs-gitea-author-token", rendered)
|
||||||
|
self.assertNotIn(FAKE_TOKEN, rendered)
|
||||||
|
|
||||||
|
def test_audit_works_for_v1_config(self):
|
||||||
|
report = gitea_config.audit_config({
|
||||||
|
"version": 1,
|
||||||
|
"profiles": {
|
||||||
|
"prgs": {
|
||||||
|
"base_url": "https://gitea.prgs.cc",
|
||||||
|
"auth": {"type": "keychain", "id": "prgs-gitea-token"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
profiles = {p["name"]: p for p in report["profiles"]}
|
||||||
|
self.assertTrue(profiles["prgs"]["enabled"])
|
||||||
|
self.assertEqual(profiles["prgs"]["auth"], "keychain")
|
||||||
|
self.assertNotIn("https://", json.dumps(report))
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceSummaryTests(unittest.TestCase):
|
||||||
|
"""Safe one-line summaries for LLM sessions: label + state only."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.config = load(contexts_config())
|
||||||
|
|
||||||
|
def test_summaries_show_state_without_urls_or_ids(self):
|
||||||
|
lines = gitea_config.service_summaries(
|
||||||
|
self.config, auth_check=lambda service: True)
|
||||||
|
text = "\n".join(lines)
|
||||||
|
self.assertIn("PRGS Jenkins: enabled, read-only, authenticated", text)
|
||||||
|
self.assertIn("PRGS Sentry: disabled", text)
|
||||||
|
self.assertIn("Lab Jenkins: disabled", text)
|
||||||
|
for leaked in ("https://", "http://", "keychain",
|
||||||
|
"prgs-jenkins-token"):
|
||||||
|
self.assertNotIn(leaked, text)
|
||||||
|
|
||||||
|
def test_summary_reports_missing_auth_without_secrets(self):
|
||||||
|
lines = gitea_config.service_summaries(
|
||||||
|
self.config, auth_check=lambda service: False)
|
||||||
|
text = "\n".join(lines)
|
||||||
|
self.assertIn("PRGS Jenkins: enabled, read-only, no credential", text)
|
||||||
|
|
||||||
|
|
||||||
|
class NoSilentFallbackTests(unittest.TestCase):
|
||||||
|
def test_broken_config_fails_auth_instead_of_falling_back(self):
|
||||||
|
"""With GITEA_MCP_CONFIG set but unloadable, auth must fail closed."""
|
||||||
|
path = write_config({"version": 2}) # invalid: no contexts/environments
|
||||||
|
env = {
|
||||||
|
gitea_config.ENV_CONFIG_PATH: path,
|
||||||
|
gitea_config.ENV_PROFILE: "prgs-author",
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
with patch.dict(os.environ, env, clear=False), \
|
||||||
|
patch.object(gitea_auth, "get_credentials",
|
||||||
|
return_value=(None, None)):
|
||||||
|
for var in ("GITEA_TOKEN", "GITEA_TOKEN_PRGS",
|
||||||
|
"GITEA_TOKEN_DADESCHOOLS"):
|
||||||
|
os.environ.pop(var, None)
|
||||||
|
with self.assertRaises(gitea_config.ConfigError):
|
||||||
|
gitea_auth.get_auth_header("https://gitea.prgs.cc")
|
||||||
|
finally:
|
||||||
|
os.unlink(path)
|
||||||
|
|
||||||
|
def test_env_only_users_unaffected(self):
|
||||||
|
"""Without GITEA_MCP_CONFIG, a missing token still degrades quietly."""
|
||||||
|
env = dict(os.environ)
|
||||||
|
env.pop(gitea_config.ENV_CONFIG_PATH, None)
|
||||||
|
with patch.dict(os.environ, env, clear=True), \
|
||||||
|
patch.object(gitea_auth, "get_credentials",
|
||||||
|
return_value=(None, None)):
|
||||||
|
for var in ("GITEA_TOKEN", "GITEA_TOKEN_PRGS",
|
||||||
|
"GITEA_TOKEN_DADESCHOOLS"):
|
||||||
|
os.environ.pop(var, None)
|
||||||
|
self.assertIsNone(
|
||||||
|
gitea_auth.get_auth_header("https://gitea.prgs.cc"))
|
||||||
|
|
||||||
|
|
||||||
|
class ValidateConfigTests(unittest.TestCase):
|
||||||
|
def test_valid_contexts_config_has_no_problems(self):
|
||||||
|
self.assertEqual(gitea_config.validate_config(contexts_config()), [])
|
||||||
|
|
||||||
|
def test_repo_example_file_validates(self):
|
||||||
|
example = __import__("pathlib").Path(__file__).resolve().parent.parent \
|
||||||
|
/ "gitea-mcp.v2-contexts.example.json"
|
||||||
|
with open(example, encoding="utf-8") as fh:
|
||||||
|
self.assertEqual(gitea_config.validate_config(json.load(fh)), [])
|
||||||
|
|
||||||
|
def test_broken_contexts_config_reports_problems(self):
|
||||||
|
data = contexts_config()
|
||||||
|
data["profiles"]["prgs-author"]["context"] = "nope"
|
||||||
|
problems = gitea_config.validate_config(data)
|
||||||
|
self.assertTrue(problems)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
"""Negative tests for LLM-Agent-SHA attribution (#86, Phase 0).
|
||||||
|
|
||||||
|
``LLM-Agent-SHA`` (docs/llm-agent-sha.md) is attribution metadata ONLY. These
|
||||||
|
tests prove it can never bypass the review/merge safety gates:
|
||||||
|
|
||||||
|
1. Same Gitea user + different LLM-Agent-SHA still fails self-review/approval.
|
||||||
|
2. Same Gitea user + different LLM-Agent-SHA still fails self-merge.
|
||||||
|
3. The eligibility logic does not consult SHA metadata at all — results are
|
||||||
|
identical with no SHA, one SHA, or a different SHA in the environment, and
|
||||||
|
no gate accepts an agent-SHA input.
|
||||||
|
|
||||||
|
Phase 0 adds no SHA support to any MCP tool; the environment variables set
|
||||||
|
here (``LLM_AGENT_SHA`` / ``LLM_AGENT_ROLE``) simulate a future launcher and
|
||||||
|
must be ignored by every gate.
|
||||||
|
"""
|
||||||
|
import inspect
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from mcp_server import ( # noqa: E402
|
||||||
|
gitea_check_pr_eligibility,
|
||||||
|
gitea_merge_pr,
|
||||||
|
gitea_review_pr,
|
||||||
|
gitea_submit_pr_review,
|
||||||
|
)
|
||||||
|
|
||||||
|
FAKE_AUTH = "Basic dGVzdDp0ZXN0"
|
||||||
|
SHA_PATTERN = re.compile(r"^llm-[0-9a-f]{12}$")
|
||||||
|
# Two distinct, well-formed agent SHAs "belonging" to the same Gitea user.
|
||||||
|
SHA_IMPLEMENTER = "llm-8f3a9c2d6b41"
|
||||||
|
SHA_WOULD_BE_REVIEWER = "llm-41d0e7aa9f2c"
|
||||||
|
|
||||||
|
|
||||||
|
def _pr(author, state="open", sha="abc123", mergeable=True):
|
||||||
|
return {
|
||||||
|
"user": {"login": author},
|
||||||
|
"state": state,
|
||||||
|
"head": {"sha": sha},
|
||||||
|
"mergeable": mergeable,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestShaFormatConvention(unittest.TestCase):
|
||||||
|
"""The documented format: llm-<12 lowercase hex>, nothing identifying."""
|
||||||
|
|
||||||
|
def test_documented_examples_are_valid(self):
|
||||||
|
for value in (SHA_IMPLEMENTER, SHA_WOULD_BE_REVIEWER, "llm-b7c93d441a08"):
|
||||||
|
self.assertRegex(value, SHA_PATTERN)
|
||||||
|
|
||||||
|
def test_identifying_or_malformed_values_are_rejected(self):
|
||||||
|
for bad in (
|
||||||
|
"llm-8F3A9C2D6B41", # uppercase
|
||||||
|
"llm-8f3a9c2d6b4", # too short
|
||||||
|
"llm-8f3a9c2d6b411", # too long
|
||||||
|
"llm-opus4", # model name, not hex
|
||||||
|
"claude-8f3a9c2d6b41", # provider prefix
|
||||||
|
"jcwalker3", # username
|
||||||
|
"llm-user@example.com", # email
|
||||||
|
"8f3a9c2d6b41", # missing prefix
|
||||||
|
"",
|
||||||
|
):
|
||||||
|
self.assertNotRegex(bad, SHA_PATTERN)
|
||||||
|
|
||||||
|
|
||||||
|
class TestShaCannotBypassSelfReview(unittest.TestCase):
|
||||||
|
"""Scenario: user A (SHA 1) authored the PR; user A (SHA 2) tries to act."""
|
||||||
|
|
||||||
|
def _env(self, agent_sha, role):
|
||||||
|
# Reviewer-capable profile + a simulated launcher-injected agent SHA.
|
||||||
|
return {
|
||||||
|
"GITEA_PROFILE_NAME": "gitea-reviewer",
|
||||||
|
"GITEA_ALLOWED_OPERATIONS": "read,review,approve,merge",
|
||||||
|
"LLM_AGENT_SHA": agent_sha,
|
||||||
|
"LLM_AGENT_ROLE": role,
|
||||||
|
}
|
||||||
|
|
||||||
|
@patch("mcp_server.api_request")
|
||||||
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
|
def test_same_user_different_sha_cannot_approve(self, _auth, mock_api):
|
||||||
|
mock_api.side_effect = [{"login": "jcwalker3"}, _pr("jcwalker3")]
|
||||||
|
env = self._env(SHA_WOULD_BE_REVIEWER, "reviewer")
|
||||||
|
with patch.dict(os.environ, env, clear=True):
|
||||||
|
r = gitea_check_pr_eligibility(pr_number=9, action="approve", remote="prgs")
|
||||||
|
self.assertFalse(r["eligible"])
|
||||||
|
self.assertIn("authenticated user is PR author", r["reasons"])
|
||||||
|
|
||||||
|
@patch("mcp_server.api_request")
|
||||||
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
|
def test_same_user_different_sha_cannot_merge(self, _auth, mock_api):
|
||||||
|
mock_api.side_effect = [{"login": "jcwalker3"}, _pr("jcwalker3")]
|
||||||
|
env = self._env(SHA_WOULD_BE_REVIEWER, "merger")
|
||||||
|
with patch.dict(os.environ, env, clear=True):
|
||||||
|
r = gitea_check_pr_eligibility(pr_number=9, action="merge", remote="prgs")
|
||||||
|
self.assertFalse(r["eligible"])
|
||||||
|
self.assertIn("authenticated user is PR author", r["reasons"])
|
||||||
|
|
||||||
|
@patch("mcp_server.api_request")
|
||||||
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
|
def test_gated_merge_tool_refuses_self_merge_despite_sha(self, _auth, mock_api):
|
||||||
|
# Even the fully-confirmed gated merge path must refuse: correct
|
||||||
|
# confirmation string, mergeable PR, merge-capable profile — but the
|
||||||
|
# authenticated user is the PR author, whatever the agent SHA says.
|
||||||
|
mock_api.side_effect = [{"login": "jcwalker3"}, _pr("jcwalker3")]
|
||||||
|
env = self._env(SHA_WOULD_BE_REVIEWER, "merger")
|
||||||
|
with patch.dict(os.environ, env, clear=True):
|
||||||
|
r = gitea_merge_pr(
|
||||||
|
pr_number=9, confirmation="MERGE PR 9", remote="prgs")
|
||||||
|
self.assertFalse(r.get("performed"))
|
||||||
|
for call in mock_api.call_args_list:
|
||||||
|
method, url = call.args[0], call.args[1]
|
||||||
|
self.assertFalse(
|
||||||
|
method == "POST" and url.endswith("/merge"),
|
||||||
|
f"self-merge mutation reached the API: {method} {url}",
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch("mcp_server.api_request")
|
||||||
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
|
def test_review_tool_refuses_self_approval_despite_sha(self, _auth, mock_api):
|
||||||
|
mock_api.side_effect = [{"login": "jcwalker3"}, _pr("jcwalker3")]
|
||||||
|
env = self._env(SHA_WOULD_BE_REVIEWER, "reviewer")
|
||||||
|
with patch.dict(os.environ, env, clear=True):
|
||||||
|
r = gitea_review_pr(
|
||||||
|
pr_number=9, event="APPROVE", body="self approve", merge=False,
|
||||||
|
remote="prgs")
|
||||||
|
self.assertFalse(r["success"])
|
||||||
|
self.assertIn("authenticated user is PR author", r["message"])
|
||||||
|
for call in mock_api.call_args_list:
|
||||||
|
method, url = call.args[0], call.args[1]
|
||||||
|
self.assertFalse(
|
||||||
|
method == "POST" and url.endswith("/reviews"),
|
||||||
|
f"self-review mutation reached the API: {method} {url}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestEligibilityNeverConsultsSha(unittest.TestCase):
|
||||||
|
"""The gates have no SHA input: not via env, not via parameters."""
|
||||||
|
|
||||||
|
def _run_eligibility(self, extra_env):
|
||||||
|
env = {
|
||||||
|
"GITEA_PROFILE_NAME": "gitea-reviewer",
|
||||||
|
"GITEA_ALLOWED_OPERATIONS": "read,review,approve",
|
||||||
|
}
|
||||||
|
env.update(extra_env)
|
||||||
|
with patch("mcp_server.get_auth_header", return_value=FAKE_AUTH), \
|
||||||
|
patch("mcp_server.api_request") as mock_api:
|
||||||
|
mock_api.side_effect = [{"login": "reviewer-bot"}, _pr("author-bot")]
|
||||||
|
with patch.dict(os.environ, env, clear=True):
|
||||||
|
return gitea_check_pr_eligibility(
|
||||||
|
pr_number=5, action="approve", remote="prgs")
|
||||||
|
|
||||||
|
def test_result_identical_with_without_and_across_shas(self):
|
||||||
|
baseline = self._run_eligibility({})
|
||||||
|
with_one = self._run_eligibility(
|
||||||
|
{"LLM_AGENT_SHA": SHA_IMPLEMENTER, "LLM_AGENT_ROLE": "implementer"})
|
||||||
|
with_other = self._run_eligibility(
|
||||||
|
{"LLM_AGENT_SHA": SHA_WOULD_BE_REVIEWER, "LLM_AGENT_ROLE": "reviewer"})
|
||||||
|
self.assertEqual(baseline, with_one)
|
||||||
|
self.assertEqual(baseline, with_other)
|
||||||
|
self.assertTrue(baseline["eligible"]) # sanity: a real decision ran
|
||||||
|
|
||||||
|
def test_eligibility_result_carries_no_agent_sha(self):
|
||||||
|
r = self._run_eligibility(
|
||||||
|
{"LLM_AGENT_SHA": SHA_IMPLEMENTER, "LLM_AGENT_ROLE": "implementer"})
|
||||||
|
blob = repr(r)
|
||||||
|
self.assertNotIn(SHA_IMPLEMENTER, blob)
|
||||||
|
self.assertNotIn("LLM_AGENT", blob)
|
||||||
|
|
||||||
|
def test_gate_functions_accept_no_agent_sha_parameter(self):
|
||||||
|
for fn in (gitea_check_pr_eligibility, gitea_merge_pr,
|
||||||
|
gitea_review_pr, gitea_submit_pr_review):
|
||||||
|
for param in inspect.signature(fn).parameters:
|
||||||
|
lowered = param.lower()
|
||||||
|
self.assertNotIn("agent", lowered,
|
||||||
|
f"{fn.__name__} accepts agent param {param!r}")
|
||||||
|
self.assertNotIn("llm", lowered,
|
||||||
|
f"{fn.__name__} accepts llm param {param!r}")
|
||||||
|
|
||||||
|
def test_gate_sources_never_read_agent_sha(self):
|
||||||
|
# Phase 0 guarantee: no gate reads LLM_AGENT_* metadata at all.
|
||||||
|
for fn in (gitea_check_pr_eligibility, gitea_merge_pr,
|
||||||
|
gitea_review_pr, gitea_submit_pr_review):
|
||||||
|
src = inspect.getsource(fn)
|
||||||
|
self.assertNotIn("LLM_AGENT", src,
|
||||||
|
f"{fn.__name__} reads LLM_AGENT metadata")
|
||||||
|
self.assertNotIn("agent_sha", src,
|
||||||
|
f"{fn.__name__} consults an agent SHA")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -137,5 +137,89 @@ class TestConstants(unittest.TestCase):
|
|||||||
f"Label '{label['name']}' has invalid color")
|
f"Label '{label['name']}' has invalid color")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Modes: --create-labels / --apply-mapping / --add-label (#6)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
class TestModes(unittest.TestCase):
|
||||||
|
|
||||||
|
def _methods(self, mock_api):
|
||||||
|
return [(c[0][0], c[0][1]) for c in mock_api.call_args_list]
|
||||||
|
|
||||||
|
@patch("manage_labels.get_auth_header", return_value=FAKE_AUTH)
|
||||||
|
@patch("manage_labels.api")
|
||||||
|
def test_create_labels_only_no_mapping(self, mock_api, _auth):
|
||||||
|
def se(method, path, auth, payload=None):
|
||||||
|
if method == "GET":
|
||||||
|
return [] # no existing labels
|
||||||
|
if method == "POST" and path == "/labels":
|
||||||
|
return {"id": 1, "name": payload["name"]}
|
||||||
|
return None
|
||||||
|
mock_api.side_effect = se
|
||||||
|
manage_labels.main(["--create-labels"])
|
||||||
|
methods = self._methods(mock_api)
|
||||||
|
self.assertTrue(any(m == ("POST", "/labels") for m in methods))
|
||||||
|
self.assertFalse(any(m[0] == "PUT" for m in methods)) # no mapping applied
|
||||||
|
|
||||||
|
@patch("manage_labels.get_auth_header", return_value=FAKE_AUTH)
|
||||||
|
@patch("manage_labels.api")
|
||||||
|
def test_apply_mapping_only_no_label_creation(self, mock_api, _auth):
|
||||||
|
existing = [_make_label(l["name"], i + 1)
|
||||||
|
for i, l in enumerate(manage_labels.LABELS)]
|
||||||
|
|
||||||
|
def se(method, path, auth, payload=None):
|
||||||
|
if method == "GET":
|
||||||
|
return existing
|
||||||
|
if method == "PUT":
|
||||||
|
return [{"name": "applied"}]
|
||||||
|
return None
|
||||||
|
mock_api.side_effect = se
|
||||||
|
manage_labels.main(["--apply-mapping"])
|
||||||
|
methods = self._methods(mock_api)
|
||||||
|
self.assertFalse(any(m == ("POST", "/labels") for m in methods))
|
||||||
|
put_calls = [m for m in methods if m[0] == "PUT"]
|
||||||
|
self.assertEqual(len(put_calls), len(manage_labels.MAPPING))
|
||||||
|
|
||||||
|
@patch("manage_labels.get_auth_header", return_value=FAKE_AUTH)
|
||||||
|
@patch("manage_labels.api")
|
||||||
|
def test_add_label_appends_to_issue(self, mock_api, _auth):
|
||||||
|
existing = [_make_label("chore", 5)]
|
||||||
|
|
||||||
|
def se(method, path, auth, payload=None):
|
||||||
|
if method == "GET":
|
||||||
|
return existing
|
||||||
|
if method == "POST":
|
||||||
|
return [{"name": "chore"}]
|
||||||
|
return None
|
||||||
|
mock_api.side_effect = se
|
||||||
|
manage_labels.main(["--add-label", "42", "chore"])
|
||||||
|
posts = [c for c in mock_api.call_args_list
|
||||||
|
if c[0][0] == "POST" and c[0][1] == "/issues/42/labels"]
|
||||||
|
self.assertEqual(len(posts), 1)
|
||||||
|
self.assertEqual(posts[0][0][3], {"labels": [5]}) # append, id 5
|
||||||
|
# POST appends; no PUT (which would replace the whole set).
|
||||||
|
self.assertFalse(any(c[0][0] == "PUT" for c in mock_api.call_args_list))
|
||||||
|
|
||||||
|
@patch("manage_labels.get_auth_header", return_value=FAKE_AUTH)
|
||||||
|
@patch("manage_labels.api")
|
||||||
|
def test_add_label_unknown_makes_no_write(self, mock_api, _auth):
|
||||||
|
mock_api.side_effect = lambda *a, **k: [] if a[0] == "GET" else None
|
||||||
|
manage_labels.main(["--add-label", "42", "ghost"])
|
||||||
|
# Only the GET label lookup; no POST/PUT for an undefined label.
|
||||||
|
self.assertTrue(all(c[0][0] == "GET" for c in mock_api.call_args_list))
|
||||||
|
|
||||||
|
@patch("manage_labels.get_auth_header", return_value=FAKE_AUTH)
|
||||||
|
@patch("manage_labels.api")
|
||||||
|
def test_add_label_dry_makes_no_write(self, mock_api, _auth):
|
||||||
|
mock_api.side_effect = lambda *a, **k: [_make_label("chore", 5)] if a[0] == "GET" else None
|
||||||
|
manage_labels.main(["--dry", "--add-label", "42", "chore"])
|
||||||
|
self.assertTrue(all(c[0][0] == "GET" for c in mock_api.call_args_list))
|
||||||
|
|
||||||
|
@patch("manage_labels.get_auth_header", return_value=FAKE_AUTH)
|
||||||
|
@patch("manage_labels.api")
|
||||||
|
def test_add_label_non_numeric_issue_exits(self, mock_api, _auth):
|
||||||
|
with self.assertRaises(SystemExit):
|
||||||
|
manage_labels.main(["--add-label", "notanum", "chore"])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
+469
-10
@@ -3,6 +3,7 @@
|
|||||||
Each tool is tested by calling the underlying function directly (not through
|
Each tool is tested by calling the underlying function directly (not through
|
||||||
the MCP protocol) with mocked API responses.
|
the MCP protocol) with mocked API responses.
|
||||||
"""
|
"""
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import unittest
|
import unittest
|
||||||
@@ -93,7 +94,8 @@ class TestCloseIssue(unittest.TestCase):
|
|||||||
result = gitea_close_issue(issue_number=42)
|
result = gitea_close_issue(issue_number=42)
|
||||||
self.assertTrue(result["success"])
|
self.assertTrue(result["success"])
|
||||||
self.assertIn("42", result["message"])
|
self.assertIn("42", result["message"])
|
||||||
payload = mock_api.call_args[0][3]
|
patch_call = next(call for call in mock_api.call_args_list if call[0][0] == "PATCH")
|
||||||
|
payload = patch_call[0][3]
|
||||||
self.assertEqual(payload["state"], "closed")
|
self.assertEqual(payload["state"], "closed")
|
||||||
|
|
||||||
|
|
||||||
@@ -102,7 +104,7 @@ class TestCloseIssue(unittest.TestCase):
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
class TestListIssues(unittest.TestCase):
|
class TestListIssues(unittest.TestCase):
|
||||||
|
|
||||||
@patch("mcp_server.api_request")
|
@patch("mcp_server.api_get_all")
|
||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
def test_returns_formatted_list(self, _auth, mock_api):
|
def test_returns_formatted_list(self, _auth, mock_api):
|
||||||
mock_api.return_value = [
|
mock_api.return_value = [
|
||||||
@@ -123,20 +125,20 @@ class TestListIssues(unittest.TestCase):
|
|||||||
self.assertEqual(result[0]["assignee"], "alice")
|
self.assertEqual(result[0]["assignee"], "alice")
|
||||||
self.assertEqual(result[1]["assignee"], "")
|
self.assertEqual(result[1]["assignee"], "")
|
||||||
|
|
||||||
@patch("mcp_server.api_request")
|
@patch("mcp_server.api_get_all")
|
||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
def test_passes_label_filter(self, _auth, mock_api):
|
def test_passes_label_filter(self, _auth, mock_api):
|
||||||
mock_api.return_value = []
|
mock_api.return_value = []
|
||||||
gitea_list_issues(label="important")
|
gitea_list_issues(label="important")
|
||||||
url = mock_api.call_args[0][1]
|
url = mock_api.call_args[0][0]
|
||||||
self.assertIn("labels=important", url)
|
self.assertIn("labels=important", url)
|
||||||
|
|
||||||
@patch("mcp_server.api_request")
|
@patch("mcp_server.api_get_all")
|
||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
def test_passes_state_filter(self, _auth, mock_api):
|
def test_passes_state_filter(self, _auth, mock_api):
|
||||||
mock_api.return_value = []
|
mock_api.return_value = []
|
||||||
gitea_list_issues(state="closed")
|
gitea_list_issues(state="closed")
|
||||||
url = mock_api.call_args[0][1]
|
url = mock_api.call_args[0][0]
|
||||||
self.assertIn("state=closed", url)
|
self.assertIn("state=closed", url)
|
||||||
|
|
||||||
|
|
||||||
@@ -258,7 +260,7 @@ class TestMirrorRefs(unittest.TestCase):
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
class TestListPRs(unittest.TestCase):
|
class TestListPRs(unittest.TestCase):
|
||||||
|
|
||||||
@patch("mcp_server.api_request")
|
@patch("mcp_server.api_get_all")
|
||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
def test_list_prs(self, _auth, mock_api):
|
def test_list_prs(self, _auth, mock_api):
|
||||||
mock_api.return_value = [
|
mock_api.return_value = [
|
||||||
@@ -369,6 +371,55 @@ class TestMergePR(unittest.TestCase):
|
|||||||
expected_changed_files=["b.py", "a.py"], remote="prgs")
|
expected_changed_files=["b.py", "a.py"], remote="prgs")
|
||||||
self.assertTrue(r["performed"])
|
self.assertTrue(r["performed"])
|
||||||
|
|
||||||
|
# -- read-back / cleanup surfacing (#98) -----------------------------------
|
||||||
|
|
||||||
|
@patch("mcp_server.api_request")
|
||||||
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
|
def test_readback_failure_reports_skipped_cleanup(self, _auth, mock_api):
|
||||||
|
"""Merge OK + read-back GET failure => explicit cleanup skip, not silence."""
|
||||||
|
mock_api.side_effect = [
|
||||||
|
{"login": "merger-bot"}, self._pr("author-bot"),
|
||||||
|
{}, # merge POST
|
||||||
|
RuntimeError("HTTP 502: Gitea upstream unavailable"), # read-back fails
|
||||||
|
]
|
||||||
|
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
||||||
|
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
||||||
|
with patch.dict(os.environ, env, clear=True):
|
||||||
|
r = gitea_merge_pr(pr_number=8, confirmation=self._confirm(8),
|
||||||
|
remote="prgs")
|
||||||
|
# The merge itself is still reported performed/successful.
|
||||||
|
self.assertTrue(r["performed"])
|
||||||
|
self.assertEqual(r["merge_result"], "PR #8 merged via 'merge'.")
|
||||||
|
self.assertIsNone(r["merge_commit"])
|
||||||
|
# The skip is explicit, not silent.
|
||||||
|
self.assertEqual(r["cleanup_status"], "skipped (merge read-back failed)")
|
||||||
|
# No tracker-cleanup API traffic after the failed read-back:
|
||||||
|
# user, PR (eligibility), merge POST, read-back — and nothing more.
|
||||||
|
self.assertEqual(mock_api.call_count, 4)
|
||||||
|
for c in mock_api.call_args_list:
|
||||||
|
self.assertNotEqual(c.args[0], "DELETE")
|
||||||
|
|
||||||
|
@patch("mcp_server.cleanup_in_progress_for_pr",
|
||||||
|
side_effect=RuntimeError("boom token secret-xyz"))
|
||||||
|
@patch("mcp_server.api_request")
|
||||||
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
|
def test_cleanup_exception_surfaced_and_redacted(self, _auth, mock_api, _cleanup):
|
||||||
|
"""Unexpected cleanup exception => merge still succeeds; error surfaced redacted."""
|
||||||
|
mock_api.side_effect = [
|
||||||
|
{"login": "merger-bot"}, self._pr("author-bot"),
|
||||||
|
{}, # merge POST
|
||||||
|
{"merged_commit_sha": "c9"}, # read-back OK
|
||||||
|
]
|
||||||
|
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
||||||
|
"GITEA_ALLOWED_OPERATIONS": "read,merge"}
|
||||||
|
with patch.dict(os.environ, env, clear=True):
|
||||||
|
r = gitea_merge_pr(pr_number=8, confirmation=self._confirm(8),
|
||||||
|
remote="prgs")
|
||||||
|
self.assertTrue(r["performed"])
|
||||||
|
self.assertEqual(r["merge_commit"], "c9")
|
||||||
|
self.assertTrue(r["cleanup_status"].startswith("skipped (cleanup error:"))
|
||||||
|
self.assertNotIn("secret-xyz", r["cleanup_status"])
|
||||||
|
|
||||||
# -- confirmation ---------------------------------------------------------
|
# -- confirmation ---------------------------------------------------------
|
||||||
|
|
||||||
@patch("mcp_server.api_request")
|
@patch("mcp_server.api_request")
|
||||||
@@ -830,7 +881,9 @@ class TestWhoami(unittest.TestCase):
|
|||||||
self.assertEqual(result["username"], "reviewer-bot")
|
self.assertEqual(result["username"], "reviewer-bot")
|
||||||
self.assertEqual(result["display_name"], "Reviewer Bot")
|
self.assertEqual(result["display_name"], "Reviewer Bot")
|
||||||
self.assertEqual(result["user_id"], 42)
|
self.assertEqual(result["user_id"], 42)
|
||||||
self.assertEqual(result["server"], "https://gitea.prgs.cc")
|
# Endpoint URLs are hidden from normal LLM-facing output (#120);
|
||||||
|
# the logical remote name is the addressing surface.
|
||||||
|
self.assertNotIn("server", result)
|
||||||
self.assertEqual(result["remote"], "prgs")
|
self.assertEqual(result["remote"], "prgs")
|
||||||
# Read-only: GET against the authenticated-user endpoint.
|
# Read-only: GET against the authenticated-user endpoint.
|
||||||
call_args = mock_api.call_args
|
call_args = mock_api.call_args
|
||||||
@@ -861,6 +914,34 @@ class TestWhoami(unittest.TestCase):
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Runtime profile (env-configured profile metadata) — issue #19
|
# Runtime profile (env-configured profile metadata) — issue #19
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@patch("mcp_server.api_request")
|
||||||
|
@patch("mcp_server._auth")
|
||||||
|
def test_gitea_get_authenticated_user_alias(self, _auth, mock_api):
|
||||||
|
mock_api.return_value = {
|
||||||
|
"login": "alias_user",
|
||||||
|
"full_name": "Alias User",
|
||||||
|
"id": 999,
|
||||||
|
"email": "alias@example.com"
|
||||||
|
}
|
||||||
|
from mcp_server import gitea_get_authenticated_user
|
||||||
|
result = gitea_get_authenticated_user(remote="prgs")
|
||||||
|
self.assertEqual(result["username"], "alias_user")
|
||||||
|
|
||||||
|
@patch("mcp_server.api_request")
|
||||||
|
@patch("mcp_server._auth")
|
||||||
|
def test_gitea_get_current_user_alias(self, _auth, mock_api):
|
||||||
|
mock_api.return_value = {
|
||||||
|
"login": "alias_user",
|
||||||
|
"full_name": "Alias User",
|
||||||
|
"id": 999,
|
||||||
|
"email": "alias@example.com"
|
||||||
|
}
|
||||||
|
from mcp_server import gitea_get_current_user
|
||||||
|
result = gitea_get_current_user(remote="prgs")
|
||||||
|
self.assertEqual(result["username"], "alias_user")
|
||||||
|
|
||||||
|
|
||||||
class TestRuntimeProfile(unittest.TestCase):
|
class TestRuntimeProfile(unittest.TestCase):
|
||||||
|
|
||||||
def test_defaults_when_unset(self):
|
def test_defaults_when_unset(self):
|
||||||
@@ -957,8 +1038,12 @@ class TestProfileDiscovery(unittest.TestCase):
|
|||||||
self.assertEqual(result["allowed_operations"], ["read", "review", "approve"])
|
self.assertEqual(result["allowed_operations"], ["read", "review", "approve"])
|
||||||
self.assertEqual(result["authenticated_username"], "reviewer-bot")
|
self.assertEqual(result["authenticated_username"], "reviewer-bot")
|
||||||
self.assertEqual(result["identity_status"], "verified")
|
self.assertEqual(result["identity_status"], "verified")
|
||||||
self.assertEqual(result["server"], "https://gitea.prgs.cc")
|
# Endpoint URLs and token source names are hidden from normal
|
||||||
self.assertEqual(result["token_source_name"], "GITEA_TOKEN")
|
# LLM-facing output (#120); auth is reported as a status only.
|
||||||
|
self.assertNotIn("server", result)
|
||||||
|
self.assertNotIn("base_url", result)
|
||||||
|
self.assertNotIn("token_source_name", result)
|
||||||
|
self.assertEqual(result["auth_status"], "configured")
|
||||||
# Read-only: only a GET to the user endpoint was issued.
|
# Read-only: only a GET to the user endpoint was issued.
|
||||||
self.assertEqual(mock_api.call_args[0][0], "GET")
|
self.assertEqual(mock_api.call_args[0][0], "GET")
|
||||||
self.assertTrue(mock_api.call_args[0][1].endswith("/api/v1/user"))
|
self.assertTrue(mock_api.call_args[0][1].endswith("/api/v1/user"))
|
||||||
@@ -1352,3 +1437,377 @@ class TestSubmitPrReview(unittest.TestCase):
|
|||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tracker Hygiene Cleanup Tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
class TestTrackerHygieneCleanup(unittest.TestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.mock_api = patch("mcp_server.api_request").start()
|
||||||
|
self.mock_auth = patch("mcp_server.get_auth_header", return_value=FAKE_AUTH).start()
|
||||||
|
patch("gitea_audit.audit_enabled", return_value=True).start()
|
||||||
|
self.mock_audit = patch("gitea_audit.write_event").start()
|
||||||
|
patch("mcp_server.get_profile", return_value={"profile_name": "test", "allowed_operations": ["merge", "edit", "close"], "audit_label": "test", "forbidden_operations": []}).start()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
patch.stopall()
|
||||||
|
|
||||||
|
def test_close_issue_removes_in_progress(self):
|
||||||
|
def api_side_effect(method, url, auth, payload=None):
|
||||||
|
if method == "PATCH" and "issues/1" in url:
|
||||||
|
return {"state": "closed"}
|
||||||
|
if method == "GET" and "labels" in url and "issues" not in url:
|
||||||
|
return [{"name": "status:in-progress", "id": 1}, {"name": "bug", "id": 2}]
|
||||||
|
if method == "GET" and "issues/1" in url:
|
||||||
|
return {"labels": [{"name": "status:in-progress"}, {"name": "bug"}]}
|
||||||
|
if method == "DELETE" and url.endswith("/issues/1/labels/1"):
|
||||||
|
return {}
|
||||||
|
if method == "PUT" and "labels" in url:
|
||||||
|
self.fail("Should not replace the issue label set")
|
||||||
|
return {}
|
||||||
|
self.mock_api.side_effect = api_side_effect
|
||||||
|
|
||||||
|
res = gitea_close_issue(issue_number=1)
|
||||||
|
self.assertTrue(res["success"])
|
||||||
|
self.assertEqual(res["cleanup_status"].get(1), "released")
|
||||||
|
self.mock_audit.assert_called()
|
||||||
|
|
||||||
|
def test_close_issue_no_label_is_noop(self):
|
||||||
|
def api_side_effect(method, url, auth, payload=None):
|
||||||
|
if method == "PATCH" and "issues/1" in url:
|
||||||
|
return {"state": "closed"}
|
||||||
|
if method == "GET" and "labels" in url and "issues" not in url:
|
||||||
|
return [{"name": "status:in-progress", "id": 1}, {"name": "bug", "id": 2}]
|
||||||
|
if method == "GET" and "issues/1" in url:
|
||||||
|
return {"labels": [{"name": "bug"}]}
|
||||||
|
if method == "DELETE" and "labels" in url:
|
||||||
|
self.fail("Should not DELETE labels")
|
||||||
|
if method == "PUT" and "labels" in url:
|
||||||
|
self.fail("Should not replace the issue label set")
|
||||||
|
return {}
|
||||||
|
self.mock_api.side_effect = api_side_effect
|
||||||
|
|
||||||
|
res = gitea_close_issue(issue_number=1)
|
||||||
|
self.assertTrue(res["success"])
|
||||||
|
self.assertEqual(res["cleanup_status"].get(1), "not present")
|
||||||
|
|
||||||
|
def test_merge_pr_with_closes_removes_label(self):
|
||||||
|
def api_side_effect(method, url, auth, payload=None):
|
||||||
|
if method == "GET" and "/user" in url:
|
||||||
|
return {"login": "merger"}
|
||||||
|
if method == "GET" and "pulls/1" in url and "/files" not in url:
|
||||||
|
return {
|
||||||
|
"user": {"login": "author"},
|
||||||
|
"state": "open",
|
||||||
|
"head": {"sha": "sha123", "ref": "feat/my-branch"},
|
||||||
|
"base": {"ref": "main"},
|
||||||
|
"mergeable": True,
|
||||||
|
"merged_commit_sha": "merge123",
|
||||||
|
"title": "My PR",
|
||||||
|
"body": "Closes #123"
|
||||||
|
}
|
||||||
|
if method == "POST" and "merge" in url:
|
||||||
|
return {}
|
||||||
|
if method == "GET" and "labels" in url and "issues" not in url:
|
||||||
|
return [{"name": "status:in-progress", "id": 1}, {"name": "bug", "id": 2}]
|
||||||
|
if method == "GET" and "issues/123" in url:
|
||||||
|
return {"labels": [{"name": "status:in-progress"}, {"name": "bug"}]}
|
||||||
|
if method == "DELETE" and url.endswith("/issues/123/labels/1"):
|
||||||
|
return {}
|
||||||
|
if method == "PUT" and "labels" in url:
|
||||||
|
self.fail("Should not replace the issue label set")
|
||||||
|
return {}
|
||||||
|
self.mock_api.side_effect = api_side_effect
|
||||||
|
|
||||||
|
res = gitea_merge_pr(pr_number=1, confirmation="MERGE PR 1", do="merge")
|
||||||
|
self.assertTrue(res["performed"])
|
||||||
|
self.assertEqual(res["cleanup_status"].get(123), "released")
|
||||||
|
|
||||||
|
def test_merge_pr_with_branch_name_removes_label(self):
|
||||||
|
def api_side_effect(method, url, auth, payload=None):
|
||||||
|
if method == "GET" and "/user" in url:
|
||||||
|
return {"login": "merger"}
|
||||||
|
if method == "GET" and "pulls/1" in url and "/files" not in url:
|
||||||
|
return {
|
||||||
|
"user": {"login": "author"},
|
||||||
|
"state": "open",
|
||||||
|
"head": {"sha": "sha123", "ref": "fix/issue-123-slug"},
|
||||||
|
"base": {"ref": "main"},
|
||||||
|
"mergeable": True,
|
||||||
|
"merged_commit_sha": "merge123",
|
||||||
|
"title": "My PR",
|
||||||
|
"body": "Fixing things"
|
||||||
|
}
|
||||||
|
if method == "POST" and "merge" in url:
|
||||||
|
return {}
|
||||||
|
if method == "GET" and "labels" in url and "issues" not in url:
|
||||||
|
return [{"name": "status:in-progress", "id": 1}, {"name": "bug", "id": 2}]
|
||||||
|
if method == "GET" and "issues/123" in url:
|
||||||
|
return {"labels": [{"name": "status:in-progress"}, {"name": "bug"}]}
|
||||||
|
if method == "DELETE" and url.endswith("/issues/123/labels/1"):
|
||||||
|
return {}
|
||||||
|
if method == "PUT" and "labels" in url:
|
||||||
|
self.fail("Should not replace the issue label set")
|
||||||
|
return {}
|
||||||
|
self.mock_api.side_effect = api_side_effect
|
||||||
|
|
||||||
|
res = gitea_merge_pr(pr_number=1, confirmation="MERGE PR 1", do="merge")
|
||||||
|
self.assertTrue(res["performed"])
|
||||||
|
self.assertEqual(res["cleanup_status"].get(123), "released")
|
||||||
|
|
||||||
|
def test_close_pr_removes_label_but_does_not_close_issue(self):
|
||||||
|
def api_side_effect(method, url, auth, payload=None):
|
||||||
|
if method == "PATCH" and "pulls/1" in url:
|
||||||
|
return {
|
||||||
|
"number": 1,
|
||||||
|
"title": "My PR",
|
||||||
|
"state": "closed",
|
||||||
|
"html_url": "url",
|
||||||
|
"body": "Closes #123",
|
||||||
|
"head": {"ref": "feat/my-branch"}
|
||||||
|
}
|
||||||
|
if method == "GET" and "labels" in url and "issues" not in url:
|
||||||
|
return [{"name": "status:in-progress", "id": 1}]
|
||||||
|
if method == "GET" and "issues/123" in url:
|
||||||
|
return {"labels": [{"name": "status:in-progress"}]}
|
||||||
|
if method == "DELETE" and url.endswith("/issues/123/labels/1"):
|
||||||
|
return {}
|
||||||
|
if method == "PUT" and "labels" in url:
|
||||||
|
self.fail("Should not replace the issue label set")
|
||||||
|
if method == "POST" and "comments" in url:
|
||||||
|
return {}
|
||||||
|
return {}
|
||||||
|
self.mock_api.side_effect = api_side_effect
|
||||||
|
|
||||||
|
res = gitea_edit_pr(pr_number=1, state="closed")
|
||||||
|
self.assertTrue(res["success"])
|
||||||
|
self.assertEqual(res["cleanup_status"].get(123), "released")
|
||||||
|
|
||||||
|
def test_multiple_linked_issues(self):
|
||||||
|
def api_side_effect(method, url, auth, payload=None):
|
||||||
|
if method == "PATCH" and "pulls/1" in url:
|
||||||
|
return {
|
||||||
|
"number": 1,
|
||||||
|
"title": "My PR",
|
||||||
|
"state": "closed",
|
||||||
|
"html_url": "url",
|
||||||
|
"body": "Closes #123\nFixes #124",
|
||||||
|
"head": {"ref": "issue-125"}
|
||||||
|
}
|
||||||
|
if method == "GET" and "labels" in url and "issues" not in url:
|
||||||
|
return [{"name": "status:in-progress", "id": 1}]
|
||||||
|
if method == "GET" and "issues/123" in url:
|
||||||
|
return {"labels": [{"name": "status:in-progress"}]}
|
||||||
|
if method == "GET" and "issues/124" in url:
|
||||||
|
return {"labels": [{"name": "status:in-progress"}]}
|
||||||
|
if method == "GET" and "issues/125" in url:
|
||||||
|
return {"labels": []}
|
||||||
|
if method == "DELETE" and url.endswith("/issues/123/labels/1"):
|
||||||
|
return {}
|
||||||
|
if method == "DELETE" and url.endswith("/issues/124/labels/1"):
|
||||||
|
return {}
|
||||||
|
if method == "PUT" and "labels" in url:
|
||||||
|
self.fail("Should not replace the issue label set")
|
||||||
|
if method == "POST" and "comments" in url:
|
||||||
|
return {}
|
||||||
|
return {}
|
||||||
|
self.mock_api.side_effect = api_side_effect
|
||||||
|
|
||||||
|
res = gitea_edit_pr(pr_number=1, state="closed")
|
||||||
|
self.assertTrue(res["success"])
|
||||||
|
self.assertEqual(res["cleanup_status"].get(123), "released")
|
||||||
|
self.assertEqual(res["cleanup_status"].get(124), "released")
|
||||||
|
self.assertEqual(res["cleanup_status"].get(125), "not present")
|
||||||
|
|
||||||
|
def test_no_linked_issue_found(self):
|
||||||
|
def api_side_effect(method, url, auth, payload=None):
|
||||||
|
if method == "PATCH" and "pulls/1" in url:
|
||||||
|
return {
|
||||||
|
"number": 1,
|
||||||
|
"title": "My PR",
|
||||||
|
"state": "closed",
|
||||||
|
"html_url": "url",
|
||||||
|
"body": "No issue link",
|
||||||
|
"head": {"ref": "main"}
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
self.mock_api.side_effect = api_side_effect
|
||||||
|
|
||||||
|
res = gitea_edit_pr(pr_number=1, state="closed")
|
||||||
|
self.assertTrue(res["success"])
|
||||||
|
self.assertEqual(res["cleanup_status"], "no linked issue found")
|
||||||
|
|
||||||
|
def test_label_removal_failure_reported(self):
|
||||||
|
def api_side_effect(method, url, auth, payload=None):
|
||||||
|
if method == "PATCH" and "issues/1" in url:
|
||||||
|
return {"state": "closed"}
|
||||||
|
if method == "GET" and "labels" in url and "issues" not in url:
|
||||||
|
return [{"name": "status:in-progress", "id": 1}]
|
||||||
|
if method == "GET" and "issues/1" in url:
|
||||||
|
return {"labels": [{"name": "status:in-progress"}]}
|
||||||
|
if method == "DELETE" and url.endswith("/issues/1/labels/1"):
|
||||||
|
raise RuntimeError("API failure")
|
||||||
|
if method == "PUT" and "labels" in url:
|
||||||
|
self.fail("Should not replace the issue label set")
|
||||||
|
return {}
|
||||||
|
self.mock_api.side_effect = api_side_effect
|
||||||
|
|
||||||
|
res = gitea_close_issue(issue_number=1)
|
||||||
|
self.assertTrue(res["success"])
|
||||||
|
self.assertIn("error:", res["cleanup_status"].get(1))
|
||||||
|
|
||||||
|
def test_extract_linked_issue_numbers_hygiene(self):
|
||||||
|
from mcp_server import extract_linked_issue_numbers
|
||||||
|
# Standard closing keywords
|
||||||
|
self.assertEqual(extract_linked_issue_numbers("Closes #123"), [123])
|
||||||
|
self.assertEqual(extract_linked_issue_numbers("Fixes #123"), [123])
|
||||||
|
self.assertEqual(extract_linked_issue_numbers("Resolves #123"), [123])
|
||||||
|
|
||||||
|
# New implements/implemented keywords
|
||||||
|
self.assertEqual(extract_linked_issue_numbers("Implements #123"), [123])
|
||||||
|
self.assertEqual(extract_linked_issue_numbers("implemented #123"), [123])
|
||||||
|
self.assertEqual(extract_linked_issue_numbers("implement #123"), [123])
|
||||||
|
|
||||||
|
# refs / ref should NOT match
|
||||||
|
self.assertEqual(extract_linked_issue_numbers("Refs #123"), [])
|
||||||
|
self.assertEqual(extract_linked_issue_numbers("ref #123"), [])
|
||||||
|
|
||||||
|
# branch name fallback
|
||||||
|
self.assertEqual(extract_linked_issue_numbers("", branch_name="issue-123"), [123])
|
||||||
|
self.assertEqual(extract_linked_issue_numbers("", branch_name="feat/issue-123-foo"), [123])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Endpoint/keychain redaction in LLM-facing output — issue #120
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
class TestEndpointRedaction(unittest.TestCase):
|
||||||
|
"""Normal MCP output hides endpoint URLs and keychain ids; the admin
|
||||||
|
opt-in (GITEA_MCP_REVEAL_ENDPOINTS) restores them for local diagnostics
|
||||||
|
without ever revealing token values."""
|
||||||
|
|
||||||
|
def _contexts_config_file(self):
|
||||||
|
import tempfile
|
||||||
|
config = {
|
||||||
|
"version": 2,
|
||||||
|
"contexts": {
|
||||||
|
"prgs": {
|
||||||
|
"enabled": True,
|
||||||
|
"gitea": {"enabled": True, "kind": "gitea",
|
||||||
|
"base_url": "https://gitea.prgs.cc"},
|
||||||
|
"services": {
|
||||||
|
"jenkins": {
|
||||||
|
"enabled": True, "kind": "jenkins",
|
||||||
|
"label": "PRGS Jenkins",
|
||||||
|
"base_url": "https://jenkins.prgs.cc",
|
||||||
|
"auth": {"type": "keychain",
|
||||||
|
"id": "prgs-jenkins-token"},
|
||||||
|
"capabilities": ["read"],
|
||||||
|
},
|
||||||
|
"sentry": {
|
||||||
|
"enabled": False, "kind": "sentry",
|
||||||
|
"label": "PRGS Sentry", "base_url": "",
|
||||||
|
"auth": {"type": "keychain",
|
||||||
|
"id": "prgs-sentry-token"},
|
||||||
|
"capabilities": ["read"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"profiles": {
|
||||||
|
"prgs-author": {
|
||||||
|
"enabled": True, "context": "prgs", "role": "author",
|
||||||
|
"username": "jcwalker3",
|
||||||
|
"base_url": "https://gitea.prgs.cc",
|
||||||
|
"auth": {"type": "keychain",
|
||||||
|
"id": "prgs-gitea-author-token"},
|
||||||
|
"allowed_operations": ["read"],
|
||||||
|
"forbidden_operations": [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"projects": {},
|
||||||
|
"rules": {"hide_service_urls_from_llm": True,
|
||||||
|
"hide_keychain_ids_from_llm": True,
|
||||||
|
"mcp_resolves_endpoints": True},
|
||||||
|
}
|
||||||
|
fd, path = tempfile.mkstemp(suffix=".json")
|
||||||
|
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
||||||
|
json.dump(config, fh)
|
||||||
|
return path
|
||||||
|
|
||||||
|
@patch("mcp_server.api_request")
|
||||||
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
|
def test_whoami_hides_endpoint_url_by_default(self, _auth, mock_api):
|
||||||
|
mock_api.return_value = {"id": 1, "login": "someone"}
|
||||||
|
with patch.dict(os.environ, {}, clear=True):
|
||||||
|
result = gitea_whoami(remote="prgs")
|
||||||
|
self.assertNotIn("server", result)
|
||||||
|
self.assertNotIn("https://", repr(result))
|
||||||
|
|
||||||
|
@patch("mcp_server.api_request")
|
||||||
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
|
def test_whoami_reveals_endpoint_with_admin_optin(self, _auth, mock_api):
|
||||||
|
mock_api.return_value = {"id": 1, "login": "someone"}
|
||||||
|
with patch.dict(os.environ,
|
||||||
|
{"GITEA_MCP_REVEAL_ENDPOINTS": "1"}, clear=True):
|
||||||
|
result = gitea_whoami(remote="prgs")
|
||||||
|
self.assertEqual(result["server"], "https://gitea.prgs.cc")
|
||||||
|
|
||||||
|
def test_get_profile_hides_url_and_token_source_by_default(self):
|
||||||
|
env = {
|
||||||
|
"GITEA_PROFILE_NAME": "gitea-author",
|
||||||
|
"GITEA_BASE_URL": "https://gitea.example.invalid",
|
||||||
|
"GITEA_TOKEN_SOURCE": "keychain:some-item-id",
|
||||||
|
}
|
||||||
|
with patch.dict(os.environ, env, clear=True):
|
||||||
|
result = gitea_get_profile(remote="prgs",
|
||||||
|
resolve_identity=False)
|
||||||
|
blob = repr(result)
|
||||||
|
for leaked in ("https://", "keychain:", "some-item-id",
|
||||||
|
"base_url", "server", "token_source_name"):
|
||||||
|
self.assertNotIn(leaked, blob)
|
||||||
|
self.assertEqual(result["auth_status"], "configured")
|
||||||
|
|
||||||
|
def test_get_profile_reports_unconfigured_auth(self):
|
||||||
|
with patch.dict(os.environ,
|
||||||
|
{"GITEA_PROFILE_NAME": "gitea-author"}, clear=True):
|
||||||
|
result = gitea_get_profile(remote="prgs",
|
||||||
|
resolve_identity=False)
|
||||||
|
self.assertEqual(result["auth_status"], "unconfigured")
|
||||||
|
|
||||||
|
def test_get_profile_reveals_with_admin_optin(self):
|
||||||
|
env = {
|
||||||
|
"GITEA_PROFILE_NAME": "gitea-author",
|
||||||
|
"GITEA_TOKEN_SOURCE": "keychain:some-item-id",
|
||||||
|
"GITEA_MCP_REVEAL_ENDPOINTS": "1",
|
||||||
|
}
|
||||||
|
with patch.dict(os.environ, env, clear=True):
|
||||||
|
result = gitea_get_profile(remote="prgs",
|
||||||
|
resolve_identity=False)
|
||||||
|
self.assertEqual(result["server"], "https://gitea.prgs.cc")
|
||||||
|
self.assertEqual(result["token_source_name"], "keychain:some-item-id")
|
||||||
|
|
||||||
|
def test_audit_tool_reports_state_without_urls_or_ids(self):
|
||||||
|
from mcp_server import gitea_audit_config
|
||||||
|
path = self._contexts_config_file()
|
||||||
|
try:
|
||||||
|
env = {"GITEA_MCP_CONFIG": path,
|
||||||
|
"GITEA_MCP_PROFILE": "prgs-author"}
|
||||||
|
with patch.dict(os.environ, env, clear=True), \
|
||||||
|
patch("gitea_config._keychain_token", return_value="x"):
|
||||||
|
result = gitea_audit_config()
|
||||||
|
finally:
|
||||||
|
os.unlink(path)
|
||||||
|
blob = json.dumps(result)
|
||||||
|
self.assertIn("PRGS Jenkins: enabled, read-only, authenticated",
|
||||||
|
result["summaries"])
|
||||||
|
self.assertIn("PRGS Sentry: disabled", result["summaries"])
|
||||||
|
for leaked in ("https://", "http://", "prgs-jenkins-token",
|
||||||
|
"prgs-gitea-author-token", "base_url"):
|
||||||
|
self.assertNotIn(leaked, blob)
|
||||||
|
|
||||||
|
def test_audit_tool_without_config_reports_off(self):
|
||||||
|
with patch.dict(os.environ, {}, clear=True):
|
||||||
|
from mcp_server import gitea_audit_config
|
||||||
|
result = gitea_audit_config()
|
||||||
|
self.assertFalse(result["configured"])
|
||||||
|
|||||||
@@ -0,0 +1,239 @@
|
|||||||
|
"""Operation-name normalization table and enforcement tests — issue #106.
|
||||||
|
|
||||||
|
Covers the required matrix from #106:
|
||||||
|
|
||||||
|
- fully qualified allowed / forbidden operations
|
||||||
|
- legacy unqualified allowed / forbidden operations
|
||||||
|
- unknown operations (fail closed)
|
||||||
|
- ambiguous operations (fail closed)
|
||||||
|
- service mismatch (cross-service names never accepted by the wrong service)
|
||||||
|
- forbidden-overrides-allowed
|
||||||
|
- empty / missing allowed list
|
||||||
|
- duplicate operations after normalization
|
||||||
|
- no silent permission widening
|
||||||
|
- eligibility enforcement normalizes before checking
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
import gitea_config # noqa: E402
|
||||||
|
from gitea_config import ( # noqa: E402
|
||||||
|
ConfigError,
|
||||||
|
check_operation,
|
||||||
|
normalize_operation,
|
||||||
|
)
|
||||||
|
from mcp_server import gitea_check_pr_eligibility # noqa: E402
|
||||||
|
|
||||||
|
FAKE_AUTH = "Basic dGVzdDp0ZXN0"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# normalize_operation — canonical table
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
class TestNormalizeOperation(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_fully_qualified_gitea_op_unchanged(self):
|
||||||
|
self.assertEqual(normalize_operation("gitea.pr.merge"), "gitea.pr.merge")
|
||||||
|
|
||||||
|
def test_legacy_aliases_map_to_canonical_names(self):
|
||||||
|
expected = {
|
||||||
|
"merge": "gitea.pr.merge",
|
||||||
|
"approve": "gitea.pr.approve",
|
||||||
|
"request_changes": "gitea.pr.request_changes",
|
||||||
|
"review": "gitea.pr.review",
|
||||||
|
"comment": "gitea.pr.comment",
|
||||||
|
"read": "gitea.read",
|
||||||
|
}
|
||||||
|
for legacy, canonical in expected.items():
|
||||||
|
self.assertEqual(normalize_operation(legacy), canonical)
|
||||||
|
|
||||||
|
def test_contexts_shape_author_verbs(self):
|
||||||
|
self.assertEqual(normalize_operation("branch"), "gitea.branch.create")
|
||||||
|
self.assertEqual(normalize_operation("commit"), "gitea.repo.commit")
|
||||||
|
self.assertEqual(normalize_operation("push"), "gitea.branch.push")
|
||||||
|
self.assertEqual(normalize_operation("open_pr"), "gitea.pr.create")
|
||||||
|
|
||||||
|
def test_unknown_unqualified_op_fails_closed(self):
|
||||||
|
with self.assertRaises(ConfigError):
|
||||||
|
normalize_operation("frobnicate")
|
||||||
|
|
||||||
|
def test_ambiguous_dotted_op_fails_closed(self):
|
||||||
|
# Dotted but neither gitea-prefixed nor an explicit alias: refuse to
|
||||||
|
# guess which namespace was meant.
|
||||||
|
with self.assertRaises(ConfigError):
|
||||||
|
normalize_operation("build.read")
|
||||||
|
|
||||||
|
def test_cross_service_name_rejected_by_wrong_service(self):
|
||||||
|
with self.assertRaises(ConfigError):
|
||||||
|
normalize_operation("jenkins.read", service="gitea")
|
||||||
|
with self.assertRaises(ConfigError):
|
||||||
|
normalize_operation("gitea.read", service="jenkins")
|
||||||
|
|
||||||
|
def test_non_gitea_single_word_namespaced_to_service(self):
|
||||||
|
self.assertEqual(normalize_operation("read", service="jenkins"),
|
||||||
|
"jenkins.read")
|
||||||
|
|
||||||
|
def test_non_gitea_qualified_own_prefix_unchanged(self):
|
||||||
|
self.assertEqual(
|
||||||
|
normalize_operation("jenkins.build.read", service="jenkins"),
|
||||||
|
"jenkins.build.read",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_empty_and_non_string_fail_closed(self):
|
||||||
|
for bad in ("", None, 3, ["merge"]):
|
||||||
|
with self.assertRaises(ConfigError):
|
||||||
|
normalize_operation(bad)
|
||||||
|
|
||||||
|
def test_gitea_alias_not_applied_to_other_services(self):
|
||||||
|
# "merge" on jenkins must not resolve to the *gitea* merge permission.
|
||||||
|
self.assertEqual(normalize_operation("merge", service="jenkins"),
|
||||||
|
"jenkins.merge")
|
||||||
|
|
||||||
|
def test_table_is_documented_and_matches_normalization(self):
|
||||||
|
table = gitea_config.GITEA_OPERATION_ALIASES
|
||||||
|
self.assertIsInstance(table, dict)
|
||||||
|
self.assertTrue(table)
|
||||||
|
for legacy, canonical in table.items():
|
||||||
|
self.assertEqual(normalize_operation(legacy), canonical)
|
||||||
|
self.assertTrue(canonical.startswith("gitea."))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# check_operation — enforcement semantics (normalize BEFORE checking)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
class TestCheckOperation(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_fully_qualified_allowed(self):
|
||||||
|
ok, reason = check_operation("gitea.pr.merge", ["gitea.pr.merge"])
|
||||||
|
self.assertTrue(ok)
|
||||||
|
self.assertEqual(reason, "allowed")
|
||||||
|
|
||||||
|
def test_fully_qualified_forbidden(self):
|
||||||
|
ok, reason = check_operation(
|
||||||
|
"gitea.pr.merge", ["gitea.pr.merge"], ["gitea.pr.merge"])
|
||||||
|
self.assertFalse(ok)
|
||||||
|
self.assertEqual(reason, "forbidden")
|
||||||
|
|
||||||
|
def test_legacy_unqualified_allowed(self):
|
||||||
|
ok, reason = check_operation("merge", ["gitea.pr.merge"])
|
||||||
|
self.assertTrue(ok)
|
||||||
|
self.assertEqual(reason, "allowed")
|
||||||
|
|
||||||
|
def test_legacy_unqualified_forbidden(self):
|
||||||
|
ok, reason = check_operation("merge", ["gitea.pr.merge"], ["merge"])
|
||||||
|
self.assertFalse(ok)
|
||||||
|
self.assertEqual(reason, "forbidden")
|
||||||
|
|
||||||
|
def test_unknown_operation_fails_closed(self):
|
||||||
|
ok, reason = check_operation("frobnicate", ["gitea.read"])
|
||||||
|
self.assertFalse(ok)
|
||||||
|
self.assertEqual(reason, "invalid-operation")
|
||||||
|
|
||||||
|
def test_ambiguous_operation_fails_closed(self):
|
||||||
|
ok, reason = check_operation("build.read", ["gitea.read"])
|
||||||
|
self.assertFalse(ok)
|
||||||
|
self.assertEqual(reason, "invalid-operation")
|
||||||
|
|
||||||
|
def test_service_mismatch_rejected(self):
|
||||||
|
ok, reason = check_operation("jenkins.read", ["gitea.read"])
|
||||||
|
self.assertFalse(ok)
|
||||||
|
self.assertEqual(reason, "invalid-operation")
|
||||||
|
|
||||||
|
def test_forbidden_overrides_allowed_across_spellings(self):
|
||||||
|
# Allowed via legacy spelling, forbidden via canonical spelling: the
|
||||||
|
# forbidden entry must win after both normalize to the same op.
|
||||||
|
ok, reason = check_operation("merge", ["merge"], ["gitea.pr.merge"])
|
||||||
|
self.assertFalse(ok)
|
||||||
|
self.assertEqual(reason, "forbidden")
|
||||||
|
|
||||||
|
def test_empty_allowed_list_denies(self):
|
||||||
|
ok, reason = check_operation("gitea.read", [])
|
||||||
|
self.assertFalse(ok)
|
||||||
|
self.assertEqual(reason, "no-allowed-operations")
|
||||||
|
|
||||||
|
def test_missing_allowed_list_denies(self):
|
||||||
|
ok, reason = check_operation("gitea.read", None)
|
||||||
|
self.assertFalse(ok)
|
||||||
|
self.assertEqual(reason, "no-allowed-operations")
|
||||||
|
|
||||||
|
def test_duplicates_after_normalization_are_harmless(self):
|
||||||
|
ok, reason = check_operation(
|
||||||
|
"merge", ["merge", "gitea.pr.merge", "merge"])
|
||||||
|
self.assertTrue(ok)
|
||||||
|
self.assertEqual(reason, "allowed")
|
||||||
|
|
||||||
|
def test_unnormalizable_allowed_entry_grants_nothing(self):
|
||||||
|
# A junk allowed entry must not widen permissions to anything.
|
||||||
|
ok, reason = check_operation("gitea.read", ["frobnicate"])
|
||||||
|
self.assertFalse(ok)
|
||||||
|
self.assertEqual(reason, "not-allowed")
|
||||||
|
|
||||||
|
def test_unnormalizable_forbidden_entry_fails_closed(self):
|
||||||
|
# If a forbidden entry cannot be understood, deny rather than risk
|
||||||
|
# silently narrowing the forbidden set (which would widen permissions).
|
||||||
|
ok, reason = check_operation(
|
||||||
|
"gitea.read", ["gitea.read"], ["frobnicate"])
|
||||||
|
self.assertFalse(ok)
|
||||||
|
self.assertEqual(reason, "invalid-forbidden-entry")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Eligibility enforcement — normalization happens before checking (#106)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
class TestEligibilityNormalizesOperations(unittest.TestCase):
|
||||||
|
|
||||||
|
def _pr(self, author, state="open", sha="abc123", mergeable=True):
|
||||||
|
return {
|
||||||
|
"user": {"login": author},
|
||||||
|
"state": state,
|
||||||
|
"head": {"sha": sha},
|
||||||
|
"mergeable": mergeable,
|
||||||
|
}
|
||||||
|
|
||||||
|
@patch("mcp_server.api_request")
|
||||||
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
|
def test_namespaced_profile_ops_allow_legacy_action(self, _auth, mock_api):
|
||||||
|
# JSON-config profiles carry canonical namespaced ops; the raw action
|
||||||
|
# "merge" must still match them after normalization.
|
||||||
|
mock_api.side_effect = [{"login": "merger-bot"}, self._pr("author-bot")]
|
||||||
|
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
||||||
|
"GITEA_ALLOWED_OPERATIONS": "gitea.read,gitea.pr.merge"}
|
||||||
|
with patch.dict(os.environ, env, clear=True):
|
||||||
|
r = gitea_check_pr_eligibility(pr_number=9, action="merge",
|
||||||
|
remote="prgs")
|
||||||
|
self.assertTrue(r["eligible"])
|
||||||
|
self.assertNotIn("profile is not allowed to merge", r["reasons"])
|
||||||
|
|
||||||
|
@patch("mcp_server.api_request")
|
||||||
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
|
def test_namespaced_forbidden_op_blocks_legacy_action(self, _auth, mock_api):
|
||||||
|
mock_api.side_effect = [{"login": "merger-bot"}, self._pr("author-bot")]
|
||||||
|
env = {"GITEA_PROFILE_NAME": "gitea-merger",
|
||||||
|
"GITEA_ALLOWED_OPERATIONS": "gitea.read,gitea.pr.merge",
|
||||||
|
"GITEA_FORBIDDEN_OPERATIONS": "gitea.pr.merge"}
|
||||||
|
with patch.dict(os.environ, env, clear=True):
|
||||||
|
r = gitea_check_pr_eligibility(pr_number=9, action="merge",
|
||||||
|
remote="prgs")
|
||||||
|
self.assertFalse(r["eligible"])
|
||||||
|
self.assertIn("profile forbids 'merge'", r["reasons"])
|
||||||
|
|
||||||
|
@patch("mcp_server.api_request")
|
||||||
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
|
def test_legacy_env_ops_still_work(self, _auth, mock_api):
|
||||||
|
# v1/env behaviour stays compatible: unqualified env ops keep working.
|
||||||
|
mock_api.side_effect = [{"login": "reviewer-bot"}, self._pr("author-bot")]
|
||||||
|
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
||||||
|
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
|
||||||
|
with patch.dict(os.environ, env, clear=True):
|
||||||
|
r = gitea_check_pr_eligibility(pr_number=5, action="review",
|
||||||
|
remote="prgs")
|
||||||
|
self.assertTrue(r["eligible"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
"""Tests for scripts/release-tag (#50).
|
||||||
|
|
||||||
|
Each test builds a throwaway git repo with a LOCAL bare remote named per
|
||||||
|
RELEASE_TAG_REMOTE — no network, no pushing from the project repo, no real tags
|
||||||
|
created here. The test suite gate is stubbed via RELEASE_TAG_TEST_CMD (true =
|
||||||
|
pass, false = fail) so no real pytest/venv is needed inside the temp repo.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO = Path(__file__).resolve().parent.parent
|
||||||
|
SCRIPT_SRC = REPO / "scripts" / "release-tag"
|
||||||
|
REMOTE = "prgs"
|
||||||
|
|
||||||
|
|
||||||
|
def _git(cwd, *args):
|
||||||
|
return subprocess.run(["git", *args], cwd=str(cwd),
|
||||||
|
capture_output=True, text=True)
|
||||||
|
|
||||||
|
|
||||||
|
class _ReleaseTagCase(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = Path(tempfile.mkdtemp())
|
||||||
|
self.work = self.tmp / "work"
|
||||||
|
self.bare = self.tmp / "remote.git"
|
||||||
|
self.work.mkdir()
|
||||||
|
_git(self.work, "init", "-b", "master")
|
||||||
|
_git(self.work, "config", "user.email", "t@example.invalid")
|
||||||
|
_git(self.work, "config", "user.name", "Test")
|
||||||
|
(self.work / "README").write_text("hello\n")
|
||||||
|
# Install the script under test and commit it so the worktree is clean.
|
||||||
|
(self.work / "scripts").mkdir()
|
||||||
|
dst = self.work / "scripts" / "release-tag"
|
||||||
|
shutil.copy(SCRIPT_SRC, dst)
|
||||||
|
dst.chmod(0o755)
|
||||||
|
_git(self.work, "add", "README", "scripts/release-tag")
|
||||||
|
_git(self.work, "commit", "-m", "initial")
|
||||||
|
# Seed the bare remote by cloning the work repo (already has master +
|
||||||
|
# the script). Avoids `git push <remote> master`, which the harness blocks.
|
||||||
|
_git(self.tmp, "clone", "--bare", str(self.work), str(self.bare))
|
||||||
|
_git(self.work, "remote", "add", REMOTE, str(self.bare))
|
||||||
|
_git(self.work, "fetch", REMOTE, "--prune")
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
shutil.rmtree(self.tmp, ignore_errors=True)
|
||||||
|
|
||||||
|
def rt(self, *args, test_cmd="true"):
|
||||||
|
env = dict(os.environ, RELEASE_TAG_REMOTE=REMOTE,
|
||||||
|
RELEASE_TAG_TEST_CMD=test_cmd)
|
||||||
|
proc = subprocess.run(
|
||||||
|
["bash", str(self.work / "scripts" / "release-tag"), *args],
|
||||||
|
cwd=str(self.work), capture_output=True, text=True, env=env)
|
||||||
|
return proc.returncode, proc.stdout, proc.stderr
|
||||||
|
|
||||||
|
def tag_type(self, name):
|
||||||
|
r = _git(self.work, "cat-file", "-t", name)
|
||||||
|
return r.stdout.strip()
|
||||||
|
|
||||||
|
def local_tags(self):
|
||||||
|
return _git(self.work, "tag").stdout.split()
|
||||||
|
|
||||||
|
def remote_has_tag(self, name):
|
||||||
|
r = _git(self.work, "ls-remote", "--tags", REMOTE, f"refs/tags/{name}")
|
||||||
|
return bool(r.stdout.strip())
|
||||||
|
|
||||||
|
|
||||||
|
class TestValidation(_ReleaseTagCase):
|
||||||
|
|
||||||
|
def test_accepts_valid_semver_dry_run(self):
|
||||||
|
rc, out, err = self.rt("--dry-run", "v0.4.0")
|
||||||
|
self.assertEqual(rc, 0, err)
|
||||||
|
self.assertIn("would create annotated tag v0.4.0", out)
|
||||||
|
self.assertEqual(self.local_tags(), []) # dry-run creates nothing
|
||||||
|
|
||||||
|
def test_rejects_invalid_version(self):
|
||||||
|
for bad in ("v1.2", "1.0.0", "v1.0", "release-1", "vx.y.z"):
|
||||||
|
rc, _, err = self.rt(bad)
|
||||||
|
self.assertEqual(rc, 2, bad)
|
||||||
|
self.assertIn("invalid version", err)
|
||||||
|
|
||||||
|
def test_rejects_dirty_worktree(self):
|
||||||
|
(self.work / "README").write_text("dirty\n")
|
||||||
|
rc, _, err = self.rt("v0.4.0", "--skip-tests")
|
||||||
|
self.assertEqual(rc, 1)
|
||||||
|
self.assertIn("dirty", err)
|
||||||
|
|
||||||
|
def test_rejects_non_master_branch(self):
|
||||||
|
_git(self.work, "checkout", "-b", "feat/issue-1-x")
|
||||||
|
rc, _, err = self.rt("v0.4.0", "--skip-tests")
|
||||||
|
self.assertEqual(rc, 1)
|
||||||
|
self.assertIn("not on master", err)
|
||||||
|
|
||||||
|
def test_rejects_master_remote_mismatch(self):
|
||||||
|
(self.work / "extra").write_text("x\n")
|
||||||
|
_git(self.work, "add", "extra")
|
||||||
|
_git(self.work, "commit", "-m", "local-only") # not pushed
|
||||||
|
rc, _, err = self.rt("v0.4.0", "--skip-tests")
|
||||||
|
self.assertEqual(rc, 1)
|
||||||
|
self.assertIn("master", err)
|
||||||
|
|
||||||
|
def test_rejects_existing_local_tag(self):
|
||||||
|
_git(self.work, "tag", "-a", "v0.4.0", "-m", "pre-existing")
|
||||||
|
rc, _, err = self.rt("v0.4.0", "--skip-tests")
|
||||||
|
self.assertEqual(rc, 1)
|
||||||
|
self.assertIn("already exists", err)
|
||||||
|
|
||||||
|
def test_missing_notes_file_rejected(self):
|
||||||
|
rc, _, err = self.rt("v0.4.0", "--skip-tests", "--notes-file",
|
||||||
|
str(self.tmp / "nope.md"))
|
||||||
|
self.assertEqual(rc, 2)
|
||||||
|
self.assertIn("notes file not found", err)
|
||||||
|
|
||||||
|
|
||||||
|
class TestTagging(_ReleaseTagCase):
|
||||||
|
|
||||||
|
def test_creates_annotated_tag_not_lightweight(self):
|
||||||
|
rc, out, err = self.rt("v0.4.0", "--skip-tests")
|
||||||
|
self.assertEqual(rc, 0, err)
|
||||||
|
self.assertEqual(self.tag_type("v0.4.0"), "tag") # annotated, not "commit"
|
||||||
|
self.assertIn("tag_created: yes", out)
|
||||||
|
|
||||||
|
def test_no_push_without_flag(self):
|
||||||
|
rc, out, _ = self.rt("v0.5.0", "--skip-tests")
|
||||||
|
self.assertEqual(rc, 0)
|
||||||
|
self.assertIn("tag_pushed: no", out)
|
||||||
|
self.assertFalse(self.remote_has_tag("v0.5.0"))
|
||||||
|
|
||||||
|
def test_push_only_with_flag(self):
|
||||||
|
rc, out, err = self.rt("v0.6.0", "--skip-tests", "--push")
|
||||||
|
self.assertEqual(rc, 0, err)
|
||||||
|
self.assertIn("tag_pushed: yes", out)
|
||||||
|
self.assertTrue(self.remote_has_tag("v0.6.0"))
|
||||||
|
|
||||||
|
def test_notes_file_used_as_message(self):
|
||||||
|
notes = self.tmp / "notes.md"
|
||||||
|
notes.write_text("Release v0.4.0\n\n- #50 release-tag helper\n")
|
||||||
|
rc, _, err = self.rt("v0.4.0", "--skip-tests", "--notes-file", str(notes))
|
||||||
|
self.assertEqual(rc, 0, err)
|
||||||
|
msg = _git(self.work, "tag", "-n99", "-l", "v0.4.0").stdout
|
||||||
|
self.assertIn("release-tag helper", msg)
|
||||||
|
|
||||||
|
|
||||||
|
class TestTestsGate(_ReleaseTagCase):
|
||||||
|
|
||||||
|
def test_skip_tests_warns_and_skips(self):
|
||||||
|
rc, out, err = self.rt("v0.4.0", "--skip-tests")
|
||||||
|
self.assertEqual(rc, 0)
|
||||||
|
self.assertIn("WARNING", err)
|
||||||
|
self.assertIn("tests_run: no", out)
|
||||||
|
|
||||||
|
def test_default_runs_tests_and_failure_blocks_tag(self):
|
||||||
|
rc, out, err = self.rt("v0.4.0", test_cmd="false") # tests "fail"
|
||||||
|
self.assertEqual(rc, 1)
|
||||||
|
self.assertIn("tests failed", err)
|
||||||
|
self.assertEqual(self.local_tags(), []) # no tag on failure
|
||||||
|
|
||||||
|
def test_default_runs_tests_and_passes(self):
|
||||||
|
rc, out, err = self.rt("v0.4.0", test_cmd="true")
|
||||||
|
self.assertEqual(rc, 0, err)
|
||||||
|
self.assertIn("tests_run: yes", out)
|
||||||
|
self.assertEqual(self.tag_type("v0.4.0"), "tag")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
+29
-2
@@ -35,16 +35,43 @@ class TestWorktreeStart(unittest.TestCase):
|
|||||||
self.assertEqual(rc, 2)
|
self.assertEqual(rc, 2)
|
||||||
|
|
||||||
def test_refuses_existing_worktree(self):
|
def test_refuses_existing_worktree(self):
|
||||||
slug = f"zz-refuse-start-{os.getpid()}"
|
branch = f"fix/issue-999-refuse-{os.getpid()}"
|
||||||
|
slug = branch.replace("/", "-")
|
||||||
target = BRANCHES / slug
|
target = BRANCHES / slug
|
||||||
target.mkdir(parents=True, exist_ok=True)
|
target.mkdir(parents=True, exist_ok=True)
|
||||||
try:
|
try:
|
||||||
rc, _, err = run("worktree-start", "--dry-run", slug)
|
rc, _, err = run("worktree-start", "--dry-run", branch)
|
||||||
self.assertEqual(rc, 1)
|
self.assertEqual(rc, 1)
|
||||||
self.assertIn("Refusing to reuse", err)
|
self.assertIn("Refusing to reuse", err)
|
||||||
finally:
|
finally:
|
||||||
target.rmdir()
|
target.rmdir()
|
||||||
|
|
||||||
|
# -- issue-linked branch validation (#48) --------------------------------
|
||||||
|
|
||||||
|
def test_accepts_issue_linked_impl_branches(self):
|
||||||
|
for branch in ("fix/issue-123-example", "feat/issue-123-example",
|
||||||
|
"docs/issue-123-example", "chore/issue-123-example"):
|
||||||
|
rc, out, err = run("worktree-start", "--dry-run", branch)
|
||||||
|
self.assertEqual(rc, 0, f"{branch}: {err}")
|
||||||
|
self.assertIn(f"branches/{branch.replace('/', '-')}", out)
|
||||||
|
|
||||||
|
def test_accepts_review_branch(self):
|
||||||
|
rc, out, _ = run("worktree-start", "--dry-run", "review/pr-456-example")
|
||||||
|
self.assertEqual(rc, 0)
|
||||||
|
self.assertIn("branches/review-pr-456-example", out)
|
||||||
|
|
||||||
|
def test_rejects_untraceable_branches(self):
|
||||||
|
for branch in ("fix/random-name", "my-branch", "feat/no-issue-here",
|
||||||
|
"fix/issue-abc-x"):
|
||||||
|
rc, _, err = run("worktree-start", "--dry-run", branch)
|
||||||
|
self.assertEqual(rc, 2, branch)
|
||||||
|
self.assertIn("Untraceable branch name", err)
|
||||||
|
|
||||||
|
def test_allow_unlinked_override(self):
|
||||||
|
rc, out, _ = run("worktree-start", "--dry-run", "--allow-unlinked", "my-branch")
|
||||||
|
self.assertEqual(rc, 0)
|
||||||
|
self.assertIn("branches/my-branch", out)
|
||||||
|
|
||||||
|
|
||||||
class TestWorktreeReview(unittest.TestCase):
|
class TestWorktreeReview(unittest.TestCase):
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user