Compare commits
22 Commits
v1.1.0
...
9d6a2e0a5f
| Author | SHA1 | Date | |
|---|---|---|---|
| 9d6a2e0a5f | |||
| 205f089c44 | |||
| ff920a6496 | |||
| fbf1bc5f5c | |||
| 255cfc87dd | |||
| 8d2eb23237 | |||
| 7fa1bb9cfb | |||
| ed3cc106aa | |||
| 472e6850fe | |||
| e63cf5b5eb | |||
| 6dbd51b2a4 | |||
| 2e2da05eab | |||
| e9c67e7292 | |||
| 65ea7514d2 | |||
| 790c2c80b1 | |||
| cdc32669c7 | |||
| 3eff8d1cb3 | |||
| 8120486109 | |||
| 02c0c2023b | |||
| 4b61e80f39 | |||
| e730c391a2 | |||
| 31f5bf9975 |
@@ -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,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,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,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.
|
||||||
@@ -208,17 +208,18 @@ git diff --cached | grep -nEi "authorization: (basic|bearer)|password|token=[A-Z
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. Unit tests vs. future Docker integration tests
|
## 8. Unit tests vs. Docker integration tests
|
||||||
|
|
||||||
* **Unit tests (today, default):** fast, fully mocked, no network, no keychain.
|
* **Unit tests (default):** fast, fully mocked, no network, no keychain.
|
||||||
This is where the vast majority of coverage lives and where new tests should
|
This is where the vast majority of coverage lives and where new tests should
|
||||||
go. They must stay fast and must not require credentials.
|
go. They must stay fast and must not require credentials.
|
||||||
* **Docker/local-Gitea integration tests (planned, see #66):** opt-in and
|
* **Docker/local-Gitea integration tests (#66, `tests/integration/`):** opt-in
|
||||||
skipped by default, gated behind an explicit environment variable and run
|
and skipped by default — enabled only by `GITEA_INTEGRATION=1` and run
|
||||||
against a pinned, disposable Gitea container. They validate real API behavior
|
against a pinned, disposable Gitea container
|
||||||
(pagination, permissions, label/PR-review endpoints, error payloads) that
|
(`tests/integration/gitea-integration up|token|down`). They validate real
|
||||||
mocks cannot prove. They must not require production credentials and must not
|
API behavior (pagination, permissions, label endpoints, error payloads) that
|
||||||
leak tokens.
|
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
|
Rule of thumb: prove **logic and request-shaping** with unit tests; reserve
|
||||||
integration tests for **real-server compatibility**. Do not convert unit tests
|
integration tests for **real-server compatibility**. Do not convert unit tests
|
||||||
|
|||||||
@@ -124,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
|
||||||
|
|
||||||
@@ -200,7 +227,7 @@ tied to an issue number so the work is traceable end to end:
|
|||||||
| Issue | `#123` (claimed with `status:in-progress`) |
|
| Issue | `#123` (claimed with `status:in-progress`) |
|
||||||
| Branch | `(fix\|feat\|docs\|chore)/issue-123-<slug>` (review: `review/pr-456-<slug>`) |
|
| Branch | `(fix\|feat\|docs\|chore)/issue-123-<slug>` (review: `review/pr-456-<slug>`) |
|
||||||
| Worktree | `branches/fix-issue-123-<slug>` (slashes → hyphens) |
|
| Worktree | `branches/fix-issue-123-<slug>` (slashes → hyphens) |
|
||||||
| PR | body says `Closes #123` (closes) or `Refs #123` (related) |
|
| 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` |
|
| Cleanup | remove remote+local branch + worktree folder; drop `status:in-progress` |
|
||||||
|
|
||||||
`scripts/worktree-start` **rejects** implementation branches that are not
|
`scripts/worktree-start` **rejects** implementation branches that are not
|
||||||
@@ -312,22 +339,25 @@ 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 / Reconciliation
|
### 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; release `status:in-progress` (if it cannot be removed, report why).
|
- Run: `git fetch <remote> --prune; git checkout master; git pull <remote> master --ff-only`
|
||||||
|
- Verify that 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 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`.
|
- **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.
|
- **fully landed:** comment it landed, remove `status:in-progress`, clean up.
|
||||||
- **partially landed:** reopen issue, create corrective PR for missing pieces.
|
- **partially landed:** reopen issue, create corrective PR for missing pieces.
|
||||||
- **not landed:** reopen issue/PR, do not clean up.
|
- **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.
|
- **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).
|
- **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 confirming master contains the merge of PR #N, close issue #M and delete the merged branch.`
|
- **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.`
|
- **Prompt (reconcile):** `Reconcile closed-not-merged PR #N by verifying if its content landed on master.`
|
||||||
|
|
||||||
### Stop on blocker
|
### Stop on blocker
|
||||||
@@ -337,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,
|
||||||
@@ -361,6 +423,9 @@ with the profile and authenticated user when `GITEA_AUDIT_LOG` is set (see
|
|||||||
|
|
||||||
## Releases and version tags
|
## Releases and version tags
|
||||||
|
|
||||||
|
All release tagging, version bumps, and validation must comply with the [Release / Version Process SOP](release-version-sop.md).
|
||||||
|
|
||||||
|
|
||||||
Versions follow SemVer — **`vMAJOR.MINOR.PATCH`**, using **`v0.x.y`** while
|
Versions follow SemVer — **`vMAJOR.MINOR.PATCH`**, using **`v0.x.y`** while
|
||||||
unstable. Pick the bump by the largest change since the last tag:
|
unstable. Pick the bump by the largest change since the last tag:
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
# Release / Version Process SOP
|
||||||
|
|
||||||
|
Operator standard operating procedure for cutting a versioned release of
|
||||||
|
Gitea-Tools: version bump, checks, merge, tag, and cleanup.
|
||||||
|
|
||||||
|
> **Scope.** This is the **human/operator** SOP. It is deliberately distinct
|
||||||
|
> from [`release-workflows.md`](release-workflows.md), which describes the
|
||||||
|
> **future `release-mcp` orchestrator** boundary (a coordination concept), not
|
||||||
|
> the day-to-day tagging process. When they disagree, this document governs how
|
||||||
|
> a release is actually cut today.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Branch flow
|
||||||
|
|
||||||
|
The repo is **`master`-based**. Releases are cut from `master`; there is no
|
||||||
|
separate `dev`/`release` branch unless and until that is explicitly introduced
|
||||||
|
and this SOP is updated to match. All work lands on `master` via reviewed PRs
|
||||||
|
from short-lived, issue-linked branches (e.g. `docs/issue-68-...`).
|
||||||
|
|
||||||
|
## 2. Where "the version" lives
|
||||||
|
|
||||||
|
There is **no `VERSION` file and no `CHANGELOG` file** in the repo today. The
|
||||||
|
released version is expressed **only as an annotated git tag** of the form
|
||||||
|
`vMAJOR.MINOR.PATCH` (existing tags: `v1.0.0`, `v1.0.1`). Release notes are
|
||||||
|
carried as the **annotated tag's message** (via `--notes-file`), not a tracked
|
||||||
|
changelog.
|
||||||
|
|
||||||
|
> Do **not** confuse this with `SUPPORTED_VERSION` in `gitea_config.py` — that is
|
||||||
|
> the **config-schema** version, unrelated to the application release version.
|
||||||
|
|
||||||
|
If a `VERSION`/`CHANGELOG` file is added later, update this SOP to list it under
|
||||||
|
"files to update".
|
||||||
|
|
||||||
|
## 3. Deciding the version bump (SemVer)
|
||||||
|
|
||||||
|
Pick the bump against the last tag using semantic-versioning intent:
|
||||||
|
|
||||||
|
* **PATCH** (`v1.0.1 → v1.0.2`): bug fixes, docs, tests, internal cleanups — no
|
||||||
|
change to tool names, parameters, return payloads, or behavior.
|
||||||
|
* **MINOR** (`v1.0.1 → v1.1.0`): backward-compatible additions — new MCP tool,
|
||||||
|
new optional parameter, new script, additive behavior.
|
||||||
|
* **MAJOR** (`v1.1.0 → v2.0.0`): backward-**incompatible** changes — renamed or
|
||||||
|
removed tools, changed return-payload shape, changed default behavior, or a
|
||||||
|
tightened safety gate that rejects previously-accepted input.
|
||||||
|
|
||||||
|
When unsure between two levels, choose the higher one.
|
||||||
|
|
||||||
|
## 4. Preparing a version-bump / release PR
|
||||||
|
|
||||||
|
Releases are still gated by the normal issue-first, PR-reviewed flow.
|
||||||
|
|
||||||
|
1. Open (or use) a tracking issue for the release and **claim it** with
|
||||||
|
`status:in-progress` (see §9).
|
||||||
|
2. Create an isolated, issue-linked branch + worktree from latest `master`
|
||||||
|
(e.g. `chore/issue-63-v1.1.0`). Never commit directly to `master`.
|
||||||
|
3. Include in the PR:
|
||||||
|
* Any code/docs changes that belong to the release.
|
||||||
|
* The **release notes** for the annotated tag (draft them in the PR body or a
|
||||||
|
notes file you will pass to `scripts/release-tag --notes-file`).
|
||||||
|
* If a `VERSION`/`CHANGELOG` file exists at that time, its update.
|
||||||
|
4. Open the PR **targeting `master`**.
|
||||||
|
|
||||||
|
The tag is **not** created in the PR. Tagging happens only after merge (§6).
|
||||||
|
|
||||||
|
## 5. Required checks before release
|
||||||
|
|
||||||
|
Run all of these green before merging the release PR and before tagging:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m py_compile mcp_server.py
|
||||||
|
python3 -m py_compile manage_labels.py
|
||||||
|
bash -n scripts/clear-provenance
|
||||||
|
./venv/bin/python -m pytest tests/ -q
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
Plus a secret sweep (there is no third-party scanner wired in; do a staged-diff
|
||||||
|
sweep — see [`developer-testing-guidelines.md`](developer-testing-guidelines.md)
|
||||||
|
§7):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git diff --cached | grep -nEi "authorization: (basic|bearer)|password[:=]|token=[A-Za-z0-9]" || echo "clean"
|
||||||
|
```
|
||||||
|
|
||||||
|
`scripts/release-tag` **also** runs the test suite itself before tagging (unless
|
||||||
|
`--skip-tests` is passed), so tests are enforced twice by default.
|
||||||
|
|
||||||
|
## 6. Running `scripts/release-tag`
|
||||||
|
|
||||||
|
Tag **only after** the release PR is merged to `master`. `scripts/release-tag`
|
||||||
|
enforces the tagging policy and is **safe by default** (creates nothing on a
|
||||||
|
dry-run; never pushes without `--push`).
|
||||||
|
|
||||||
|
Before it tags, it requires **all** of:
|
||||||
|
|
||||||
|
* version matches `vMAJOR.MINOR.PATCH` (SemVer);
|
||||||
|
* `fetch --prune` has run;
|
||||||
|
* you are **on `master`**;
|
||||||
|
* the worktree is **clean** (no uncommitted changes);
|
||||||
|
* local `master` **equals** `<remote>/master`;
|
||||||
|
* `HEAD` is that same commit (the commit is present on remote master);
|
||||||
|
* the tag does **not** already exist locally or on the remote;
|
||||||
|
* the test suite passes (unless `--skip-tests`, which warns).
|
||||||
|
|
||||||
|
Typical sequence:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Dry-run to confirm the plan (changes nothing)
|
||||||
|
scripts/release-tag --dry-run v1.1.0
|
||||||
|
|
||||||
|
# 2. Create the annotated tag locally, with release notes
|
||||||
|
scripts/release-tag v1.1.0 --notes-file /path/to/release-notes.md
|
||||||
|
|
||||||
|
# 3. Push the tag only when ready
|
||||||
|
scripts/release-tag v1.1.0 --notes-file /path/to/release-notes.md --push
|
||||||
|
```
|
||||||
|
|
||||||
|
Env injection points (mainly for CI/tests):
|
||||||
|
`RELEASE_TAG_REMOTE` (default `prgs`), `RELEASE_TAG_TEST_CMD`
|
||||||
|
(default `./venv/bin/python -m pytest tests/ -q`).
|
||||||
|
|
||||||
|
## 7. Who may merge / tag
|
||||||
|
|
||||||
|
* The release PR must be **merged by someone other than its author** — the
|
||||||
|
author-cannot-merge safety gate applies to releases exactly as to any other PR.
|
||||||
|
* Merge uses the gated `gitea_merge_pr` workflow; CLI/legacy merge is disabled.
|
||||||
|
* Whoever tags must operate on clean master synced to the remote (enforced by
|
||||||
|
`scripts/release-tag`). Tagging is an operator action performed after merge.
|
||||||
|
|
||||||
|
## 8. Self-review / self-merge restrictions
|
||||||
|
|
||||||
|
Release PRs are **not** exempt from the safety model:
|
||||||
|
|
||||||
|
* No self-review — the author may not approve their own release PR.
|
||||||
|
* No self-merge — a different eligible identity merges.
|
||||||
|
* These gates are enforced by the MCP tooling and must not be bypassed.
|
||||||
|
|
||||||
|
## 9. Handling `status:in-progress` during release work
|
||||||
|
|
||||||
|
* **Claim** the release tracking issue with `status:in-progress` before starting.
|
||||||
|
* Keep it claimed while the release PR is open and under review.
|
||||||
|
* On merge/close, the tracker-hygiene automation releases `status:in-progress`
|
||||||
|
for issues the PR closes; if it remains after the release lands, release it
|
||||||
|
explicitly. Do not leave a shipped release issue marked in-progress.
|
||||||
|
|
||||||
|
## 10. Branch / worktree cleanup after merge
|
||||||
|
|
||||||
|
After the release PR merges and the tag is pushed:
|
||||||
|
|
||||||
|
* Delete the remote release branch (if repo policy allows).
|
||||||
|
* Remove the local worktree and delete the local branch:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git worktree remove branches/<release-worktree>
|
||||||
|
git branch -d <release-branch>
|
||||||
|
git worktree prune
|
||||||
|
```
|
||||||
|
* Confirm the root repo is clean and on `master` synced to the remote.
|
||||||
|
|
||||||
|
## 11. What NOT to do
|
||||||
|
|
||||||
|
* **No direct commits to `master`.** All changes land via reviewed PRs.
|
||||||
|
* **No force-push** (to `master` or to tags).
|
||||||
|
* **No self-merge** of a release PR.
|
||||||
|
* **No tagging before merge** — tag only commits already on remote `master`.
|
||||||
|
* **No release from a dirty worktree** — `scripts/release-tag` refuses, and so
|
||||||
|
should you.
|
||||||
|
* **No `--skip-tests`** for a real release unless there is an explicit,
|
||||||
|
documented reason.
|
||||||
|
* **No re-tagging / moving an existing tag** — pick the next version instead.
|
||||||
|
|
||||||
|
## 12. Post-Merge Verification & Audit Lessons (v1.1.0)
|
||||||
|
|
||||||
|
During the v1.1.0 release audit, we identified a critical reconciliation issue (captured in historical PRs/issues #68 and #82):
|
||||||
|
* **The "Closed" State Trap:** Gitea PRs marked as `closed` are not guaranteed to be `merged` (they can be closed without merging, leading to silent omissions of code/documentation changes).
|
||||||
|
* **Mandatory Post-Merge File/Commit Presence Probe:** Reviewers/mergers must perform explicit post-merge validation. Do not assume a merge succeeded.
|
||||||
|
- Check that the merged branch head is an ancestor of the target branch (`master`):
|
||||||
|
```bash
|
||||||
|
git fetch <remote> --prune
|
||||||
|
git merge-base --is-ancestor <pr-head-sha> <remote>/master
|
||||||
|
```
|
||||||
|
- Probe file presence for expected modifications/additions:
|
||||||
|
```bash
|
||||||
|
git log --oneline -- <expected-file>
|
||||||
|
# and confirm file presence:
|
||||||
|
ls -la docs/release-version-sop.md
|
||||||
|
```
|
||||||
|
* **Verify in Handoff:** Final report blocks must explicitly document the verification method and probe results.
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
-3
@@ -123,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:
|
||||||
|
|||||||
+634
-10
@@ -54,11 +54,67 @@ 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")
|
||||||
|
|
||||||
|
# ── Minimal operation normalization (#103) ─────────────────────────────────────
|
||||||
|
# Only what the #103 invariants need. The full normalization table, deprecation
|
||||||
|
# handling, and enforcement test matrix belong to issue #106 — do not grow this
|
||||||
|
# beyond invariant safety here.
|
||||||
|
_MINIMAL_GITEA_OP_MAP = {
|
||||||
|
"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_op(service, op, addr):
|
||||||
|
"""Normalize *op* for *service*, or fail closed (#103 minimal subset).
|
||||||
|
|
||||||
|
- already namespaced for this service (``{service}.*``) → unchanged
|
||||||
|
- known unqualified Gitea ops → mapped via ``_MINIMAL_GITEA_OP_MAP``
|
||||||
|
- unqualified single-word ops on non-Gitea services → ``{service}.{op}``
|
||||||
|
- anything else (foreign prefixes, unknown unqualified names) → ConfigError
|
||||||
|
"""
|
||||||
|
if not isinstance(op, str) or not op:
|
||||||
|
raise ConfigError(f"identity '{addr}' has an empty or non-string operation")
|
||||||
|
if op.startswith(service + "."):
|
||||||
|
return op
|
||||||
|
if service == "gitea" and op in _MINIMAL_GITEA_OP_MAP:
|
||||||
|
return _MINIMAL_GITEA_OP_MAP[op]
|
||||||
|
if service != "gitea" and "." not in op:
|
||||||
|
return f"{service}.{op}"
|
||||||
|
raise ConfigError(
|
||||||
|
f"identity '{addr}' has operation {op!r} that cannot be normalized "
|
||||||
|
f"safely for service '{service}' (fail closed; full table is issue #106)"
|
||||||
|
)
|
||||||
|
|
||||||
# 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 +164,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 +737,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 +889,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 +1054,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)
|
||||||
|
|||||||
+80
-14
@@ -43,6 +43,16 @@ from gitea_auth import ( # noqa: E402
|
|||||||
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. "
|
||||||
@@ -53,7 +63,7 @@ mcp = FastMCP("gitea-tools", instructions=(
|
|||||||
def extract_linked_issue_numbers(text: str | None, branch_name: str | None = None) -> list[int]:
|
def extract_linked_issue_numbers(text: str | None, branch_name: str | None = None) -> list[int]:
|
||||||
issues = set()
|
issues = set()
|
||||||
if text:
|
if text:
|
||||||
pattern = re.compile(r'(?i)(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?|ref[s]?)\s+#(\d+)')
|
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))
|
issues.update(int(m) for m in pattern.findall(text))
|
||||||
if branch_name:
|
if branch_name:
|
||||||
pattern = re.compile(r'(?i)issue-(\d+)')
|
pattern = re.compile(r'(?i)issue-(\d+)')
|
||||||
@@ -1098,16 +1108,30 @@ 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
|
||||||
)
|
)
|
||||||
result["merge_commit"] = (merged or {}).get("merged_commit_sha")
|
result["merge_commit"] = (merged or {}).get("merged_commit_sha")
|
||||||
|
|
||||||
cleanup = cleanup_in_progress_for_pr(merged or {}, remote, host, org, repo)
|
|
||||||
result["cleanup_status"] = cleanup.get("cleanup_status")
|
|
||||||
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
|
||||||
@@ -1368,21 +1392,26 @@ 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
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1413,9 +1442,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,
|
||||||
@@ -1433,18 +1464,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.
|
||||||
@@ -1453,7 +1491,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:
|
||||||
@@ -1473,6 +1512,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,
|
||||||
|
|||||||
@@ -41,6 +41,18 @@ editing, deleting, or `chmod`-ing files; docs; scripts; commits; pushes; and PRs
|
|||||||
Reading the repo, running read-only status/`git log`, and creating/claiming the
|
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.
|
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
|
## B. Isolated worktree rule
|
||||||
|
|
||||||
**Never implement or review in the main checkout.** The main checkout is for
|
**Never implement or review in the main checkout.** The main checkout is for
|
||||||
@@ -78,8 +90,8 @@ is maintained by:
|
|||||||
- the branch name (contains the issue number),
|
- the branch name (contains the issue number),
|
||||||
- a claim comment on the issue, e.g.
|
- a claim comment on the issue, e.g.
|
||||||
`Claimed. Branch: fix/issue-123-short-description. Worktree: branches/fix-issue-123-short-description.`,
|
`Claimed. Branch: fix/issue-123-short-description. Worktree: branches/fix-issue-123-short-description.`,
|
||||||
- the PR body — `Closes #123` when the PR should close the issue, `Refs #123`
|
- the PR body — `Closes #123` or `Fixes #123` when the PR should close the issue
|
||||||
when related but not closing,
|
(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
|
- cleanup after merge — remove the remote branch, local branch, and the issue
|
||||||
worktree folder, and drop `status:in-progress`.
|
worktree folder, and drop `status:in-progress`.
|
||||||
|
|
||||||
@@ -104,16 +116,17 @@ interpreter path, or create a venv inside the branch folder.
|
|||||||
|
|
||||||
## C. Identity and profile safety
|
## C. Identity and profile safety
|
||||||
|
|
||||||
- Use canonical execution profiles where available; the profile is the role, not
|
- 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.
|
||||||
the LLM. A task selects a profile; a profile is not permanently assigned.
|
|
||||||
- **Author and reviewer identities must be distinct.**
|
- **Author and reviewer identities must be distinct.**
|
||||||
- Never place raw tokens/passwords in an LLM/MCP client config. Reference secrets
|
- 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.:
|
||||||
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_CONFIG` — path to the canonical profiles file
|
||||||
- `GITEA_MCP_PROFILE` — the profile to activate
|
- `GITEA_MCP_PROFILE` — the profile to activate
|
||||||
- **If the authenticated user equals the PR author, stop** — no self-review, no
|
- **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`.
|
||||||
self-merge.
|
- 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
|
## D. Branch naming
|
||||||
|
|
||||||
@@ -162,9 +175,14 @@ Worktree folder = branch with `/` replaced by `-`
|
|||||||
|
|
||||||
## G. Merge / cleanup workflow
|
## G. Merge / cleanup workflow
|
||||||
|
|
||||||
Only an eligible (non-author) reviewer merges. After a real merge:
|
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 (A PR is not done just because `master` moved. A PR is done only when: Gitea reports the PR merged or reconciliation documents equivalent content on `master`; remote `master` contains the expected content; linked issues are closed; `status:in-progress` is removed).
|
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.
|
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.
|
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.
|
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.
|
||||||
@@ -172,7 +190,7 @@ Only an eligible (non-author) reviewer merges. After a real merge:
|
|||||||
6. Remove the branch worktree folder (`scripts/worktree-clean --delete-branch <branch>`). Branches/worktrees are cleaned only after the above is verified.
|
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.
|
7. Fetch/prune.
|
||||||
8. Confirm the main checkout is clean and current (`0 0` vs remote).
|
8. Confirm the main checkout is clean and current (`0 0` vs remote).
|
||||||
9. Final merge/reconciliation reports must include both: PR metadata (state, merged flag, merge commit/hash) and Git content (remote master hash, expected content present or not).
|
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`.
|
Never run cleanup before the merge is confirmed on remote `master`.
|
||||||
|
|
||||||
@@ -230,6 +248,169 @@ Ready-to-copy templates live in [`templates/`](templates/):
|
|||||||
- [`worktree-cleanup.md`](templates/worktree-cleanup.md) — clean up after merge.
|
- [`worktree-cleanup.md`](templates/worktree-cleanup.md) — clean up after merge.
|
||||||
- [`release-tag.md`](templates/release-tag.md) — create a release tag.
|
- [`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
|
## Adapting to a project
|
||||||
|
|
||||||
Replace these project-specific names when copying the skill elsewhere:
|
Replace these project-specific names when copying the skill elsewhere:
|
||||||
@@ -242,7 +423,7 @@ Replace these project-specific names when copying the skill elsewhere:
|
|||||||
| `branches/` | Ignored worktree directory | `branches/` |
|
| `branches/` | Ignored worktree directory | `branches/` |
|
||||||
| helper scripts | Worktree helpers | `scripts/worktree-start` / `-review` / `-clean` |
|
| helper scripts | Worktree helpers | `scripts/worktree-start` / `-review` / `-clean` |
|
||||||
|
|
||||||
The rules in §A–§I are project-agnostic and should not change.
|
The rules in §A–§K are project-agnostic and should not change.
|
||||||
|
|
||||||
## Versioning And Tagging
|
## Versioning And Tagging
|
||||||
|
|
||||||
@@ -263,6 +444,14 @@ Tags must:
|
|||||||
**Never tag** feature branches, dirty worktrees, unreviewed or self-authored
|
**Never tag** feature branches, dirty worktrees, unreviewed or self-authored
|
||||||
work, or commits not present on remote `master`.
|
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)):
|
Release process (see [`templates/release-tag.md`](templates/release-tag.md)):
|
||||||
|
|
||||||
1. `git fetch <remote> --prune`.
|
1. `git fetch <remote> --prune`.
|
||||||
|
|||||||
@@ -13,17 +13,27 @@ Rules (llm-project-workflow):
|
|||||||
- If the PR is closed but `merged=false`, STOP and run reconciliation. Do not clean up.
|
- If the PR is closed but `merged=false`, STOP and run reconciliation. Do not clean up.
|
||||||
|
|
||||||
Steps:
|
Steps:
|
||||||
1. Verify authenticated identity + active profile.
|
1. Identity Checklist: Before claiming/working on merge, verify and state:
|
||||||
2. Confirm PR #<pr>: author (not you), state open, mergeable, review approved.
|
- Required identity/profile for this task: merger (allowed to merge PRs)
|
||||||
3. If any gate fails → STOP and report.
|
- 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>"),
|
4. Merge with explicit confirmation (e.g. confirmation="MERGE PR <pr>"),
|
||||||
optionally pinning the reviewed head SHA / changed-file set.
|
optionally pinning the reviewed head SHA / changed-file set.
|
||||||
5. Confirm remote master now contains the merge commit.
|
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):
|
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)
|
- close/release issue #<n>, remove status:in-progress (if it cannot be removed, report why)
|
||||||
- delete remote branch, remove local branch + worktree folder
|
- delete remote branch, remove local branch + worktree folder
|
||||||
- fetch/prune; confirm main checkout is clean and current (0 0).
|
- 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 & Git content check.
|
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.
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -13,13 +13,18 @@ Rules (llm-project-workflow):
|
|||||||
- Do not merge if any check fails.
|
- Do not merge if any check fails.
|
||||||
|
|
||||||
Steps:
|
Steps:
|
||||||
1. Verify your authenticated identity (whoami) and the active profile.
|
1. Identity Checklist: Before claiming/working on review, verify and state:
|
||||||
2. Fetch the PR facts: PR author, head SHA, state (must be open), base branch.
|
- Required identity/profile for this task: reviewer (allowed to review/approve/request_changes)
|
||||||
3. If authenticated user == PR author → STOP (no self-review).
|
- 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-*
|
4. scripts/worktree-review <pr-head-branch> # detached, branches/review-*
|
||||||
cd branches/review-<pr-head-branch-slug>
|
cd branches/review-<pr-head-branch-slug>
|
||||||
5. Confirm the worktree is clean. Inspect the FULL diff; confirm scope matches
|
5. Confirm the worktree is clean. Inspect the FULL diff; confirm scope matches
|
||||||
issue #<n>; flag any unrelated files, secrets, or formatting churn.
|
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.
|
6. Run the test suite; note results.
|
||||||
7. Post the review verdict: approve only if scope is clean and checks pass;
|
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.
|
otherwise request changes with specifics. Never merge from this review step.
|
||||||
@@ -32,5 +37,7 @@ Steps:
|
|||||||
- MCP-Profile: <profile name>
|
- MCP-Profile: <profile name>
|
||||||
- Eligibility: passed/failed
|
- Eligibility: passed/failed
|
||||||
|
|
||||||
Handoff: reviewer identity, PR author, scope verdict, checks + results, decision.
|
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.
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -13,16 +13,22 @@ Rules (llm-project-workflow):
|
|||||||
- Do not self-review or self-merge.
|
- Do not self-review or self-merge.
|
||||||
|
|
||||||
Steps:
|
Steps:
|
||||||
1. Verify the orchestration checkout is the right repo and clean.
|
1. Identity Checklist: Before claiming work, verify and state:
|
||||||
2. git fetch <remote> --prune; confirm local master == <remote>/master (0 0).
|
- Required identity/profile for this task: author (allowed to push branches / create PRs)
|
||||||
3. Create the issue "<title>" (problem, scope, acceptance) and claim it
|
- 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).
|
(status:in-progress + a "starting work" comment naming the branch).
|
||||||
4. scripts/worktree-start <type>/issue-<n>-<slug> # type = fix|feat|docs
|
5. scripts/worktree-start <type>/issue-<n>-<slug> # type = fix|feat|docs
|
||||||
cd branches/<type>-issue-<n>-<slug>
|
cd branches/<type>-issue-<n>-<slug>
|
||||||
5. Implement the narrow scope only; add/update focused tests if behavior changes.
|
6. Implement the narrow scope only; add/update focused tests if behavior changes.
|
||||||
6. Checks: run the test suite, compile/lint changed files, git diff --check,
|
7. Checks: run the test suite, compile/lint changed files, git diff --check,
|
||||||
and scan the diff for secrets.
|
and scan the diff for secrets.
|
||||||
7. Commit (issue-linked message), push the branch, open a PR to master.
|
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;
|
Include an "LLM Handoff Metadata" block in the PR body (attribution only;
|
||||||
never an eligibility input — docs/llm-agent-sha.md):
|
never an eligibility input — docs/llm-agent-sha.md):
|
||||||
|
|
||||||
@@ -34,7 +40,9 @@ Steps:
|
|||||||
- Branch: <branch>
|
- Branch: <branch>
|
||||||
- Worktree: <worktree path>
|
- Worktree: <worktree path>
|
||||||
- Self-review allowed: no
|
- Self-review allowed: no
|
||||||
8. Stop before review/merge — you are the author.
|
9. Stop before review/merge — you are the author.
|
||||||
|
|
||||||
Handoff: issue #, branch, worktree path, files changed, checks + results, PR URL.
|
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,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
|
||||||
+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()
|
||||||
+214
-3
@@ -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
|
||||||
@@ -370,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")
|
||||||
@@ -831,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
|
||||||
@@ -986,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"))
|
||||||
@@ -1600,3 +1656,158 @@ class TestTrackerHygieneCleanup(unittest.TestCase):
|
|||||||
res = gitea_close_issue(issue_number=1)
|
res = gitea_close_issue(issue_number=1)
|
||||||
self.assertTrue(res["success"])
|
self.assertTrue(res["success"])
|
||||||
self.assertIn("error:", res["cleanup_status"].get(1))
|
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"])
|
||||||
|
|||||||
Reference in New Issue
Block a user