feat!: full-access Gitea MCP server (1.0.0): full API coverage, stdio + streamable HTTP, admin and per-user OAuth #71
No files matched your search
@@ -1,128 +0,0 @@
|
||||
# API Reference
|
||||
|
||||
## Core Endpoints
|
||||
|
||||
- `GET /`: server metadata.
|
||||
- `GET /health`: health probe.
|
||||
- `GET /metrics`: Prometheus metrics (when enabled).
|
||||
|
||||
## OAuth Discovery and Token Exchange
|
||||
|
||||
- `GET /.well-known/oauth-protected-resource`
|
||||
- Returns OAuth protected resource metadata used by MCP clients.
|
||||
- `GET /.well-known/oauth-authorization-server`
|
||||
- Returns OAuth authorization server metadata.
|
||||
- `POST /register`
|
||||
- Registers an OAuth client and persists the client metadata.
|
||||
- `POST /oauth/token`
|
||||
- Proxies OAuth authorization-code token exchange to Gitea.
|
||||
|
||||
## MCP Endpoints
|
||||
|
||||
- `GET /mcp/tools`: list tool definitions.
|
||||
- `GET /mcp` and `POST /mcp`: streamable HTTP transport.
|
||||
- `GET /mcp/sse` and `POST /mcp/sse`: MCP SSE transport alias.
|
||||
- `POST /mcp/tool/call`: direct tool-call endpoint.
|
||||
|
||||
Authentication requirements:
|
||||
|
||||
- MCP tool execution requires `Authorization: Bearer <token>`.
|
||||
- Missing or invalid tokens return `401` with:
|
||||
- `WWW-Authenticate: Bearer resource_metadata="<absolute metadata url>", scope="read:repository"`
|
||||
|
||||
Scope requirements:
|
||||
|
||||
- Read tools require `read:repository`.
|
||||
- Write tools require `write:repository`.
|
||||
- Insufficient scope returns `403`.
|
||||
|
||||
## Automation Endpoints
|
||||
|
||||
- `POST /automation/webhook`: ingest policy-controlled webhook events.
|
||||
- `POST /automation/jobs/run`: run policy-controlled automation jobs.
|
||||
|
||||
## Read Tools
|
||||
|
||||
- `list_repositories`
|
||||
- `get_repository_info` (`owner`, `repo`)
|
||||
- `get_file_tree` (`owner`, `repo`, optional `ref`, `recursive`)
|
||||
- `get_file_contents` (`owner`, `repo`, `filepath`, optional `ref`)
|
||||
- `search_code` (`owner`, `repo`, `query`, optional `ref`, `page`, `limit`)
|
||||
- `list_commits` (`owner`, `repo`, optional `ref`, `page`, `limit`)
|
||||
- `get_commit_diff` (`owner`, `repo`, `sha`)
|
||||
- `compare_refs` (`owner`, `repo`, `base`, `head`)
|
||||
- `list_issues` (`owner`, `repo`, optional `state`, `page`, `limit`, `labels`)
|
||||
- `get_issue` (`owner`, `repo`, `issue_number`)
|
||||
- `list_pull_requests` (`owner`, `repo`, optional `state`, `page`, `limit`)
|
||||
- `get_pull_request` (`owner`, `repo`, `pull_number`)
|
||||
- `list_labels` (`owner`, `repo`, optional `page`, `limit`)
|
||||
- `list_tags` (`owner`, `repo`, optional `page`, `limit`)
|
||||
- `list_releases` (`owner`, `repo`, optional `page`, `limit`)
|
||||
- `list_pull_request_files` (`owner`, `repo`, `pull_number`, optional `page`, `limit`)
|
||||
- `list_pull_request_commits` (`owner`, `repo`, `pull_number`, optional `page`, `limit`)
|
||||
- `list_issue_comments` (`owner`, `repo`, `issue_number`, optional `page`, `limit`)
|
||||
- `list_branches` (`owner`, `repo`, optional `page`, `limit`)
|
||||
- `get_branch` (`owner`, `repo`, `branch`)
|
||||
- `get_release` (`owner`, `repo`, `release_id`)
|
||||
- `get_latest_release` (`owner`, `repo`)
|
||||
- `list_milestones` (`owner`, `repo`, optional `state`, `page`, `limit`)
|
||||
- `get_commit_status` (`owner`, `repo`, `sha`)
|
||||
- `list_org_repositories` (`org`, optional `page`, `limit`)
|
||||
- `list_organizations` (optional `page`, `limit`)
|
||||
- `get_repo_languages` (`owner`, `repo`)
|
||||
- `list_repo_topics` (`owner`, `repo`)
|
||||
|
||||
## Write Tools (Write Mode Required)
|
||||
|
||||
- `create_issue` (`owner`, `repo`, `title`, optional `body`, `labels`, `assignees`, `milestone`)
|
||||
- `update_issue` (`owner`, `repo`, `issue_number`, one or more of `title`, `body`, `state`, `milestone`)
|
||||
- `create_issue_comment` (`owner`, `repo`, `issue_number`, `body`)
|
||||
- `create_pr_comment` (`owner`, `repo`, `pull_number`, `body`)
|
||||
- `add_labels` (`owner`, `repo`, `issue_number`, `labels` by name)
|
||||
- `remove_labels` (`owner`, `repo`, `issue_number`, `labels` by name)
|
||||
- `assign_issue` (`owner`, `repo`, `issue_number`, `assignees`)
|
||||
- `create_label` (`owner`, `repo`, `name`, `color` hex e.g. `#00aabb`, optional `description`, `exclusive`)
|
||||
- `update_label` (`owner`, `repo`, `name`, one or more of `new_name`, `color`, `description`)
|
||||
- `create_pull_request` (`owner`, `repo`, `title`, `head`, `base`, optional `body`)
|
||||
- `create_release` (`owner`, `repo`, `tag_name`, optional `name`, `body`, `draft`, `prerelease`, `target`)
|
||||
- `edit_release` (`owner`, `repo`, `release_id`, one or more of `name`, `body`, `draft`, `prerelease`)
|
||||
- `create_branch` (`owner`, `repo`, `new_branch_name`, optional `old_branch_name`)
|
||||
- `create_milestone` (`owner`, `repo`, `title`, optional `description`, `due_on`)
|
||||
- `edit_issue_comment` (`owner`, `repo`, `comment_id`, `body`)
|
||||
|
||||
Not supported by the dedicated tools by design: merge, branch/label/release deletion,
|
||||
force push, repo/admin management. Endpoints not covered above are reachable through the
|
||||
generic `gitea_request` escape hatch (subject to policy, write-mode, and a sensitive-path
|
||||
denylist) — see [Raw API Dispatch](raw-api.md).
|
||||
|
||||
## Raw API Dispatch
|
||||
|
||||
- `gitea_request` (`method`, `path`, optional `query`, `body`)
|
||||
- Calls an arbitrary Gitea REST endpoint. `GET`/`HEAD` are reads; other methods are
|
||||
writes and require write-mode plus a whitelisted repository. Admin/credential
|
||||
endpoints are blocked unless `RAW_API_ALLOW_SENSITIVE=true`. See
|
||||
[Raw API Dispatch](raw-api.md) for the two-layer policy model and full details.
|
||||
|
||||
Note: `create_issue`, `add_labels`, and `remove_labels` accept label **names**; the
|
||||
server resolves them to Gitea label ids and returns a clear error for unknown labels.
|
||||
|
||||
Note: the `milestone` argument on `create_issue`/`update_issue` accepts either a numeric
|
||||
milestone **id** or a milestone **title** (resolved case-insensitively against open and
|
||||
closed milestones; unknown titles return a clear error). On `update_issue`, `milestone: 0`
|
||||
clears the issue's milestone. Gitea Projects (Kanban boards) are intentionally unsupported:
|
||||
the Gitea REST API exposes no project endpoints.
|
||||
|
||||
## Validation and Limits
|
||||
|
||||
- All tool argument schemas reject unknown fields.
|
||||
- List responses are capped by `MAX_TOOL_RESPONSE_ITEMS`.
|
||||
- Text payloads are capped by `MAX_TOOL_RESPONSE_CHARS`.
|
||||
- File reads are capped by `MAX_FILE_SIZE_BYTES`.
|
||||
|
||||
## Error Model
|
||||
|
||||
- Auth error: HTTP `401`.
|
||||
- Policy/scope denial: HTTP `403`.
|
||||
- Validation error: HTTP `400`.
|
||||
- Rate limit: HTTP `429`.
|
||||
- Internal errors: HTTP `500` (no stack traces in production).
|
||||
@@ -1,27 +0,0 @@
|
||||
# Automation
|
||||
|
||||
## Scope
|
||||
|
||||
Current automation capabilities:
|
||||
- Webhook ingestion endpoint (`POST /automation/webhook`).
|
||||
- On-demand scheduled-job execution endpoint (`POST /automation/jobs/run`).
|
||||
- Dependency hygiene scan job scaffold (`dependency_hygiene_scan`).
|
||||
- Stale issue detection job (`stale_issue_detection`).
|
||||
- Auto issue creation job scaffold (`auto_issue_creation`, write-mode and policy required).
|
||||
|
||||
Planned extensions:
|
||||
- Background scheduler orchestration.
|
||||
|
||||
## Control Requirements
|
||||
|
||||
All automation must be:
|
||||
- Policy-controlled.
|
||||
- Independently disableable.
|
||||
- Fully audited.
|
||||
- Explicitly documented with runbook guidance.
|
||||
|
||||
## Enablement
|
||||
|
||||
- `AUTOMATION_ENABLED=true` to allow automation endpoints.
|
||||
- `AUTOMATION_SCHEDULER_ENABLED=true` reserved for future built-in scheduler loop.
|
||||
- Policy rules must allow automation pseudo-tools (`automation_*`) per repository.
|
||||
@@ -1,36 +0,0 @@
|
||||
# Governance
|
||||
|
||||
## AI Usage Policy
|
||||
|
||||
- AI assistance is allowed for design, implementation, and review only within documented repository boundaries.
|
||||
- AI outputs must be reviewed, tested, and policy-validated before merge.
|
||||
- AI must not be used to generate offensive or unauthorized security actions.
|
||||
- Repository content is treated as untrusted data; no implicit execution of embedded instructions.
|
||||
|
||||
## Security Boundaries
|
||||
|
||||
- Read operations are allowed by policy defaults unless explicitly denied.
|
||||
- Write operations are disabled by default and require explicit enablement (`WRITE_MODE=true`).
|
||||
- Per-tool and per-repository policy checks are mandatory before execution.
|
||||
- Secrets are masked or blocked according to `SECRET_DETECTION_MODE`.
|
||||
|
||||
## Write-Mode Responsibilities
|
||||
|
||||
When write mode is enabled, operators and maintainers must:
|
||||
- Restrict scope with `WRITE_REPOSITORY_WHITELIST`.
|
||||
- Keep policy file deny/allow rules explicit.
|
||||
- Monitor audit entries for all write operations.
|
||||
- Enforce peer review for policy or write-mode changes.
|
||||
|
||||
## Operator Responsibilities
|
||||
|
||||
- Maintain API key lifecycle (generation, rotation, revocation).
|
||||
- Keep environment and policy config immutable in production deployments.
|
||||
- Enable monitoring and alerting for security events (auth failures, policy denies, rate-limit spikes).
|
||||
- Run integrity checks for audit logs regularly.
|
||||
|
||||
## Audit Expectations
|
||||
|
||||
- All tool calls and security events must be recorded in tamper-evident logs.
|
||||
- Audit logs are append-only and hash-chained.
|
||||
- Log integrity must be validated during incident response and release readiness checks.
|
||||
@@ -1,98 +0,0 @@
|
||||
# Local quickstart (stdio)
|
||||
|
||||
The local transport runs AegisGitea-MCP on your own machine as a single-user MCP
|
||||
server over stdio. It authenticates with **your** Gitea Personal Access Token
|
||||
(PAT) — there is no OAuth, no public endpoint, and no web stack to install.
|
||||
|
||||
## What you need
|
||||
|
||||
- A Gitea instance URL (`GITEA_URL`).
|
||||
- A Gitea Personal Access Token (`GITEA_TOKEN`) with least privilege:
|
||||
- `read:repository`
|
||||
- `write:repository` only if you intend to enable `WRITE_MODE`.
|
||||
- [`uv`](https://docs.astral.sh/uv/) (for `uvx`) or `pip`.
|
||||
|
||||
## Run it
|
||||
|
||||
With `uvx` (no install):
|
||||
|
||||
```bash
|
||||
GITEA_URL=https://git.hiddenden.cafe \
|
||||
GITEA_TOKEN=<pat> \
|
||||
uvx aegis-gitea-mcp
|
||||
```
|
||||
|
||||
With pip:
|
||||
|
||||
```bash
|
||||
pip install aegis-gitea-mcp
|
||||
GITEA_URL=https://git.hiddenden.cafe GITEA_TOKEN=<pat> aegis-gitea-mcp
|
||||
```
|
||||
|
||||
A local `.env` file is also supported — drop `GITEA_URL` and `GITEA_TOKEN` in it
|
||||
and just run `aegis-gitea-mcp`.
|
||||
|
||||
If a required variable is missing the server exits with a clear message instead
|
||||
of a traceback.
|
||||
|
||||
## Wire it into a client
|
||||
|
||||
Claude Code:
|
||||
|
||||
```bash
|
||||
claude mcp add aegis-gitea \
|
||||
-e GITEA_URL=https://git.hiddenden.cafe \
|
||||
-e GITEA_TOKEN=<pat> \
|
||||
-- uvx aegis-gitea-mcp
|
||||
```
|
||||
|
||||
Claude Desktop (`claude_desktop_config.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"aegis-gitea": {
|
||||
"command": "uvx",
|
||||
"args": ["aegis-gitea-mcp"],
|
||||
"env": {
|
||||
"GITEA_URL": "https://git.hiddenden.cafe",
|
||||
"GITEA_TOKEN": "<pat>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## What still applies locally
|
||||
|
||||
The local adapter is single-user and trusts the PAT owner, so it skips the
|
||||
per-user repository-permission probe used by the public server. Everything else
|
||||
is identical to the server:
|
||||
|
||||
- **Policy engine** (`policy.yaml`) — same allow/deny rules.
|
||||
- **`WRITE_MODE`** — off by default; writes are denied unless explicitly enabled
|
||||
and whitelisted.
|
||||
- **`gitea_request`** full-API escape hatch — same write classifier, known-path
|
||||
gate, and admin/credential denylist.
|
||||
- **Secret sanitization** of tool output.
|
||||
- **Tamper-evident audit log** — written to a per-user path when the container
|
||||
default is not writable:
|
||||
- Windows: `%LOCALAPPDATA%\aegis-gitea-mcp\audit.log`
|
||||
- Linux/macOS: `$XDG_STATE_HOME/aegis-gitea-mcp/audit.log` or
|
||||
`~/.local/state/aegis-gitea-mcp/audit.log`
|
||||
- Override with `AUDIT_LOG_PATH`.
|
||||
|
||||
## Enabling writes locally
|
||||
|
||||
Writes are opt-in, exactly as on the server:
|
||||
|
||||
```bash
|
||||
GITEA_URL=https://git.hiddenden.cafe \
|
||||
GITEA_TOKEN=<pat-with-write-repository> \
|
||||
WRITE_MODE=true \
|
||||
WRITE_REPOSITORY_WHITELIST=acme/app,acme/docs \
|
||||
uvx aegis-gitea-mcp
|
||||
```
|
||||
|
||||
See [configuration.md](configuration.md) for the full variable reference and
|
||||
[write-mode.md](write-mode.md) for the write-mode model.
|
||||
@@ -1,48 +0,0 @@
|
||||
# Observability
|
||||
|
||||
## Logging
|
||||
|
||||
- Structured JSON logs.
|
||||
- Request correlation via `X-Request-ID`.
|
||||
- Security events and policy denials are audit logged.
|
||||
|
||||
### Structured event helpers
|
||||
|
||||
`logging_utils` exposes reusable helpers so endpoints emit consistent,
|
||||
secret-safe structured events instead of ad-hoc inline logging:
|
||||
|
||||
- `log_event(logger, level, event, **context)` — emit a named event with a
|
||||
`context` mapping; keys in `SENSITIVE_CONTEXT_KEYS` (e.g. `token`,
|
||||
`authorization`, `password`) are masked as `***`.
|
||||
- `log_nullable_field(logger, event, field, value)` — record whether a parsed
|
||||
response field is `None` and its runtime type, without dumping its contents.
|
||||
- `sanitize_context(context)` — the masking primitive used by the above.
|
||||
|
||||
The `context` mapping is serialized into the JSON log payload under a `context`
|
||||
key. These run at `DEBUG`, so they are silent unless `LOG_LEVEL=DEBUG`.
|
||||
|
||||
`get_issue` is instrumented with these helpers (`get_issue.start`,
|
||||
`get_issue.payload_shape`, `get_issue.field_check`) to make nullable-field
|
||||
parsing failures diagnosable. The same pattern can be reused for other
|
||||
parsing-heavy endpoints (`get_pull_request`, `list_issues`, `get_commit_diff`).
|
||||
|
||||
## Metrics
|
||||
|
||||
Prometheus-compatible endpoint: `GET /metrics`.
|
||||
|
||||
Current metrics:
|
||||
- `aegis_http_requests_total{method,path,status}`
|
||||
- `aegis_tool_calls_total{tool,status}`
|
||||
- `aegis_tool_duration_seconds_sum{tool}`
|
||||
- `aegis_tool_duration_seconds_count{tool}`
|
||||
|
||||
## Tracing and Correlation
|
||||
|
||||
- Request IDs propagate in response header (`X-Request-ID`).
|
||||
- Tool-level correlation IDs included in MCP responses.
|
||||
|
||||
## Operational Guidance
|
||||
|
||||
- Alert on spikes in 401/403/429 rates.
|
||||
- Alert on repeated `access_denied` and auth-rate-limit events.
|
||||
- Track tool latency trends for incident triage.
|
||||
@@ -1,52 +0,0 @@
|
||||
# Policy Engine
|
||||
|
||||
## Overview
|
||||
|
||||
Aegis uses a YAML policy engine to authorize tool execution before any Gitea API call is made.
|
||||
|
||||
## Behavior Summary
|
||||
|
||||
- Global tool allow/deny supported.
|
||||
- Per-repository tool allow/deny supported.
|
||||
- Optional repository path allow/deny supported.
|
||||
- Write operations are denied by default.
|
||||
- Write operations also require `WRITE_MODE=true` and either:
|
||||
- `WRITE_REPOSITORY_WHITELIST` match, or
|
||||
- `WRITE_ALLOW_ALL_TOKEN_REPOS=true`.
|
||||
|
||||
## Example Configuration
|
||||
|
||||
```yaml
|
||||
defaults:
|
||||
read: allow
|
||||
write: deny
|
||||
|
||||
tools:
|
||||
deny:
|
||||
- search_code
|
||||
|
||||
repositories:
|
||||
acme/service-a:
|
||||
tools:
|
||||
allow:
|
||||
- get_file_contents
|
||||
- list_commits
|
||||
paths:
|
||||
allow:
|
||||
- src/*
|
||||
deny:
|
||||
- src/secrets/*
|
||||
```
|
||||
|
||||
## Failure Behavior
|
||||
|
||||
- Invalid YAML or invalid schema: startup failure (fail closed).
|
||||
- Denied tool call: HTTP `403` + audit `access_denied` entry.
|
||||
- Path traversal attempt in path-scoped tools: denied by validation/policy checks.
|
||||
|
||||
## Operational Guidance
|
||||
|
||||
- Keep policy files version-controlled and code-reviewed.
|
||||
- Prefer explicit deny entries for sensitive tools.
|
||||
- Use repository-specific allow lists for high-risk environments.
|
||||
- Test policy updates in staging before production rollout.
|
||||
-105
@@ -1,105 +0,0 @@
|
||||
# TODO
|
||||
|
||||
## Phase 0 Governance
|
||||
|
||||
- [x] Add `CODE_OF_CONDUCT.md`.
|
||||
- [x] Add governance policy documentation.
|
||||
- [x] Upgrade `AGENTS.md` as authoritative AI contract.
|
||||
|
||||
## Phase 1 Architecture
|
||||
|
||||
- [x] Publish roadmap and threat/security model updates.
|
||||
- [x] Publish phased TODO tracker.
|
||||
|
||||
## Phase 2 Expanded Read Tools
|
||||
|
||||
- [x] Implement `search_code`.
|
||||
- [x] Implement `list_commits`.
|
||||
- [x] Implement `get_commit_diff`.
|
||||
- [x] Implement `compare_refs`.
|
||||
- [x] Implement `list_issues`.
|
||||
- [x] Implement `get_issue`.
|
||||
- [x] Implement `list_pull_requests`.
|
||||
- [x] Implement `get_pull_request`.
|
||||
- [x] Implement `list_labels`.
|
||||
- [x] Implement `list_tags`.
|
||||
- [x] Implement `list_releases`.
|
||||
- [x] Add input validation and response bounds.
|
||||
- [x] Add unit/failure-mode tests.
|
||||
|
||||
## Phase 3 Policy Engine
|
||||
|
||||
- [x] Implement YAML policy loader and validator.
|
||||
- [x] Implement per-tool and per-repo allow/deny.
|
||||
- [x] Implement optional path restrictions.
|
||||
- [x] Enforce default write deny.
|
||||
- [x] Add policy unit tests.
|
||||
|
||||
## Phase 4 Write Mode
|
||||
|
||||
- [x] Implement write tools (`create_issue`, `update_issue`, comments, labels, assignment).
|
||||
- [x] Keep write mode disabled by default.
|
||||
- [x] Enforce repository whitelist.
|
||||
- [x] Ensure no merge/deletion/force-push capabilities.
|
||||
- [x] Add write denial tests.
|
||||
|
||||
## Phase 5 Hardening
|
||||
|
||||
- [x] Add secret detection + mask/block controls.
|
||||
- [x] Add prompt-injection defensive model (data-only handling).
|
||||
- [x] Add tamper-evident audit chaining and validation.
|
||||
- [x] Add per-IP and per-token rate limiting.
|
||||
|
||||
## Phase 6 Automation
|
||||
|
||||
- [x] Implement webhook ingestion pipeline.
|
||||
- [x] Implement on-demand scheduled jobs runner endpoint.
|
||||
- [x] Implement auto issue creation job scaffold from findings.
|
||||
- [x] Implement dependency hygiene scan orchestration scaffold.
|
||||
- [x] Implement stale issue detection automation.
|
||||
- [x] Add automation endpoint tests.
|
||||
|
||||
## Phase 7 Deployment
|
||||
|
||||
- [x] Harden Docker runtime defaults.
|
||||
- [x] Separate dev/prod compose profiles.
|
||||
- [x] Preserve non-root runtime and health checks.
|
||||
|
||||
## Phase 8 Observability
|
||||
|
||||
- [x] Add Prometheus metrics endpoint.
|
||||
- [x] Add structured JSON logging.
|
||||
- [x] Add request ID correlation.
|
||||
- [x] Add tool timing metrics.
|
||||
|
||||
## Phase 9 Testing and Release Readiness
|
||||
|
||||
- [x] Extend unit tests.
|
||||
- [x] Add policy tests.
|
||||
- [x] Add secret detection tests.
|
||||
- [x] Add write-mode denial tests.
|
||||
- [x] Add audit integrity tests.
|
||||
- [ ] Add integration-tagged tests against live Gitea (optional CI stage).
|
||||
- [ ] Final security review sign-off.
|
||||
- [ ] Release checklist execution.
|
||||
|
||||
## Phase 10 Local Package & Safe Full Coverage (0.2.0)
|
||||
|
||||
- [x] Extract transport-agnostic core + shared tool registry.
|
||||
- [x] Lock the core/web boundary with a no-fastapi import test.
|
||||
- [x] Add local stdio adapter (`stdio_app.py`) over the `mcp` SDK.
|
||||
- [x] Restructure packaging: core install + `[server]` extra + console scripts.
|
||||
- [x] Resource-type-aware authorization (repo/org/user/admin/misc), fail-closed.
|
||||
- [x] Classified `gitea_request`: write classifier + known-path gate + denylist.
|
||||
- [x] Authz matrix, write-mode bypass, classifier, and stdio adapter tests.
|
||||
- [x] `.gitea/workflows/publish.yml` (uv build + publish to Gitea registry on tag).
|
||||
- [ ] Make `list_organizations` user-scoped in service-PAT mode (`/users/{login}/orgs`)
|
||||
so it can be allowed instead of denied. (TODO(authz))
|
||||
|
||||
## Release Checklist
|
||||
|
||||
- [ ] `make lint`
|
||||
- [ ] `make test`
|
||||
- [ ] Documentation review complete
|
||||
- [ ] Policy file reviewed for production scope
|
||||
- [ ] Write mode remains disabled unless explicitly approved
|
||||
@@ -1,55 +0,0 @@
|
||||
# Write Mode
|
||||
|
||||
## Threat Model
|
||||
|
||||
Write mode introduces mutation risk (issue/PR changes, metadata updates). Risks include unauthorized action, accidental mass updates, and audit evasion.
|
||||
|
||||
## Default Posture
|
||||
|
||||
- `WRITE_MODE=false` by default.
|
||||
- When enabled, writes require repository whitelist membership by default.
|
||||
- Optional opt-in: `WRITE_ALLOW_ALL_TOKEN_REPOS=true` allows writes to any repo the token can access.
|
||||
- Policy engine remains authoritative and may deny specific write tools.
|
||||
|
||||
## Supported Write Tools
|
||||
|
||||
- `create_issue` (optional `milestone` id or title)
|
||||
- `update_issue` (optional `milestone`; `0` clears it)
|
||||
- `create_issue_comment`
|
||||
- `create_pr_comment`
|
||||
- `edit_issue_comment`
|
||||
- `add_labels`
|
||||
- `remove_labels`
|
||||
- `assign_issue`
|
||||
- `create_label`
|
||||
- `update_label`
|
||||
- `create_pull_request`
|
||||
- `create_release`
|
||||
- `edit_release`
|
||||
- `create_branch`
|
||||
- `create_milestone`
|
||||
|
||||
Not supported (explicitly forbidden): merge actions, branch/label/release deletion,
|
||||
force push, repo/admin management, and repository content writes (file create/edit,
|
||||
commits). Gitea Projects (Kanban boards) are unsupported because the Gitea REST API
|
||||
exposes no project endpoints.
|
||||
|
||||
## Enablement Steps
|
||||
|
||||
1. Set `WRITE_MODE=true`.
|
||||
2. Choose one:
|
||||
- `WRITE_REPOSITORY_WHITELIST=owner/repo,...` (recommended)
|
||||
- `WRITE_ALLOW_ALL_TOKEN_REPOS=true` (broader scope)
|
||||
3. Review policy file for write-tool scope.
|
||||
4. Verify audit logging and alerting before rollout.
|
||||
|
||||
## Safe Operations
|
||||
|
||||
- Start with one repository in whitelist.
|
||||
- Use narrowly scoped bot credentials.
|
||||
- Require peer review for whitelist/policy changes.
|
||||
- Disable write mode during incident response if abuse is suspected.
|
||||
|
||||
## Risk Tradeoffs
|
||||
|
||||
Write mode improves automation and triage speed but increases blast radius. Use least privilege, tight policy, and strong monitoring.
|
||||
@@ -0,0 +1,414 @@
|
||||
"""Generate and check the tool inventory in the documentation.
|
||||
|
||||
Usage:
|
||||
uv run python scripts/check_docs.py # check mode (CI job ``docs-check``)
|
||||
uv run python scripts/check_docs.py --write # regenerate docs/tools.md and the README block
|
||||
|
||||
Check mode fails (exit code 1) when:
|
||||
|
||||
* ``docs/tools.md`` differs from what the catalog generates;
|
||||
* the generated block of ``README.md`` (between ``<!-- tool-summary:start -->`` and
|
||||
``<!-- tool-summary:end -->``) differs from what the catalog generates;
|
||||
* ``README.md`` or a top-level ``docs/*.md`` file (``docs/adr``, ``docs/research`` and
|
||||
``docs/audit.md`` are excluded) mentions a ``gitea_*`` tool name that is not registered.
|
||||
|
||||
The inventory comes from ``load_catalog()`` plus the hand-written raw tool
|
||||
``gitea_api_request``. Output is deterministic. The logic is importable so tests can
|
||||
drive it against a temporary tree.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import difflib
|
||||
import re
|
||||
import sys
|
||||
from collections import Counter
|
||||
from collections.abc import Iterable, Sequence
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(REPO_ROOT / "src"))
|
||||
|
||||
from aegis_gitea_mcp.catalog import Catalog, Operation, load_catalog # noqa: E402
|
||||
from aegis_gitea_mcp.gitea import RAW_TOOL_NAME # noqa: E402
|
||||
|
||||
README_NAME = "README.md"
|
||||
TOOLS_DOC = Path("docs") / "tools.md"
|
||||
BLOCK_START = "<!-- tool-summary:start -->"
|
||||
BLOCK_END = "<!-- tool-summary:end -->"
|
||||
#: Documentation that may name tools that no longer exist (history, research, audit).
|
||||
EXCLUDED_DOCS = frozenset({"audit.md"})
|
||||
REGEN_HINT = "run: uv run python scripts/check_docs.py --write"
|
||||
|
||||
RAW_DOMAIN = "misc"
|
||||
RAW_SUMMARY = "Raw request to any Gitea endpoint, for endpoints newer than the pinned spec"
|
||||
|
||||
_TOOL_MENTION = re.compile(r"(?<![A-Za-z0-9_])gitea_[a-z0-9_]+")
|
||||
_MAX_DIFF_LINES = 24
|
||||
|
||||
DOMAIN_BLURBS: dict[str, str] = {
|
||||
"actions": (
|
||||
"Gitea Actions: workflow runs, jobs, logs, artifacts, dispatch, secrets, variables "
|
||||
"and runners at repository, organization, user and instance level."
|
||||
),
|
||||
"admin": (
|
||||
"Instance administration: users, organizations, emails, cron tasks, system hooks, "
|
||||
"unadopted repositories, runners and packages. Needs a site-admin token."
|
||||
),
|
||||
"hook": "Webhooks of repositories, organizations, users and the instance (system hooks).",
|
||||
"issue": (
|
||||
"Issues, comments, labels, milestones, reactions, pins, subscriptions, time tracking, "
|
||||
"stopwatches, dependencies and attachments."
|
||||
),
|
||||
"misc": (
|
||||
"Version, settings, markdown rendering, license, gitignore and label templates, "
|
||||
"signing keys, the current token, and the raw request tool."
|
||||
),
|
||||
"notification": "Notification threads and their read state.",
|
||||
"org": "Organizations, members, public members, blocks, labels, avatars and activity.",
|
||||
"package": "Package registry: list, get, delete, files, link and unlink.",
|
||||
"pull": "Pull requests, merging, reviews, review comments, requested reviewers, diffs.",
|
||||
"release": "Releases, tags of releases and release attachments.",
|
||||
"repo": (
|
||||
"Repositories: CRUD, contents, branches, tags, commits, forks, mirrors, collaborators, "
|
||||
"branch protection, deploy keys, archives, topics, LFS and more."
|
||||
),
|
||||
"team": "Organization teams, their members and repositories.",
|
||||
"user": (
|
||||
"The signed-in user and other users: profile, emails, keys, followers, blocks, tokens, "
|
||||
"OAuth applications, stars, watches, settings."
|
||||
),
|
||||
"wiki": "Repository wiki pages and revisions.",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolRow:
|
||||
"""One row of the inventory (a catalog operation or the raw request tool)."""
|
||||
|
||||
name: str
|
||||
domain: str
|
||||
method: str
|
||||
path: str
|
||||
summary: str
|
||||
flags: tuple[str, ...]
|
||||
|
||||
|
||||
def _flags(operation: Operation) -> tuple[str, ...]:
|
||||
flags: list[str] = []
|
||||
if operation.read_only:
|
||||
flags.append("read-only")
|
||||
if operation.destructive:
|
||||
flags.append("destructive")
|
||||
if operation.paginated:
|
||||
flags.append("paginated")
|
||||
if any(parameter.is_file for parameter in operation.parameters):
|
||||
flags.append("file upload")
|
||||
if operation.domain == "admin":
|
||||
flags.append("admin")
|
||||
return tuple(flags)
|
||||
|
||||
|
||||
def build_rows(catalog: Catalog) -> list[ToolRow]:
|
||||
"""Return every tool (catalog operations plus the raw tool), sorted by domain then name."""
|
||||
rows = [
|
||||
ToolRow(
|
||||
name=op.tool_name,
|
||||
domain=op.domain,
|
||||
method=op.method,
|
||||
path=op.path,
|
||||
summary=op.summary,
|
||||
flags=_flags(op),
|
||||
)
|
||||
for op in catalog.operations
|
||||
]
|
||||
rows.append(
|
||||
ToolRow(
|
||||
name=RAW_TOOL_NAME,
|
||||
domain=RAW_DOMAIN,
|
||||
method="any",
|
||||
path="(caller supplied)",
|
||||
summary=RAW_SUMMARY,
|
||||
flags=("destructive",),
|
||||
)
|
||||
)
|
||||
return sorted(rows, key=lambda row: (row.domain, row.name))
|
||||
|
||||
|
||||
def registered_names(catalog: Catalog) -> frozenset[str]:
|
||||
"""Return every registered tool name, including the raw tool."""
|
||||
return frozenset({op.tool_name for op in catalog.operations} | {RAW_TOOL_NAME})
|
||||
|
||||
|
||||
def _cell(text: str) -> str:
|
||||
"""Make ``text`` safe inside a Markdown table cell."""
|
||||
return " ".join(text.split()).replace("|", "\\|")
|
||||
|
||||
|
||||
def _domain_order(catalog: Catalog, rows: Iterable[ToolRow]) -> list[str]:
|
||||
present = {row.domain for row in rows}
|
||||
ordered = [domain for domain in catalog.domains() if domain in present]
|
||||
ordered.extend(sorted(present - set(ordered)))
|
||||
return ordered
|
||||
|
||||
|
||||
def render_summary_table(catalog: Catalog) -> str:
|
||||
"""Render the per-domain count table (also used inside the README block)."""
|
||||
rows = build_rows(catalog)
|
||||
totals: Counter[str] = Counter(row.domain for row in rows)
|
||||
read_only: Counter[str] = Counter(row.domain for row in rows if "read-only" in row.flags)
|
||||
lines = ["| Domain | Tools | Read-only | Mutating |", "|---|---:|---:|---:|"]
|
||||
for domain in _domain_order(catalog, rows):
|
||||
total = totals[domain]
|
||||
lines.append(f"| {domain} | {total} | {read_only[domain]} | {total - read_only[domain]} |")
|
||||
total_all = len(rows)
|
||||
read_all = sum(read_only.values())
|
||||
lines.append(f"| **all** | **{total_all}** | **{read_all}** | **{total_all - read_all}** |")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def render_readme_block(catalog: Catalog) -> str:
|
||||
"""Render the text that sits between the README markers (without the markers)."""
|
||||
rows = build_rows(catalog)
|
||||
generated = len(rows) - 1
|
||||
body = (
|
||||
f"{len(rows)} tools: {generated} generated from the Gitea {catalog.spec_version} "
|
||||
f"swagger (one per operation) plus `{RAW_TOOL_NAME}`. The raw tool is counted "
|
||||
"under misc.\n\n"
|
||||
f"{render_summary_table(catalog)}\n\n"
|
||||
"The full list with HTTP method, path and flags is in [docs/tools.md](docs/tools.md)."
|
||||
)
|
||||
return f"\n{body}\n"
|
||||
|
||||
|
||||
def render_tools_md(catalog: Catalog) -> str:
|
||||
"""Render ``docs/tools.md`` completely."""
|
||||
rows = build_rows(catalog)
|
||||
generated = len(rows) - 1
|
||||
parts: list[str] = [
|
||||
"# Tool inventory\n",
|
||||
"This file is generated. Do not edit it by hand: run "
|
||||
"`uv run python scripts/check_docs.py --write` after changing the catalog. CI fails "
|
||||
"when it is out of date.\n",
|
||||
f"There are {len(rows)} tools: {generated} generated from the pinned Gitea "
|
||||
f"{catalog.spec_version} swagger (`spec/gitea-swagger.v1.json`, one tool per "
|
||||
f"operation) plus the hand-written `{RAW_TOOL_NAME}`, which is listed under misc.\n",
|
||||
"Naming: `gitea_<domain>_<action>`, lowercase snake_case, at most 64 characters. The "
|
||||
"domain comes from the request path, the action from the swagger operationId. Names "
|
||||
"are stable for a given spec version.\n",
|
||||
"Every tool also accepts an optional `sudo` argument (a Gitea login). With an admin "
|
||||
"token the call then runs as that user. Paths are relative to `/api/v1`.\n",
|
||||
"Filters: `TOOLSETS` limits the exposed domains, `READ_ONLY` exposes only read-only "
|
||||
f"operations (`{RAW_TOOL_NAME}` then accepts only GET), `DISABLED_TOOLS` hides "
|
||||
"single tools by name. A filtered tool is neither listed nor callable. See "
|
||||
"[configuration.md](configuration.md).\n",
|
||||
"Flags: read-only (maps to the MCP `readOnlyHint`), destructive (`destructiveHint`: "
|
||||
"deletes and calls that replace state), paginated (accepts `page` and `limit`), file "
|
||||
"upload (takes base64 file content), admin (needs a site-admin token).\n",
|
||||
"## Summary\n",
|
||||
render_summary_table(catalog) + "\n",
|
||||
]
|
||||
by_domain: dict[str, list[ToolRow]] = {}
|
||||
for row in rows:
|
||||
by_domain.setdefault(row.domain, []).append(row)
|
||||
for domain in _domain_order(catalog, rows):
|
||||
domain_rows = by_domain[domain]
|
||||
parts.append(f"## {domain}\n")
|
||||
blurb = DOMAIN_BLURBS.get(domain)
|
||||
if blurb:
|
||||
parts.append(f"{blurb} {len(domain_rows)} tools.\n")
|
||||
table = [
|
||||
"| Tool | Method | Path | Summary | Flags |",
|
||||
"|---|---|---|---|---|",
|
||||
]
|
||||
for row in domain_rows:
|
||||
table.append(
|
||||
f"| `{row.name}` | {row.method} | `{_cell(row.path)}` | {_cell(row.summary)} "
|
||||
f"| {', '.join(row.flags)} |"
|
||||
)
|
||||
parts.append("\n".join(table) + "\n")
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# README block handling
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def extract_block(readme_text: str) -> str | None:
|
||||
"""Return the text between the README markers, or None when a marker is missing."""
|
||||
start = readme_text.find(BLOCK_START)
|
||||
end = readme_text.find(BLOCK_END)
|
||||
if start == -1 or end == -1 or end < start:
|
||||
return None
|
||||
return readme_text[start + len(BLOCK_START) : end]
|
||||
|
||||
|
||||
def replace_block(readme_text: str, block: str) -> str:
|
||||
"""Return ``readme_text`` with the generated block replaced (markers must exist)."""
|
||||
start = readme_text.find(BLOCK_START)
|
||||
end = readme_text.find(BLOCK_END)
|
||||
if start == -1 or end == -1 or end < start:
|
||||
raise ValueError(f"README.md must contain {BLOCK_START} and {BLOCK_END} (in that order)")
|
||||
return readme_text[: start + len(BLOCK_START)] + block + readme_text[end:]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool name mentions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def doc_files(root: Path) -> list[Path]:
|
||||
"""Return the Markdown files scanned for tool names (README plus top-level docs)."""
|
||||
files: list[Path] = []
|
||||
readme = root / README_NAME
|
||||
if readme.is_file():
|
||||
files.append(readme)
|
||||
docs = root / "docs"
|
||||
if docs.is_dir():
|
||||
files.extend(
|
||||
path
|
||||
for path in sorted(docs.glob("*.md"))
|
||||
if path.name not in EXCLUDED_DOCS and path.is_file()
|
||||
)
|
||||
return files
|
||||
|
||||
|
||||
def is_known_mention(mention: str, names: frozenset[str]) -> bool:
|
||||
"""Return True for a registered tool name or a prefix such as ``gitea_admin_``."""
|
||||
if mention in names:
|
||||
return True
|
||||
return mention.endswith("_") and any(name.startswith(mention) for name in names)
|
||||
|
||||
|
||||
def find_unknown_mentions(text: str, names: frozenset[str]) -> list[tuple[int, str]]:
|
||||
"""Return ``(line number, name)`` for every mention that is not a registered tool."""
|
||||
unknown: list[tuple[int, str]] = []
|
||||
for number, line in enumerate(text.splitlines(), start=1):
|
||||
for match in _TOOL_MENTION.finditer(line):
|
||||
mention = match.group(0)
|
||||
if not is_known_mention(mention, names):
|
||||
unknown.append((number, mention))
|
||||
return unknown
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# check and write
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _diff_summary(label: str, current: str, expected: str) -> str:
|
||||
diff = list(
|
||||
difflib.unified_diff(
|
||||
current.splitlines(),
|
||||
expected.splitlines(),
|
||||
fromfile=f"{label} (committed)",
|
||||
tofile=f"{label} (generated)",
|
||||
lineterm="",
|
||||
n=0,
|
||||
)
|
||||
)
|
||||
shown = diff[:_MAX_DIFF_LINES]
|
||||
if len(diff) > len(shown):
|
||||
shown.append(f"... {len(diff) - len(shown)} more diff lines")
|
||||
return "\n".join(shown)
|
||||
|
||||
|
||||
def check(root: Path, catalog: Catalog | None = None) -> list[str]:
|
||||
"""Run every drift rule against the tree at ``root`` and return the problems found."""
|
||||
catalog = catalog or load_catalog()
|
||||
problems: list[str] = []
|
||||
|
||||
tools_path = root / TOOLS_DOC
|
||||
expected_tools = render_tools_md(catalog)
|
||||
if not tools_path.is_file():
|
||||
problems.append(f"{TOOLS_DOC} is missing. {REGEN_HINT}")
|
||||
else:
|
||||
current = tools_path.read_text(encoding="utf-8")
|
||||
if current != expected_tools:
|
||||
problems.append(
|
||||
f"{TOOLS_DOC} is out of date. {REGEN_HINT}\n"
|
||||
+ _diff_summary(str(TOOLS_DOC), current, expected_tools)
|
||||
)
|
||||
|
||||
readme_path = root / README_NAME
|
||||
if not readme_path.is_file():
|
||||
problems.append(f"{README_NAME} is missing")
|
||||
else:
|
||||
readme = readme_path.read_text(encoding="utf-8")
|
||||
block = extract_block(readme)
|
||||
expected_block = render_readme_block(catalog)
|
||||
if block is None:
|
||||
problems.append(
|
||||
f"{README_NAME} has no generated block: add {BLOCK_START} and {BLOCK_END}, "
|
||||
"then run: uv run python scripts/check_docs.py --write"
|
||||
)
|
||||
elif block != expected_block:
|
||||
problems.append(
|
||||
f"the tool summary in {README_NAME} is out of date. {REGEN_HINT}\n"
|
||||
+ _diff_summary(f"{README_NAME} tool summary", block, expected_block)
|
||||
)
|
||||
|
||||
names = registered_names(catalog)
|
||||
for path in doc_files(root):
|
||||
text = path.read_text(encoding="utf-8")
|
||||
for number, mention in find_unknown_mentions(text, names):
|
||||
problems.append(
|
||||
f"{path.relative_to(root)}:{number}: '{mention}' is not a registered tool name"
|
||||
)
|
||||
return problems
|
||||
|
||||
|
||||
def write(root: Path, catalog: Catalog | None = None) -> list[Path]:
|
||||
"""Regenerate ``docs/tools.md`` and the README block. Returns the files written."""
|
||||
catalog = catalog or load_catalog()
|
||||
written: list[Path] = []
|
||||
|
||||
tools_path = root / TOOLS_DOC
|
||||
tools_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tools_path.write_text(render_tools_md(catalog), encoding="utf-8")
|
||||
written.append(tools_path)
|
||||
|
||||
readme_path = root / README_NAME
|
||||
readme = readme_path.read_text(encoding="utf-8")
|
||||
updated = replace_block(readme, render_readme_block(catalog))
|
||||
if updated != readme:
|
||||
readme_path.write_text(updated, encoding="utf-8")
|
||||
written.append(readme_path)
|
||||
return written
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None, root: Path = REPO_ROOT) -> int:
|
||||
"""Run the script and return the process exit code."""
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
parser.add_argument(
|
||||
"--write",
|
||||
action="store_true",
|
||||
help="regenerate docs/tools.md and the README tool summary instead of checking",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.write:
|
||||
try:
|
||||
written = write(root)
|
||||
except ValueError as exc:
|
||||
print(f"error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
for path in written:
|
||||
print(f"wrote {path.relative_to(root)}")
|
||||
return 0
|
||||
|
||||
problems = check(root)
|
||||
if problems:
|
||||
print(f"docs check failed with {len(problems)} problem(s):", file=sys.stderr)
|
||||
for problem in problems:
|
||||
print(f"- {problem}", file=sys.stderr)
|
||||
return 1
|
||||
print("docs check passed")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,239 @@
|
||||
"""Tests for scripts/check_docs.py: generation, drift detection and tool name mentions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
|
||||
import pytest
|
||||
|
||||
from aegis_gitea_mcp.catalog import load_catalog
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPT = REPO_ROOT / "scripts" / "check_docs.py"
|
||||
|
||||
README_TEMPLATE = (
|
||||
"# Title\n\nIntro.\n\n<!-- tool-summary:start -->\nold\n<!-- tool-summary:end -->\n\nOutro.\n"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def script() -> ModuleType:
|
||||
spec = importlib.util.spec_from_file_location("check_docs_script", SCRIPT)
|
||||
assert spec is not None
|
||||
assert spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module # dataclasses resolves string annotations through it
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tree(script: ModuleType, tmp_path: Path) -> Path:
|
||||
"""A minimal docs tree in which the generated files are up to date."""
|
||||
(tmp_path / "docs").mkdir()
|
||||
(tmp_path / "README.md").write_text(README_TEMPLATE, encoding="utf-8")
|
||||
script.write(tmp_path)
|
||||
return tmp_path
|
||||
|
||||
|
||||
def test_repository_docs_are_in_sync(script: ModuleType) -> None:
|
||||
# docs/security.md still describes the 0.x design until the security phase rewrites it;
|
||||
# its stale tool names are the only accepted problems. Drop the filter after that rewrite.
|
||||
problems = [p for p in script.check(REPO_ROOT) if not p.startswith("docs/security.md:")]
|
||||
assert problems == []
|
||||
|
||||
|
||||
def test_tools_md_lists_every_tool_once(script: ModuleType) -> None:
|
||||
catalog = load_catalog()
|
||||
text = script.render_tools_md(catalog)
|
||||
rows = re.findall(r"^\| `(gitea_[a-z0-9_]+)` \|", text, flags=re.MULTILINE)
|
||||
expected = {op.tool_name for op in catalog.operations} | {"gitea_api_request"}
|
||||
assert len(rows) == len(expected) == len(catalog.operations) + 1
|
||||
assert set(rows) == expected
|
||||
assert f"There are {len(expected)} tools" in text
|
||||
assert catalog.spec_version in text
|
||||
|
||||
|
||||
def test_tools_md_row_content(script: ModuleType) -> None:
|
||||
catalog = load_catalog()
|
||||
text = script.render_tools_md(catalog)
|
||||
delete = next(op for op in catalog.operations if op.tool_name == "gitea_repo_delete")
|
||||
row = next(line for line in text.splitlines() if line.startswith("| `gitea_repo_delete` |"))
|
||||
assert f"| {delete.method} | `{delete.path}` |" in row
|
||||
assert "destructive" in row
|
||||
raw_row = next(line for line in text.splitlines() if line.startswith("| `gitea_api_request`"))
|
||||
assert "any" in raw_row
|
||||
assert "destructive" in raw_row
|
||||
|
||||
|
||||
def test_flags_cover_admin_upload_and_pagination(script: ModuleType) -> None:
|
||||
catalog = load_catalog()
|
||||
rows = {row.name: row for row in script.build_rows(catalog)}
|
||||
admin = next(op.tool_name for op in catalog.operations if op.domain == "admin")
|
||||
assert "admin" in rows[admin].flags
|
||||
assert any("file upload" in row.flags for row in rows.values())
|
||||
assert any("paginated" in row.flags for row in rows.values())
|
||||
assert any("read-only" in row.flags for row in rows.values())
|
||||
|
||||
|
||||
def test_output_is_deterministic(script: ModuleType) -> None:
|
||||
catalog = load_catalog()
|
||||
assert script.render_tools_md(catalog) == script.render_tools_md(catalog)
|
||||
assert script.render_readme_block(catalog) == script.render_readme_block(catalog)
|
||||
|
||||
|
||||
def test_summary_table_counts_match_catalog(script: ModuleType) -> None:
|
||||
catalog = load_catalog()
|
||||
block = script.render_readme_block(catalog)
|
||||
total = len(catalog.operations) + 1
|
||||
assert f"{total} tools" in block
|
||||
misc = sum(1 for op in catalog.operations if op.domain == "misc") + 1
|
||||
assert f"| misc | {misc} |" in block
|
||||
assert f"| **all** | **{total}** |" in block
|
||||
|
||||
|
||||
def test_table_cells_escape_pipes_and_newlines(script: ModuleType) -> None:
|
||||
assert script._cell("a | b\nc") == "a \\| b c"
|
||||
|
||||
|
||||
def test_write_mode_creates_files_and_replaces_only_the_block(
|
||||
script: ModuleType, tmp_path: Path
|
||||
) -> None:
|
||||
(tmp_path / "README.md").write_text(README_TEMPLATE, encoding="utf-8")
|
||||
written = script.write(tmp_path)
|
||||
assert {path.name for path in written} == {"tools.md", "README.md"}
|
||||
readme = (tmp_path / "README.md").read_text(encoding="utf-8")
|
||||
assert readme.startswith("# Title\n\nIntro.\n\n<!-- tool-summary:start -->")
|
||||
assert readme.endswith("<!-- tool-summary:end -->\n\nOutro.\n")
|
||||
assert "old" not in readme
|
||||
assert (tmp_path / "docs" / "tools.md").read_text(encoding="utf-8").startswith("# Tool")
|
||||
|
||||
|
||||
def test_write_mode_is_idempotent(script: ModuleType, tree: Path) -> None:
|
||||
before = (tree / "README.md").read_text(encoding="utf-8")
|
||||
script.write(tree)
|
||||
assert (tree / "README.md").read_text(encoding="utf-8") == before
|
||||
|
||||
|
||||
def test_write_requires_markers(script: ModuleType, tmp_path: Path) -> None:
|
||||
(tmp_path / "README.md").write_text("# no markers\n", encoding="utf-8")
|
||||
with pytest.raises(ValueError, match="tool-summary"):
|
||||
script.write(tmp_path)
|
||||
assert script.main(["--write"], root=tmp_path) == 1
|
||||
|
||||
|
||||
def test_main_write_then_check(
|
||||
script: ModuleType, tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
(tmp_path / "README.md").write_text(README_TEMPLATE, encoding="utf-8")
|
||||
assert script.main(["--write"], root=tmp_path) == 0
|
||||
assert "wrote docs/tools.md" in capsys.readouterr().out
|
||||
assert script.main([], root=tmp_path) == 0
|
||||
assert "docs check passed" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_clean_tree_passes(script: ModuleType, tree: Path) -> None:
|
||||
assert script.check(tree) == []
|
||||
|
||||
|
||||
def test_detects_stale_tools_md(
|
||||
script: ModuleType, tree: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
path = tree / "docs" / "tools.md"
|
||||
path.write_text(path.read_text(encoding="utf-8").replace("| repo |", "| repos |"))
|
||||
problems = script.check(tree)
|
||||
assert len(problems) == 1
|
||||
assert "docs/tools.md is out of date" in problems[0]
|
||||
assert "check_docs.py --write" in problems[0]
|
||||
assert "-| repos |" in problems[0]
|
||||
assert "+| repo |" in problems[0]
|
||||
assert script.main([], root=tree) == 1
|
||||
assert "docs check failed" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_detects_missing_tools_md(script: ModuleType, tree: Path) -> None:
|
||||
(tree / "docs" / "tools.md").unlink()
|
||||
problems = script.check(tree)
|
||||
assert any("docs/tools.md is missing" in problem for problem in problems)
|
||||
|
||||
|
||||
def test_detects_stale_readme_block(script: ModuleType, tree: Path) -> None:
|
||||
readme = tree / "README.md"
|
||||
readme.write_text(readme.read_text(encoding="utf-8").replace("| misc |", "| miscx |"))
|
||||
problems = script.check(tree)
|
||||
assert len(problems) == 1
|
||||
assert "tool summary in README.md is out of date" in problems[0]
|
||||
|
||||
|
||||
def test_detects_missing_readme_markers(script: ModuleType, tree: Path) -> None:
|
||||
(tree / "README.md").write_text("# no block here\n", encoding="utf-8")
|
||||
problems = script.check(tree)
|
||||
assert any("no generated block" in problem for problem in problems)
|
||||
|
||||
|
||||
def test_detects_reversed_markers(script: ModuleType, tree: Path) -> None:
|
||||
(tree / "README.md").write_text(
|
||||
"<!-- tool-summary:end -->\n<!-- tool-summary:start -->\n", encoding="utf-8"
|
||||
)
|
||||
assert any("no generated block" in problem for problem in script.check(tree))
|
||||
|
||||
|
||||
def test_detects_unknown_tool_name_in_readme(script: ModuleType, tree: Path) -> None:
|
||||
readme = tree / "README.md"
|
||||
readme.write_text(readme.read_text(encoding="utf-8") + "\nUse `gitea_made_up_tool`.\n")
|
||||
problems = script.check(tree)
|
||||
assert len(problems) == 1
|
||||
assert "README.md" in problems[0]
|
||||
assert "'gitea_made_up_tool' is not a registered tool name" in problems[0]
|
||||
|
||||
|
||||
def test_detects_unknown_tool_name_in_docs(script: ModuleType, tree: Path) -> None:
|
||||
(tree / "docs" / "guide.md").write_text("one\ntwo `gitea_request` three\n", encoding="utf-8")
|
||||
problems = script.check(tree)
|
||||
assert problems == ["docs/guide.md:2: 'gitea_request' is not a registered tool name"]
|
||||
|
||||
|
||||
def test_registered_names_and_prefixes_are_accepted(script: ModuleType, tree: Path) -> None:
|
||||
(tree / "docs" / "guide.md").write_text(
|
||||
"`gitea_repo_get`, `gitea_api_request` and the gitea_admin_ tools.\n", encoding="utf-8"
|
||||
)
|
||||
assert script.check(tree) == []
|
||||
|
||||
|
||||
def test_unknown_prefix_is_rejected(script: ModuleType, tree: Path) -> None:
|
||||
(tree / "docs" / "guide.md").write_text("the gitea_bogus_ tools\n", encoding="utf-8")
|
||||
assert len(script.check(tree)) == 1
|
||||
|
||||
|
||||
def test_similar_identifiers_are_not_tool_mentions(script: ModuleType) -> None:
|
||||
names = script.registered_names(load_catalog())
|
||||
text = "aegis_gitea_mcp, my_gitea_thing, GITEA_URL and gitea_<domain>_<action>\n"
|
||||
assert script.find_unknown_mentions(text, names) == []
|
||||
|
||||
|
||||
def test_excluded_documents_may_name_old_tools(script: ModuleType, tree: Path) -> None:
|
||||
(tree / "docs" / "audit.md").write_text("old `gitea_request`\n", encoding="utf-8")
|
||||
(tree / "docs" / "adr").mkdir()
|
||||
(tree / "docs" / "adr" / "0001.md").write_text("old `gitea_request`\n", encoding="utf-8")
|
||||
(tree / "docs" / "research").mkdir()
|
||||
(tree / "docs" / "research" / "notes.md").write_text("`gitea_request`\n", encoding="utf-8")
|
||||
assert script.check(tree) == []
|
||||
scanned = {path.name for path in script.doc_files(tree)}
|
||||
assert scanned == {"README.md", "tools.md"}
|
||||
|
||||
|
||||
def test_missing_readme_is_reported(script: ModuleType, tree: Path) -> None:
|
||||
(tree / "README.md").unlink()
|
||||
assert any("README.md is missing" in problem for problem in script.check(tree))
|
||||
|
||||
|
||||
def test_long_diffs_are_truncated(script: ModuleType) -> None:
|
||||
current = "\n".join(f"old {i}" for i in range(200))
|
||||
expected = "\n".join(f"new {i}" for i in range(200))
|
||||
summary = script._diff_summary("x", current, expected)
|
||||
assert "more diff lines" in summary
|
||||
assert len(summary.splitlines()) <= 30
|
||||
Reference in new issue
Block a user