10 Commits

Author SHA1 Message Date
sysadmin 472e6850fe fix: close implements tracker gap and clarify closing keywords (#110) 2026-07-02 19:46:21 -04:00
sysadmin 2e2da05eab docs: document dual-profile MCP launcher pattern and add identity checklist (#109) (#113)
Co-authored-by: Jason Walker <913443@dadeschools.net>
Co-committed-by: Jason Walker <913443@dadeschools.net>
2026-07-02 18:18:43 -05:00
sysadmin 790c2c80b1 docs: require Controller Handoff Summary + codify LLM workflow rules (#101) (#102)
Co-authored-by: Jason Walker <913443@dadeschools.net>
Co-committed-by: Jason Walker <913443@dadeschools.net>
2026-07-02 17:25:26 -05:00
sysadmin cdc32669c7 Merge pull request 'fix: surface skipped tracker cleanup on merge read-back failure (#98)' (#99) from fix/issue-98-merge-cleanup-status into master 2026-07-02 15:32:47 -05:00
sysadmin 3eff8d1cb3 feat: opt-in Docker-based Gitea integration test suite (#66) (#97)
Co-authored-by: Jason Walker <913443@dadeschools.net>
Co-committed-by: Jason Walker <913443@dadeschools.net>
2026-07-02 15:31:56 -05:00
sysadmin 8120486109 fix: surface skipped tracker cleanup on merge read-back failure (#98)
gitea_merge_pr ran cleanup_in_progress_for_pr inside the same try as the
post-merge read-back GET; a read-back failure silently skipped tracker
cleanup, leaving only merge_commit=null and no cleanup_status at all, so
status:in-progress could stay stuck while the merge read as full success.

Split the block: read-back failure now returns an explicit
cleanup_status='skipped (merge read-back failed)', and an unexpected
cleanup exception returns 'skipped (cleanup error: <redacted>)' instead of
masking merge_commit. Cleanup still never blocks a performed merge, the
happy-path API call sequence is unchanged, and _redact keeps credentials
out of surfaced errors.

Add regression tests: read-back failure => merge still performed, explicit
skip status, no tracker DELETE traffic; cleanup exception => surfaced and
redacted.

Fixes #98.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 16:15:47 -04:00
sysadmin 02c0c2023b docs: design Gitea tools refactor compatibility matrix and stages (#65) (#96)
Co-authored-by: Jason Walker <913443@dadeschools.net>
Co-committed-by: Jason Walker <913443@dadeschools.net>
2026-07-02 15:01:44 -05:00
sysadmin 4b61e80f39 docs: design GlitchTip/Gitea deduplication and linking (#78) (#95)
Co-authored-by: Jason Walker <913443@dadeschools.net>
Co-committed-by: Jason Walker <913443@dadeschools.net>
2026-07-02 15:01:37 -05:00
jcwalker3 e730c391a2 Merge pull request 'docs: design glitchtip to gitea issue filing workflow (#74)' (#94) from docs/issue-74-glitchtip-filing-workflow into master 2026-07-02 14:57:53 -05:00
sysadmin 31f5bf9975 docs: design glitchtip to gitea issue filing workflow (#74) 2026-07-02 15:48:23 -04:00
17 changed files with 872 additions and 43 deletions
@@ -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.
+9 -8
View File
@@ -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
+53 -3
View File
@@ -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
@@ -337,6 +364,29 @@ 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 Summary (required, every task)
Every task — implementation, review, merge, triage, documentation,
discussion-only, or blocked planning — **must end with a
`Controller Handoff Summary`** so a controller LLM can pick up the state
without rereading the conversation. The canonical format and rules live in the
portable skill:
[`../skills/llm-project-workflow/SKILL.md`](../skills/llm-project-workflow/SKILL.md) §K.
Sections (in order): Work performed · Current state (repo, branch/master
commit, issue #s, PR #s, complete/blocked/ready-for-review/discussion-only) ·
Files changed · Validation · Issues encountered · Review needed? (one of the
five fixed answers) · Next recommended action · Safety confirmations
(no self-review; no self-merge; no release/tag changes unless requested; no
secrets; no production access unless authorized).
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 summary.
## 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,
+18 -4
View File
@@ -53,7 +53,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 +1098,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
+163 -11
View File
@@ -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 Summary (§K).
- **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,7 +175,12 @@ 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 (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).
2. Close/release the issue. 2. Close/release the issue.
@@ -230,6 +248,132 @@ 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 Summary (required, every task)
Every LLM task **must end with a `Controller Handoff Summary`** — 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.
Rules:
- Never omit the summary.
- 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.
Required format:
```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 +386,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 +407,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,9 +13,14 @@ 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.
@@ -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 as the Controller Handoff Summary (SKILL.md §K); if you could not
merge, name the exact gate that blocked it.
``` ```
@@ -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 Controller Handoff Summary (SKILL.md §K); end with
"Review needed — PR is open".
``` ```
+60
View File
@@ -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).
View File
+65
View File
@@ -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
+19
View File
@@ -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:
+59
View File
@@ -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
+115
View File
@@ -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
+69
View File
@@ -370,6 +370,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")
@@ -1600,3 +1649,23 @@ 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])