Compare commits
38 Commits
d1d2bc2505
..
v1.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 0e2840b76c | |||
| d8269fc704 | |||
| 0d6d31d341 | |||
| ba6064e51e | |||
| fac10dd6cc | |||
| 0cdbf63660 | |||
| 6f9699b51a | |||
| 58e4bcd157 | |||
| 0a61e8a92d | |||
| fd68c439b2 | |||
| 4f5b732741 | |||
| 1bc2f20623 | |||
| 007b5dad14 | |||
| 104907e311 | |||
| 74a7e8f792 | |||
| 53e061bafd | |||
| cfe3ff6755 | |||
| 093945254d | |||
| 625f835aa7 | |||
| be4cd82c37 | |||
| afa57fa65c | |||
| 2d5cb4bb29 | |||
| 1441591e74 | |||
| 848a4294ba | |||
| 496e796cdd | |||
| 642adf4705 | |||
| e842b60ad8 | |||
| 3a246ab553 | |||
| dbfa0fe188 | |||
| b3728c54ce | |||
| 4afada098c | |||
| 6089ec724a | |||
| c6c6e75af6 | |||
| 4e43347b2d | |||
| ec9ddb09a7 | |||
| f18cecc998 | |||
| 00ec883014 | |||
| 92b449f080 |
@@ -0,0 +1,41 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.1.0] - 2026-07-02
|
||||
|
||||
### Added
|
||||
- Read-only identity and eligibility tooling: `gitea_whoami` authenticated-user lookup (#11), `gitea_get_profile` runtime-profile discovery (#13), and `gitea_check_pr_eligibility` fail-closed PR eligibility checks (#14).
|
||||
- Identity lookup aliases (`gitea_get_authenticated_user` and `gitea_get_current_user`) for common MCP/LLM tool discovery (#9).
|
||||
- Gated PR review actions (`gitea_submit_pr_review`) reusing the eligibility gates (#15).
|
||||
- Gated PR merge workflow (`gitea_merge_pr`) with explicit `MERGE PR <n>` confirmation, head-SHA and changed-file pinning, and self-merge blocking as the only merge path (#16).
|
||||
- Task-scoped Gitea MCP execution profiles: documented profile model (#12) and runtime profiles via environment config with `allowed_operations` (#19).
|
||||
- Audit logging for all mutating MCP actions with execution-profile metadata and secret redaction (#18).
|
||||
- Shared API pagination (`api_get_all`) and hardened failure handling in `gitea_auth.api_request`: request timeouts, clear network/DNS errors, explicit 502/503/504 upstream errors, malformed-JSON handling, and redacted error text (#67).
|
||||
- `scripts/release-tag` SemVer-gated annotated-tag helper (safe-by-default, master-only, tests required) (#50).
|
||||
- Automatic `status:in-progress` release on issue close and PR close/merge (#56, #58).
|
||||
- `LLM-Agent-SHA` opaque agent attribution convention (Phase 0): documentation, handoff/review templates, and negative tests proving the SHA can never bypass self-review/self-merge gates (#86).
|
||||
- macOS `com.apple.provenance` cleanup helper tool and documentation (#3).
|
||||
- `manage_labels.py` refactored into reusable modes (`--create-labels`, `--apply-mapping`, `--add-label`) (#6).
|
||||
|
||||
### Changed
|
||||
- HTTP 429 responses now honor `Retry-After` with jittered exponential backoff (#27).
|
||||
- Read-only list tools (`gitea_list_issues`, `gitea_list_prs`, `gitea_list_labels`) now paginate across pages with bounded page caps (#67).
|
||||
- Automatic `status:in-progress` cleanup on issue/PR close and merge.
|
||||
- Label cleanup now utilizes safe targeted label deletion behavior rather than replacing the entire label set.
|
||||
|
||||
### Documentation
|
||||
- MCP security model and trust-boundary documentation (#8).
|
||||
- Developer testing guidelines (#70).
|
||||
- Jenkins read-only build-status tools design (#72).
|
||||
- Jenkins repo/branch/PR → job mapping design (#77).
|
||||
- Safety and boundary docs updated for Jenkins/GlitchTip: `glitchtip-mcp` boundary, read-only-first policy, mutation gating (#79).
|
||||
- Proposed label taxonomy for Jenkins/GlitchTip workflows (#80).
|
||||
- GlitchTip read-only error/event tools design (#73).
|
||||
- Multi-service MCP profile model extension (#76).
|
||||
|
||||
## [v1.0.1]
|
||||
- Fix Recent Timesheets Remove button text clipping and copy theme/whats_new in build.
|
||||
|
||||
## [v1.0.0]
|
||||
- Initial versioned release.
|
||||
@@ -274,6 +274,15 @@ The generated launcher snippets contain only `command`, `args`,
|
||||
`GITEA_MCP_CONFIG`, and `GITEA_MCP_PROFILE` — never a token or password.
|
||||
</details>
|
||||
|
||||
### Portable LLM workflow skill
|
||||
|
||||
Reusable LLM operating rules are packaged as a portable skill at
|
||||
[`skills/llm-project-workflow/SKILL.md`](skills/llm-project-workflow/SKILL.md).
|
||||
It documents issue-first work, isolated branch worktrees, no self-review or
|
||||
self-merge, profile safety, fail-closed behavior, merge cleanup, and recovery
|
||||
patterns. Copy the `skills/llm-project-workflow/` directory into other projects
|
||||
that should use the same workflow.
|
||||
|
||||
<details>
|
||||
<summary><strong>Codex / non-MCP tools</strong></summary>
|
||||
|
||||
@@ -372,4 +381,43 @@ python3 -m pytest tests/ -v
|
||||
| `test_python_cli.py` | `close_issue.py` + `mark_issue.py` CLI validation |
|
||||
| `test_mirror_refs.py` | Flags, safety defaults, local integration tests |
|
||||
|
||||
(Core suites — the table is non-exhaustive; see `tests/` for the full set.)
|
||||
|
||||
All tests mock network and keychain access — no real API calls are made.
|
||||
|
||||
For how to write tests — mocking the API/auth safely, testing profile and
|
||||
self-review/self-merge gates, no-secret regression expectations, and unit vs.
|
||||
integration guidance — see
|
||||
[`docs/developer-testing-guidelines.md`](docs/developer-testing-guidelines.md).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### macOS: `com.apple.provenance` blocks Python execution (#3)
|
||||
|
||||
On macOS Sequoia and later, files written by an agent/IDE terminal receive the
|
||||
`com.apple.provenance` extended attribute, and macOS blocks `Python.app` from
|
||||
**executing** such files. Symptoms: newly created/restored `.py` files fail to
|
||||
run (e.g. `create_issue.py` "vanishing" or refusing to execute), while shell
|
||||
scripts and files created before the session are unaffected. This is a macOS
|
||||
security feature, not a bug in this project's code.
|
||||
|
||||
Workarounds (run from a terminal with **Full Disk Access**, e.g. `Terminal.app`
|
||||
— not the IDE terminal, or the removal itself may be blocked):
|
||||
|
||||
```bash
|
||||
# Preferred: strip only com.apple.provenance under the repo (dry-run first)
|
||||
./scripts/clear-provenance --dry-run
|
||||
./scripts/clear-provenance
|
||||
|
||||
# Or a single file
|
||||
./scripts/clear-provenance /path/to/file.py
|
||||
|
||||
# Manual equivalents
|
||||
xattr -r -d com.apple.provenance /Users/jasonwalker/Development/Gitea-Tools/
|
||||
xattr -cr /Users/jasonwalker/Development/Gitea-Tools/ # clears ALL xattrs
|
||||
```
|
||||
|
||||
Alternatively, grant Full Disk Access to the terminal app in
|
||||
**System Settings → Privacy & Security**. `scripts/clear-provenance` removes only
|
||||
`com.apple.provenance` (leaving other extended attributes intact) and supports
|
||||
`--dry-run`.
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
# GlitchTip Read-Only Error/Event Tools — Design Notes
|
||||
|
||||
- **Status:** Design (implementation-ready notes; **no implementation in this repo**)
|
||||
- **Issue:** #73 (umbrella: #75; boundary decision: ADR-0001, #71)
|
||||
- **Related:** #74 (GlitchTip→Gitea filing workflow — composes these read tools),
|
||||
#78 (dedup/linking, child of #74), #76 (per-service profile schema)
|
||||
- **Date:** 2026-07-02
|
||||
|
||||
## 1. Purpose and scope
|
||||
|
||||
Define the minimum **read-only** GlitchTip MCP tool set that lets an LLM answer:
|
||||
*"What unresolved errors does project X have (by environment/release), and what
|
||||
is this specific error?"* — with privacy-safe output suitable for LLM context,
|
||||
issue bodies, and audit logs.
|
||||
|
||||
Strictly read-only, per ADR-0001:
|
||||
|
||||
- **No mutation tools** — no resolving/ignoring/assigning issues, no comment
|
||||
posting, no project/team/key administration, no deletes.
|
||||
- **No automatic GlitchTip→Gitea filing** (that is #74's *orchestrated,
|
||||
explicitly-invoked* workflow; it composes these read tools and Gitea write
|
||||
tools — never one dual-credential server).
|
||||
- **This server never holds Gitea write credentials.**
|
||||
|
||||
## 2. Boundary placement (namespace pending)
|
||||
|
||||
These tools belong to the GlitchTip observability boundary of the MCP Control
|
||||
Plane — `glitchtip-mcp` (ADR-0001's recommendation), `observability-mcp`, or
|
||||
folded into `ops-mcp`. **ADR-0001 open owner decision #2 picks the name; this
|
||||
design does not assume it.** Tool names below use the `glitchtip_` prefix for
|
||||
readability and rename mechanically with the decision.
|
||||
|
||||
Fixed regardless of the name (per `tool-boundaries.md`,
|
||||
`credential-isolation.md`):
|
||||
|
||||
- Own server process, own `.env`, GlitchTip credentials only.
|
||||
- No Gitea, Jenkins, or Ops tokens in this runtime; no GlitchTip token
|
||||
anywhere else.
|
||||
|
||||
## 3. API surface note (Sentry compatibility)
|
||||
|
||||
GlitchTip implements a Sentry-compatible REST API (`/api/0/...` — organizations,
|
||||
projects, issues, events). The design targets **GlitchTip's documented subset**
|
||||
only; Sentry-only endpoints must not be assumed. The implementation should pin
|
||||
against a tested GlitchTip version and treat missing endpoints/fields as
|
||||
degraded-but-safe (omit field, never crash).
|
||||
|
||||
## 4. Minimum read-only tool set
|
||||
|
||||
| Tool | Purpose |
|
||||
|---|---|
|
||||
| `glitchtip_whoami` | Verify authenticated identity + active profile (mirror of `gitea_whoami`; fail-closed identity proof) |
|
||||
| `glitchtip_list_projects` | Projects visible to the token (org-scoped), with pagination bounds |
|
||||
| `glitchtip_list_unresolved` | Unresolved issues for a project, filterable (§6), sorted by last-seen |
|
||||
| `glitchtip_get_issue` | Safe detail of one issue (fields §5) |
|
||||
| `glitchtip_recent_events` | Recent events for an issue (summaries only, §5) |
|
||||
| `glitchtip_search` | Issue search within a project (query + filters §6) |
|
||||
|
||||
All tools are `GET`-only. No tool issues PUT/POST/DELETE.
|
||||
|
||||
## 5. Privacy: field-level allowlist (the core rule)
|
||||
|
||||
Error events routinely contain PII and secrets (request bodies, cookies,
|
||||
headers, tokens, user emails/IPs, local variables). Therefore: **allowlist
|
||||
projection only — raw event/issue payloads are never passed through.**
|
||||
|
||||
### Issue-level safe fields (`glitchtip_list_unresolved`, `glitchtip_get_issue`, `glitchtip_search`)
|
||||
|
||||
| Field | Notes |
|
||||
|---|---|
|
||||
| `issue_id` | GlitchTip issue ID (dedup key for #78) |
|
||||
| `fingerprint` | When available (dedup key for #78) |
|
||||
| `title` / `culprit` | Error type + short message/transaction — redactor-passed |
|
||||
| `project` | Slug |
|
||||
| `level` | error/warning/… |
|
||||
| `status` | unresolved/… |
|
||||
| `environment` | When filtered/available |
|
||||
| `release` | Version string |
|
||||
| `first_seen` / `last_seen` | ISO-8601 UTC |
|
||||
| `event_count` / `user_count` | Numbers only — never user identities |
|
||||
| `permalink` | GlitchTip web URL (the "link, not dump" principle) |
|
||||
|
||||
### Event-level safe fields (`glitchtip_recent_events`)
|
||||
|
||||
`event_id`, `timestamp`, `level`, `environment`, `release`, redactor-passed
|
||||
`message`, and a **stack summary** only: top N (default 5) frames as
|
||||
`module/filename:function:line` — in-app frames preferred.
|
||||
|
||||
### Redact / omit — never returned
|
||||
|
||||
Request headers; cookies; auth/session fields; user emails, usernames, IPs;
|
||||
request/form bodies; query strings; local variables; full raw stack frames
|
||||
(source context lines); SDK/device metadata beyond platform name; breadcrumbs;
|
||||
any `extra`/`context` blobs.
|
||||
|
||||
Full raw frames or request context require a **separate, explicitly approved**
|
||||
operation (`glitchtip.event.read_raw`) that is absent from default profiles —
|
||||
same pattern as `jenkins.console.read` in the #72 design. Even then, output
|
||||
passes the shared secret redactor; redaction failure ⇒ error, never raw text.
|
||||
|
||||
**Default output = fingerprint / release / summary + permalink.** The
|
||||
permalink carries the human to the full data in GlitchTip's own UI, where its
|
||||
access control applies — the MCP layer does not re-serve raw payloads.
|
||||
|
||||
## 6. Filtering and pagination
|
||||
|
||||
Filters (all optional, combinable): `project` (required for issue/event
|
||||
queries), `environment`, `release`, `fingerprint`, free-text `query`
|
||||
(GlitchTip search syntax, e.g. `is:unresolved`).
|
||||
|
||||
Pagination: cursor-based per the API. Bounds: per-page cap 50; default overall
|
||||
cap 100 items; hard cap `max_pages` (default 10) against runaway loops —
|
||||
mirroring `gitea_auth.api_get_all`. Truncation is **explicit** in the return
|
||||
(`"truncated": true`) — never silent.
|
||||
|
||||
## 7. Credentials and profile requirements
|
||||
|
||||
Per-service profile model (`gitea-execution-profiles.md`, extended by #76):
|
||||
|
||||
- Env/config: `GLITCHTIP_URL`, `GLITCHTIP_ORG`, `GLITCHTIP_TOKEN_SOURCE_NAME`
|
||||
(secret **name** only; value resolved at runtime, never logged/committed).
|
||||
- Profile: e.g. `glitchtip-readonly` with namespaced
|
||||
`allowed_operations: ["glitchtip.read", "glitchtip.event.read"]`
|
||||
(+ `glitchtip.event.read_raw` only with explicit approval);
|
||||
`forbidden_operations: ["glitchtip.issue.mutate", "glitchtip.admin"]`
|
||||
belt-and-braces though no mutating tool exists.
|
||||
- Missing URL/org/token/profile ⇒ **fail closed** before any network call.
|
||||
- Read-only ⇒ no confirmation gates; identity (`glitchtip_whoami`) must work so
|
||||
workflows can prove which account they read as.
|
||||
|
||||
## 8. Failure behavior (fail closed, clear, safe)
|
||||
|
||||
| Condition | Behavior |
|
||||
|---|---|
|
||||
| Unknown project/issue | Explicit `{"found": false, ...}` — no fuzzy matching |
|
||||
| GlitchTip unreachable (DNS/timeout) | `"network error contacting GlitchTip: <redacted reason>"` — mirror `gitea_auth.api_request` conversion |
|
||||
| 502/503/504 | "GlitchTip upstream unavailable" |
|
||||
| 401/403 | "GlitchTip auth failed / insufficient permissions" — no credential echo |
|
||||
| 429 | Honor Retry-After with capped jittered backoff (as `gitea_auth`) |
|
||||
| Malformed JSON | "malformed JSON response from GlitchTip" — no raw-body dump |
|
||||
| Missing profile/creds | Fail closed before any network call (§7) |
|
||||
|
||||
All error text passes the shared secret redactor.
|
||||
|
||||
## 9. Testing strategy (mocked; for the implementing package)
|
||||
|
||||
Mocked-GlitchTip unit tests only, per `docs/developer-testing-guidelines.md`:
|
||||
|
||||
- Assert method is always `GET`; URL/filter/cursor shape correct.
|
||||
- **Projection tests:** response fixtures containing emails, IPs, cookies,
|
||||
headers, request bodies, locals, full frames ⇒ none appear in output
|
||||
(explicit negative assertions per §5's redact list).
|
||||
- Stack summary: top-N frame cap enforced; source-context lines absent.
|
||||
- Pagination: per-page/overall/max-pages caps; explicit `truncated` flag.
|
||||
- Filters: environment/release/fingerprint/query passed through correctly.
|
||||
- Failure matrix of §8 incl. no-token-in-error assertions.
|
||||
- Profile gate: missing/insufficient profile ⇒ no network call
|
||||
(`mock_api.assert_not_called()` pattern).
|
||||
- `read_raw` op absent ⇒ raw-frame request refused without an API call.
|
||||
|
||||
## 10. Implementation-readiness checklist
|
||||
|
||||
Ready to implement once:
|
||||
|
||||
1. ADR-0001 owner decision #2 (namespace/placement) is made — mechanical
|
||||
rename of the `glitchtip_` prefix if needed.
|
||||
2. ADR-0001 owner decision #1 (repo home) is made.
|
||||
3. #76 profile schema exists (or a minimal `glitchtip-readonly` profile is
|
||||
hand-rolled to the same rules).
|
||||
4. A pinned GlitchTip version is chosen for API-subset testing (§3).
|
||||
|
||||
Explicitly **not** unlocked by this document: any GlitchTip mutation, any
|
||||
automatic Gitea filing (#74 designs that as a gated, explicitly-invoked
|
||||
orchestrated workflow), any Gitea credentials in this boundary.
|
||||
@@ -0,0 +1,165 @@
|
||||
# Jenkins Repo/Branch/PR → Job Mapping — Design Notes
|
||||
|
||||
- **Status:** Design (implementation-ready notes; **no implementation in this repo**)
|
||||
- **Issue:** #77 (parent: #72 read-only tools design; umbrella: #75; boundary: ADR-0001, #71)
|
||||
- **Related docs:** [`jenkins-readonly-build-status-design.md`](jenkins-readonly-build-status-design.md)
|
||||
- **Date:** 2026-07-02
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
The #72 tool set addresses Jenkins jobs by **explicit fully-qualified job
|
||||
path**. This document designs the layer above it: how a *(repository, branch,
|
||||
PR)* tuple — the vocabulary of Gitea workflows — resolves deterministically to
|
||||
a Jenkins job path, so an LLM can ask "did the build for `Gitea-Tools`
|
||||
`master` pass?" without knowing Jenkins internals.
|
||||
|
||||
Hard constraints inherited from #72 / ADR-0001:
|
||||
|
||||
- **No silent guessing of job names.** Unmapped input returns an explicit
|
||||
"no mapping" result — never a fuzzy match, never a constructed-and-probed
|
||||
name.
|
||||
- **Read-only.** Mapping introduces no Jenkins write actions.
|
||||
- Lives in the **`jenkins-mcp`** boundary; no Gitea credentials involved.
|
||||
|
||||
## 2. Mapping format
|
||||
|
||||
Declarative, versioned config (TOML or JSON — match whatever config format
|
||||
`jenkins-mcp` adopts; illustrated here as TOML):
|
||||
|
||||
```toml
|
||||
version = 1
|
||||
|
||||
[[mapping]]
|
||||
# Source side (what the caller supplies)
|
||||
repo = "Scaled-Tech-Consulting/Gitea-Tools" # org/repo, exact
|
||||
# Target side (where it lives in Jenkins)
|
||||
job = "scaled-tech/gitea-tools" # foldered job path
|
||||
type = "multibranch" # "multibranch" | "single" | "parameterized-view"
|
||||
|
||||
[[mapping]]
|
||||
repo = "Scaled-Tech-Consulting/Timesheet"
|
||||
branch = "master" # optional: branch-specific override
|
||||
job = "scaled-tech/timesheet-master"
|
||||
type = "single"
|
||||
```
|
||||
|
||||
Field semantics:
|
||||
|
||||
| Field | Required | Meaning |
|
||||
|---|---|---|
|
||||
| `repo` | yes | Exact `org/repo` (case-insensitive compare, stored canonical) |
|
||||
| `branch` | no | Exact branch name this entry pins; absent = all branches |
|
||||
| `job` | yes | Fully-qualified Jenkins job path, folders `/`-joined |
|
||||
| `type` | yes | How branch/PR resolves under the job (§3) |
|
||||
|
||||
Rules:
|
||||
|
||||
- **Exact matching only** on `repo` and `branch`. No globs in v1 (globs invite
|
||||
accidental over-matching; add later behind an explicit `pattern = true` flag
|
||||
if ever needed).
|
||||
- Unknown `type` or malformed entry ⇒ config load fails closed with a clear
|
||||
error naming the entry — a broken mapping file must not half-load.
|
||||
- Duplicate `(repo, branch)` keys ⇒ load error (ambiguity is refused, not
|
||||
resolved).
|
||||
|
||||
## 3. Resolution semantics by job type
|
||||
|
||||
Given caller input `(repo, branch?, pr?)`:
|
||||
|
||||
- **`multibranch`** — branch job addressed as `<job>/<url-encoded-branch>`
|
||||
(e.g. `feature/x` → `feature%2Fx`). PRs addressed as `<job>/PR-<number>`
|
||||
(Jenkins multibranch PR-discovery naming). Both per #72 §8.
|
||||
- **`single`** — the job path is used as-is; `branch`/`pr` input beyond the
|
||||
entry's pinned branch is a **no-mapping** result (a single job cannot answer
|
||||
for arbitrary branches).
|
||||
- **`parameterized-view`** — read-only variant for jobs that encode branch as
|
||||
a build parameter: resolution returns the base job path plus a
|
||||
`branch_param` filter hint the status tools may apply client-side when
|
||||
scanning recent builds. It never triggers anything (read-only rule).
|
||||
|
||||
## 4. Precedence
|
||||
|
||||
Most-specific entry wins, evaluated in this order:
|
||||
|
||||
1. `(repo, branch)` exact entry — branch-pinned override.
|
||||
2. `(repo)` entry — repo-wide (multibranch typical).
|
||||
3. Nothing → **no mapping** (§5).
|
||||
|
||||
PR input resolves through the same chain: a PR belongs to its **base repo**'s
|
||||
mapping; forks never introduce their own mapping (a fork's head repo is not
|
||||
consulted — CI runs live under the base repo's job). If the base repo is
|
||||
unmapped, the PR is unmapped.
|
||||
|
||||
Ties are impossible by construction (duplicate keys refused at load).
|
||||
|
||||
## 5. No-match behavior
|
||||
|
||||
```json
|
||||
{
|
||||
"mapped": false,
|
||||
"repo": "org/unknown-repo",
|
||||
"branch": "master",
|
||||
"error": "no Jenkins job mapping for this repo/branch",
|
||||
"hint": "add an entry to the jenkins-mcp mapping config"
|
||||
}
|
||||
```
|
||||
|
||||
- Deterministic, explicit, machine-checkable (`mapped: false`).
|
||||
- **Never** falls back to name construction ("repo name probably equals job
|
||||
name"), never probes Jenkins for candidates, never string-similarity ranks.
|
||||
- The hint names the config, not a guessed job.
|
||||
|
||||
## 6. Where the mapping config lives
|
||||
|
||||
- **In the `jenkins-mcp` package/deployment** (e.g. `jenkins-mcp/mapping.toml`),
|
||||
version-controlled next to the server that consumes it — *not* in Gitea-Tools
|
||||
and *not* in per-user env vars (mappings are shared team facts, not
|
||||
credentials).
|
||||
- Path overridable via env (`JENKINS_MCP_MAPPING_FILE`) for tests/containers.
|
||||
- Contains **no secrets** — job paths and repo names only — so it is safe to
|
||||
commit and review like any other config.
|
||||
- Reloaded at server start; a hot-reload tool is out of scope (restart is the
|
||||
documented path).
|
||||
|
||||
## 7. Exposed tool surface (read-only)
|
||||
|
||||
One addition to the #72 tool set:
|
||||
|
||||
| Tool | Purpose |
|
||||
|---|---|
|
||||
| `jenkins_resolve_job` | `(repo, branch?, pr?)` → `{mapped, job, addressed_path, type}` or the §5 no-match result. Pure config lookup — **no Jenkins API call at all.** |
|
||||
|
||||
Status tools (`jenkins_latest_build` etc.) accept either an explicit job path
|
||||
(as designed in #72) **or** `(repo, branch)` which they resolve via the same
|
||||
mapping layer first. Resolution failure surfaces the §5 payload rather than
|
||||
querying Jenkins.
|
||||
|
||||
## 8. Testing strategy (mocked; for the implementing package)
|
||||
|
||||
Config-layer tests (no network at all):
|
||||
|
||||
- Exact-match hit: repo-wide and branch-pinned entries.
|
||||
- Precedence: branch-pinned beats repo-wide.
|
||||
- Multibranch encoding: `feature/x` → `<job>/feature%2Fx`; PR → `<job>/PR-7`.
|
||||
- `single` type with non-pinned branch ⇒ no-mapping.
|
||||
- Fork PR resolves through base repo; unmapped base ⇒ no-mapping.
|
||||
- Unknown repo/branch ⇒ §5 payload, and **no Jenkins client call**
|
||||
(`mock_api.assert_not_called()`).
|
||||
- Malformed config / duplicate keys / unknown type ⇒ load fails closed with
|
||||
entry-naming error.
|
||||
- No-secret check: mapping load/error paths never touch or print credentials.
|
||||
|
||||
Integration with mocked Jenkins API (per #72 §9): resolved path is used
|
||||
verbatim in the GET URL; no write verbs anywhere.
|
||||
|
||||
## 9. Standalone-worthiness and readiness
|
||||
|
||||
#77 was split from #72 on the condition it stays "standalone only if mapping
|
||||
is nontrivial." The precedence rules, fork/PR semantics, three job types, and
|
||||
fail-closed config loading above are the nontrivial part; this document is the
|
||||
justification.
|
||||
|
||||
Ready to implement in `jenkins-mcp` when #72's readiness checklist clears
|
||||
(ADR-0001 owner decision #1; profile schema per #76 or hand-rolled
|
||||
`jenkins-readonly`). Nothing here unlocks build triggers, deploys, or
|
||||
parameterized launches.
|
||||
@@ -0,0 +1,151 @@
|
||||
# Jenkins Read-Only Build Status Tools — Design Notes
|
||||
|
||||
- **Status:** Design (implementation-ready notes; **no implementation in this repo**)
|
||||
- **Issue:** #72 (parent umbrella: #75; boundary decision: ADR-0001, #71)
|
||||
- **Related:** #77 (repo/branch/PR → job mapping, designed separately)
|
||||
- **Date:** 2026-07-02
|
||||
|
||||
## 1. Purpose and scope
|
||||
|
||||
Define the minimum **read-only** Jenkins MCP tool set that lets an LLM answer:
|
||||
*"Did the latest build for this project/branch succeed or fail?"* — plus enough
|
||||
detail (build URL, number, timing, result) to report or investigate.
|
||||
|
||||
Phase 1 is **strictly read-only**, per ADR-0001
|
||||
([`adr-0001-mcp-control-plane-boundaries.md`](adr-0001-mcp-control-plane-boundaries.md)):
|
||||
|
||||
- **Excluded: build triggers.**
|
||||
- **Excluded: deploy triggers.**
|
||||
- **Excluded: parameterized job launches.**
|
||||
- Excluded: job creation/deletion/config changes, queue manipulation, node
|
||||
management — any Jenkins mutation whatsoever.
|
||||
|
||||
## 2. Boundary placement
|
||||
|
||||
These tools belong to the **`jenkins-mcp`** package/server of the MCP Control
|
||||
Plane — **never** inside `gitea-mcp` (`mcp_server.py` in this repo).
|
||||
Consequences (from `tool-boundaries.md`, `credential-isolation.md`, ADR-0001):
|
||||
|
||||
- `jenkins-mcp` runs as its own server process with its own `.env`.
|
||||
- **Jenkins credentials never enter the Gitea MCP runtime**, and Gitea
|
||||
credentials never enter `jenkins-mcp`.
|
||||
- This document lands in this repo only because the repo currently hosts the
|
||||
Control Plane's architecture docs; the code ships elsewhere (owner decision
|
||||
#1 of ADR-0001).
|
||||
|
||||
## 3. Minimum read-only tool set
|
||||
|
||||
| Tool | Purpose |
|
||||
|---|---|
|
||||
| `jenkins_whoami` | Verify authenticated Jenkins identity + active profile (mirror of `gitea_whoami`; fail-closed identity proof before anything else) |
|
||||
| `jenkins_list_jobs` | List visible jobs (supports folder paths), with pagination bounds |
|
||||
| `jenkins_latest_build` | The primary question: latest build of a job (or job+branch for multibranch) → status summary |
|
||||
| `jenkins_build_status` | Status of a specific build number (job, number) |
|
||||
| `jenkins_get_build` | Full safe detail of a build (fields in §4) |
|
||||
| `jenkins_console_tail` | Bounded, redacted tail of a build's console log (§6) — optional, approval-gated addition |
|
||||
|
||||
All tools are `GET`-only against the Jenkins JSON API (`/api/json`,
|
||||
`.../lastBuild/api/json`, `.../consoleText`). No tool issues POST/PUT/DELETE.
|
||||
|
||||
## 4. Return payloads (safe fields)
|
||||
|
||||
`jenkins_latest_build` / `jenkins_build_status` / `jenkins_get_build` return:
|
||||
|
||||
| Field | Source | Notes |
|
||||
|---|---|---|
|
||||
| `job` | request | Fully-qualified job path (folders joined with `/`) |
|
||||
| `build_number` | `number` | int |
|
||||
| `result` | `result` | `SUCCESS` / `FAILURE` / `UNSTABLE` / `ABORTED` / `NOT_BUILT`; `null` → `IN_PROGRESS` when `building=true` |
|
||||
| `building` | `building` | bool |
|
||||
| `url` | `url` | Build URL |
|
||||
| `branch` | multibranch job name / SCM action | Best-effort; omitted when unknown |
|
||||
| `timestamp` | `timestamp` | ISO-8601 UTC (converted from epoch ms) |
|
||||
| `duration_seconds` | `duration` | 0/omitted while building |
|
||||
| `commit_sha` | SCM build action | Best-effort; omitted when unknown |
|
||||
|
||||
Rules: no raw Jenkins payload passthrough (allowlist projection only); no
|
||||
`Authorization` header, token, or crumb material in any output or error
|
||||
(reuse the shared redaction approach of `safety-model.md` §3 / `gitea_audit`).
|
||||
|
||||
## 5. Failure behavior (fail closed, clear, safe)
|
||||
|
||||
| Condition | Behavior |
|
||||
|---|---|
|
||||
| Unknown job | Explicit `{"found": false, "job": "<path>", "error": "job not found"}` — never guess or fuzzy-match a job name (hard rule; see also #77) |
|
||||
| Jenkins unreachable (DNS/timeout/conn refused) | Clear `"network error contacting Jenkins: <redacted reason>"`; no retry storm — mirror `gitea_auth.api_request` timeout + failure conversion |
|
||||
| 502/503/504 | Explicit "Jenkins upstream unavailable" |
|
||||
| 401/403 | "Jenkins auth failed / insufficient permissions" — **without** echoing credentials or the request's auth material |
|
||||
| Malformed JSON | "malformed JSON response from Jenkins" (no raw-body dump) |
|
||||
| Missing profile/creds | Fail closed before any network call (§7) |
|
||||
|
||||
## 6. Console tail safety (`jenkins_console_tail`)
|
||||
|
||||
Console logs are the highest-risk surface (secrets, tokens, internal hosts
|
||||
routinely leak into build logs). If included at all (owner may defer it):
|
||||
|
||||
- **Bounded:** hard server-side cap (default: last 200 lines AND ≤ 64 KiB,
|
||||
whichever is smaller; caller may request less, never more).
|
||||
- **Redacted:** pass through the shared secret redactor (token/`Basic`/`Bearer`/
|
||||
password/key-value patterns) before returning; redaction failure ⇒ return an
|
||||
error, never the raw text.
|
||||
- **Default off:** summary fields (`result`, failing stage if cheaply available)
|
||||
are preferred; the tail requires an explicit `allowed_operations` entry
|
||||
(`jenkins.console.read`) distinct from plain `jenkins.build.read`.
|
||||
|
||||
## 7. Credentials and profile requirements
|
||||
|
||||
Follows the per-service profile model (`gitea-execution-profiles.md`, extended
|
||||
by #76):
|
||||
|
||||
- Env/config: `JENKINS_URL`, `JENKINS_USER`, `JENKINS_TOKEN_SOURCE_NAME`
|
||||
(name-of-secret only — value resolved at runtime, never logged/committed).
|
||||
- Profile: e.g. `jenkins-readonly` with namespaced
|
||||
`allowed_operations: ["jenkins.read", "jenkins.build.read"]`
|
||||
(+ `jenkins.console.read` only if the tail tool is approved);
|
||||
`forbidden_operations: ["jenkins.build.trigger", "jenkins.deploy", "jenkins.job.configure"]`
|
||||
as belt-and-braces even though no mutating tool exists.
|
||||
- Missing URL/user/token/profile ⇒ **fail closed** with a clear message.
|
||||
- Since every tool is read-only, no confirmation gates are needed — but
|
||||
identity (`jenkins_whoami`) must still work so workflows can prove which
|
||||
Jenkins account they act as.
|
||||
|
||||
## 8. Job addressing and mapping
|
||||
|
||||
Tools accept an explicit fully-qualified job path (folder-aware:
|
||||
`folder/subfolder/job`). How a *repo/branch/PR* resolves to that job path is
|
||||
**out of scope here** and designed in **#77**, with these fixed constraints:
|
||||
|
||||
- No silent guessing of job names — unmapped input returns an explicit
|
||||
"no mapping" result.
|
||||
- Multibranch pipelines address a branch job as `<job>/<branch>` with proper
|
||||
URL-encoding of branch names (e.g. `feature%2Fx`).
|
||||
|
||||
## 9. Testing strategy (for the implementing package)
|
||||
|
||||
Mocked-Jenkins unit tests only (no live Jenkins in unit CI), mirroring this
|
||||
repo's conventions (`docs/developer-testing-guidelines.md`):
|
||||
|
||||
- Patch the HTTP client; assert method is always `GET` and URL shape is correct
|
||||
(folders, multibranch encoding).
|
||||
- Success projections: field allowlist exactly as §4; unknown fields dropped.
|
||||
- `result=null + building=true` ⇒ `IN_PROGRESS`.
|
||||
- Unknown job ⇒ found:false, no fuzzy match, no API retry.
|
||||
- Timeout/DNS/5xx/malformed-JSON ⇒ safe errors, no secret/credential leakage
|
||||
(explicit no-token-in-error assertions).
|
||||
- Console tail: cap enforcement (lines and bytes), redaction applied, redaction
|
||||
failure ⇒ error not raw text, gated behind `jenkins.console.read`.
|
||||
- Profile gate: missing/insufficient profile ⇒ no network call
|
||||
(`mock_api.assert_not_called()` pattern).
|
||||
|
||||
## 10. Implementation-readiness checklist
|
||||
|
||||
Ready to implement in `jenkins-mcp` once:
|
||||
|
||||
1. ADR-0001 owner decision #1 (where `jenkins-mcp` lives) is made.
|
||||
2. #76 profile schema exists (or a minimal `jenkins-readonly` profile is
|
||||
hand-rolled to the same rules).
|
||||
3. #77 mapping design is accepted (or tools ship path-addressed only, mapping
|
||||
deferred).
|
||||
|
||||
Explicitly **not** unlocked by this document: build triggers, deploys,
|
||||
parameterized launches, any Jenkins code in `mcp_server.py`.
|
||||
@@ -0,0 +1,68 @@
|
||||
# Multi-Service MCP Profile and Configuration Model
|
||||
|
||||
- **Status:** Design (no implementation in this repo yet)
|
||||
- **Issue:** #76 (parent umbrella: #75; boundary decision: ADR-0001, #71)
|
||||
- **Date:** 2026-07-02
|
||||
|
||||
## 1. Purpose and Scope
|
||||
|
||||
Extend the existing Gitea execution-profile model (`docs/gitea-execution-profiles.md`) into a generic **per-service** MCP profile/config model. This supports integrating Jenkins and GlitchTip into the MCP Control Plane while strictly preserving isolation and fail-closed safety.
|
||||
|
||||
**Crucial Constraints:**
|
||||
* The shared profile/config model is a **schema / library**, **not a shared credential pool**.
|
||||
* Tokens remain **service-local**; profiles are **per service**.
|
||||
* Orchestrators **must not** directly hold every service credential.
|
||||
|
||||
## 2. Profile Schema (Per Service)
|
||||
|
||||
The schema reuses the proven Gitea field model, adapted per service.
|
||||
|
||||
```json
|
||||
{
|
||||
"profile_name": "readonly-metrics",
|
||||
"service": "glitchtip",
|
||||
"token_source_name": "GLITCHTIP_API_TOKEN_READONLY",
|
||||
"allowed_operations": [
|
||||
"glitchtip.event.read",
|
||||
"glitchtip.issue.read"
|
||||
],
|
||||
"forbidden_operations": [
|
||||
"glitchtip.issue.resolve",
|
||||
"glitchtip.issue.delete"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Schema Rules
|
||||
|
||||
* `allowed_operations` are **namespaced** (e.g., `gitea.issue.create`, `jenkins.build.read`, `glitchtip.event.read`).
|
||||
* `forbidden_operations`, if present, **always override** `allowed_operations`.
|
||||
* `token_source_name` records the source **name only, never the value**. Tokens must never be printed, logged, or included in telemetry.
|
||||
|
||||
## 3. Fail-Closed Behavior
|
||||
|
||||
The model enforces strict fail-closed constraints before any network call occurs:
|
||||
* **Missing Profile:** If a requested profile is undefined for the target service, the operation fails immediately.
|
||||
* **Missing Credentials:** If the `token_source_name` cannot be resolved to a valid token at runtime, the operation fails immediately without retrying or prompting.
|
||||
|
||||
## 4. Environment Overrides
|
||||
|
||||
Profiles can be dynamically overridden or injected via environment variables, following the established hierarchy:
|
||||
|
||||
1. **Explicit Environment Variable:** (Highest precedence) e.g., `MCP_GLITCHTIP_TOKEN` overrides any JSON profile.
|
||||
2. **Profile Mapping in JSON:** Resolved via `token_source_name` (e.g., `GLITCHTIP_API_TOKEN_READONLY`) mapping to an environment variable or secret store.
|
||||
3. **No Auth:** Fails closed.
|
||||
|
||||
## 5. Audit Logging
|
||||
|
||||
To maintain accountability across multi-service workflows, all mutating actions must include the audit identity and source:
|
||||
* The audit log must record the `profile_name`, the orchestrator source (e.g., `sysadmin`, `jenkins-mcp`), and the action taken.
|
||||
* The audit system must sanitize all output to ensure tokens are stripped (see `safety-model.md`).
|
||||
|
||||
## 6. Backward Compatibility
|
||||
|
||||
The existing Gitea profile behavior (`gitea_whoami`, etc.) remains strictly backward compatible. The generic profile library will parse existing Gitea profile objects without requiring them to migrate their schemas, defaulting the `service` attribute to `gitea`.
|
||||
|
||||
## 7. Implementation Boundary
|
||||
|
||||
Per the namespace decisions in #71 and #75, this generic model belongs in the `common` package or library. It will be imported by `gitea-mcp` (this repo), `jenkins-mcp`, and `glitchtip-mcp` without forcing a monolithic architecture.
|
||||
@@ -5,5 +5,5 @@ This document describes how credentials and sensitive environment variables are
|
||||
## Separate Credentials
|
||||
Even though multiple MCP servers share the same monorepo, they **must** have separate credentials and runtimes.
|
||||
|
||||
- **No Shared Environments**: Each MCP server (`gitea-mcp`, `jenkins-mcp`, `ops-mcp`, etc.) must be instantiated as an independent service with its own dedicated `.env` configuration file.
|
||||
- **No Shared Environments**: Each MCP server (`gitea-mcp`, `jenkins-mcp`, `glitchtip-mcp`, `ops-mcp`, etc.) must be instantiated as an independent service with its own dedicated `.env` configuration file.
|
||||
- **Strict Isolation**: A server will only have access to the credentials required for its specific trust boundary. For instance, `gitea-mcp` has no access to Jenkins or Ops authentication tokens.
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
# Developer Testing Guidelines
|
||||
|
||||
How to write and run tests for Gitea-Tools. This guide reflects the current
|
||||
repository behavior and the safety model documented in
|
||||
[`safety-model.md`](safety-model.md),
|
||||
[`credential-isolation.md`](credential-isolation.md), and
|
||||
[`gitea-execution-profiles.md`](gitea-execution-profiles.md).
|
||||
|
||||
Core principle: **tests never make real network calls and never touch real
|
||||
credentials.** Every test mocks the HTTP client and the keychain/auth lookup.
|
||||
|
||||
---
|
||||
|
||||
## 1. Standard test commands
|
||||
|
||||
The test suite needs the project virtualenv (it provides the MCP SDK):
|
||||
|
||||
```bash
|
||||
# From the repository root
|
||||
source venv/bin/activate
|
||||
python3 -m pytest tests/ -q
|
||||
```
|
||||
|
||||
Or invoke the venv interpreter directly without activating:
|
||||
|
||||
```bash
|
||||
./venv/bin/python -m pytest tests/ -q
|
||||
```
|
||||
|
||||
Use `-q` for a compact summary and `-v` to see individual test names.
|
||||
|
||||
### Run the full suite
|
||||
|
||||
```bash
|
||||
./venv/bin/python -m pytest tests/ -q
|
||||
```
|
||||
|
||||
### Run targeted tests
|
||||
|
||||
```bash
|
||||
# One file
|
||||
./venv/bin/python -m pytest tests/test_mcp_server.py -q
|
||||
|
||||
# One class
|
||||
./venv/bin/python -m pytest tests/test_merge_pr.py::TestMergeDisabled -q
|
||||
|
||||
# One test, by node id
|
||||
./venv/bin/python -m pytest tests/test_review_pr.py::TestAPIPayload::test_payload_fields_and_workflow -q
|
||||
|
||||
# By keyword expression
|
||||
./venv/bin/python -m pytest tests/ -q -k "merge and fails_closed"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Syntax and formatting checks
|
||||
|
||||
These are fast and belong in any pre-PR loop:
|
||||
|
||||
```bash
|
||||
# Byte-compile the main modules (catches syntax errors)
|
||||
python3 -m py_compile mcp_server.py
|
||||
python3 -m py_compile manage_labels.py
|
||||
|
||||
# Lint shell scripts without executing them
|
||||
bash -n scripts/clear-provenance
|
||||
|
||||
# Detect stray conflict markers and whitespace errors in the diff
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Run `git diff --check` before every commit; it flags leftover merge-conflict
|
||||
markers and trailing-whitespace/whitespace-error lines.
|
||||
|
||||
---
|
||||
|
||||
## 3. How to add an MCP tool test
|
||||
|
||||
MCP tools live in `mcp_server.py` and are exercised in
|
||||
`tests/test_mcp_server.py`. Tests call the underlying tool function directly
|
||||
with the network layer and auth mocked. The established pattern:
|
||||
|
||||
```python
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
FAKE_AUTH = "Basic ZmFrZTpmYWtl" # not a real credential
|
||||
|
||||
class TestCreateIssue(unittest.TestCase):
|
||||
@patch("mcp_server.api_request")
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_creates_issue(self, _auth, mock_api):
|
||||
mock_api.return_value = {"number": 1, "html_url": "https://gitea.example.com/issues/1"}
|
||||
|
||||
result = mcp_server.gitea_create_issue(title="Add tests", remote="prgs")
|
||||
|
||||
# Assert on the request the tool would have made
|
||||
mock_api.assert_called_once()
|
||||
method, url = mock_api.call_args[0][0], mock_api.call_args[0][1]
|
||||
self.assertEqual(method, "POST")
|
||||
self.assertIn("/issues", url)
|
||||
```
|
||||
|
||||
Checklist when adding a tool test:
|
||||
|
||||
* Patch `mcp_server.api_request` — never hit the network.
|
||||
* Patch `mcp_server.get_auth_header` to return a fake header — never read the
|
||||
keychain.
|
||||
* Assert on the **method, URL, and payload** the tool builds, and on the shape
|
||||
of the returned payload.
|
||||
* Cover both `dadeschools` and `prgs` remotes when the tool takes `remote`, and
|
||||
confirm the correct host/org/repo are used.
|
||||
* Cover the error path (e.g. `api_request` raising) and confirm the tool
|
||||
surfaces a clear message without leaking secrets.
|
||||
|
||||
---
|
||||
|
||||
## 4. How to mock API requests safely
|
||||
|
||||
* **Always patch `mcp_server.api_request`** (or `gitea_auth.api_request` for the
|
||||
CLI/auth-level tests). No test should open a socket.
|
||||
* **Always patch the auth lookup** (`get_auth_header` / `get_credentials`) and
|
||||
return an obviously fake value. Do not put a real token or password in a test,
|
||||
a fixture, or an environment file.
|
||||
* Prefer asserting on `mock_api.call_args` (method/URL/payload) over asserting on
|
||||
a real response body.
|
||||
* For keychain behavior specifically, see `tests/test_credentials.py`, which
|
||||
mocks the `git credential fill` subprocess (`Popen`) and the environment.
|
||||
|
||||
---
|
||||
|
||||
## 5. How to test profile / allowed-operation failures
|
||||
|
||||
The execution-profile model (see
|
||||
[`gitea-execution-profiles.md`](gitea-execution-profiles.md)) enforces that a
|
||||
tool may only perform operations in its profile's `allowed_operations`, and that
|
||||
`forbidden_operations` always override `allowed_operations`. Mutating tools must
|
||||
**fail closed** when the active profile does not permit the operation.
|
||||
|
||||
When adding or changing a gated tool, add tests that:
|
||||
|
||||
* Configure a profile whose `allowed_operations` does **not** include the
|
||||
requested operation, and assert the tool refuses **without** calling
|
||||
`api_request` (assert `mock_api.assert_not_called()`).
|
||||
* Configure a profile where the operation is in **both** allowed and forbidden,
|
||||
and assert forbidden wins (still refused).
|
||||
* Confirm the refusal message names the missing operation and does not include
|
||||
any secret material.
|
||||
* Confirm the happy path (operation allowed) still reaches `api_request`.
|
||||
|
||||
The guiding assertion is: **no mutation path may reach `api_request` unless the
|
||||
profile/allowed-operation check passed first.**
|
||||
|
||||
---
|
||||
|
||||
## 6. How to test self-review / self-merge gates
|
||||
|
||||
Author-cannot-review and author-cannot-merge are hard safety gates. The merge
|
||||
path is gated (`gitea_merge_pr`), the legacy review wrapper fails closed on
|
||||
`merge=True`, and `gitea_submit_pr_review` never merges. Existing coverage lives
|
||||
in `tests/test_merge_pr.py` and `tests/test_review_pr.py`.
|
||||
|
||||
Patterns to follow (see those files for concrete examples):
|
||||
|
||||
* **Self-merge blocked:** authenticated user == PR author → the tool returns a
|
||||
refusal and **never calls the merge endpoint** (`mock_api.assert_not_called()`
|
||||
or assert no `POST .../merge`).
|
||||
* **Fail-closed inputs:** missing confirmation string, or an unexpected
|
||||
`expected_head_sha`/changed-file set → refuse before any API call.
|
||||
* **Legacy wrapper:** `merge=True` on the review wrapper fails closed and points
|
||||
to the gated workflow, with no API call
|
||||
(`test_merge_flag_fails_closed_without_api_call`).
|
||||
* **Self-approval blocked:** authenticated user == PR author → `approve` /
|
||||
`request_changes` refused.
|
||||
|
||||
Every new gate should have a test proving the mutating endpoint is **not**
|
||||
reached when the gate should block.
|
||||
|
||||
---
|
||||
|
||||
## 7. No-secret / no-token regression expectations
|
||||
|
||||
Secrets must never appear in logs, tool return values, audit records, or test
|
||||
output (see [`safety-model.md`](safety-model.md) §3). The audit module
|
||||
(`gitea_audit.py`) redacts secret-like keys and value prefixes; see
|
||||
`tests/test_audit.py`.
|
||||
|
||||
Expectations for new tests:
|
||||
|
||||
* Assert that token/authorization/password fields are replaced with
|
||||
`gitea_audit.REDACTED` in any structured output or audit record
|
||||
(`test_redacts_secret_keys`, `test_redacts_nested_and_lists`).
|
||||
* Assert that credential-looking substrings in free-text (error messages,
|
||||
reasons) are redacted (`test_redacts_credential_value_prefixes`,
|
||||
`test_metadata_and_reason_redacted`).
|
||||
* Never commit a real token/password, even in a fixture. Use obviously fake
|
||||
values (e.g. `FAKE_AUTH` above).
|
||||
* When a tool returns identity/profile metadata, assert it contains the
|
||||
non-secret fields (username, profile name) and **not** the token.
|
||||
|
||||
There is no third-party secret scanner wired into this repo today; secret safety
|
||||
is enforced by `gitea_audit.redact` plus the regression tests above. A quick
|
||||
manual sweep before a PR:
|
||||
|
||||
```bash
|
||||
# Look for accidentally committed credentials in the diff
|
||||
git diff --cached | grep -nEi "authorization: (basic|bearer)|password|token=[A-Za-z0-9]" || echo "clean"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Unit tests vs. future Docker integration tests
|
||||
|
||||
* **Unit tests (today, default):** fast, fully mocked, no network, no keychain.
|
||||
This is where the vast majority of coverage lives and where new tests should
|
||||
go. They must stay fast and must not require credentials.
|
||||
* **Docker/local-Gitea integration tests (planned, see #66):** opt-in and
|
||||
skipped by default, gated behind an explicit environment variable and run
|
||||
against a pinned, disposable Gitea container. They validate real API behavior
|
||||
(pagination, permissions, label/PR-review endpoints, error payloads) that
|
||||
mocks cannot prove. They must not require production credentials and must not
|
||||
leak tokens.
|
||||
|
||||
Rule of thumb: prove **logic and request-shaping** with unit tests; reserve
|
||||
integration tests for **real-server compatibility**. Do not convert unit tests
|
||||
into network tests.
|
||||
|
||||
---
|
||||
|
||||
## 9. Read-only vs. mutating tool expectations
|
||||
|
||||
* **Read-only tools** (e.g. `gitea_whoami`, `gitea_view_*`, `gitea_list_*`,
|
||||
`gitea_get_profile`): test that they never issue a mutating HTTP method and
|
||||
never require a mutation gate. Assert the request method is `GET`.
|
||||
* **Mutating tools** (create/edit/close/label, review, merge, mirror): test that
|
||||
they (a) pass the profile/allowed-operation gate, (b) honor confirmation and
|
||||
self-action gates, (c) emit an audit record with the authenticated identity
|
||||
and outcome, and (d) fail closed — no `api_request` call — when any gate fails.
|
||||
|
||||
Keep this split explicit in test names and assertions so a reviewer can see, per
|
||||
tool, which category it belongs to and which gates it must respect.
|
||||
@@ -0,0 +1,34 @@
|
||||
# Label Taxonomy
|
||||
|
||||
This document catalogs the issue labels used for MCP workflows, including Jenkins and GlitchTip (observability).
|
||||
|
||||
> **Approval Required:** Do not create or apply new labels in `manage_labels.py` without explicit owner approval of this document.
|
||||
|
||||
## Existing Labels
|
||||
|
||||
* **`jenkins`**
|
||||
* Description: Jenkins integration
|
||||
* Color: `d93f0b`
|
||||
* Use: Used to mark issues, PRs, or tasks that involve the `jenkins-mcp` boundaries, CI/CD designs, or build failures.
|
||||
|
||||
* **`glitchtip`**
|
||||
* Description: GlitchTip integration
|
||||
* Color: `b60205`
|
||||
* Use: Used to mark issues related to the `glitchtip-mcp` boundary and observability integration.
|
||||
|
||||
## Proposed / Missing Labels
|
||||
|
||||
* **`observability`**
|
||||
* Proposed Description: Observability, metrics, and monitoring tasks
|
||||
* Proposed Color: `5319e7`
|
||||
* Use: Broader than GlitchTip alone; covers logging, metrics, traces, and general observability pipeline improvements.
|
||||
|
||||
* **`source:glitchtip`**
|
||||
* Proposed Description: Issue filed automatically by GlitchTip orchestration
|
||||
* Proposed Color: `b60205`
|
||||
* Use: Applied automatically by the orchestrator when a GlitchTip error event is converted into a Gitea issue.
|
||||
|
||||
* **`status:triage`**
|
||||
* Proposed Description: Issue needs human or orchestrator triage
|
||||
* Proposed Color: `fbca04`
|
||||
* Use: Used for incoming issues (especially automated ones like `source:glitchtip`) that have not yet been evaluated for priority or resolution.
|
||||
@@ -0,0 +1,138 @@
|
||||
# LLM-Agent-SHA — Opaque Agent Attribution (Phase 0)
|
||||
|
||||
Convention for attributing work to a specific LLM session/workstream across
|
||||
issues, branches, PRs, and review handoffs, without exposing a human or model
|
||||
identity. Approved by the owner decision on issue #86
|
||||
(`#issuecomment-1354`); this document implements **Phase 0 only**.
|
||||
|
||||
## The one rule that matters
|
||||
|
||||
`LLM-Agent-SHA` is **informational attribution metadata only**. It must never
|
||||
be used for authentication, authorization, review eligibility, merge
|
||||
eligibility, profile permissions, or any other security decision.
|
||||
|
||||
The security gates remain, unchanged:
|
||||
|
||||
- the **authenticated Gitea user** (self-review/self-merge protection),
|
||||
- the **active MCP profile** and its `allowed_operations`
|
||||
(see [`gitea-execution-profiles.md`](gitea-execution-profiles.md)),
|
||||
- the fail-closed eligibility checks in `gitea_check_pr_eligibility`.
|
||||
|
||||
Two sessions with different `LLM-Agent-SHA` values that authenticate as the
|
||||
same Gitea user are **the same actor** for review/merge safety. A different
|
||||
SHA never unlocks self-review or self-merge. `tests/test_llm_agent_sha.py`
|
||||
proves the eligibility logic never consults the SHA.
|
||||
|
||||
## Format
|
||||
|
||||
```text
|
||||
LLM-Agent-SHA: llm-<12 lowercase hex chars>
|
||||
```
|
||||
|
||||
Validation regex:
|
||||
|
||||
```text
|
||||
^llm-[0-9a-f]{12}$
|
||||
```
|
||||
|
||||
Examples: `llm-8f3a9c2d6b41`, `llm-41d0e7aa9f2c`, `llm-b7c93d441a08`.
|
||||
|
||||
### Generation
|
||||
|
||||
Generate 48 random bits, e.g. `python3 -c "import secrets; print('llm-' +
|
||||
secrets.token_hex(6))"`, or hash a non-secret session UUID. An
|
||||
operator-provided opaque ID is also fine.
|
||||
|
||||
Do **not** derive the value from any of:
|
||||
|
||||
- a Gitea token or other secret,
|
||||
- an email address or username,
|
||||
- a machine hostname or private filesystem path,
|
||||
- a model or provider name,
|
||||
- conversation contents.
|
||||
|
||||
The SHA must contain no model name, provider name, human name, email,
|
||||
hostname, token, private path, or conversation-derived content. It is safe to
|
||||
include in PR bodies, issue comments, and audit logs — and only there.
|
||||
|
||||
## Lifetime
|
||||
|
||||
Canonical lifetime is **per PR/workstream**: pick one SHA when starting an
|
||||
issue and keep it through the branch, PR, and handoff for that workstream. A
|
||||
per-session SHA is acceptable when the session maps cleanly to one
|
||||
workstream. Do not reuse a SHA across unrelated workstreams.
|
||||
|
||||
## Placement
|
||||
|
||||
Phase 0 uses **visible markdown metadata blocks** (not hidden HTML
|
||||
comments). Include the block in PR bodies and review handoffs; keep it out of
|
||||
ordinary comments unless attribution is genuinely useful there.
|
||||
|
||||
**Never put the SHA in branch or worktree names.** Branches stay
|
||||
issue-linked and human-readable (`docs/issue-86-llm-agent-sha-phase0`), per
|
||||
the branch standard.
|
||||
|
||||
### Handoff metadata block (implementer → PR body / handoff report)
|
||||
|
||||
```markdown
|
||||
LLM Handoff Metadata:
|
||||
- LLM-Agent-SHA: llm-8f3a9c2d6b41
|
||||
- LLM-Role: implementer
|
||||
- Authenticated-Gitea-User: jcwalker3
|
||||
- MCP-Profile: gitea-default
|
||||
- Branch: docs/example-branch
|
||||
- Worktree: branches/docs-example-branch
|
||||
- Self-review allowed: no
|
||||
```
|
||||
|
||||
### Review metadata block (reviewer → review comment)
|
||||
|
||||
```markdown
|
||||
Review Metadata:
|
||||
- LLM-Agent-SHA: llm-41d0e7aa9f2c
|
||||
- LLM-Role: reviewer
|
||||
- Authenticated-Gitea-User: sysadmin
|
||||
- MCP-Profile: prgs-reviewer
|
||||
- Eligibility: passed
|
||||
```
|
||||
|
||||
## Same SHA vs same user vs same profile
|
||||
|
||||
Reviewers and operators must keep three distinct identities straight:
|
||||
|
||||
| Comparison | Meaning | Effect on eligibility |
|
||||
|---|---|---|
|
||||
| same `LLM-Agent-SHA` | same LLM session/workstream wrote both artifacts | **none — attribution only** |
|
||||
| same authenticated Gitea user | same Gitea actor | **blocks** self-review / self-merge, regardless of SHA |
|
||||
| same MCP profile | same capability set | governs `allowed_operations` (what actions are permitted at all) |
|
||||
|
||||
Concretely: an implementer session (`llm-8f3a…`, user `jcwalker3`) and a
|
||||
would-be reviewer session (`llm-41d0…`, also user `jcwalker3`) have different
|
||||
SHAs but the **same Gitea user** — the reviewer session is still the PR
|
||||
author to Gitea and must not review, approve, or merge. Review handoffs
|
||||
require a genuinely different authenticated user (e.g. `sysadmin` /
|
||||
`prgs-reviewer`).
|
||||
|
||||
## Phase 0 scope (and what is deferred)
|
||||
|
||||
Phase 0 is documentation, handoff/review templates, and negative tests only.
|
||||
Deferred to later owner-approved phases; none of this exists yet:
|
||||
|
||||
- launcher-enforced SHA generation,
|
||||
- `LLM_AGENT_SHA` / `LLM_AGENT_ROLE` environment injection,
|
||||
- `gitea_whoami` returning SHA/role,
|
||||
- automatic PR body injection by MCP tools,
|
||||
- audit schema changes requiring the SHA,
|
||||
- release/orchestrator lineage tracking.
|
||||
|
||||
MCP tools neither read nor emit the SHA. Setting an `LLM_AGENT_SHA`
|
||||
environment variable has no effect on any tool; the negative tests assert
|
||||
eligibility results are byte-identical with and without it.
|
||||
|
||||
## Related documents
|
||||
|
||||
- [`llm-workflow-runbooks.md`](llm-workflow-runbooks.md) — the runbooks whose
|
||||
handoffs carry these blocks
|
||||
- [`gitea-execution-profiles.md`](gitea-execution-profiles.md) — profiles and
|
||||
`allowed_operations` (the real permission gate)
|
||||
- [`safety-model.md`](safety-model.md) — audit, redaction, confirmation gates
|
||||
@@ -18,6 +18,12 @@ behavior they rely on already exists (canonical runtime profiles, the
|
||||
interactive setup menu, identity/eligibility checks, gated review/merge, and
|
||||
audit logging). See [Related documents](#related-documents).
|
||||
|
||||
For cross-project use, copy the portable workflow skill at
|
||||
[`../skills/llm-project-workflow/SKILL.md`](../skills/llm-project-workflow/SKILL.md).
|
||||
It extracts the issue-first, isolated-worktree, no-self-review, profile-safety,
|
||||
merge-cleanup, fail-closed, and recovery rules into a reusable package that can
|
||||
be adapted to other repositories.
|
||||
|
||||
## Principle: the profile is the role, not the LLM
|
||||
|
||||
```text
|
||||
@@ -39,6 +45,18 @@ Use any eligible reviewer profile to review PR #N.
|
||||
Use any eligible merger profile to merge PR #N if checks pass.
|
||||
```
|
||||
|
||||
### Attribution: `LLM-Agent-SHA` (metadata only)
|
||||
|
||||
Sessions may attribute their work with an opaque `LLM-Agent-SHA`
|
||||
(`llm-<12 lowercase hex>`, e.g. `llm-8f3a9c2d6b41`) in PR-body and
|
||||
review-handoff metadata blocks — see
|
||||
[`llm-agent-sha.md`](llm-agent-sha.md) for the full convention. It is
|
||||
**attribution only**: eligibility is decided solely by the authenticated
|
||||
Gitea user and the profile's allowed operations. Two sessions with different
|
||||
SHAs under the same Gitea user are the same actor — a different SHA never
|
||||
permits self-review or self-merge. Keep the SHA out of branch and worktree
|
||||
names.
|
||||
|
||||
## Prerequisites: canonical config + thin launchers
|
||||
|
||||
Runtime profiles live in **one canonical JSON file**, referenced by every LLM
|
||||
@@ -174,6 +192,24 @@ under `branches/`. The main repository checkout is an orchestration checkout:
|
||||
use it for status checks, issue creation/claiming, and creating worktrees, but
|
||||
do not edit tracked repository files there.
|
||||
|
||||
**Issue → branch → worktree → PR → cleanup.** Every implementation branch is
|
||||
tied to an issue number so the work is traceable end to end:
|
||||
|
||||
| Stage | Form |
|
||||
|-------|------|
|
||||
| Issue | `#123` (claimed with `status:in-progress`) |
|
||||
| Branch | `(fix\|feat\|docs\|chore)/issue-123-<slug>` (review: `review/pr-456-<slug>`) |
|
||||
| Worktree | `branches/fix-issue-123-<slug>` (slashes → hyphens) |
|
||||
| PR | body says `Closes #123` (closes) or `Refs #123` (related) |
|
||||
| Cleanup | remove remote+local branch + worktree folder; drop `status:in-progress` |
|
||||
|
||||
`scripts/worktree-start` **rejects** implementation branches that are not
|
||||
issue-linked (use `--allow-unlinked` only for genuine exceptions). When claiming,
|
||||
post a comment like
|
||||
`Claimed. Branch: fix/issue-123-<slug>. Worktree: branches/fix-issue-123-<slug>.`
|
||||
Gitea has no native issue→branch API field (only a PR's head branch), so this
|
||||
linkage is enforced by branch name + claim comment + PR body + cleanup.
|
||||
|
||||
Branch folders are ignored by git via `branches/`, so dirty work in one issue
|
||||
does not block starting an unrelated issue in a separate branch folder. No LLM
|
||||
may edit another issue's branch folder unless explicitly assigned to that issue.
|
||||
@@ -250,7 +286,8 @@ touching anything.
|
||||
`fix/...` / `docs/...`); `cd` into that worktree; implement narrowly; add or
|
||||
update tests if behavior changes; run the full suite; commit with an
|
||||
issue-linked message; open a PR to `master`. **Do not** review or merge your
|
||||
own PR.
|
||||
own PR. Include an `LLM Handoff Metadata` block (with `LLM-Agent-SHA`) in
|
||||
the PR body — see [`llm-agent-sha.md`](llm-agent-sha.md).
|
||||
- **Prompt:** `Use an author profile to implement issue #N and open a PR to
|
||||
master. Do not self-review or self-merge.`
|
||||
|
||||
@@ -261,7 +298,11 @@ touching anything.
|
||||
- **Steps:** confirm identity + eligibility (menu eligibility check or
|
||||
`gitea_check_pr_eligibility`); read the diff; confirm scope matches the linked
|
||||
issue; post the review (`comment` / `request_changes` / `approve`) via the
|
||||
gated review tool. Pin the reviewed head SHA where supported.
|
||||
gated review tool. Pin the reviewed head SHA where supported. Include a
|
||||
`Review Metadata` block (with your own `LLM-Agent-SHA`) in the review —
|
||||
and remember: a different `LLM-Agent-SHA` does **not** make you a different
|
||||
actor; only a different authenticated Gitea user does
|
||||
([`llm-agent-sha.md`](llm-agent-sha.md)).
|
||||
- **Prompt:** `Use any eligible reviewer profile to review PR #N. Approve only
|
||||
if scope matches issue #M and checks pass; otherwise request changes.`
|
||||
|
||||
@@ -275,14 +316,19 @@ touching anything.
|
||||
- **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.`
|
||||
|
||||
### Close the issue after merge
|
||||
### Close the issue after merge / Reconciliation
|
||||
|
||||
- **Profile:** issue-manager or merger.
|
||||
- **Steps:** verify remote `master` actually contains the merge; close the
|
||||
issue (or rely on a `Closes #N` keyword); release `status:in-progress`;
|
||||
clean up merged branches.
|
||||
- **Prompt:** `After confirming master contains the merge of PR #N, close issue
|
||||
#M and delete the merged branch.`
|
||||
issue; release `status:in-progress` (if it cannot be removed, report why).
|
||||
- **If closed but not merged (`merged=false`):** Stop normal flow. Do not delete worktrees. Compare PR content to remote `master`.
|
||||
- **fully landed:** comment it landed, remove `status:in-progress`, clean up.
|
||||
- **partially landed:** reopen issue, create corrective PR for missing pieces.
|
||||
- **not landed:** reopen issue/PR, do not clean up.
|
||||
- **Direct push to master:** is forbidden except as a documented recovery exception. Final reports must include why, commits, PR metadata, and repaired labels.
|
||||
- **Final reports:** must include both PR metadata (state, merged flag, merge commit) and Git content (remote master hash, expected content present).
|
||||
- **Prompt (normal):** `After confirming master contains the merge of PR #N, close issue #M and delete the merged branch.`
|
||||
- **Prompt (reconcile):** `Reconcile closed-not-merged PR #N by verifying if its content landed on master.`
|
||||
|
||||
### Stop on blocker
|
||||
|
||||
@@ -313,6 +359,44 @@ All mutating attempts — allowed, blocked, failed, or succeeded — are audit-l
|
||||
with the profile and authenticated user when `GITEA_AUDIT_LOG` is set (see
|
||||
[`safety-model.md`](safety-model.md)).
|
||||
|
||||
## Releases and version tags
|
||||
|
||||
Versions follow SemVer — **`vMAJOR.MINOR.PATCH`**, using **`v0.x.y`** while
|
||||
unstable. Pick the bump by the largest change since the last tag:
|
||||
|
||||
- **PATCH** — bug fixes, docs, tests, wrappers, non-breaking workflow polish.
|
||||
- **MINOR** — new MCP tools, new workflow helpers, new config features;
|
||||
backward-compatible behavior.
|
||||
- **MAJOR** — breaking config/schema/API behavior or a changed MCP contract.
|
||||
|
||||
Tags are **annotated** (`git tag -a`), created **only from the exact commit on
|
||||
remote `master`**, **only after the full suite passes**, and carry release notes
|
||||
referencing the merged PRs/issues. **Never tag** feature branches, dirty
|
||||
worktrees, unreviewed or self-authored work, or commits not on remote `master`.
|
||||
|
||||
Release runbook (see [`../skills/llm-project-workflow/templates/release-tag.md`](../skills/llm-project-workflow/templates/release-tag.md)):
|
||||
|
||||
1. `git fetch prgs --prune`.
|
||||
2. Confirm local `master` equals `prgs/master` (`0 0`) and the tree is clean.
|
||||
3. Run the full test suite; stop on failure.
|
||||
4. Review merged issues/PRs since the last tag
|
||||
(`git log --oneline <last-tag>..prgs/master`).
|
||||
5. Choose the version bump.
|
||||
6. `git tag -a <vX.Y.Z> prgs/master -m "<notes referencing #issues / PRs>"`.
|
||||
7. `git push prgs <vX.Y.Z>`; add release notes if the forge supports it.
|
||||
|
||||
`scripts/release-tag` automates steps 1–7 with these gates built in (SemVer
|
||||
check, fetch/prune, on-master, clean tree, local==remote master, HEAD on remote
|
||||
master, no duplicate tag, tests run unless `--skip-tests`, annotated tag only).
|
||||
It is **safe by default** — no push unless `--push`, and `--dry-run` changes
|
||||
nothing:
|
||||
|
||||
```bash
|
||||
scripts/release-tag --dry-run v0.4.0
|
||||
scripts/release-tag v0.4.0 --notes-file /tmp/release-notes.md
|
||||
scripts/release-tag v0.4.0 --notes-file /tmp/release-notes.md --push
|
||||
```
|
||||
|
||||
## Safety notes
|
||||
|
||||
- Never place raw tokens or passwords in any LLM MCP config; reference secrets
|
||||
@@ -322,7 +406,9 @@ with the profile and authenticated user when `GITEA_AUDIT_LOG` is set (see
|
||||
|
||||
## Related documents
|
||||
|
||||
- [`../skills/llm-project-workflow/SKILL.md`](../skills/llm-project-workflow/SKILL.md) — portable cross-project LLM workflow skill.
|
||||
- [`gitea-execution-profiles.md`](gitea-execution-profiles.md) — the profile model.
|
||||
- [`llm-agent-sha.md`](llm-agent-sha.md) — opaque agent attribution metadata (never an eligibility input).
|
||||
- [`safety-model.md`](safety-model.md) — trust boundaries and audit logging.
|
||||
- [`tool-boundaries.md`](tool-boundaries.md) — per-tool allowed operations.
|
||||
- [`credential-isolation.md`](credential-isolation.md) — credential handling.
|
||||
|
||||
@@ -13,3 +13,11 @@ To maintain a secure environment, all secrets, tokens, passwords, and sensitive
|
||||
- System and application logs
|
||||
- Tool return values/outputs
|
||||
- Any form of persistent storage or console output
|
||||
|
||||
## 4. Read-Only First Policy
|
||||
By default, MCP servers (such as `jenkins-mcp` and `ops-mcp`) operate in a **read-only** mode. Mutation capabilities are deny-by-default and fail-closed.
|
||||
|
||||
## 5. Mutation Gating
|
||||
Any mutating action (e.g., Gitea issue creation from GlitchTip, or Jenkins builds) must be explicitly allowed by the execution profile.
|
||||
- **Jenkins build triggers** are explicitly deferred for phase 1.
|
||||
- **GlitchTip to Gitea issue filing** is documented as a gated, orchestrated workflow, not a direct unprompted automatic action.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
This document defines the strict boundaries between the different MCP server packages within the monorepo.
|
||||
|
||||
The project is named **MCP Control Plane** and lives in the `mcp-control-plane` repository. It groups the following packages: `common`, `gitea-mcp`, `jenkins-mcp`, `ops-mcp`, and `release-mcp`.
|
||||
The project is named **MCP Control Plane** and lives in the `mcp-control-plane` repository. It groups the following packages: `common`, `gitea-mcp`, `jenkins-mcp`, `glitchtip-mcp`, `ops-mcp`, and `release-mcp`.
|
||||
|
||||
## 1. Architectural Philosophy
|
||||
- **One MCP Server per Trust Boundary**: While the packages share a monorepo, their runtime services must remain entirely separate. There is no single "everything" server.
|
||||
@@ -10,4 +10,5 @@ The project is named **MCP Control Plane** and lives in the `mcp-control-plane`
|
||||
## 2. Package-Specific Boundaries
|
||||
- **gitea-mcp**: Restricted to source-control and work-item capabilities (issues, PRs, comments). This package **must not** have Jenkins or Ops credentials, nor can it execute deploy operations.
|
||||
- **jenkins-mcp**: Focused on CI/CD capabilities. This package **must not** have Ops credentials unless explicitly configured for a specific, isolated pipeline later.
|
||||
- **glitchtip-mcp**: Dedicated to observability and error reporting. This package **must not** have Gitea write credentials or Jenkins deploy capabilities.
|
||||
- **ops-mcp**: Dedicated to live environment and host checks. In its initial state, this package starts as strictly read-only (e.g., health checks, status checks, log reading).
|
||||
|
||||
+119
-6
@@ -14,6 +14,7 @@ import datetime
|
||||
import subprocess
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
from email.utils import parsedate_to_datetime
|
||||
from dotenv import dotenv_values, load_dotenv
|
||||
|
||||
@@ -188,6 +189,39 @@ DEFAULT_MAX_RETRIES = _env_int("GITEA_MAX_RETRIES", 3)
|
||||
DEFAULT_BASE_DELAY = _env_float("GITEA_RETRY_BASE_DELAY", 1.0) # seconds
|
||||
DEFAULT_MAX_DELAY = _env_float("GITEA_RETRY_MAX_DELAY", 60.0) # seconds
|
||||
|
||||
# Per-request socket timeout (seconds). Overridable via environment.
|
||||
DEFAULT_HTTP_TIMEOUT = _env_float("GITEA_HTTP_TIMEOUT", 30.0)
|
||||
|
||||
|
||||
def _redact(text):
|
||||
"""Best-effort strip of credential-like substrings from error text.
|
||||
|
||||
Reuses the audit module's redactor so error messages never surface tokens,
|
||||
Basic/Bearer headers, or password-like values. Falls back to the plain
|
||||
string if the audit helper is unavailable.
|
||||
"""
|
||||
try:
|
||||
from gitea_audit import _redact_str
|
||||
return _redact_str(str(text))
|
||||
except Exception:
|
||||
return str(text)
|
||||
|
||||
|
||||
def _add_query(url, **params):
|
||||
"""Return *url* with the given query parameters added or overridden.
|
||||
|
||||
Preserves any existing query string on *url* (e.g. ``?state=open``) so
|
||||
pagination params can be layered on top of an already-filtered endpoint.
|
||||
"""
|
||||
parts = urllib.parse.urlsplit(url)
|
||||
query = dict(urllib.parse.parse_qsl(parts.query, keep_blank_values=True))
|
||||
for key, value in params.items():
|
||||
query[str(key)] = str(value)
|
||||
new_query = urllib.parse.urlencode(query)
|
||||
return urllib.parse.urlunsplit(
|
||||
(parts.scheme, parts.netloc, parts.path, new_query, parts.fragment)
|
||||
)
|
||||
|
||||
|
||||
def parse_retry_after(value, now=None):
|
||||
"""Parse a ``Retry-After`` header into a non-negative delay in seconds.
|
||||
@@ -239,16 +273,31 @@ def backoff_delay(attempt, base=DEFAULT_BASE_DELAY, cap=DEFAULT_MAX_DELAY, rand=
|
||||
|
||||
def api_request(method, url, auth_header, payload=None, *,
|
||||
max_retries=None, base_delay=None, max_delay=None,
|
||||
timeout=None,
|
||||
sleep_func=time.sleep, rand_func=random.random,
|
||||
now_func=time.time):
|
||||
"""Make an authenticated JSON request to the Gitea API.
|
||||
|
||||
Returns parsed JSON on success, raises ``RuntimeError`` on HTTP errors.
|
||||
Returns parsed JSON on success (or ``None`` for an empty body), and raises
|
||||
``RuntimeError`` on failure.
|
||||
|
||||
On HTTP 429 the request is retried up to *max_retries* times: honoring a
|
||||
valid ``Retry-After`` header (seconds or HTTP-date) when present, otherwise
|
||||
using capped jittered exponential backoff. Non-429 errors and successful
|
||||
responses are unchanged. The ``*_func`` parameters are injection points for
|
||||
using capped jittered exponential backoff. Successful responses are
|
||||
unchanged.
|
||||
|
||||
All failures are converted to a ``RuntimeError`` with a clear, secret
|
||||
-redacted message (no raw stack traces or credential material):
|
||||
|
||||
- Non-429 HTTP errors surface the status code and a redacted response body.
|
||||
502/503/504 upstream errors get an explicit "Gitea upstream unavailable"
|
||||
message.
|
||||
- Timeouts and network/DNS failures (``URLError`` / ``TimeoutError``) surface
|
||||
a generic "network error contacting Gitea" message.
|
||||
- A malformed (non-JSON) success body surfaces a "malformed JSON response"
|
||||
message rather than a raw decode error.
|
||||
|
||||
The ``*_func`` parameters and ``timeout`` are injection points for
|
||||
deterministic testing.
|
||||
"""
|
||||
if max_retries is None:
|
||||
@@ -257,6 +306,8 @@ def api_request(method, url, auth_header, payload=None, *,
|
||||
base_delay = DEFAULT_BASE_DELAY
|
||||
if max_delay is None:
|
||||
max_delay = DEFAULT_MAX_DELAY
|
||||
if timeout is None:
|
||||
timeout = DEFAULT_HTTP_TIMEOUT
|
||||
|
||||
data = json.dumps(payload).encode("utf-8") if payload is not None else None
|
||||
req = urllib.request.Request(url, data=data, method=method)
|
||||
@@ -267,9 +318,8 @@ def api_request(method, url, auth_header, payload=None, *,
|
||||
attempt = 0
|
||||
while True:
|
||||
try:
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
body = resp.read().decode("utf-8")
|
||||
return json.loads(body) if body else None
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 429 and attempt < max_retries:
|
||||
header = e.headers.get("Retry-After") if e.headers else None
|
||||
@@ -279,8 +329,71 @@ def api_request(method, url, auth_header, payload=None, *,
|
||||
attempt += 1
|
||||
sleep_func(delay)
|
||||
continue
|
||||
try:
|
||||
error_body = e.read().decode("utf-8", errors="replace")
|
||||
raise RuntimeError(f"HTTP {e.code}: {error_body}") from e
|
||||
except Exception:
|
||||
error_body = ""
|
||||
detail = _redact(error_body).strip()
|
||||
if e.code in (502, 503, 504):
|
||||
msg = f"HTTP {e.code}: Gitea upstream unavailable"
|
||||
raise RuntimeError(f"{msg}: {detail}" if detail else msg) from e
|
||||
raise RuntimeError(f"HTTP {e.code}: {detail}") from e
|
||||
except (urllib.error.URLError, TimeoutError) as e:
|
||||
reason = getattr(e, "reason", e)
|
||||
raise RuntimeError(
|
||||
f"network error contacting Gitea: {_redact(reason)}"
|
||||
) from e
|
||||
|
||||
if not body:
|
||||
return None
|
||||
try:
|
||||
return json.loads(body)
|
||||
except ValueError as e:
|
||||
raise RuntimeError("malformed JSON response from Gitea") from e
|
||||
|
||||
|
||||
def api_get_all(url, auth_header, *, limit=None, page_size=50, max_pages=100,
|
||||
**kwargs):
|
||||
"""Fetch a paginated Gitea collection, following page-based pagination.
|
||||
|
||||
Issues successive ``GET`` requests with ``page`` and ``limit`` (per-page)
|
||||
query parameters, accumulating list items until one of:
|
||||
|
||||
- a page returns fewer items than the page size (the last page),
|
||||
- an empty or ``None`` page is returned (also treated as the end — this is
|
||||
how missing/malformed pagination metadata degrades safely),
|
||||
- *limit* total items have been collected, or
|
||||
- *max_pages* pages have been fetched (a safety cap against runaway loops).
|
||||
|
||||
Pagination relies on the *length of each returned page*, not on
|
||||
``X-Total-Count`` / ``Link`` headers, so it tolerates missing or malformed
|
||||
pagination metadata. Returns a list (possibly empty). Raises ``RuntimeError``
|
||||
(via :func:`api_request`) on network/HTTP/malformed failures, or if a page is
|
||||
not a JSON list. Extra ``kwargs`` pass through to :func:`api_request`.
|
||||
"""
|
||||
if page_size < 1:
|
||||
page_size = 1
|
||||
if page_size > 50:
|
||||
page_size = 50 # Gitea caps per-page results at 50
|
||||
if limit is not None and limit < page_size:
|
||||
page_size = max(1, limit)
|
||||
|
||||
results = []
|
||||
for page in range(1, max_pages + 1):
|
||||
page_url = _add_query(url, page=page, limit=page_size)
|
||||
data = api_request("GET", page_url, auth_header, **kwargs)
|
||||
if data is None:
|
||||
break
|
||||
if not isinstance(data, list):
|
||||
raise RuntimeError(
|
||||
f"expected a list page from Gitea, got {type(data).__name__}"
|
||||
)
|
||||
results.extend(data)
|
||||
if limit is not None and len(results) >= limit:
|
||||
return results[:limit]
|
||||
if len(data) < page_size:
|
||||
break
|
||||
return results
|
||||
|
||||
|
||||
def repo_api_url(host, org, repo):
|
||||
|
||||
+78
-17
@@ -4,9 +4,14 @@
|
||||
Auth follows the project convention: credentials are pulled from the macOS
|
||||
keychain via `git credential fill` (HTTPS), then sent as Basic auth.
|
||||
|
||||
Usage:
|
||||
./manage_labels.py # create labels, then apply the mapping below
|
||||
./manage_labels.py --dry # print actions without writing
|
||||
Modes (default = create labels then apply the one-off MAPPING, preserving the
|
||||
original behavior):
|
||||
|
||||
./manage_labels.py # create labels + apply MAPPING
|
||||
./manage_labels.py --create-labels # idempotent label creation only
|
||||
./manage_labels.py --apply-mapping # one-off MAPPING labeling only
|
||||
./manage_labels.py --add-label 42 chore # add one label to one issue
|
||||
./manage_labels.py --dry ... # print actions without writing
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
@@ -34,7 +39,7 @@ LABELS = [
|
||||
"description": "Issue is being worked on"},
|
||||
]
|
||||
|
||||
# issue number -> label names to apply
|
||||
# issue number -> label names to apply (one-off backfill)
|
||||
MAPPING = {
|
||||
23: ["chore"],
|
||||
22: ["chore"],
|
||||
@@ -56,6 +61,11 @@ MAPPING = {
|
||||
|
||||
BASE_URL = repo_api_url(HOST, ORG, REPO)
|
||||
|
||||
USAGE = (
|
||||
"usage: manage_labels.py [--dry] "
|
||||
"[--create-labels | --apply-mapping | --add-label <issue> <label>]"
|
||||
)
|
||||
|
||||
|
||||
def api(method, path, auth, payload=None):
|
||||
"""Thin wrapper around auth.api_request that prepends BASE_URL and
|
||||
@@ -68,19 +78,15 @@ def api(method, path, auth, payload=None):
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
dry = "--dry" in sys.argv
|
||||
auth = get_auth_header(HOST)
|
||||
if auth is None:
|
||||
print("Could not get credentials from git credential fill",
|
||||
file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# 1. Existing labels -> name:id
|
||||
def _labels_by_name(auth):
|
||||
"""Return {label name: id} for the repo's existing labels."""
|
||||
existing = api("GET", "/labels?limit=100", auth) or []
|
||||
by_name = {l["name"]: l["id"] for l in existing}
|
||||
return {lb["name"]: lb["id"] for lb in existing}
|
||||
|
||||
# 2. Create missing labels
|
||||
|
||||
def create_labels(auth, dry=False):
|
||||
"""Idempotently create the LABELS set; return the resulting name->id map."""
|
||||
by_name = _labels_by_name(auth)
|
||||
for spec in LABELS:
|
||||
if spec["name"] in by_name:
|
||||
print(f"label exists: {spec['name']}")
|
||||
@@ -92,8 +98,13 @@ def main():
|
||||
if created:
|
||||
by_name[created["name"]] = created["id"]
|
||||
print(f"created label: {created['name']} (id {created['id']})")
|
||||
return by_name
|
||||
|
||||
# 3. Apply mapping
|
||||
|
||||
def apply_mapping(auth, by_name=None, dry=False):
|
||||
"""Apply the one-off MAPPING (PUT replaces each issue's label set)."""
|
||||
if by_name is None:
|
||||
by_name = _labels_by_name(auth)
|
||||
for issue, names in sorted(MAPPING.items(), reverse=True):
|
||||
ids = [by_name[n] for n in names if n in by_name]
|
||||
missing = [n for n in names if n not in by_name]
|
||||
@@ -105,9 +116,59 @@ def main():
|
||||
# PUT replaces the issue's labels with exactly this set (idempotent).
|
||||
res = api("PUT", f"/issues/{issue}/labels", auth, {"labels": ids})
|
||||
if res is not None:
|
||||
applied = [l["name"] for l in res]
|
||||
applied = [lb["name"] for lb in res]
|
||||
print(f"#{issue} labeled: {applied}")
|
||||
|
||||
|
||||
def add_label(auth, issue, label, dry=False):
|
||||
"""Ad-hoc: ADD a single existing label to one issue (append, not replace)."""
|
||||
by_name = _labels_by_name(auth)
|
||||
if label not in by_name:
|
||||
print(f" unknown label '{label}'; create it first (--create-labels)",
|
||||
file=sys.stderr)
|
||||
return False
|
||||
if dry:
|
||||
print(f"[dry] #{issue} += {label}")
|
||||
return True
|
||||
# POST appends to the issue's existing labels (does not replace).
|
||||
res = api("POST", f"/issues/{issue}/labels", auth, {"labels": [by_name[label]]})
|
||||
if res is not None:
|
||||
print(f"#{issue} += {label}")
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
dry = "--dry" in argv or "--dry-run" in argv
|
||||
|
||||
auth = get_auth_header(HOST)
|
||||
if auth is None:
|
||||
print("Could not get credentials from git credential fill",
|
||||
file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if "--create-labels" in argv:
|
||||
create_labels(auth, dry=dry)
|
||||
elif "--apply-mapping" in argv:
|
||||
apply_mapping(auth, dry=dry)
|
||||
elif "--add-label" in argv:
|
||||
i = argv.index("--add-label")
|
||||
if i + 2 >= len(argv):
|
||||
print(USAGE, file=sys.stderr)
|
||||
sys.exit(2)
|
||||
try:
|
||||
issue = int(argv[i + 1])
|
||||
except ValueError:
|
||||
print(f"--add-label: issue must be a number, got '{argv[i + 1]}'",
|
||||
file=sys.stderr)
|
||||
sys.exit(2)
|
||||
add_label(auth, issue, argv[i + 2], dry=dry)
|
||||
else:
|
||||
# Default (backward compatible): create labels, then apply the mapping.
|
||||
by_name = create_labels(auth, dry=dry)
|
||||
apply_mapping(auth, by_name, dry=dry)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
+116
-6
@@ -14,6 +14,7 @@ Configuration (mcp_config.json):
|
||||
}
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import functools
|
||||
import contextlib
|
||||
@@ -37,6 +38,7 @@ from gitea_auth import ( # noqa: E402
|
||||
get_credentials,
|
||||
get_auth_header,
|
||||
api_request,
|
||||
api_get_all,
|
||||
repo_api_url,
|
||||
get_profile,
|
||||
)
|
||||
@@ -48,6 +50,71 @@ mcp = FastMCP("gitea-tools", instructions=(
|
||||
))
|
||||
|
||||
|
||||
def extract_linked_issue_numbers(text: str | None, branch_name: str | None = None) -> list[int]:
|
||||
issues = set()
|
||||
if text:
|
||||
pattern = re.compile(r'(?i)(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?|ref[s]?)\s+#(\d+)')
|
||||
issues.update(int(m) for m in pattern.findall(text))
|
||||
if branch_name:
|
||||
pattern = re.compile(r'(?i)issue-(\d+)')
|
||||
issues.update(int(m) for m in pattern.findall(branch_name))
|
||||
return sorted(list(issues))
|
||||
|
||||
def release_in_progress_label(issue_numbers: list[int], remote: str, host: str | None, org: str | None, repo: str | None) -> dict:
|
||||
if not issue_numbers:
|
||||
return {}
|
||||
|
||||
h, o, r = _resolve(remote, host, org, repo)
|
||||
auth = _auth(h)
|
||||
base = repo_api_url(h, o, r)
|
||||
|
||||
try:
|
||||
labels = api_request("GET", f"{base}/labels?limit=100", auth)
|
||||
label_id = None
|
||||
for lb in labels:
|
||||
if lb["name"] == "status:in-progress":
|
||||
label_id = lb["id"]
|
||||
break
|
||||
except Exception as exc:
|
||||
return {num: f"error fetching repo labels: {_redact(str(exc))}" for num in issue_numbers}
|
||||
|
||||
results = {}
|
||||
if label_id is None:
|
||||
for num in issue_numbers:
|
||||
results[num] = "not present"
|
||||
return results
|
||||
|
||||
for num in issue_numbers:
|
||||
try:
|
||||
url = f"{base}/issues/{num}"
|
||||
issue_data = api_request("GET", url, auth)
|
||||
issue_labels = [lb["name"] for lb in issue_data.get("labels", [])]
|
||||
|
||||
if "status:in-progress" in issue_labels:
|
||||
with _audited("release_in_progress_label", host=h, remote=remote, org=o, repo=r, issue_number=num, request_metadata={"action": "remove status:in-progress"}):
|
||||
api_request("DELETE", f"{url}/labels/{label_id}", auth)
|
||||
results[num] = "released"
|
||||
else:
|
||||
results[num] = "not present"
|
||||
except Exception as exc:
|
||||
results[num] = f"error: {_redact(str(exc))}"
|
||||
|
||||
return results
|
||||
|
||||
def cleanup_in_progress_for_pr(pr_payload: dict, remote: str, host: str | None, org: str | None, repo: str | None) -> dict:
|
||||
body = pr_payload.get("body") or ""
|
||||
title = pr_payload.get("title") or ""
|
||||
branch = pr_payload.get("head", {}).get("ref") or ""
|
||||
|
||||
text = f"{title}\n{body}"
|
||||
issues = extract_linked_issue_numbers(text, branch)
|
||||
|
||||
if not issues:
|
||||
return {"cleanup_status": "no linked issue found"}
|
||||
|
||||
results = release_in_progress_label(issues, remote, host, org, repo)
|
||||
return {"cleanup_status": results}
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _resolve(remote: str, host: str | None, org: str | None, repo: str | None):
|
||||
@@ -318,7 +385,7 @@ def gitea_list_prs(
|
||||
h, o, r = _resolve(remote, host, org, repo)
|
||||
auth = _auth(h)
|
||||
url = f"{repo_api_url(h, o, r)}/pulls?state={state}"
|
||||
prs = api_request("GET", url, auth) or []
|
||||
prs = api_get_all(url, auth)
|
||||
return [
|
||||
{
|
||||
"number": pr["number"],
|
||||
@@ -743,6 +810,20 @@ def gitea_edit_pr(
|
||||
with _audited("edit_pr", host=h, remote=remote, org=o, repo=r,
|
||||
pr_number=pr_number, request_metadata={"fields": sorted(payload)}):
|
||||
data = api_request("PATCH", url, auth, payload)
|
||||
|
||||
cleanup_status = None
|
||||
if state == "closed":
|
||||
cleanup = cleanup_in_progress_for_pr(data, remote, host, org, repo)
|
||||
cleanup_status = cleanup.get("cleanup_status")
|
||||
if isinstance(cleanup_status, dict):
|
||||
for issue_num, st in cleanup_status.items():
|
||||
if st == "released":
|
||||
try:
|
||||
comment_url = f"{repo_api_url(h, o, r)}/issues/{issue_num}/comments"
|
||||
api_request("POST", comment_url, auth, {"body": f"Tracker cleanup: removed `status:in-progress` from this issue because linked PR #{pr_number} was closed."})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"number": data["number"],
|
||||
@@ -750,6 +831,7 @@ def gitea_edit_pr(
|
||||
"body": data.get("body", ""),
|
||||
"state": data["state"],
|
||||
"url": data["html_url"],
|
||||
"cleanup_status": cleanup_status,
|
||||
}
|
||||
|
||||
|
||||
@@ -1021,6 +1103,9 @@ def gitea_merge_pr(
|
||||
"GET", f"{repo_api_url(h, o, r)}/pulls/{pr_number}", auth
|
||||
)
|
||||
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:
|
||||
result["merge_commit"] = None
|
||||
except Exception as exc: # noqa: BLE001 — redact before surfacing
|
||||
@@ -1157,7 +1242,14 @@ def gitea_close_issue(
|
||||
with _audited("close_issue", host=h, remote=remote, org=o, repo=r,
|
||||
issue_number=issue_number, request_metadata={"state": "closed"}):
|
||||
api_request("PATCH", url, auth, {"state": "closed"})
|
||||
return {"success": True, "message": f"Issue #{issue_number} closed."}
|
||||
|
||||
cleanup_result = release_in_progress_label([issue_number], remote, host, org, repo)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Issue #{issue_number} closed.",
|
||||
"cleanup_status": cleanup_result
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@@ -1175,7 +1267,7 @@ def gitea_list_issues(
|
||||
Args:
|
||||
state: Filter by state — 'open', 'closed', or 'all'.
|
||||
label: Filter by label name (e.g. 'important').
|
||||
limit: Max number of issues to return (default: 50).
|
||||
limit: Max number of issues to return across all pages (default: 50).
|
||||
remote: Known instance — 'dadeschools' or 'prgs'.
|
||||
host: Override the Gitea host.
|
||||
org: Override the owner/organization.
|
||||
@@ -1186,11 +1278,11 @@ def gitea_list_issues(
|
||||
"""
|
||||
h, o, r = _resolve(remote, host, org, repo)
|
||||
auth = _auth(h)
|
||||
params = f"state={state}&limit={limit}&type=issues"
|
||||
params = f"state={state}&type=issues"
|
||||
if label:
|
||||
params += f"&labels={label}"
|
||||
url = f"{repo_api_url(h, o, r)}/issues?{params}"
|
||||
issues = api_request("GET", url, auth)
|
||||
issues = api_get_all(url, auth, limit=limit)
|
||||
return [
|
||||
{
|
||||
"number": i["number"],
|
||||
@@ -1293,6 +1385,24 @@ def gitea_whoami(
|
||||
}
|
||||
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def gitea_get_authenticated_user(
|
||||
remote: str = "dadeschools",
|
||||
host: str | None = None,
|
||||
) -> dict:
|
||||
"""Alias for gitea_whoami. Look up the authenticated Gitea account."""
|
||||
return gitea_whoami(remote=remote, host=host)
|
||||
|
||||
@mcp.tool()
|
||||
def gitea_get_current_user(
|
||||
remote: str = "dadeschools",
|
||||
host: str | None = None,
|
||||
) -> dict:
|
||||
"""Alias for gitea_whoami. Look up the authenticated Gitea account."""
|
||||
return gitea_whoami(remote=remote, host=host)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def gitea_get_profile(
|
||||
remote: str = "dadeschools",
|
||||
@@ -1445,7 +1555,7 @@ def gitea_list_labels(
|
||||
h, o, r = _resolve(remote, host, org, repo)
|
||||
auth = _auth(h)
|
||||
base = repo_api_url(h, o, r)
|
||||
return api_request("GET", f"{base}/labels?limit=100", auth)
|
||||
return api_get_all(f"{base}/labels", auth)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
|
||||
Executable
+61
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# clear-provenance — strip the macOS com.apple.provenance extended attribute so
|
||||
# Python.app can execute .py files created by agent/IDE terminals (issue #3).
|
||||
#
|
||||
# macOS Sequoia+ blocks Python.app from executing files carrying
|
||||
# com.apple.provenance. Files written by the agent terminal get it; shell
|
||||
# scripts are unaffected. This is a macOS security feature, not a bug in our
|
||||
# code — see the Troubleshooting section of the README.
|
||||
#
|
||||
# Run from a terminal with Full Disk Access (e.g. Terminal.app), not the IDE
|
||||
# terminal, or the removal itself may be blocked.
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
usage: scripts/clear-provenance [--dry-run] [path]
|
||||
|
||||
Recursively remove the com.apple.provenance extended attribute under <path>
|
||||
(default: the repository root). macOS only. Only that attribute is removed;
|
||||
other extended attributes are left intact.
|
||||
|
||||
Examples:
|
||||
scripts/clear-provenance --dry-run
|
||||
scripts/clear-provenance
|
||||
scripts/clear-provenance /path/to/file.py
|
||||
EOF
|
||||
}
|
||||
|
||||
dry_run=0
|
||||
while [[ "${1:-}" == --* ]]; do
|
||||
case "$1" in
|
||||
--dry-run) dry_run=1 ;;
|
||||
--help) usage; exit 0 ;;
|
||||
*) usage >&2; exit 2 ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
if [[ $# -gt 1 ]]; then
|
||||
usage >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
repo_root="$(cd "$script_dir/.." && pwd)"
|
||||
target="${1:-$repo_root}"
|
||||
|
||||
if [[ ! -e "$target" ]]; then
|
||||
printf 'clear-provenance: no such path: %s\n' "$target" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Remove only com.apple.provenance; tolerate files that do not carry it.
|
||||
if [[ "$dry_run" -eq 1 ]]; then
|
||||
printf 'clear-provenance: [dry-run] would run: xattr -r -d com.apple.provenance %q\n' "$target"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
xattr -r -d com.apple.provenance "$target" 2>/dev/null || true
|
||||
printf 'clear-provenance: removed com.apple.provenance recursively under: %s\n' "$target"
|
||||
Executable
+139
@@ -0,0 +1,139 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# release-tag — create an annotated release tag safely from remote master.
|
||||
# Enforces the documented tagging policy (see docs/llm-workflow-runbooks.md and
|
||||
# skills/llm-project-workflow/SKILL.md). Never pushes unless --push is given.
|
||||
#
|
||||
# Test/CI injection points (env):
|
||||
# RELEASE_TAG_REMOTE git remote name (default: prgs)
|
||||
# RELEASE_TAG_TEST_CMD test command run before tagging
|
||||
# (default: ./venv/bin/python -m pytest tests/ -q)
|
||||
|
||||
REMOTE="${RELEASE_TAG_REMOTE:-prgs}"
|
||||
TEST_CMD="${RELEASE_TAG_TEST_CMD:-./venv/bin/python -m pytest tests/ -q}"
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
usage: scripts/release-tag [--dry-run] [--skip-tests] [--push]
|
||||
[--notes-file <path>] <vMAJOR.MINOR.PATCH>
|
||||
|
||||
Create an annotated release tag from remote master, only when the tree/branch
|
||||
are clean and tests pass. Safe by default: no push unless --push; --dry-run
|
||||
changes nothing.
|
||||
|
||||
Options:
|
||||
--dry-run Print planned actions; create/push nothing.
|
||||
--skip-tests Skip the test suite (explicit opt-out; prints a warning).
|
||||
--push Push the tag to the remote after creating it.
|
||||
--notes-file <path> Use this file's contents as the annotated-tag message.
|
||||
--help Show this help.
|
||||
|
||||
Examples:
|
||||
scripts/release-tag --dry-run v0.4.0
|
||||
scripts/release-tag v0.4.0 --notes-file /tmp/release-notes.md
|
||||
scripts/release-tag v0.4.0 --notes-file /tmp/release-notes.md --push
|
||||
EOF
|
||||
}
|
||||
|
||||
fail() { printf 'release-tag: %s\n' "$1" >&2; exit "${2:-1}"; }
|
||||
|
||||
dry_run=0
|
||||
skip_tests=0
|
||||
push=0
|
||||
notes_file=""
|
||||
version=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--dry-run) dry_run=1 ;;
|
||||
--skip-tests) skip_tests=1 ;;
|
||||
--push) push=1 ;;
|
||||
--notes-file) shift; notes_file="${1:-}"; [[ -n "$notes_file" ]] || fail "--notes-file needs a path" 2 ;;
|
||||
--help) usage; exit 0 ;;
|
||||
-*) usage >&2; exit 2 ;;
|
||||
*) if [[ -z "$version" ]]; then version="$1"; else usage >&2; exit 2; fi ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
[[ -n "$version" ]] || { usage >&2; exit 2; }
|
||||
|
||||
# 1. SemVer validation (before any git/network work).
|
||||
if [[ ! "$version" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
fail "invalid version '$version' (expected vMAJOR.MINOR.PATCH, e.g. v0.4.0)" 2
|
||||
fi
|
||||
|
||||
if [[ -n "$notes_file" && ! -f "$notes_file" ]]; then
|
||||
fail "notes file not found: $notes_file" 2
|
||||
fi
|
||||
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
repo_root="$(cd "$script_dir/.." && pwd)"
|
||||
git_c=(git -C "$repo_root")
|
||||
|
||||
# 2. Fetch/prune first.
|
||||
"${git_c[@]}" fetch "$REMOTE" --prune
|
||||
|
||||
# 3. Must be on master.
|
||||
current_branch="$("${git_c[@]}" symbolic-ref --quiet --short HEAD || echo DETACHED)"
|
||||
[[ "$current_branch" == "master" ]] || fail "not on master (on '$current_branch'); tag only from master"
|
||||
|
||||
# 4. Clean worktree.
|
||||
[[ -z "$("${git_c[@]}" status --porcelain)" ]] || fail "worktree is dirty; commit/stash before tagging"
|
||||
|
||||
# 5. Local master must equal remote master.
|
||||
local_sha="$("${git_c[@]}" rev-parse master)"
|
||||
remote_sha="$("${git_c[@]}" rev-parse "$REMOTE/master")"
|
||||
[[ "$local_sha" == "$remote_sha" ]] || fail "local master ($local_sha) != $REMOTE/master ($remote_sha)"
|
||||
|
||||
# 6. HEAD must be that same commit (present on remote master).
|
||||
head_sha="$("${git_c[@]}" rev-parse HEAD)"
|
||||
[[ "$head_sha" == "$remote_sha" ]] || fail "HEAD ($head_sha) is not $REMOTE/master; tag only commits on remote master"
|
||||
|
||||
# 7. Tag must not already exist locally or on the remote.
|
||||
if "${git_c[@]}" rev-parse -q --verify "refs/tags/$version" >/dev/null 2>&1; then
|
||||
fail "tag $version already exists locally"
|
||||
fi
|
||||
if "${git_c[@]}" ls-remote --tags "$REMOTE" "refs/tags/$version" | grep -q .; then
|
||||
fail "tag $version already exists on $REMOTE"
|
||||
fi
|
||||
|
||||
# Annotation message: notes file, or a minimal default.
|
||||
if [[ -n "$notes_file" ]]; then
|
||||
notes_arg=(-F "$notes_file")
|
||||
else
|
||||
notes_arg=(-m "$version")
|
||||
fi
|
||||
|
||||
# Tests (default on; explicit --skip-tests warns). Not executed in dry-run.
|
||||
tests_run="no"
|
||||
if [[ "$skip_tests" -eq 1 ]]; then
|
||||
printf 'release-tag: WARNING --skip-tests set; NOT running the test suite before tagging.\n' >&2
|
||||
elif [[ "$dry_run" -eq 1 ]]; then
|
||||
printf 'release-tag: [dry-run] would run tests: %s\n' "$TEST_CMD"
|
||||
else
|
||||
tests_run="yes"
|
||||
( cd "$repo_root" && eval "$TEST_CMD" ) || fail "tests failed; refusing to tag"
|
||||
fi
|
||||
|
||||
# Create (and optionally push) the annotated tag.
|
||||
tag_created="no"
|
||||
tag_pushed="no"
|
||||
if [[ "$dry_run" -eq 1 ]]; then
|
||||
printf 'release-tag: [dry-run] would create annotated tag %s at %s\n' "$version" "$head_sha"
|
||||
[[ "$push" -eq 1 ]] && printf 'release-tag: [dry-run] would push %s to %s\n' "$version" "$REMOTE"
|
||||
else
|
||||
"${git_c[@]}" tag -a "$version" "$head_sha" "${notes_arg[@]}"
|
||||
tag_created="yes"
|
||||
if [[ "$push" -eq 1 ]]; then
|
||||
"${git_c[@]}" push "$REMOTE" "$version"
|
||||
tag_pushed="yes"
|
||||
fi
|
||||
fi
|
||||
|
||||
printf 'commit: %s\n' "$head_sha"
|
||||
printf 'tag: %s\n' "$version"
|
||||
printf 'tests_run: %s\n' "$tests_run"
|
||||
printf 'tag_created: %s\n' "$tag_created"
|
||||
printf 'tag_pushed: %s\n' "$tag_pushed"
|
||||
+37
-6
@@ -3,21 +3,32 @@ set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
usage: scripts/worktree-start [--dry-run] <branch-name> [start-ref]
|
||||
usage: scripts/worktree-start [--dry-run] [--allow-unlinked] <branch-name> [start-ref]
|
||||
|
||||
Create an issue-specific git worktree under branches/<branch-name-with-slashes-replaced>.
|
||||
Create an issue-linked git worktree under branches/<branch-name-with-slashes-replaced>.
|
||||
|
||||
Branch names must be traceable to an issue (or a PR, for review branches):
|
||||
implementation: (fix|feat|docs|chore)/issue-<number>-<short-description>
|
||||
review: review/pr-<number>-<short-description>
|
||||
Use --allow-unlinked to bypass the check (discouraged).
|
||||
|
||||
Examples:
|
||||
scripts/worktree-start fix/issue-123-example
|
||||
scripts/worktree-start --dry-run review/pr-123-scope-check prgs/master
|
||||
scripts/worktree-start --dry-run review/pr-456-scope-check prgs/master
|
||||
EOF
|
||||
}
|
||||
|
||||
dry_run=0
|
||||
if [[ "${1:-}" == "--dry-run" ]]; then
|
||||
dry_run=1
|
||||
allow_unlinked=0
|
||||
while [[ "${1:-}" == --* ]]; do
|
||||
case "$1" in
|
||||
--dry-run) dry_run=1 ;;
|
||||
--allow-unlinked) allow_unlinked=1 ;;
|
||||
--help) usage; exit 0 ;;
|
||||
*) usage >&2; exit 2 ;;
|
||||
esac
|
||||
shift
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ $# -lt 1 || $# -gt 2 ]]; then
|
||||
usage >&2
|
||||
@@ -27,6 +38,26 @@ fi
|
||||
branch="$1"
|
||||
start_ref="${2:-prgs/master}"
|
||||
|
||||
# Enforce issue-linked, traceable branch names (issue → branch → worktree → PR).
|
||||
if [[ "$allow_unlinked" -eq 0 ]]; then
|
||||
if [[ "$branch" =~ ^(fix|feat|docs|chore)/issue-[0-9]+-.+ ]] \
|
||||
|| [[ "$branch" =~ ^review/pr-[0-9]+-.+ ]]; then
|
||||
:
|
||||
else
|
||||
cat >&2 <<EOF
|
||||
Untraceable branch name: $branch
|
||||
|
||||
Implementation branches must be issue-linked:
|
||||
(fix|feat|docs|chore)/issue-<number>-<short-description>
|
||||
Review branches:
|
||||
review/pr-<number>-<short-description>
|
||||
|
||||
Fix the branch name, or pass --allow-unlinked to override (discouraged).
|
||||
EOF
|
||||
exit 2
|
||||
fi
|
||||
fi
|
||||
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
repo_root="$(cd "$script_dir/.." && pwd)"
|
||||
worktree_name="${branch//\//-}"
|
||||
|
||||
@@ -19,6 +19,14 @@ identity, and cleaned up only after a real merge.
|
||||
|
||||
---
|
||||
|
||||
|
||||
## Definitions
|
||||
|
||||
- **Merged**: Gitea PR metadata says `merged=true`.
|
||||
- **Landed**: Equivalent content is present on remote `master`, but PR metadata may not say merged.
|
||||
- **Closed-not-merged**: PR state is closed and `merged=false`.
|
||||
- **Reconciled**: A human/LLM verified whether closed-not-merged content landed, partially landed, or was lost, and repaired issue/label/tracker state.
|
||||
|
||||
## A. Issue-first rule
|
||||
|
||||
**No repository change without a tracking issue.** This includes creating,
|
||||
@@ -45,7 +53,37 @@ orchestration and status only (issue creation, `git status`, creating worktrees)
|
||||
- Branch folders are removed only after the PR is merged/closed **and** cleanup
|
||||
is explicitly part of the task.
|
||||
|
||||
Preferred helpers (if present in the project):
|
||||
Every implementation branch **must include its issue number** so it is
|
||||
traceable end to end: **issue → branch → worktree folder → PR → cleanup.**
|
||||
|
||||
Allowed implementation patterns:
|
||||
|
||||
- `fix/issue-123-short-description`
|
||||
- `feat/issue-123-short-description`
|
||||
- `docs/issue-123-short-description`
|
||||
- `chore/issue-123-short-description`
|
||||
|
||||
Review-only branches:
|
||||
|
||||
- `review/pr-456-short-description`
|
||||
|
||||
Use a filesystem-safe folder under `branches/` by replacing slashes with
|
||||
hyphens, for example `branches/fix-issue-123-short-description`.
|
||||
|
||||
`scripts/worktree-start` **enforces** this: it rejects an implementation branch
|
||||
that does not match `(fix|feat|docs|chore)/issue-<number>-…` (or a
|
||||
`review/pr-<number>-…` branch), unless `--allow-unlinked` is passed. Traceability
|
||||
is maintained by:
|
||||
|
||||
- the branch name (contains the issue number),
|
||||
- a claim comment on the issue, e.g.
|
||||
`Claimed. Branch: fix/issue-123-short-description. Worktree: branches/fix-issue-123-short-description.`,
|
||||
- the PR body — `Closes #123` when the PR should close the issue, `Refs #123`
|
||||
when related but not closing,
|
||||
- cleanup after merge — remove the remote branch, local branch, and the issue
|
||||
worktree folder, and drop `status:in-progress`.
|
||||
|
||||
For projects using `Gitea-Tools` helpers:
|
||||
|
||||
```bash
|
||||
scripts/worktree-start fix/issue-123-example # → branches/fix-issue-123-example
|
||||
@@ -103,6 +141,14 @@ Worktree folder = branch with `/` replaced by `-`
|
||||
10. Push the branch.
|
||||
11. Open a PR to `master`.
|
||||
12. **If you are the author, stop before review/merge.**
|
||||
13. **Normal issue work must not directly push to `master`.** PR content should be merged through the forge PR merge mechanism.
|
||||
14. Direct push to `master` is allowed only as a documented recovery exception. If used, the final report must include:
|
||||
- why the PR merge path could not be used
|
||||
- exact commits pushed
|
||||
- PR metadata state
|
||||
- issue labels/state repaired
|
||||
- whether the PR is closed-not-merged
|
||||
|
||||
|
||||
## F. Review workflow
|
||||
|
||||
@@ -118,13 +164,15 @@ Worktree folder = branch with `/` replaced by `-`
|
||||
|
||||
Only an eligible (non-author) reviewer merges. After a real merge:
|
||||
|
||||
1. Confirm remote `master` actually contains the merge commit.
|
||||
2. Close/release the issue; remove `status:in-progress` if used.
|
||||
3. Delete the remote branch.
|
||||
4. Remove the local branch.
|
||||
5. Remove the branch worktree folder (`scripts/worktree-clean --delete-branch <branch>`).
|
||||
6. Fetch/prune.
|
||||
7. Confirm the main checkout is clean and current (`0 0` vs remote).
|
||||
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.
|
||||
3. Whenever an issue is closed, check for `status:in-progress`: remove it, or report why it could not be removed.
|
||||
4. Do not delete the remote source branch until: PR `merged=true`, or reconciliation confirms content is safely landed, or the issue owner explicitly abandons the work.
|
||||
5. Remove the local branch.
|
||||
6. Remove the branch worktree folder (`scripts/worktree-clean --delete-branch <branch>`). Branches/worktrees are cleaned only after the above is verified.
|
||||
7. Fetch/prune.
|
||||
8. Confirm the main checkout is clean and current (`0 0` vs remote).
|
||||
9. Final merge/reconciliation reports must include both: PR metadata (state, merged flag, merge commit/hash) and Git content (remote master hash, expected content present or not).
|
||||
|
||||
Never run cleanup before the merge is confirmed on remote `master`.
|
||||
|
||||
@@ -135,7 +183,11 @@ Never run cleanup before the merge is confirmed on remote `master`.
|
||||
- No issue exists and one cannot be created.
|
||||
- Worktree state is unclear or unexpected.
|
||||
- Branch/PR state conflicts with the prompt (e.g. prompt says "merged" but it is not).
|
||||
- A PR is closed but not merged.
|
||||
- A PR is closed but not merged (closed with `merged=false`). In this case:
|
||||
- stop normal review/merge
|
||||
- do not delete branches/worktrees
|
||||
- do not start dependent work
|
||||
- run reconciliation
|
||||
- Local `master` is ahead of remote unexpectedly.
|
||||
- The authenticated user is the PR author (for review/merge).
|
||||
- Secrets/tokens appear in the diff.
|
||||
@@ -152,9 +204,10 @@ When in doubt, stop and surface the discrepancy; do not guess or work around a g
|
||||
the commits are preserved on a feature branch (local + remote) first, then
|
||||
`git reset --hard <remote>/master` to realign. Never discard commits that are
|
||||
not safely pushed elsewhere.
|
||||
- **PR closed but not merged:** the work is not in mainline. Re-push the branch,
|
||||
reopen (or open a replacement) PR, and let an eligible reviewer merge. Do not
|
||||
assume "closed" means "merged" — verify remote `master` contains the commits.
|
||||
- **PR closed but not merged (`merged=false`):** do not merge. Run reconciliation: compare PR content to remote `master` and decide:
|
||||
- **fully landed:** comment that content is present on `master`, remove `status:in-progress`, keep/close issue as appropriate, clean up only after content equivalence is confirmed.
|
||||
- **partially landed:** do not clean up, reopen issue if needed, create corrective issue/PR for missing pieces.
|
||||
- **not landed:** reopen issue if needed, reopen PR or create replacement PR, do not clean up source branch/worktree.
|
||||
- **Branch deleted before merge:** if the commits still exist locally (a branch or
|
||||
reflog), re-push them and reopen the PR; otherwise recover via
|
||||
`git fsck --lost-found`. Preserve first, then proceed.
|
||||
@@ -173,7 +226,9 @@ Ready-to-copy templates live in [`templates/`](templates/):
|
||||
- [`review-pr.md`](templates/review-pr.md) — review a PR.
|
||||
- [`merge-pr.md`](templates/merge-pr.md) — merge a PR (eligible reviewer only).
|
||||
- [`recover-bad-state.md`](templates/recover-bad-state.md) — recover from bad state.
|
||||
- [`reconcile-closed-not-merged-pr.md`](templates/reconcile-closed-not-merged-pr.md) — reconcile a closed-not-merged PR.
|
||||
- [`worktree-cleanup.md`](templates/worktree-cleanup.md) — clean up after merge.
|
||||
- [`release-tag.md`](templates/release-tag.md) — create a release tag.
|
||||
|
||||
## Adapting to a project
|
||||
|
||||
@@ -188,3 +243,40 @@ Replace these project-specific names when copying the skill elsewhere:
|
||||
| helper scripts | Worktree helpers | `scripts/worktree-start` / `-review` / `-clean` |
|
||||
|
||||
The rules in §A–§I are project-agnostic and should not change.
|
||||
|
||||
## Versioning And Tagging
|
||||
|
||||
Releases follow SemVer: **`vMAJOR.MINOR.PATCH`** (use **`v0.x.y`** while
|
||||
unstable). Choose the bump by the largest change since the last tag:
|
||||
|
||||
- **PATCH** — bug fixes, docs, tests, wrappers, non-breaking workflow polish.
|
||||
- **MINOR** — new tools/helpers/config features; backward-compatible behavior.
|
||||
- **MAJOR** — breaking config/schema/API behavior or a changed MCP contract.
|
||||
|
||||
Tags must:
|
||||
|
||||
- be created **only from `master`** (the exact commit on remote `master`),
|
||||
- be created **only after the full test suite passes**,
|
||||
- be **annotated** tags (`git tag -a`), never lightweight,
|
||||
- include release notes / a changelog summary referencing the merged PRs/issues.
|
||||
|
||||
**Never tag** feature branches, dirty worktrees, unreviewed or self-authored
|
||||
work, or commits not present on remote `master`.
|
||||
|
||||
Release process (see [`templates/release-tag.md`](templates/release-tag.md)):
|
||||
|
||||
1. `git fetch <remote> --prune`.
|
||||
2. Verify local `master` equals remote `master` (`0 0`) and the tree is clean.
|
||||
3. Run the full test suite; stop on any failure.
|
||||
4. Inspect merged issues/PRs since the last tag
|
||||
(`git log --oneline <last-tag>..<remote>/master`).
|
||||
5. Choose the version bump.
|
||||
6. Create the annotated tag on remote `master` with release notes.
|
||||
7. Push the tag.
|
||||
8. Create/update release notes if the forge supports it.
|
||||
|
||||
Where present, `scripts/release-tag` automates this with all gates built in
|
||||
(SemVer, fetch/prune, on-master, clean tree, local==remote master, HEAD on
|
||||
remote master, no duplicate tag, tests, annotated-only). Safe by default: no
|
||||
push without `--push`; `--dry-run` changes nothing; `--skip-tests` must be
|
||||
explicit and warns.
|
||||
|
||||
@@ -10,6 +10,7 @@ Rules (llm-project-workflow):
|
||||
author → STOP.
|
||||
- Do not merge unless the PR is open, mergeable, and its checks/review pass.
|
||||
- No force-merge, no bypassing branch protections.
|
||||
- If the PR is closed but `merged=false`, STOP and run reconciliation. Do not clean up.
|
||||
|
||||
Steps:
|
||||
1. Verify authenticated identity + active profile.
|
||||
@@ -20,9 +21,9 @@ Steps:
|
||||
5. Confirm remote master now contains the merge commit.
|
||||
|
||||
Then run the cleanup template (worktree-cleanup.md):
|
||||
- close/release issue #<n>, remove status:in-progress
|
||||
- close/release issue #<n>, remove status:in-progress (if it cannot be removed, report why)
|
||||
- delete remote branch, remove local branch + worktree folder
|
||||
- fetch/prune; confirm main checkout is clean and current (0 0).
|
||||
|
||||
Handoff: reviewer identity, merge result + commit, cleanup done, issue closed.
|
||||
Handoff: reviewer identity, merge result + commit, cleanup done, issue closed, PR metadata state/merged flag/hash, remote master hash & Git content check.
|
||||
```
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# Reconcile Closed-Not-Merged PR Prompt
|
||||
|
||||
You are reconciling PR `<pr-number>` in `<repo-name>` which is closed but `merged=false`.
|
||||
|
||||
Rules:
|
||||
|
||||
- Do not delete branches or worktrees before reconciliation is complete.
|
||||
- Compare the PR's exact content to remote `<default-branch>`.
|
||||
- Determine if the content is fully landed, partially landed, or not landed.
|
||||
|
||||
Workflow:
|
||||
|
||||
1. Verify the PR metadata says `state=closed` and `merged=false`.
|
||||
2. Fetch/prune and inspect remote `<default-branch>`.
|
||||
3. If fully landed: comment that it landed, remove `status:in-progress`, close issue, and clean up.
|
||||
4. If partially landed: reopen issue if needed, create corrective PR for missing pieces, do not clean up.
|
||||
5. If not landed: reopen issue/PR, do not clean up.
|
||||
|
||||
Final handoff:
|
||||
|
||||
- PR metadata (state, merged flag, hash)
|
||||
- Git content verification (remote master hash, expected content present or not)
|
||||
- reconciliation decision (fully/partially/not landed)
|
||||
- issue/label state repaired
|
||||
@@ -22,8 +22,7 @@ Act per case:
|
||||
- Local master ahead of remote: confirm the extra commits live on a branch
|
||||
pushed to <remote>, THEN git reset --hard <remote>/master. Verify with
|
||||
`git branch --contains <sha>` first.
|
||||
- PR closed but not merged: re-push the branch, reopen/replace the PR, let an
|
||||
eligible reviewer merge. Do not merge your own.
|
||||
- PR closed but not merged (`merged=false`): stop normal flow and use reconcile-closed-not-merged-pr.md instead.
|
||||
- Branch deleted before merge: recover commits from a local branch/reflog (or
|
||||
git fsck --lost-found), re-push, reopen the PR.
|
||||
- Unauthorized untracked file: do not commit it; leave pre-existing artifacts.
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# Template: cut a release tag
|
||||
|
||||
Copy, fill the `<...>` fields, and paste as the task prompt. Tagging is
|
||||
irreversible-ish and outward-facing — fail closed on any doubt.
|
||||
|
||||
> If the project ships `scripts/release-tag`, prefer it — it enforces every gate
|
||||
> below automatically and is safe by default (no push without `--push`,
|
||||
> `--dry-run` changes nothing):
|
||||
>
|
||||
> ```bash
|
||||
> scripts/release-tag --dry-run <vX.Y.Z>
|
||||
> scripts/release-tag <vX.Y.Z> --notes-file <path>
|
||||
> scripts/release-tag <vX.Y.Z> --notes-file <path> --push
|
||||
> ```
|
||||
>
|
||||
> The manual steps below are the fallback / what the script does.
|
||||
|
||||
```text
|
||||
Task: cut release <vX.Y.Z> from master.
|
||||
|
||||
Rules (llm-project-workflow — versioning & tagging):
|
||||
- SemVer: vMAJOR.MINOR.PATCH (v0.x.y while unstable).
|
||||
- PATCH: bug fixes, docs, tests, wrappers, non-breaking workflow polish.
|
||||
- MINOR: new tools/helpers/config features, backward-compatible behavior.
|
||||
- MAJOR: breaking config/schema/API or changed MCP contract.
|
||||
- Tag ONLY from clean, tested remote master. Annotated tags only (git tag -a).
|
||||
- NEVER tag: feature branches, dirty worktrees, unreviewed/self-authored work,
|
||||
or commits not present on remote master.
|
||||
|
||||
Steps:
|
||||
1. git fetch <remote> --prune
|
||||
2. Confirm local master == <remote>/master (git rev-list --left-right --count
|
||||
<remote>/master...master → 0 0) and the tree is clean.
|
||||
3. Run the FULL test suite; it must pass. STOP on any failure.
|
||||
4. Inspect merged issues/PRs since the last tag:
|
||||
git log --oneline <last-tag>..<remote>/master
|
||||
5. Choose the bump (PATCH/MINOR/MAJOR) per the rules above; set <vX.Y.Z>.
|
||||
6. Create an ANNOTATED tag on <remote>/master with release notes that reference
|
||||
the merged PRs/issues:
|
||||
git tag -a <vX.Y.Z> <remote>/master -m "<vX.Y.Z>: <summary>
|
||||
|
||||
- #<n> <title> (PR #<pr>)
|
||||
- ..."
|
||||
7. Push the tag: git push <remote> <vX.Y.Z>
|
||||
8. Create/update the release notes / changelog entry if the forge supports it.
|
||||
|
||||
Fail-closed: STOP if tests fail, the tree/worktree is dirty, master != remote,
|
||||
the target commit is not on remote master, or the work was self-authored/
|
||||
unreviewed. Never tag to "fix" a failing state.
|
||||
|
||||
Handoff: version, bump rationale, commit tagged, tests result, tag pushed,
|
||||
release notes link.
|
||||
```
|
||||
@@ -8,6 +8,8 @@ Task: review PR #<pr> for issue #<n>.
|
||||
Rules (llm-project-workflow):
|
||||
- Review in a SEPARATE detached review worktree, never the author's folder.
|
||||
- You must NOT be the PR author. If the authenticated user == PR author, stop.
|
||||
A different LLM-Agent-SHA does NOT make you a different actor — only a
|
||||
different authenticated Gitea user does (docs/llm-agent-sha.md).
|
||||
- Do not merge if any check fails.
|
||||
|
||||
Steps:
|
||||
@@ -21,6 +23,14 @@ Steps:
|
||||
6. Run the test suite; note results.
|
||||
7. Post the review verdict: approve only if scope is clean and checks pass;
|
||||
otherwise request changes with specifics. Never merge from this review step.
|
||||
Include a "Review Metadata" block (attribution only — docs/llm-agent-sha.md):
|
||||
|
||||
Review Metadata:
|
||||
- LLM-Agent-SHA: llm-<12 lowercase hex, e.g. llm-41d0e7aa9f2c>
|
||||
- LLM-Role: reviewer
|
||||
- Authenticated-Gitea-User: <whoami result>
|
||||
- MCP-Profile: <profile name>
|
||||
- Eligibility: passed/failed
|
||||
|
||||
Handoff: reviewer identity, PR author, scope verdict, checks + results, decision.
|
||||
```
|
||||
|
||||
@@ -23,6 +23,17 @@ Steps:
|
||||
6. Checks: run the test suite, compile/lint changed files, git diff --check,
|
||||
and scan the diff for secrets.
|
||||
7. Commit (issue-linked message), push the branch, open a PR to master.
|
||||
Include an "LLM Handoff Metadata" block in the PR body (attribution only;
|
||||
never an eligibility input — docs/llm-agent-sha.md):
|
||||
|
||||
LLM Handoff Metadata:
|
||||
- LLM-Agent-SHA: llm-<12 lowercase hex, e.g. llm-8f3a9c2d6b41>
|
||||
- LLM-Role: implementer
|
||||
- Authenticated-Gitea-User: <whoami result>
|
||||
- MCP-Profile: <profile name>
|
||||
- Branch: <branch>
|
||||
- Worktree: <worktree path>
|
||||
- Self-review allowed: no
|
||||
8. Stop before review/merge — you are the author.
|
||||
|
||||
Handoff: issue #, branch, worktree path, files changed, checks + results, PR URL.
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
"""Unit coverage for shared API pagination and failure handling (#67).
|
||||
|
||||
Covers gitea_auth.api_request failure conversion (timeouts, DNS/network,
|
||||
502/503, malformed error payloads, malformed success JSON, no-secret leakage,
|
||||
preserved success + 429 behavior) and gitea_auth.api_get_all pagination
|
||||
(single/multi page, missing/malformed metadata, limit cap, max_pages, query
|
||||
handling). Everything is mocked — no real network calls are made.
|
||||
"""
|
||||
import io
|
||||
import unittest
|
||||
import urllib.error
|
||||
from unittest.mock import patch
|
||||
|
||||
import gitea_auth
|
||||
import gitea_audit
|
||||
|
||||
FAKE_AUTH = "Basic ZmFrZTpmYWtl" # not a real credential
|
||||
URL = "https://gitea.example.com/api/v1/repos/o/r/issues"
|
||||
|
||||
|
||||
class FakeResp:
|
||||
"""Minimal context-manager stand-in for a urlopen response."""
|
||||
|
||||
def __init__(self, body):
|
||||
self._body = body.encode("utf-8") if isinstance(body, str) else body
|
||||
|
||||
def read(self):
|
||||
return self._body
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
|
||||
|
||||
def http_error(code, body="", headers=None):
|
||||
return urllib.error.HTTPError(
|
||||
url=URL, code=code, msg="err",
|
||||
hdrs=headers or {}, fp=io.BytesIO(body.encode("utf-8")),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# api_request — success path preserved
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestApiRequestSuccess(unittest.TestCase):
|
||||
|
||||
@patch("gitea_auth.urllib.request.urlopen")
|
||||
def test_success_returns_parsed_json(self, mock_open):
|
||||
mock_open.return_value = FakeResp('{"number": 1, "title": "ok"}')
|
||||
self.assertEqual(
|
||||
gitea_auth.api_request("GET", URL, FAKE_AUTH),
|
||||
{"number": 1, "title": "ok"},
|
||||
)
|
||||
|
||||
@patch("gitea_auth.urllib.request.urlopen")
|
||||
def test_empty_body_returns_none(self, mock_open):
|
||||
mock_open.return_value = FakeResp("")
|
||||
self.assertIsNone(gitea_auth.api_request("GET", URL, FAKE_AUTH))
|
||||
|
||||
@patch("gitea_auth.urllib.request.urlopen")
|
||||
def test_429_then_success_still_retries(self, mock_open):
|
||||
mock_open.side_effect = [http_error(429), FakeResp('{"ok": true}')]
|
||||
calls = []
|
||||
result = gitea_auth.api_request(
|
||||
"GET", URL, FAKE_AUTH,
|
||||
sleep_func=lambda d: calls.append(d), rand_func=lambda: 0.0,
|
||||
)
|
||||
self.assertEqual(result, {"ok": True})
|
||||
self.assertEqual(len(calls), 1) # slept once between the two attempts
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# api_request — failure handling
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestApiRequestFailures(unittest.TestCase):
|
||||
|
||||
@patch("gitea_auth.urllib.request.urlopen")
|
||||
def test_timeout_converted_to_runtimeerror(self, mock_open):
|
||||
mock_open.side_effect = TimeoutError("timed out")
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
||||
self.assertIn("network error contacting Gitea", str(ctx.exception))
|
||||
|
||||
@patch("gitea_auth.urllib.request.urlopen")
|
||||
def test_dns_network_failure_converted(self, mock_open):
|
||||
mock_open.side_effect = urllib.error.URLError("Name or service not known")
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
||||
self.assertIn("network error contacting Gitea", str(ctx.exception))
|
||||
|
||||
@patch("gitea_auth.urllib.request.urlopen")
|
||||
def test_502_upstream_message(self, mock_open):
|
||||
mock_open.side_effect = http_error(502, "bad gateway")
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
||||
msg = str(ctx.exception)
|
||||
self.assertIn("HTTP 502", msg)
|
||||
self.assertIn("upstream unavailable", msg)
|
||||
|
||||
@patch("gitea_auth.urllib.request.urlopen")
|
||||
def test_503_upstream_message(self, mock_open):
|
||||
mock_open.side_effect = http_error(503, "")
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
||||
self.assertIn("HTTP 503", str(ctx.exception))
|
||||
self.assertIn("upstream unavailable", str(ctx.exception))
|
||||
|
||||
@patch("gitea_auth.urllib.request.urlopen")
|
||||
def test_malformed_error_payload_does_not_crash(self, mock_open):
|
||||
# Non-JSON garbage error body must still yield a clean RuntimeError.
|
||||
mock_open.side_effect = http_error(500, "<html>garbage</html>")
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
||||
self.assertIn("HTTP 500", str(ctx.exception))
|
||||
|
||||
@patch("gitea_auth.urllib.request.urlopen")
|
||||
def test_malformed_success_json_raises_clean_error(self, mock_open):
|
||||
mock_open.return_value = FakeResp("not json{")
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
||||
self.assertIn("malformed JSON response", str(ctx.exception))
|
||||
|
||||
@patch("gitea_auth.urllib.request.urlopen")
|
||||
def test_no_secret_leak_in_error_body(self, mock_open):
|
||||
mock_open.side_effect = http_error(
|
||||
400, "failed: token supersecret123 rejected")
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
||||
msg = str(ctx.exception)
|
||||
self.assertNotIn("supersecret123", msg)
|
||||
self.assertIn(gitea_audit.REDACTED, msg)
|
||||
|
||||
@patch("gitea_auth.urllib.request.urlopen")
|
||||
def test_auth_header_never_in_error(self, mock_open):
|
||||
mock_open.side_effect = http_error(400, "bad request")
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
||||
self.assertNotIn(FAKE_AUTH, str(ctx.exception))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# api_get_all — pagination
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestApiGetAll(unittest.TestCase):
|
||||
|
||||
@patch("gitea_auth.api_request")
|
||||
def test_single_page(self, mock_req):
|
||||
mock_req.return_value = [{"id": 1}, {"id": 2}] # short page (< page_size)
|
||||
result = gitea_auth.api_get_all(URL, FAKE_AUTH, page_size=50)
|
||||
self.assertEqual(result, [{"id": 1}, {"id": 2}])
|
||||
self.assertEqual(mock_req.call_count, 1)
|
||||
|
||||
@patch("gitea_auth.api_request")
|
||||
def test_multi_page(self, mock_req):
|
||||
mock_req.side_effect = [
|
||||
[{"id": 1}, {"id": 2}], # full page
|
||||
[{"id": 3}, {"id": 4}], # full page
|
||||
[{"id": 5}], # short page -> stop
|
||||
]
|
||||
result = gitea_auth.api_get_all(URL, FAKE_AUTH, page_size=2)
|
||||
self.assertEqual([r["id"] for r in result], [1, 2, 3, 4, 5])
|
||||
self.assertEqual(mock_req.call_count, 3)
|
||||
|
||||
@patch("gitea_auth.api_request")
|
||||
def test_missing_metadata_none_page_ends(self, mock_req):
|
||||
mock_req.return_value = None # empty/malformed metadata -> treated as end
|
||||
self.assertEqual(gitea_auth.api_get_all(URL, FAKE_AUTH), [])
|
||||
self.assertEqual(mock_req.call_count, 1)
|
||||
|
||||
@patch("gitea_auth.api_request")
|
||||
def test_malformed_metadata_non_list_raises(self, mock_req):
|
||||
mock_req.return_value = {"message": "not a list"}
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
gitea_auth.api_get_all(URL, FAKE_AUTH)
|
||||
self.assertIn("expected a list page", str(ctx.exception))
|
||||
|
||||
@patch("gitea_auth.api_request")
|
||||
def test_limit_caps_results(self, mock_req):
|
||||
mock_req.side_effect = [[{"id": 1}, {"id": 2}], [{"id": 3}, {"id": 4}]]
|
||||
result = gitea_auth.api_get_all(URL, FAKE_AUTH, page_size=2, limit=3)
|
||||
self.assertEqual([r["id"] for r in result], [1, 2, 3])
|
||||
|
||||
@patch("gitea_auth.api_request")
|
||||
def test_max_pages_safety_cap(self, mock_req):
|
||||
mock_req.side_effect = [
|
||||
[{"id": 1}, {"id": 2}], [{"id": 3}, {"id": 4}], [{"id": 5}, {"id": 6}],
|
||||
]
|
||||
result = gitea_auth.api_get_all(URL, FAKE_AUTH, page_size=2, max_pages=2)
|
||||
self.assertEqual(len(result), 4)
|
||||
self.assertEqual(mock_req.call_count, 2)
|
||||
|
||||
@patch("gitea_auth.api_request")
|
||||
def test_query_params_appended_and_preserved(self, mock_req):
|
||||
mock_req.return_value = [] # first (empty) page ends immediately
|
||||
gitea_auth.api_get_all(URL + "?state=open", FAKE_AUTH, page_size=2)
|
||||
called_url = mock_req.call_args[0][1]
|
||||
self.assertIn("state=open", called_url)
|
||||
self.assertIn("page=1", called_url)
|
||||
self.assertIn("limit=2", called_url)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Tests for scripts/clear-provenance (#3).
|
||||
|
||||
Exercises argument handling and the inert --dry-run path only — no real xattr
|
||||
mutation, no network. (Actually removing com.apple.provenance is macOS-only and
|
||||
has real side effects, so it is not exercised here.)
|
||||
"""
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
SCRIPT = REPO / "scripts" / "clear-provenance"
|
||||
|
||||
|
||||
def run(*args):
|
||||
proc = subprocess.run(["bash", str(SCRIPT), *args],
|
||||
capture_output=True, text=True, cwd=str(REPO))
|
||||
return proc.returncode, proc.stdout, proc.stderr
|
||||
|
||||
|
||||
class TestClearProvenance(unittest.TestCase):
|
||||
|
||||
def test_dry_run_defaults_to_repo_root(self):
|
||||
rc, out, _ = run("--dry-run")
|
||||
self.assertEqual(rc, 0)
|
||||
self.assertIn("would run: xattr -r -d com.apple.provenance", out)
|
||||
self.assertIn(str(REPO), out)
|
||||
|
||||
def test_dry_run_explicit_path(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
f = Path(d) / "x.py"
|
||||
f.write_text("print('hi')\n")
|
||||
rc, out, _ = run("--dry-run", str(f))
|
||||
self.assertEqual(rc, 0)
|
||||
self.assertIn(str(f), out)
|
||||
|
||||
def test_missing_path_errors(self):
|
||||
rc, _, err = run("--dry-run", "/no/such/path-xyz")
|
||||
self.assertEqual(rc, 1)
|
||||
self.assertIn("no such path", err)
|
||||
|
||||
def test_bad_flag_exit_2(self):
|
||||
rc, _, _ = run("--bogus")
|
||||
self.assertEqual(rc, 2)
|
||||
|
||||
def test_too_many_args_exit_2(self):
|
||||
rc, _, _ = run("a", "b")
|
||||
self.assertEqual(rc, 2)
|
||||
|
||||
def test_only_targets_provenance_attribute(self):
|
||||
# The command removes only com.apple.provenance, not all xattrs.
|
||||
rc, out, _ = run("--dry-run")
|
||||
self.assertIn("com.apple.provenance", out)
|
||||
self.assertNotIn("xattr -rc", out) # not a blanket "clear all"
|
||||
self.assertNotIn("-c ", out)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,195 @@
|
||||
"""Negative tests for LLM-Agent-SHA attribution (#86, Phase 0).
|
||||
|
||||
``LLM-Agent-SHA`` (docs/llm-agent-sha.md) is attribution metadata ONLY. These
|
||||
tests prove it can never bypass the review/merge safety gates:
|
||||
|
||||
1. Same Gitea user + different LLM-Agent-SHA still fails self-review/approval.
|
||||
2. Same Gitea user + different LLM-Agent-SHA still fails self-merge.
|
||||
3. The eligibility logic does not consult SHA metadata at all — results are
|
||||
identical with no SHA, one SHA, or a different SHA in the environment, and
|
||||
no gate accepts an agent-SHA input.
|
||||
|
||||
Phase 0 adds no SHA support to any MCP tool; the environment variables set
|
||||
here (``LLM_AGENT_SHA`` / ``LLM_AGENT_ROLE``) simulate a future launcher and
|
||||
must be ignored by every gate.
|
||||
"""
|
||||
import inspect
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
||||
|
||||
from mcp_server import ( # noqa: E402
|
||||
gitea_check_pr_eligibility,
|
||||
gitea_merge_pr,
|
||||
gitea_review_pr,
|
||||
gitea_submit_pr_review,
|
||||
)
|
||||
|
||||
FAKE_AUTH = "Basic dGVzdDp0ZXN0"
|
||||
SHA_PATTERN = re.compile(r"^llm-[0-9a-f]{12}$")
|
||||
# Two distinct, well-formed agent SHAs "belonging" to the same Gitea user.
|
||||
SHA_IMPLEMENTER = "llm-8f3a9c2d6b41"
|
||||
SHA_WOULD_BE_REVIEWER = "llm-41d0e7aa9f2c"
|
||||
|
||||
|
||||
def _pr(author, state="open", sha="abc123", mergeable=True):
|
||||
return {
|
||||
"user": {"login": author},
|
||||
"state": state,
|
||||
"head": {"sha": sha},
|
||||
"mergeable": mergeable,
|
||||
}
|
||||
|
||||
|
||||
class TestShaFormatConvention(unittest.TestCase):
|
||||
"""The documented format: llm-<12 lowercase hex>, nothing identifying."""
|
||||
|
||||
def test_documented_examples_are_valid(self):
|
||||
for value in (SHA_IMPLEMENTER, SHA_WOULD_BE_REVIEWER, "llm-b7c93d441a08"):
|
||||
self.assertRegex(value, SHA_PATTERN)
|
||||
|
||||
def test_identifying_or_malformed_values_are_rejected(self):
|
||||
for bad in (
|
||||
"llm-8F3A9C2D6B41", # uppercase
|
||||
"llm-8f3a9c2d6b4", # too short
|
||||
"llm-8f3a9c2d6b411", # too long
|
||||
"llm-opus4", # model name, not hex
|
||||
"claude-8f3a9c2d6b41", # provider prefix
|
||||
"jcwalker3", # username
|
||||
"llm-user@example.com", # email
|
||||
"8f3a9c2d6b41", # missing prefix
|
||||
"",
|
||||
):
|
||||
self.assertNotRegex(bad, SHA_PATTERN)
|
||||
|
||||
|
||||
class TestShaCannotBypassSelfReview(unittest.TestCase):
|
||||
"""Scenario: user A (SHA 1) authored the PR; user A (SHA 2) tries to act."""
|
||||
|
||||
def _env(self, agent_sha, role):
|
||||
# Reviewer-capable profile + a simulated launcher-injected agent SHA.
|
||||
return {
|
||||
"GITEA_PROFILE_NAME": "gitea-reviewer",
|
||||
"GITEA_ALLOWED_OPERATIONS": "read,review,approve,merge",
|
||||
"LLM_AGENT_SHA": agent_sha,
|
||||
"LLM_AGENT_ROLE": role,
|
||||
}
|
||||
|
||||
@patch("mcp_server.api_request")
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_same_user_different_sha_cannot_approve(self, _auth, mock_api):
|
||||
mock_api.side_effect = [{"login": "jcwalker3"}, _pr("jcwalker3")]
|
||||
env = self._env(SHA_WOULD_BE_REVIEWER, "reviewer")
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
r = gitea_check_pr_eligibility(pr_number=9, action="approve", remote="prgs")
|
||||
self.assertFalse(r["eligible"])
|
||||
self.assertIn("authenticated user is PR author", r["reasons"])
|
||||
|
||||
@patch("mcp_server.api_request")
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_same_user_different_sha_cannot_merge(self, _auth, mock_api):
|
||||
mock_api.side_effect = [{"login": "jcwalker3"}, _pr("jcwalker3")]
|
||||
env = self._env(SHA_WOULD_BE_REVIEWER, "merger")
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
r = gitea_check_pr_eligibility(pr_number=9, action="merge", remote="prgs")
|
||||
self.assertFalse(r["eligible"])
|
||||
self.assertIn("authenticated user is PR author", r["reasons"])
|
||||
|
||||
@patch("mcp_server.api_request")
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_gated_merge_tool_refuses_self_merge_despite_sha(self, _auth, mock_api):
|
||||
# Even the fully-confirmed gated merge path must refuse: correct
|
||||
# confirmation string, mergeable PR, merge-capable profile — but the
|
||||
# authenticated user is the PR author, whatever the agent SHA says.
|
||||
mock_api.side_effect = [{"login": "jcwalker3"}, _pr("jcwalker3")]
|
||||
env = self._env(SHA_WOULD_BE_REVIEWER, "merger")
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
r = gitea_merge_pr(
|
||||
pr_number=9, confirmation="MERGE PR 9", remote="prgs")
|
||||
self.assertFalse(r.get("performed"))
|
||||
for call in mock_api.call_args_list:
|
||||
method, url = call.args[0], call.args[1]
|
||||
self.assertFalse(
|
||||
method == "POST" and url.endswith("/merge"),
|
||||
f"self-merge mutation reached the API: {method} {url}",
|
||||
)
|
||||
|
||||
@patch("mcp_server.api_request")
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_review_tool_refuses_self_approval_despite_sha(self, _auth, mock_api):
|
||||
mock_api.side_effect = [{"login": "jcwalker3"}, _pr("jcwalker3")]
|
||||
env = self._env(SHA_WOULD_BE_REVIEWER, "reviewer")
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
r = gitea_review_pr(
|
||||
pr_number=9, event="APPROVE", body="self approve", merge=False,
|
||||
remote="prgs")
|
||||
self.assertFalse(r["success"])
|
||||
self.assertIn("authenticated user is PR author", r["message"])
|
||||
for call in mock_api.call_args_list:
|
||||
method, url = call.args[0], call.args[1]
|
||||
self.assertFalse(
|
||||
method == "POST" and url.endswith("/reviews"),
|
||||
f"self-review mutation reached the API: {method} {url}",
|
||||
)
|
||||
|
||||
|
||||
class TestEligibilityNeverConsultsSha(unittest.TestCase):
|
||||
"""The gates have no SHA input: not via env, not via parameters."""
|
||||
|
||||
def _run_eligibility(self, extra_env):
|
||||
env = {
|
||||
"GITEA_PROFILE_NAME": "gitea-reviewer",
|
||||
"GITEA_ALLOWED_OPERATIONS": "read,review,approve",
|
||||
}
|
||||
env.update(extra_env)
|
||||
with patch("mcp_server.get_auth_header", return_value=FAKE_AUTH), \
|
||||
patch("mcp_server.api_request") as mock_api:
|
||||
mock_api.side_effect = [{"login": "reviewer-bot"}, _pr("author-bot")]
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
return gitea_check_pr_eligibility(
|
||||
pr_number=5, action="approve", remote="prgs")
|
||||
|
||||
def test_result_identical_with_without_and_across_shas(self):
|
||||
baseline = self._run_eligibility({})
|
||||
with_one = self._run_eligibility(
|
||||
{"LLM_AGENT_SHA": SHA_IMPLEMENTER, "LLM_AGENT_ROLE": "implementer"})
|
||||
with_other = self._run_eligibility(
|
||||
{"LLM_AGENT_SHA": SHA_WOULD_BE_REVIEWER, "LLM_AGENT_ROLE": "reviewer"})
|
||||
self.assertEqual(baseline, with_one)
|
||||
self.assertEqual(baseline, with_other)
|
||||
self.assertTrue(baseline["eligible"]) # sanity: a real decision ran
|
||||
|
||||
def test_eligibility_result_carries_no_agent_sha(self):
|
||||
r = self._run_eligibility(
|
||||
{"LLM_AGENT_SHA": SHA_IMPLEMENTER, "LLM_AGENT_ROLE": "implementer"})
|
||||
blob = repr(r)
|
||||
self.assertNotIn(SHA_IMPLEMENTER, blob)
|
||||
self.assertNotIn("LLM_AGENT", blob)
|
||||
|
||||
def test_gate_functions_accept_no_agent_sha_parameter(self):
|
||||
for fn in (gitea_check_pr_eligibility, gitea_merge_pr,
|
||||
gitea_review_pr, gitea_submit_pr_review):
|
||||
for param in inspect.signature(fn).parameters:
|
||||
lowered = param.lower()
|
||||
self.assertNotIn("agent", lowered,
|
||||
f"{fn.__name__} accepts agent param {param!r}")
|
||||
self.assertNotIn("llm", lowered,
|
||||
f"{fn.__name__} accepts llm param {param!r}")
|
||||
|
||||
def test_gate_sources_never_read_agent_sha(self):
|
||||
# Phase 0 guarantee: no gate reads LLM_AGENT_* metadata at all.
|
||||
for fn in (gitea_check_pr_eligibility, gitea_merge_pr,
|
||||
gitea_review_pr, gitea_submit_pr_review):
|
||||
src = inspect.getsource(fn)
|
||||
self.assertNotIn("LLM_AGENT", src,
|
||||
f"{fn.__name__} reads LLM_AGENT metadata")
|
||||
self.assertNotIn("agent_sha", src,
|
||||
f"{fn.__name__} consults an agent SHA")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -137,5 +137,89 @@ class TestConstants(unittest.TestCase):
|
||||
f"Label '{label['name']}' has invalid color")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Modes: --create-labels / --apply-mapping / --add-label (#6)
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestModes(unittest.TestCase):
|
||||
|
||||
def _methods(self, mock_api):
|
||||
return [(c[0][0], c[0][1]) for c in mock_api.call_args_list]
|
||||
|
||||
@patch("manage_labels.get_auth_header", return_value=FAKE_AUTH)
|
||||
@patch("manage_labels.api")
|
||||
def test_create_labels_only_no_mapping(self, mock_api, _auth):
|
||||
def se(method, path, auth, payload=None):
|
||||
if method == "GET":
|
||||
return [] # no existing labels
|
||||
if method == "POST" and path == "/labels":
|
||||
return {"id": 1, "name": payload["name"]}
|
||||
return None
|
||||
mock_api.side_effect = se
|
||||
manage_labels.main(["--create-labels"])
|
||||
methods = self._methods(mock_api)
|
||||
self.assertTrue(any(m == ("POST", "/labels") for m in methods))
|
||||
self.assertFalse(any(m[0] == "PUT" for m in methods)) # no mapping applied
|
||||
|
||||
@patch("manage_labels.get_auth_header", return_value=FAKE_AUTH)
|
||||
@patch("manage_labels.api")
|
||||
def test_apply_mapping_only_no_label_creation(self, mock_api, _auth):
|
||||
existing = [_make_label(l["name"], i + 1)
|
||||
for i, l in enumerate(manage_labels.LABELS)]
|
||||
|
||||
def se(method, path, auth, payload=None):
|
||||
if method == "GET":
|
||||
return existing
|
||||
if method == "PUT":
|
||||
return [{"name": "applied"}]
|
||||
return None
|
||||
mock_api.side_effect = se
|
||||
manage_labels.main(["--apply-mapping"])
|
||||
methods = self._methods(mock_api)
|
||||
self.assertFalse(any(m == ("POST", "/labels") for m in methods))
|
||||
put_calls = [m for m in methods if m[0] == "PUT"]
|
||||
self.assertEqual(len(put_calls), len(manage_labels.MAPPING))
|
||||
|
||||
@patch("manage_labels.get_auth_header", return_value=FAKE_AUTH)
|
||||
@patch("manage_labels.api")
|
||||
def test_add_label_appends_to_issue(self, mock_api, _auth):
|
||||
existing = [_make_label("chore", 5)]
|
||||
|
||||
def se(method, path, auth, payload=None):
|
||||
if method == "GET":
|
||||
return existing
|
||||
if method == "POST":
|
||||
return [{"name": "chore"}]
|
||||
return None
|
||||
mock_api.side_effect = se
|
||||
manage_labels.main(["--add-label", "42", "chore"])
|
||||
posts = [c for c in mock_api.call_args_list
|
||||
if c[0][0] == "POST" and c[0][1] == "/issues/42/labels"]
|
||||
self.assertEqual(len(posts), 1)
|
||||
self.assertEqual(posts[0][0][3], {"labels": [5]}) # append, id 5
|
||||
# POST appends; no PUT (which would replace the whole set).
|
||||
self.assertFalse(any(c[0][0] == "PUT" for c in mock_api.call_args_list))
|
||||
|
||||
@patch("manage_labels.get_auth_header", return_value=FAKE_AUTH)
|
||||
@patch("manage_labels.api")
|
||||
def test_add_label_unknown_makes_no_write(self, mock_api, _auth):
|
||||
mock_api.side_effect = lambda *a, **k: [] if a[0] == "GET" else None
|
||||
manage_labels.main(["--add-label", "42", "ghost"])
|
||||
# Only the GET label lookup; no POST/PUT for an undefined label.
|
||||
self.assertTrue(all(c[0][0] == "GET" for c in mock_api.call_args_list))
|
||||
|
||||
@patch("manage_labels.get_auth_header", return_value=FAKE_AUTH)
|
||||
@patch("manage_labels.api")
|
||||
def test_add_label_dry_makes_no_write(self, mock_api, _auth):
|
||||
mock_api.side_effect = lambda *a, **k: [_make_label("chore", 5)] if a[0] == "GET" else None
|
||||
manage_labels.main(["--dry", "--add-label", "42", "chore"])
|
||||
self.assertTrue(all(c[0][0] == "GET" for c in mock_api.call_args_list))
|
||||
|
||||
@patch("manage_labels.get_auth_header", return_value=FAKE_AUTH)
|
||||
@patch("manage_labels.api")
|
||||
def test_add_label_non_numeric_issue_exits(self, mock_api, _auth):
|
||||
with self.assertRaises(SystemExit):
|
||||
manage_labels.main(["--add-label", "notanum", "chore"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
+255
-7
@@ -93,7 +93,8 @@ class TestCloseIssue(unittest.TestCase):
|
||||
result = gitea_close_issue(issue_number=42)
|
||||
self.assertTrue(result["success"])
|
||||
self.assertIn("42", result["message"])
|
||||
payload = mock_api.call_args[0][3]
|
||||
patch_call = next(call for call in mock_api.call_args_list if call[0][0] == "PATCH")
|
||||
payload = patch_call[0][3]
|
||||
self.assertEqual(payload["state"], "closed")
|
||||
|
||||
|
||||
@@ -102,7 +103,7 @@ class TestCloseIssue(unittest.TestCase):
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestListIssues(unittest.TestCase):
|
||||
|
||||
@patch("mcp_server.api_request")
|
||||
@patch("mcp_server.api_get_all")
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_returns_formatted_list(self, _auth, mock_api):
|
||||
mock_api.return_value = [
|
||||
@@ -123,20 +124,20 @@ class TestListIssues(unittest.TestCase):
|
||||
self.assertEqual(result[0]["assignee"], "alice")
|
||||
self.assertEqual(result[1]["assignee"], "")
|
||||
|
||||
@patch("mcp_server.api_request")
|
||||
@patch("mcp_server.api_get_all")
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_passes_label_filter(self, _auth, mock_api):
|
||||
mock_api.return_value = []
|
||||
gitea_list_issues(label="important")
|
||||
url = mock_api.call_args[0][1]
|
||||
url = mock_api.call_args[0][0]
|
||||
self.assertIn("labels=important", url)
|
||||
|
||||
@patch("mcp_server.api_request")
|
||||
@patch("mcp_server.api_get_all")
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_passes_state_filter(self, _auth, mock_api):
|
||||
mock_api.return_value = []
|
||||
gitea_list_issues(state="closed")
|
||||
url = mock_api.call_args[0][1]
|
||||
url = mock_api.call_args[0][0]
|
||||
self.assertIn("state=closed", url)
|
||||
|
||||
|
||||
@@ -258,7 +259,7 @@ class TestMirrorRefs(unittest.TestCase):
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestListPRs(unittest.TestCase):
|
||||
|
||||
@patch("mcp_server.api_request")
|
||||
@patch("mcp_server.api_get_all")
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_list_prs(self, _auth, mock_api):
|
||||
mock_api.return_value = [
|
||||
@@ -861,6 +862,34 @@ class TestWhoami(unittest.TestCase):
|
||||
# ---------------------------------------------------------------------------
|
||||
# Runtime profile (env-configured profile metadata) — issue #19
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@patch("mcp_server.api_request")
|
||||
@patch("mcp_server._auth")
|
||||
def test_gitea_get_authenticated_user_alias(self, _auth, mock_api):
|
||||
mock_api.return_value = {
|
||||
"login": "alias_user",
|
||||
"full_name": "Alias User",
|
||||
"id": 999,
|
||||
"email": "alias@example.com"
|
||||
}
|
||||
from mcp_server import gitea_get_authenticated_user
|
||||
result = gitea_get_authenticated_user(remote="prgs")
|
||||
self.assertEqual(result["username"], "alias_user")
|
||||
|
||||
@patch("mcp_server.api_request")
|
||||
@patch("mcp_server._auth")
|
||||
def test_gitea_get_current_user_alias(self, _auth, mock_api):
|
||||
mock_api.return_value = {
|
||||
"login": "alias_user",
|
||||
"full_name": "Alias User",
|
||||
"id": 999,
|
||||
"email": "alias@example.com"
|
||||
}
|
||||
from mcp_server import gitea_get_current_user
|
||||
result = gitea_get_current_user(remote="prgs")
|
||||
self.assertEqual(result["username"], "alias_user")
|
||||
|
||||
|
||||
class TestRuntimeProfile(unittest.TestCase):
|
||||
|
||||
def test_defaults_when_unset(self):
|
||||
@@ -1352,3 +1381,222 @@ class TestSubmitPrReview(unittest.TestCase):
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tracker Hygiene Cleanup Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestTrackerHygieneCleanup(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.mock_api = patch("mcp_server.api_request").start()
|
||||
self.mock_auth = patch("mcp_server.get_auth_header", return_value=FAKE_AUTH).start()
|
||||
patch("gitea_audit.audit_enabled", return_value=True).start()
|
||||
self.mock_audit = patch("gitea_audit.write_event").start()
|
||||
patch("mcp_server.get_profile", return_value={"profile_name": "test", "allowed_operations": ["merge", "edit", "close"], "audit_label": "test", "forbidden_operations": []}).start()
|
||||
|
||||
def tearDown(self):
|
||||
patch.stopall()
|
||||
|
||||
def test_close_issue_removes_in_progress(self):
|
||||
def api_side_effect(method, url, auth, payload=None):
|
||||
if method == "PATCH" and "issues/1" in url:
|
||||
return {"state": "closed"}
|
||||
if method == "GET" and "labels" in url and "issues" not in url:
|
||||
return [{"name": "status:in-progress", "id": 1}, {"name": "bug", "id": 2}]
|
||||
if method == "GET" and "issues/1" in url:
|
||||
return {"labels": [{"name": "status:in-progress"}, {"name": "bug"}]}
|
||||
if method == "DELETE" and url.endswith("/issues/1/labels/1"):
|
||||
return {}
|
||||
if method == "PUT" and "labels" in url:
|
||||
self.fail("Should not replace the issue label set")
|
||||
return {}
|
||||
self.mock_api.side_effect = api_side_effect
|
||||
|
||||
res = gitea_close_issue(issue_number=1)
|
||||
self.assertTrue(res["success"])
|
||||
self.assertEqual(res["cleanup_status"].get(1), "released")
|
||||
self.mock_audit.assert_called()
|
||||
|
||||
def test_close_issue_no_label_is_noop(self):
|
||||
def api_side_effect(method, url, auth, payload=None):
|
||||
if method == "PATCH" and "issues/1" in url:
|
||||
return {"state": "closed"}
|
||||
if method == "GET" and "labels" in url and "issues" not in url:
|
||||
return [{"name": "status:in-progress", "id": 1}, {"name": "bug", "id": 2}]
|
||||
if method == "GET" and "issues/1" in url:
|
||||
return {"labels": [{"name": "bug"}]}
|
||||
if method == "DELETE" and "labels" in url:
|
||||
self.fail("Should not DELETE labels")
|
||||
if method == "PUT" and "labels" in url:
|
||||
self.fail("Should not replace the issue label set")
|
||||
return {}
|
||||
self.mock_api.side_effect = api_side_effect
|
||||
|
||||
res = gitea_close_issue(issue_number=1)
|
||||
self.assertTrue(res["success"])
|
||||
self.assertEqual(res["cleanup_status"].get(1), "not present")
|
||||
|
||||
def test_merge_pr_with_closes_removes_label(self):
|
||||
def api_side_effect(method, url, auth, payload=None):
|
||||
if method == "GET" and "/user" in url:
|
||||
return {"login": "merger"}
|
||||
if method == "GET" and "pulls/1" in url and "/files" not in url:
|
||||
return {
|
||||
"user": {"login": "author"},
|
||||
"state": "open",
|
||||
"head": {"sha": "sha123", "ref": "feat/my-branch"},
|
||||
"base": {"ref": "main"},
|
||||
"mergeable": True,
|
||||
"merged_commit_sha": "merge123",
|
||||
"title": "My PR",
|
||||
"body": "Closes #123"
|
||||
}
|
||||
if method == "POST" and "merge" in url:
|
||||
return {}
|
||||
if method == "GET" and "labels" in url and "issues" not in url:
|
||||
return [{"name": "status:in-progress", "id": 1}, {"name": "bug", "id": 2}]
|
||||
if method == "GET" and "issues/123" in url:
|
||||
return {"labels": [{"name": "status:in-progress"}, {"name": "bug"}]}
|
||||
if method == "DELETE" and url.endswith("/issues/123/labels/1"):
|
||||
return {}
|
||||
if method == "PUT" and "labels" in url:
|
||||
self.fail("Should not replace the issue label set")
|
||||
return {}
|
||||
self.mock_api.side_effect = api_side_effect
|
||||
|
||||
res = gitea_merge_pr(pr_number=1, confirmation="MERGE PR 1", do="merge")
|
||||
self.assertTrue(res["performed"])
|
||||
self.assertEqual(res["cleanup_status"].get(123), "released")
|
||||
|
||||
def test_merge_pr_with_branch_name_removes_label(self):
|
||||
def api_side_effect(method, url, auth, payload=None):
|
||||
if method == "GET" and "/user" in url:
|
||||
return {"login": "merger"}
|
||||
if method == "GET" and "pulls/1" in url and "/files" not in url:
|
||||
return {
|
||||
"user": {"login": "author"},
|
||||
"state": "open",
|
||||
"head": {"sha": "sha123", "ref": "fix/issue-123-slug"},
|
||||
"base": {"ref": "main"},
|
||||
"mergeable": True,
|
||||
"merged_commit_sha": "merge123",
|
||||
"title": "My PR",
|
||||
"body": "Fixing things"
|
||||
}
|
||||
if method == "POST" and "merge" in url:
|
||||
return {}
|
||||
if method == "GET" and "labels" in url and "issues" not in url:
|
||||
return [{"name": "status:in-progress", "id": 1}, {"name": "bug", "id": 2}]
|
||||
if method == "GET" and "issues/123" in url:
|
||||
return {"labels": [{"name": "status:in-progress"}, {"name": "bug"}]}
|
||||
if method == "DELETE" and url.endswith("/issues/123/labels/1"):
|
||||
return {}
|
||||
if method == "PUT" and "labels" in url:
|
||||
self.fail("Should not replace the issue label set")
|
||||
return {}
|
||||
self.mock_api.side_effect = api_side_effect
|
||||
|
||||
res = gitea_merge_pr(pr_number=1, confirmation="MERGE PR 1", do="merge")
|
||||
self.assertTrue(res["performed"])
|
||||
self.assertEqual(res["cleanup_status"].get(123), "released")
|
||||
|
||||
def test_close_pr_removes_label_but_does_not_close_issue(self):
|
||||
def api_side_effect(method, url, auth, payload=None):
|
||||
if method == "PATCH" and "pulls/1" in url:
|
||||
return {
|
||||
"number": 1,
|
||||
"title": "My PR",
|
||||
"state": "closed",
|
||||
"html_url": "url",
|
||||
"body": "Closes #123",
|
||||
"head": {"ref": "feat/my-branch"}
|
||||
}
|
||||
if method == "GET" and "labels" in url and "issues" not in url:
|
||||
return [{"name": "status:in-progress", "id": 1}]
|
||||
if method == "GET" and "issues/123" in url:
|
||||
return {"labels": [{"name": "status:in-progress"}]}
|
||||
if method == "DELETE" and url.endswith("/issues/123/labels/1"):
|
||||
return {}
|
||||
if method == "PUT" and "labels" in url:
|
||||
self.fail("Should not replace the issue label set")
|
||||
if method == "POST" and "comments" in url:
|
||||
return {}
|
||||
return {}
|
||||
self.mock_api.side_effect = api_side_effect
|
||||
|
||||
res = gitea_edit_pr(pr_number=1, state="closed")
|
||||
self.assertTrue(res["success"])
|
||||
self.assertEqual(res["cleanup_status"].get(123), "released")
|
||||
|
||||
def test_multiple_linked_issues(self):
|
||||
def api_side_effect(method, url, auth, payload=None):
|
||||
if method == "PATCH" and "pulls/1" in url:
|
||||
return {
|
||||
"number": 1,
|
||||
"title": "My PR",
|
||||
"state": "closed",
|
||||
"html_url": "url",
|
||||
"body": "Closes #123\nFixes #124",
|
||||
"head": {"ref": "issue-125"}
|
||||
}
|
||||
if method == "GET" and "labels" in url and "issues" not in url:
|
||||
return [{"name": "status:in-progress", "id": 1}]
|
||||
if method == "GET" and "issues/123" in url:
|
||||
return {"labels": [{"name": "status:in-progress"}]}
|
||||
if method == "GET" and "issues/124" in url:
|
||||
return {"labels": [{"name": "status:in-progress"}]}
|
||||
if method == "GET" and "issues/125" in url:
|
||||
return {"labels": []}
|
||||
if method == "DELETE" and url.endswith("/issues/123/labels/1"):
|
||||
return {}
|
||||
if method == "DELETE" and url.endswith("/issues/124/labels/1"):
|
||||
return {}
|
||||
if method == "PUT" and "labels" in url:
|
||||
self.fail("Should not replace the issue label set")
|
||||
if method == "POST" and "comments" in url:
|
||||
return {}
|
||||
return {}
|
||||
self.mock_api.side_effect = api_side_effect
|
||||
|
||||
res = gitea_edit_pr(pr_number=1, state="closed")
|
||||
self.assertTrue(res["success"])
|
||||
self.assertEqual(res["cleanup_status"].get(123), "released")
|
||||
self.assertEqual(res["cleanup_status"].get(124), "released")
|
||||
self.assertEqual(res["cleanup_status"].get(125), "not present")
|
||||
|
||||
def test_no_linked_issue_found(self):
|
||||
def api_side_effect(method, url, auth, payload=None):
|
||||
if method == "PATCH" and "pulls/1" in url:
|
||||
return {
|
||||
"number": 1,
|
||||
"title": "My PR",
|
||||
"state": "closed",
|
||||
"html_url": "url",
|
||||
"body": "No issue link",
|
||||
"head": {"ref": "main"}
|
||||
}
|
||||
return {}
|
||||
self.mock_api.side_effect = api_side_effect
|
||||
|
||||
res = gitea_edit_pr(pr_number=1, state="closed")
|
||||
self.assertTrue(res["success"])
|
||||
self.assertEqual(res["cleanup_status"], "no linked issue found")
|
||||
|
||||
def test_label_removal_failure_reported(self):
|
||||
def api_side_effect(method, url, auth, payload=None):
|
||||
if method == "PATCH" and "issues/1" in url:
|
||||
return {"state": "closed"}
|
||||
if method == "GET" and "labels" in url and "issues" not in url:
|
||||
return [{"name": "status:in-progress", "id": 1}]
|
||||
if method == "GET" and "issues/1" in url:
|
||||
return {"labels": [{"name": "status:in-progress"}]}
|
||||
if method == "DELETE" and url.endswith("/issues/1/labels/1"):
|
||||
raise RuntimeError("API failure")
|
||||
if method == "PUT" and "labels" in url:
|
||||
self.fail("Should not replace the issue label set")
|
||||
return {}
|
||||
self.mock_api.side_effect = api_side_effect
|
||||
|
||||
res = gitea_close_issue(issue_number=1)
|
||||
self.assertTrue(res["success"])
|
||||
self.assertIn("error:", res["cleanup_status"].get(1))
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
"""Tests for scripts/release-tag (#50).
|
||||
|
||||
Each test builds a throwaway git repo with a LOCAL bare remote named per
|
||||
RELEASE_TAG_REMOTE — no network, no pushing from the project repo, no real tags
|
||||
created here. The test suite gate is stubbed via RELEASE_TAG_TEST_CMD (true =
|
||||
pass, false = fail) so no real pytest/venv is needed inside the temp repo.
|
||||
"""
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
SCRIPT_SRC = REPO / "scripts" / "release-tag"
|
||||
REMOTE = "prgs"
|
||||
|
||||
|
||||
def _git(cwd, *args):
|
||||
return subprocess.run(["git", *args], cwd=str(cwd),
|
||||
capture_output=True, text=True)
|
||||
|
||||
|
||||
class _ReleaseTagCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = Path(tempfile.mkdtemp())
|
||||
self.work = self.tmp / "work"
|
||||
self.bare = self.tmp / "remote.git"
|
||||
self.work.mkdir()
|
||||
_git(self.work, "init", "-b", "master")
|
||||
_git(self.work, "config", "user.email", "t@example.invalid")
|
||||
_git(self.work, "config", "user.name", "Test")
|
||||
(self.work / "README").write_text("hello\n")
|
||||
# Install the script under test and commit it so the worktree is clean.
|
||||
(self.work / "scripts").mkdir()
|
||||
dst = self.work / "scripts" / "release-tag"
|
||||
shutil.copy(SCRIPT_SRC, dst)
|
||||
dst.chmod(0o755)
|
||||
_git(self.work, "add", "README", "scripts/release-tag")
|
||||
_git(self.work, "commit", "-m", "initial")
|
||||
# Seed the bare remote by cloning the work repo (already has master +
|
||||
# the script). Avoids `git push <remote> master`, which the harness blocks.
|
||||
_git(self.tmp, "clone", "--bare", str(self.work), str(self.bare))
|
||||
_git(self.work, "remote", "add", REMOTE, str(self.bare))
|
||||
_git(self.work, "fetch", REMOTE, "--prune")
|
||||
|
||||
def tearDown(self):
|
||||
shutil.rmtree(self.tmp, ignore_errors=True)
|
||||
|
||||
def rt(self, *args, test_cmd="true"):
|
||||
env = dict(os.environ, RELEASE_TAG_REMOTE=REMOTE,
|
||||
RELEASE_TAG_TEST_CMD=test_cmd)
|
||||
proc = subprocess.run(
|
||||
["bash", str(self.work / "scripts" / "release-tag"), *args],
|
||||
cwd=str(self.work), capture_output=True, text=True, env=env)
|
||||
return proc.returncode, proc.stdout, proc.stderr
|
||||
|
||||
def tag_type(self, name):
|
||||
r = _git(self.work, "cat-file", "-t", name)
|
||||
return r.stdout.strip()
|
||||
|
||||
def local_tags(self):
|
||||
return _git(self.work, "tag").stdout.split()
|
||||
|
||||
def remote_has_tag(self, name):
|
||||
r = _git(self.work, "ls-remote", "--tags", REMOTE, f"refs/tags/{name}")
|
||||
return bool(r.stdout.strip())
|
||||
|
||||
|
||||
class TestValidation(_ReleaseTagCase):
|
||||
|
||||
def test_accepts_valid_semver_dry_run(self):
|
||||
rc, out, err = self.rt("--dry-run", "v0.4.0")
|
||||
self.assertEqual(rc, 0, err)
|
||||
self.assertIn("would create annotated tag v0.4.0", out)
|
||||
self.assertEqual(self.local_tags(), []) # dry-run creates nothing
|
||||
|
||||
def test_rejects_invalid_version(self):
|
||||
for bad in ("v1.2", "1.0.0", "v1.0", "release-1", "vx.y.z"):
|
||||
rc, _, err = self.rt(bad)
|
||||
self.assertEqual(rc, 2, bad)
|
||||
self.assertIn("invalid version", err)
|
||||
|
||||
def test_rejects_dirty_worktree(self):
|
||||
(self.work / "README").write_text("dirty\n")
|
||||
rc, _, err = self.rt("v0.4.0", "--skip-tests")
|
||||
self.assertEqual(rc, 1)
|
||||
self.assertIn("dirty", err)
|
||||
|
||||
def test_rejects_non_master_branch(self):
|
||||
_git(self.work, "checkout", "-b", "feat/issue-1-x")
|
||||
rc, _, err = self.rt("v0.4.0", "--skip-tests")
|
||||
self.assertEqual(rc, 1)
|
||||
self.assertIn("not on master", err)
|
||||
|
||||
def test_rejects_master_remote_mismatch(self):
|
||||
(self.work / "extra").write_text("x\n")
|
||||
_git(self.work, "add", "extra")
|
||||
_git(self.work, "commit", "-m", "local-only") # not pushed
|
||||
rc, _, err = self.rt("v0.4.0", "--skip-tests")
|
||||
self.assertEqual(rc, 1)
|
||||
self.assertIn("master", err)
|
||||
|
||||
def test_rejects_existing_local_tag(self):
|
||||
_git(self.work, "tag", "-a", "v0.4.0", "-m", "pre-existing")
|
||||
rc, _, err = self.rt("v0.4.0", "--skip-tests")
|
||||
self.assertEqual(rc, 1)
|
||||
self.assertIn("already exists", err)
|
||||
|
||||
def test_missing_notes_file_rejected(self):
|
||||
rc, _, err = self.rt("v0.4.0", "--skip-tests", "--notes-file",
|
||||
str(self.tmp / "nope.md"))
|
||||
self.assertEqual(rc, 2)
|
||||
self.assertIn("notes file not found", err)
|
||||
|
||||
|
||||
class TestTagging(_ReleaseTagCase):
|
||||
|
||||
def test_creates_annotated_tag_not_lightweight(self):
|
||||
rc, out, err = self.rt("v0.4.0", "--skip-tests")
|
||||
self.assertEqual(rc, 0, err)
|
||||
self.assertEqual(self.tag_type("v0.4.0"), "tag") # annotated, not "commit"
|
||||
self.assertIn("tag_created: yes", out)
|
||||
|
||||
def test_no_push_without_flag(self):
|
||||
rc, out, _ = self.rt("v0.5.0", "--skip-tests")
|
||||
self.assertEqual(rc, 0)
|
||||
self.assertIn("tag_pushed: no", out)
|
||||
self.assertFalse(self.remote_has_tag("v0.5.0"))
|
||||
|
||||
def test_push_only_with_flag(self):
|
||||
rc, out, err = self.rt("v0.6.0", "--skip-tests", "--push")
|
||||
self.assertEqual(rc, 0, err)
|
||||
self.assertIn("tag_pushed: yes", out)
|
||||
self.assertTrue(self.remote_has_tag("v0.6.0"))
|
||||
|
||||
def test_notes_file_used_as_message(self):
|
||||
notes = self.tmp / "notes.md"
|
||||
notes.write_text("Release v0.4.0\n\n- #50 release-tag helper\n")
|
||||
rc, _, err = self.rt("v0.4.0", "--skip-tests", "--notes-file", str(notes))
|
||||
self.assertEqual(rc, 0, err)
|
||||
msg = _git(self.work, "tag", "-n99", "-l", "v0.4.0").stdout
|
||||
self.assertIn("release-tag helper", msg)
|
||||
|
||||
|
||||
class TestTestsGate(_ReleaseTagCase):
|
||||
|
||||
def test_skip_tests_warns_and_skips(self):
|
||||
rc, out, err = self.rt("v0.4.0", "--skip-tests")
|
||||
self.assertEqual(rc, 0)
|
||||
self.assertIn("WARNING", err)
|
||||
self.assertIn("tests_run: no", out)
|
||||
|
||||
def test_default_runs_tests_and_failure_blocks_tag(self):
|
||||
rc, out, err = self.rt("v0.4.0", test_cmd="false") # tests "fail"
|
||||
self.assertEqual(rc, 1)
|
||||
self.assertIn("tests failed", err)
|
||||
self.assertEqual(self.local_tags(), []) # no tag on failure
|
||||
|
||||
def test_default_runs_tests_and_passes(self):
|
||||
rc, out, err = self.rt("v0.4.0", test_cmd="true")
|
||||
self.assertEqual(rc, 0, err)
|
||||
self.assertIn("tests_run: yes", out)
|
||||
self.assertEqual(self.tag_type("v0.4.0"), "tag")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+29
-2
@@ -35,16 +35,43 @@ class TestWorktreeStart(unittest.TestCase):
|
||||
self.assertEqual(rc, 2)
|
||||
|
||||
def test_refuses_existing_worktree(self):
|
||||
slug = f"zz-refuse-start-{os.getpid()}"
|
||||
branch = f"fix/issue-999-refuse-{os.getpid()}"
|
||||
slug = branch.replace("/", "-")
|
||||
target = BRANCHES / slug
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
rc, _, err = run("worktree-start", "--dry-run", slug)
|
||||
rc, _, err = run("worktree-start", "--dry-run", branch)
|
||||
self.assertEqual(rc, 1)
|
||||
self.assertIn("Refusing to reuse", err)
|
||||
finally:
|
||||
target.rmdir()
|
||||
|
||||
# -- issue-linked branch validation (#48) --------------------------------
|
||||
|
||||
def test_accepts_issue_linked_impl_branches(self):
|
||||
for branch in ("fix/issue-123-example", "feat/issue-123-example",
|
||||
"docs/issue-123-example", "chore/issue-123-example"):
|
||||
rc, out, err = run("worktree-start", "--dry-run", branch)
|
||||
self.assertEqual(rc, 0, f"{branch}: {err}")
|
||||
self.assertIn(f"branches/{branch.replace('/', '-')}", out)
|
||||
|
||||
def test_accepts_review_branch(self):
|
||||
rc, out, _ = run("worktree-start", "--dry-run", "review/pr-456-example")
|
||||
self.assertEqual(rc, 0)
|
||||
self.assertIn("branches/review-pr-456-example", out)
|
||||
|
||||
def test_rejects_untraceable_branches(self):
|
||||
for branch in ("fix/random-name", "my-branch", "feat/no-issue-here",
|
||||
"fix/issue-abc-x"):
|
||||
rc, _, err = run("worktree-start", "--dry-run", branch)
|
||||
self.assertEqual(rc, 2, branch)
|
||||
self.assertIn("Untraceable branch name", err)
|
||||
|
||||
def test_allow_unlinked_override(self):
|
||||
rc, out, _ = run("worktree-start", "--dry-run", "--allow-unlinked", "my-branch")
|
||||
self.assertEqual(rc, 0)
|
||||
self.assertIn("branches/my-branch", out)
|
||||
|
||||
|
||||
class TestWorktreeReview(unittest.TestCase):
|
||||
|
||||
|
||||
Reference in New Issue
Block a user