Compare commits

..
11 Commits
Author SHA1 Message Date
Latte a4800951ef Merge pull request 'feat!: full-access Gitea MCP server (1.0.0): full API coverage, stdio + streamable HTTP, admin and per-user OAuth' (#71) from feat/full-api-coverage into dev
ci / gitleaks (push) Successful in 8s
ci / docs-check (push) Successful in 28s
ci / lint (push) Successful in 41s
ci / test-mcp (push) Successful in 1m8s
ci / security (push) Successful in 1m33s
ci / build-and-push (push) Successful in 32s
publish / test (push) Successful in 3m32s
publish / publish (push) Successful in 28s
publish / lint (push) Successful in 37s
ci / test-integration (push) Successful in 2m35s
publish / security (push) Successful in 1m37s
ci / test-unit (push) Successful in 3m39s
ci / gitleaks (pull_request) Successful in 7s
ci / docs-check (pull_request) Successful in 29s
ci / lint (pull_request) Successful in 41s
ci / test-mcp (pull_request) Successful in 1m6s
ci / security (pull_request) Successful in 1m30s
ci / test-integration (pull_request) Successful in 2m43s
ci / test-unit (pull_request) Successful in 3m38s
ci / build-and-push (pull_request) Successful in 29s
Reviewed-on: #71
2026-10-14 17:25:24 +00:00
Latte 3fead4e121 ci: keep the publish workflow comment free of em dashes
ci / gitleaks (push) Successful in 9s
ci / docs-check (push) Successful in 32s
ci / lint (push) Successful in 42s
ci / test-mcp (push) Successful in 1m20s
ci / security (push) Successful in 1m34s
ci / test-integration (push) Successful in 2m38s
ci / test-unit (push) Successful in 3m48s
ci / build-and-push (push) Successful in 35s
ci / gitleaks (pull_request) Successful in 7s
ci / lint (pull_request) Successful in 37s
ci / test-unit (pull_request) Successful in 3m39s
ci / test-integration (pull_request) Successful in 2m47s
ci / test-mcp (pull_request) Successful in 1m5s
ci / docs-check (pull_request) Successful in 27s
ci / security (pull_request) Successful in 1m31s
ci / build-and-push (pull_request) Successful in 29s
2026-10-14 18:23:51 +02:00
Latte 79bce1d210 test: regenerate the coverage report and the sweep baseline, document the baseline and the runner policy 2026-10-14 18:17:11 +02:00
Latte d1b9a4845e test: write failure paths, expired OAuth token, non-admin admin-tool refusals and webhook credential leak checks 2026-10-14 18:14:44 +02:00
Latte 84fa29e380 test: merged-commit lookup, scheduled auto-merge cancel and repository transfer answers 2026-10-14 18:12:46 +02:00
Latte 7dc749d7cd test: manage runners at every scope, rerun and delete a completed run, fail when the runner is required 2026-10-14 18:09:17 +02:00
Latte 9e51f72ec9 test: pin the sweep outcome per tool in a baseline, seed a GPG key, fail loudly when seeding finds no run 2026-10-14 18:06:29 +02:00
Latte 84b361962e docs: describe the per-run build tag and the push gate 2026-10-14 18:02:49 +02:00
Latte 4e108771f8 ci: build under a per-run local tag, untag registry refs only, gate the image push on every test job 2026-10-14 18:02:43 +02:00
Latte 8ff67f5859 docs: describe the write-ahead audit model and bring the contract files up to date 2026-10-14 18:01:55 +02:00
Latte 59d0e43aae docs: fix the claude mcp add form, pin the package index, document known limitations
The --env option swallowed the server name, so the stdio command is reordered. uvx now always
names the project registry because the package name is not registered on public PyPI.
README gains a Known limitations section, the READ_ONLY wording is corrected, packaging.md
warns about --extra-index-url and the Le Chat admin requirement is mentioned.
2026-10-14 18:01:19 +02:00
32 changed files with 1445 additions and 165 deletions

No files matched your search

+25 -11
View File
@@ -7,7 +7,9 @@ name: ci
# test-unit pytest (coverage gate from pyproject.toml: 90 percent)
# test-integration pytest against a throwaway Gitea in Docker
# test-mcp MCP smoke tests, including against the freshly built image
# build-and-push build and scan the image; push it on push events (needs lint + test-unit)
# build-and-push build and scan the image; push it on push events (needs lint, test-unit,
# test-integration, test-mcp and security, so :dev and :latest are never
# pushed while any of them is red)
# docs-check docs drift check
# security security test suite, pip-audit, ruff bandit rules
#
@@ -21,7 +23,10 @@ name: ci
#
# Docker hygiene on the shared runner: every resource this workflow creates has
# a per-run name (aegis-ci-<run id>-...) and is removed by an `if: always()`
# step that deletes exactly those names. Nothing here ever prunes.
# step that deletes exactly those names. Registry tags (:sha-<12>, :dev, :latest, :main)
# are shared with other users of the daemon: they are applied only immediately before the
# push and only untagged afterwards, never used to remove containers. Nothing here ever
# prunes.
on:
push:
@@ -153,6 +158,8 @@ jobs:
env:
AEGIS_INTEGRATION: "1"
AEGIS_IT_NETWORK_MODE: container
# A runner that cannot start must fail the job, not xfail the Actions scenario.
AEGIS_IT_REQUIRE_RUNNER: "1"
run: uv run pytest tests/integration -m integration --no-cov
- name: Cleanup
if: always()
@@ -204,8 +211,8 @@ jobs:
run: bash scripts/ci/cleanup_job_resources.sh "${MCP_IMAGE}"
# ---------------------------------------------------------------------------
# 6. Build the image from the checked-out commit. On push events it is also
# pushed:
# 6. Build the image from the checked-out commit under a per-run local tag
# (aegis-ci-<run id>-build:<sha>). On push events it is also tagged and pushed:
# :sha-<12> every branch (immutable, traceable to the commit; lets any
# feature branch image be pulled and tested)
# :dev additionally on dev
@@ -214,7 +221,7 @@ jobs:
# outputs and the job summary.
# ---------------------------------------------------------------------------
build-and-push:
needs: [lint, test-unit]
needs: [lint, test-unit, test-integration, test-mcp, security]
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
timeout-minutes: 30
@@ -233,6 +240,8 @@ jobs:
run: |
IMAGE="git.hiddenden.cafe/${GITHUB_REPOSITORY,,}"
SHA_TAG="${IMAGE}:sha-${GITHUB_SHA::12}"
# Local build tag: unique per run, never a registry tag.
BUILD_TAG="aegis-ci-${GITHUB_RUN_ID}-build:${GITHUB_SHA}"
EXTRA_TAGS=""
if [ "${GITHUB_EVENT_NAME}" = "push" ]; then
if [ "${GITHUB_REF_NAME}" = "main" ]; then
@@ -244,6 +253,7 @@ jobs:
{
echo "image=${IMAGE}"
echo "sha_tag=${SHA_TAG}"
echo "build_tag=${BUILD_TAG}"
echo "extra_tags=${EXTRA_TAGS}"
} >> "$GITHUB_OUTPUT"
@@ -253,11 +263,11 @@ jobs:
docker build -f docker/Dockerfile \
--label "org.opencontainers.image.revision=${GITHUB_SHA}" \
--label "org.opencontainers.image.source=https://git.hiddenden.cafe/${GITHUB_REPOSITORY}" \
-t "${{ steps.meta.outputs.sha_tag }}" .
-t "${{ steps.meta.outputs.build_tag }}" .
- name: Smoke-test image
shell: bash
run: docker run --rm "${{ steps.meta.outputs.sha_tag }}" version
run: docker run --rm "${{ steps.meta.outputs.build_tag }}" version
# Vulnerability scan of the image that is about to be pushed: pinned Trivy
# (sha256 verified), fails on fixable HIGH and CRITICAL findings. Waivers live
@@ -271,7 +281,7 @@ jobs:
- name: Scan image
shell: bash
run: bash scripts/ci/scan_image.sh "${{ steps.meta.outputs.sha_tag }}"
run: bash scripts/ci/scan_image.sh "${{ steps.meta.outputs.build_tag }}"
# No third-party action sees the registry token: the CLI reads it from stdin. The
# credentials go to a per-job config directory (DOCKER_CONFIG), never into the shared
@@ -290,12 +300,14 @@ jobs:
printf '%s' "${REGISTRY_TOKEN}" | DOCKER_CONFIG="${config_dir}" \
docker login git.hiddenden.cafe --username "${REGISTRY_USER}" --password-stdin
# The registry tags are applied here, right before the push, and removed again by the
# cleanup step (untag only).
- name: Tag and push
if: github.event_name == 'push'
shell: bash
run: |
for tag in ${{ steps.meta.outputs.extra_tags }}; do
docker tag "${{ steps.meta.outputs.sha_tag }}" "${tag}"
for tag in ${{ steps.meta.outputs.sha_tag }} ${{ steps.meta.outputs.extra_tags }}; do
docker tag "${{ steps.meta.outputs.build_tag }}" "${tag}"
done
for tag in ${{ steps.meta.outputs.sha_tag }} ${{ steps.meta.outputs.extra_tags }}; do
docker push "${tag}"
@@ -331,8 +343,10 @@ jobs:
if [ -n "${DOCKER_CONFIG:-}" ]; then
rm -rf "${DOCKER_CONFIG}"
fi
# Registry tags first (untag only), then the per-run build tag.
bash scripts/ci/cleanup_job_resources.sh \
"${{ steps.meta.outputs.sha_tag }}" ${{ steps.meta.outputs.extra_tags }}
"${{ steps.meta.outputs.sha_tag }}" ${{ steps.meta.outputs.extra_tags }} \
"${{ steps.meta.outputs.build_tag }}"
# ---------------------------------------------------------------------------
# 7. Docs drift check
+1 -1
View File
@@ -110,7 +110,7 @@ jobs:
# main -> aegis-gitea-mcp X.Y.Z (no-op if already there)
#
# The package name + version are patched into pyproject.toml at build time
# only — never committed. The committed file keeps name "aegis-gitea-mcp"
# only, never committed. The committed file keeps name "aegis-gitea-mcp"
# and version "X.Y.Z".
# ---------------------------------------------------------------------------
publish:
+21 -13
View File
@@ -19,7 +19,11 @@ Full access by default (ADR 0001). The server does not decide what is safe; the
owner restricts exposure with `READ_ONLY`, `TOOLSETS`, `DISABLED_TOOLS` and the
scope of the Gitea token. Everything else below is load-bearing:
- Every tool call produces one audit event in the hash-chained, append-only log.
- Every tool call is audited in the hash-chained, append-only log with a write-ahead
model: a `tool_call_started` entry is written before the call executes (the call is
refused with `audit_unavailable` when that entry cannot be written), and a
`tool_invocation` entry with the outcome is written after it. Anything that counts
calls in the log counts `tool_invocation` entries.
- No secrets or tokens in logs, errors, audit entries or results. Output is
passed through secret masking (`SECRET_DETECTION_MODE`, default `mask`).
`Identity.token` and every `SecretStr` setting never appear in `repr`.
@@ -62,18 +66,21 @@ src/aegis_gitea_mcp/
errors.py ToolError, GiteaAPIError(status, kind, message)
audit.py hash-chained audit log; security.py secret masking;
response_limits.py, rate_limit.py (HTTP), request_context.py, logging_utils.py
server.py, transports, oauth/ planned (chunks B4, B5), not present yet
scripts/ generate_catalog.py, validate_audit_log.py, ci/, check_docs.py (B11)
server.py the low-level SDK Server: tool list, call dispatcher, audit calls
transports.py stdio runner and the streamable HTTP app
auth.py static bearer verification and identity resolution
oauth/ per-user OAuth: provider, resolver, encrypted store, upstream, callback
scripts/ generate_catalog.py, validate_audit_log.py, check_docs.py, ci/
spec/ pinned Gitea swagger
tests/integration/ live Gitea in Docker (harness.py), opt-in
tests/integration/ live Gitea in Docker (harness.py), opt-in; sweep of every tool
tests/mcp/ MCP smoke tests (stdio, HTTP, container image), opt-in
tests/security/ security regression suite
```
Modules marked planned do not exist yet. Request flow (target):
client -> SDK transport and auth verifier -> `Identity` -> toolset/read-only
filters -> schema validation -> executor (Gitea call with the identity's token,
optional `sudo`) -> limits and masking -> result envelope (ADR 0005) -> audit.
The executor and the catalog exist; the server and transports that connect them
to MCP clients do not yet.
Request flow: client -> SDK transport and auth verifier -> `Identity` ->
toolset/read-only filters -> schema validation -> `tool_call_started` audit entry
-> executor (Gitea call with the identity's token, optional `sudo`) -> limits and
masking -> result envelope (ADR 0005) -> `tool_invocation` audit entry.
## Commands
@@ -87,13 +94,14 @@ make lint | format | test | test-integration | run | validate-audit | docker-bui
```
Live-Gitea integration tests carry the `integration` marker and run only with
`AEGIS_INTEGRATION=1` (`make test-integration`).
`AEGIS_INTEGRATION=1` (`make test-integration`); the MCP smoke tests under
`tests/mcp` carry `mcp_smoke`. `tests/integration/sweep_baseline.json` pins the
expected outcome of every tool, see `tests/integration/README.md`.
## Quality gates
Ruff (E,W,F,I,B,C4,UP,S,SIM,RUF), ruff format, mypy and pytest must be green
before every commit. Coverage threshold is 80% until the final chunk and 90%
from then on; never lower it. Tests must not write outside `tmp_path`
before every commit. The coverage threshold is 90%; never lower it. Tests must not write outside `tmp_path`
(the autouse fixture in `tests/conftest.py` isolates env, cwd, `DATA_DIR`,
`AUDIT_LOG_PATH` and singletons). Python 3.10+, line length 100, public
functions need docstrings and type hints. Documentation lives under `docs/`;
+13 -13
View File
@@ -12,12 +12,9 @@ HTTP, using the official `mcp` SDK v2 low-level `Server`. Full access by default
and the Gitea token scope. Auth modes: admin-token (`GITEA_TOKEN` / static
`MCP_BEARER_TOKEN`) and per-user OAuth on HTTP (user's own Gitea token).
The project is mid-rebuild (chunk plan in `docs/audit.md`). Built so far: tooling,
`config.py`, `identity.py`, `errors.py`, the generated catalog (`catalog/`), the
Gitea executor (`gitea/`), the setup command (`setup_cmd.py`, `setup_snippets.py`,
`setup_template.py`), the integration harness, CI and audit/security/limits
helpers. Planned: server and transports (B4), OAuth (B5), smoke tests (B9),
docs (B11).
The rebuild is complete: catalog, executor, server, both transports, per-user OAuth,
setup command, CI, security suite, MCP smoke tests and the live integration suite are in.
The remaining open items are tracked in `docs/roadmap.md`.
## Commands
@@ -27,7 +24,7 @@ make lint # ruff check, ruff format --check, mypy src
make format # ruff format + ruff check --fix
make test # uv run pytest (coverage gate)
make test-integration # AEGIS_INTEGRATION=1, needs Docker
make run # uv run aegis-gitea-mcp serve (not implemented until B4)
make run # uv run aegis-gitea-mcp serve
uv run python scripts/generate_catalog.py --check # catalog matches the spec
make validate-audit # verify audit hash chain
uv run pytest tests/test_config.py::test_defaults -v # single test
@@ -44,11 +41,12 @@ uv run pytest tests/test_config.py::test_defaults -v # single test
upload building, `SLASH_ROUTES`), `pagination.py`, `redaction.py`.
- `setup_cmd.py`, `setup_snippets.py`, `setup_template.py` (done): the `setup`
command, its client snippets and the built-in `.env` template.
- Server (B4, planned): low-level SDK `Server` with one `on_list_tools` and one
- `server.py` (done): low-level SDK `Server` with one `on_list_tools` and one
`on_call_tool` dispatcher, jsonschema validation, audit, `is_error` results for
tool failures, `gitea_api_request` escape-hatch tool.
- Auth (B5, planned): static bearer verifier and OAuth provider with encrypted
token store.
tool failures, `gitea_api_request` escape-hatch tool. `transports.py`: stdio
runner and streamable HTTP app.
- `auth.py` and `oauth/` (done): static bearer verifier and the OAuth provider
with encrypted token store.
- Core helpers (done): `config.py` (`Settings`, `get_settings()`,
`reset_settings()`), `identity.py`, `errors.py` (`ToolError`,
`GiteaAPIError`), `audit.py`, `security.py`, `response_limits.py`,
@@ -61,7 +59,9 @@ points `DATA_DIR`/`AUDIT_LOG_PATH` into `tmp_path`.
## Invariants
Kept: audit event per tool call; no secrets in logs/errors/results; no stack
Kept: write-ahead audit (a `tool_call_started` entry before the call runs, the
call is refused when it cannot be written, a `tool_invocation` entry with the
outcome after it; count calls by `tool_invocation`); no secrets in logs/errors/results; no stack
traces by default; `127.0.0.1` default bind and fail-closed HTTP auth off
loopback; untrusted-content labelling; schema validation with no extra
properties; response size bounds; validated `GITEA_URL` and strict
@@ -73,7 +73,7 @@ Details in `AGENTS.md`.
Python 3.10+, line length 100, ruff (E,W,F,I,B,C4,UP,S,SIM,RUF) plus ruff format,
strict-ish mypy (`disallow_untyped_defs`, relaxed in tests). Public functions need
docstrings and type hints. Coverage 80% now, 90% from the final chunk. All
docstrings and type hints. Coverage gate 90%. All
documentation goes under `docs/`; security-impacting changes update docs in the
same changeset. No em-dashes in text.
+39 -9
View File
@@ -145,9 +145,12 @@ Gitea token, `<MCP_BEARER_TOKEN>` is the value from the env file.
Claude Code:
```bash
claude mcp add --transport stdio --env GITEA_URL=https://git.example.com --env GITEA_TOKEN=<your-gitea-token> aegis-gitea -- uvx aegis-gitea-mcp
claude mcp add --env GITEA_URL=https://git.example.com --env GITEA_TOKEN=<your-gitea-token> --transport stdio aegis-gitea -- uvx --index https://git.hiddenden.cafe/api/packages/Hiddenden/pypi/simple/ aegis-gitea-mcp
```
Keep `--transport stdio` and the server name after the `--env` options: `--env` accepts several
values, so an option placed before the name would swallow it.
Claude Desktop, Cursor and other local clients that read an `mcpServers` block:
```json
@@ -155,7 +158,7 @@ Claude Desktop, Cursor and other local clients that read an `mcpServers` block:
"mcpServers": {
"aegis-gitea": {
"command": "uvx",
"args": ["aegis-gitea-mcp"],
"args": ["--index", "https://git.hiddenden.cafe/api/packages/Hiddenden/pypi/simple/", "aegis-gitea-mcp"],
"env": {
"GITEA_URL": "https://git.example.com",
"GITEA_TOKEN": "<your-gitea-token>"
@@ -165,9 +168,16 @@ Claude Desktop, Cursor and other local clients that read an `mcpServers` block:
}
```
`uvx` installs the package from your configured index (see [packaging.md](docs/packaging.md)
for the Gitea registry). To run from a clone instead, use `uv` as the command with the
arguments `--directory /path/to/AegisGitea-MCP run aegis-gitea-mcp`.
Always pass the `--index` of the project's registry. The name `aegis-gitea-mcp` is not
registered on public PyPI, so a bare `uvx aegis-gitea-mcp` would resolve there, and whoever
registers that name would receive your Gitea token through the environment. The owner should
reserve the name on PyPI to close that gap (see [packaging.md](docs/packaging.md)). To run from
a clone instead, use `uv` as the command with the arguments
`run --directory /path/to/AegisGitea-MCP aegis-gitea-mcp serve`, or on the command line:
```bash
claude mcp add --env GITEA_URL=https://git.example.com --env GITEA_TOKEN=<your-gitea-token> --transport stdio aegis-gitea -- uv run --directory /path/to/AegisGitea-MCP aegis-gitea-mcp serve
```
### Remote streamable HTTP
@@ -184,7 +194,8 @@ claude.ai custom connector: Settings, Connectors, Add custom connector, URL
Claude registers itself (dynamic client registration) and sends you to Gitea to sign in.
Mistral Le Chat custom connector: Intelligence, Connectors, Add Connector, Custom MCP
Connector, URL `https://gitea-mcp.example.com/mcp` (HTTPS with a valid certificate).
Connector, URL `https://gitea-mcp.example.com/mcp` (HTTPS with a valid certificate). Adding
connectors in Le Chat may require workspace admin rights.
Authentication is either HTTP Bearer Token with the `MCP_BEARER_TOKEN` value, or OAuth 2.1 with
dynamic client registration so users sign in with their own Gitea account.
@@ -267,7 +278,7 @@ you with mode 0600 (what `setup` writes); any other `./.env` is ignored with a w
| `MCP_HOST`, `MCP_PORT`, `MCP_PATH` | `127.0.0.1`, `8080`, `/mcp` | HTTP bind and endpoint path |
| `MCP_BEARER_TOKEN` | unset | Static bearer for HTTP, at least 32 characters |
| `OAUTH_ENABLED` | `false` | Per-user OAuth on HTTP |
| `READ_ONLY` | `false` | Expose only read-only operations |
| `READ_ONLY` | `false` | Expose only operations annotated read-only |
| `TOOLSETS` | `all` | Comma separated domains to expose |
| `DISABLED_TOOLS` | empty | Tool names to hide |
| `SECRET_DETECTION_MODE` | `mask` | `off`, `mask` or `block` |
@@ -277,8 +288,10 @@ Every variable, its default and its validation rules are in
## What is config-gated, and what is impossible
Config-gated, all optional: `READ_ONLY` (only GET operations, and `gitea_api_request` accepts
only GET), `TOOLSETS` (domains), `DISABLED_TOOLS` (single tools, an unknown name is a startup
Config-gated, all optional: `READ_ONLY` (exposes only the operations annotated read-only, which
are the GET operations plus a few POST endpoints that do not change anything, such as the
markdown and markup render endpoints and the file-contents query; `gitea_api_request` stays
listed but accepts only GET), `TOOLSETS` (domains), `DISABLED_TOOLS` (single tools, an unknown name is a startup
error), and `SECRET_DETECTION_MODE` (`mask` replaces secret-like strings in results, `block`
withholds a result that contains one, `off` disables it; exact redaction of the server's own
tokens always applies). A filtered tool is neither listed nor callable.
@@ -288,6 +301,23 @@ writes to the instance configuration, and instance-wide OAuth applications (UI o
registers its OAuth application as a user application of the admin). `gitea_api_request` reaches
anything newer than the pinned spec once Gitea ships it.
## Known limitations
A token with the `all` scope does not make every tool work. Check
[docs/integration-coverage.md](docs/integration-coverage.md), generated by running every tool
against a live Gitea 28.1.0, for the per-tool outcome.
- `gitea_user_create_token`, `gitea_user_get_tokens` and `gitea_user_delete_access_token` need
basic authentication with the account password (a Gitea API rule). The server always sends
token authentication, so these three return 401 through it. Create tokens in the Gitea UI.
- Some operations depend on instance state and answer 404 or an error until it exists: the
commit signing keys (`gitea_misc_get_signing_key`, `gitea_repo_signing_key` and their SSH
variants) when the instance has none, user badges (Gitea cannot create badges through the
API), unadopted repositories, run artifacts, git notes and the GPG key verification, which
need data the instance does not have yet. The coverage report lists them with the reason.
- Tools that depend on a runner, such as workflow runs and jobs, only return data once an
Actions runner is registered.
## Result and error format
A successful call returns one text item with JSON (see
+1 -1
View File
@@ -21,7 +21,7 @@ GiteaExecutor (gitea/executor.py)
Gitea REST API (/api/v1)
```
Every tool call ends in one audit event, and every result is wrapped in the same envelope.
Every tool call is audited (a write-ahead `tool_call_started` entry, then one `tool_invocation` entry with the outcome), and every result is wrapped in the same envelope.
## Catalog
+1 -1
View File
@@ -95,7 +95,7 @@ listed nor callable.
| Variable | Default | Rules |
|---|---|---|
| `READ_ONLY` | `false` | Expose only read-only (GET) operations. `gitea_api_request` stays listed but accepts only GET. |
| `READ_ONLY` | `false` | Expose only operations annotated read-only (GET plus a few non-mutating POST endpoints such as the render endpoints). `gitea_api_request` stays listed but accepts only GET. |
| `TOOLSETS` | `all` | Comma separated domains: `admin`, `repo`, `issue`, `pull`, `release`, `wiki`, `org`, `team`, `user`, `actions`, `hook`, `package`, `notification`, `misc`, or `all`. Unknown domains are rejected. `gitea_api_request` belongs to `misc`. |
| `DISABLED_TOOLS` | empty | Exact tool names to hide, lowercased on load. An unknown name is a startup error that lists the bad names. |
+7 -3
View File
@@ -67,7 +67,7 @@ Check it: `curl http://127.0.0.1:8080/health`.
Terminate TLS at a reverse proxy. `PUBLIC_BASE_URL` must be the public https URL (it is required
for OAuth, and http is only accepted for loopback hosts). Mistral Le Chat needs a valid public
certificate.
certificate, and adding a connector there may require workspace admin rights.
Caddy:
@@ -163,5 +163,9 @@ Security rules of the pipeline:
go in `security/trivyignore.txt` with a waiver row in [security.md](security.md).
- `publish.yml` needs the `security` job (secret scan, security tests, dependency audit, bandit
rules) as well as lint and tests.
- The cleanup script removes containers only for per-run image names (`aegis-ci-<run id>-...`)
and immutable `:sha-` tags. Shared tags (`:latest`, `:main`, `:dev`) are only untagged.
- The image is built under a per-run local tag (`aegis-ci-<run id>-build:<sha>`), scanned under
that tag, and the registry tags (`:sha-<12>`, `:dev`, `:latest`, `:main`) are applied only
immediately before the push. `build-and-push` needs `lint`, `test-unit`, `test-integration`,
`test-mcp` and `security`, so `:dev` and `:latest` are never pushed while one is red.
- The cleanup script removes containers only for per-run image names (`aegis-ci-<run id>-...`).
Registry tags, the `:sha-` tag included, are only untagged.
+6 -2
View File
@@ -96,10 +96,14 @@ make validate-audit
With `uvx`, no checkout is needed:
```bash
GITEA_URL=https://git.example.com GITEA_TOKEN=<your-gitea-token> uvx aegis-gitea-mcp
GITEA_URL=https://git.example.com GITEA_TOKEN=<your-gitea-token> \
uvx --index https://git.hiddenden.cafe/api/packages/Hiddenden/pypi/simple/ aegis-gitea-mcp
```
The package comes from the Gitea package registry, see [packaging.md](packaging.md).
The package comes from the Gitea package registry, see [packaging.md](packaging.md). Always pass
the `--index`: the name is not registered on public PyPI, and a bare `uvx aegis-gitea-mcp` would
resolve there. From a clone use
`uv run --directory /path/to/AegisGitea-MCP aegis-gitea-mcp serve`.
## Where state lives
+22 -45
View File
@@ -10,21 +10,21 @@ Target: Gitea 28.1.0. Every tool of the server (538) is called at least once thr
| Domain | Tools | 2xx | Documented non-2xx | Allowances | Not covered |
| --- | --- | --- | --- | --- | --- |
| actions | 70 | 52 | 18 | 0 | 0 |
| admin | 33 | 27 | 4 | 2 | 0 |
| actions | 70 | 65 | 5 | 0 | 0 |
| admin | 33 | 30 | 1 | 2 | 0 |
| hook | 20 | 20 | 0 | 0 | 0 |
| issue | 73 | 73 | 0 | 0 | 0 |
| misc | 20 | 18 | 0 | 2 | 0 |
| notification | 7 | 7 | 0 | 0 | 0 |
| org | 45 | 45 | 0 | 0 | 0 |
| package | 9 | 9 | 0 | 0 | 0 |
| pull | 26 | 25 | 1 | 0 | 0 |
| pull | 26 | 26 | 0 | 0 | 0 |
| release | 13 | 13 | 0 | 0 | 0 |
| repo | 124 | 118 | 4 | 2 | 0 |
| repo | 124 | 121 | 1 | 2 | 0 |
| team | 15 | 15 | 0 | 0 | 0 |
| user | 77 | 71 | 3 | 3 | 0 |
| user | 77 | 73 | 1 | 3 | 0 |
| wiki | 6 | 6 | 0 | 0 | 0 |
| total | 538 | 499 | 30 | 9 | 0 |
| total | 538 | 521 | 8 | 9 | 0 |
Excluded from the generic sweep and covered by a dedicated test:
@@ -32,32 +32,19 @@ Excluded from the generic sweep and covered by a dedicated test:
## actions
Tools with a 2xx (52):
Tools with a 2xx (65):
`gitea_actions_cancel_workflow_run`, `gitea_actions_create_org_variable`, `gitea_actions_create_repo_variable`, `gitea_actions_create_user_variable`, `gitea_actions_delete_org_secret`, `gitea_actions_delete_org_variable`, `gitea_actions_delete_repo_secret`, `gitea_actions_delete_repo_variable`, `gitea_actions_delete_user_secret`, `gitea_actions_delete_user_variable`, `gitea_actions_disable_workflow`, `gitea_actions_dispatch_workflow`, `gitea_actions_download_run_job_logs`, `gitea_actions_enable_workflow`, `gitea_actions_get_artifacts`, `gitea_actions_get_artifacts_of_run`, `gitea_actions_get_org_runners`, `gitea_actions_get_org_variable`, `gitea_actions_get_org_variables_list`, `gitea_actions_get_org_workflow_jobs`, `gitea_actions_get_org_workflow_runs`, `gitea_actions_get_repo_runners`, `gitea_actions_get_repo_variable`, `gitea_actions_get_repo_variables_list`, `gitea_actions_get_user_runners`, `gitea_actions_get_user_variable`, `gitea_actions_get_user_variables_list`, `gitea_actions_get_user_workflow_jobs`, `gitea_actions_get_user_workflow_runs`, `gitea_actions_get_workflow`, `gitea_actions_get_workflow_job`, `gitea_actions_get_workflow_run`, `gitea_actions_get_workflow_run_attempt`, `gitea_actions_get_workflow_runs`, `gitea_actions_list_action_tasks`, `gitea_actions_list_repository_workflows`, `gitea_actions_list_workflow_jobs`, `gitea_actions_list_workflow_run_attempt_jobs`, `gitea_actions_list_workflow_run_jobs`, `gitea_actions_list_workflow_runs`, `gitea_actions_org_create_runner_registration_token`, `gitea_actions_org_list_secrets`, `gitea_actions_repo_create_runner_registration_token`, `gitea_actions_repo_list_secrets`, `gitea_actions_rerun_failed_workflow_run`, `gitea_actions_update_org_secret`, `gitea_actions_update_org_variable`, `gitea_actions_update_repo_secret`, `gitea_actions_update_repo_variable`, `gitea_actions_update_user_secret`, `gitea_actions_update_user_variable`, `gitea_actions_user_create_runner_registration_token`
`gitea_actions_cancel_workflow_run`, `gitea_actions_create_org_variable`, `gitea_actions_create_repo_variable`, `gitea_actions_create_user_variable`, `gitea_actions_delete_action_run`, `gitea_actions_delete_org_runner`, `gitea_actions_delete_org_secret`, `gitea_actions_delete_org_variable`, `gitea_actions_delete_repo_runner`, `gitea_actions_delete_repo_secret`, `gitea_actions_delete_repo_variable`, `gitea_actions_delete_user_runner`, `gitea_actions_delete_user_secret`, `gitea_actions_delete_user_variable`, `gitea_actions_disable_workflow`, `gitea_actions_dispatch_workflow`, `gitea_actions_download_run_job_logs`, `gitea_actions_enable_workflow`, `gitea_actions_get_artifacts`, `gitea_actions_get_artifacts_of_run`, `gitea_actions_get_org_runner`, `gitea_actions_get_org_runners`, `gitea_actions_get_org_variable`, `gitea_actions_get_org_variables_list`, `gitea_actions_get_org_workflow_jobs`, `gitea_actions_get_org_workflow_runs`, `gitea_actions_get_repo_runner`, `gitea_actions_get_repo_runners`, `gitea_actions_get_repo_variable`, `gitea_actions_get_repo_variables_list`, `gitea_actions_get_user_runner`, `gitea_actions_get_user_runners`, `gitea_actions_get_user_variable`, `gitea_actions_get_user_variables_list`, `gitea_actions_get_user_workflow_jobs`, `gitea_actions_get_user_workflow_runs`, `gitea_actions_get_workflow`, `gitea_actions_get_workflow_job`, `gitea_actions_get_workflow_run`, `gitea_actions_get_workflow_run_attempt`, `gitea_actions_get_workflow_run_logs`, `gitea_actions_get_workflow_runs`, `gitea_actions_list_action_tasks`, `gitea_actions_list_repository_workflows`, `gitea_actions_list_workflow_jobs`, `gitea_actions_list_workflow_run_attempt_jobs`, `gitea_actions_list_workflow_run_jobs`, `gitea_actions_list_workflow_runs`, `gitea_actions_org_create_runner_registration_token`, `gitea_actions_org_list_secrets`, `gitea_actions_repo_create_runner_registration_token`, `gitea_actions_repo_list_secrets`, `gitea_actions_rerun_failed_workflow_run`, `gitea_actions_rerun_workflow_job`, `gitea_actions_rerun_workflow_run`, `gitea_actions_update_org_runner`, `gitea_actions_update_org_secret`, `gitea_actions_update_org_variable`, `gitea_actions_update_repo_runner`, `gitea_actions_update_repo_secret`, `gitea_actions_update_repo_variable`, `gitea_actions_update_user_runner`, `gitea_actions_update_user_secret`, `gitea_actions_update_user_variable`, `gitea_actions_user_create_runner_registration_token`
Tools with a documented non-2xx status (18):
Tools with a documented non-2xx status (5):
| Tool | Status |
| --- | --- |
| `gitea_actions_approve_workflow_run` | 409 |
| `gitea_actions_delete_action_run` | 400 |
| `gitea_actions_delete_artifact` | 404 |
| `gitea_actions_delete_org_runner` | 404 |
| `gitea_actions_delete_repo_runner` | 404 |
| `gitea_actions_delete_user_runner` | 404 |
| `gitea_actions_download_artifact` | 404 |
| `gitea_actions_force_cancel_workflow_run` | 409 |
| `gitea_actions_get_artifact` | 404 |
| `gitea_actions_get_org_runner` | 404 |
| `gitea_actions_get_repo_runner` | 404 |
| `gitea_actions_get_user_runner` | 404 |
| `gitea_actions_get_workflow_run_logs` | 404 |
| `gitea_actions_rerun_workflow_job` | 404 |
| `gitea_actions_rerun_workflow_run` | 400 |
| `gitea_actions_update_org_runner` | 404 |
| `gitea_actions_update_repo_runner` | 404 |
| `gitea_actions_update_user_runner` | 404 |
Allowances (0):
@@ -65,18 +52,15 @@ none
## admin
Tools with a 2xx (27):
Tools with a 2xx (30):
`gitea_admin_create_hook`, `gitea_admin_create_org`, `gitea_admin_create_public_key`, `gitea_admin_create_repo`, `gitea_admin_create_runner_registration_token`, `gitea_admin_create_user`, `gitea_admin_cron_list`, `gitea_admin_cron_run`, `gitea_admin_delete_hook`, `gitea_admin_delete_user`, `gitea_admin_delete_user_badges`, `gitea_admin_delete_user_public_key`, `gitea_admin_edit_hook`, `gitea_admin_edit_user`, `gitea_admin_get_all_emails`, `gitea_admin_get_all_orgs`, `gitea_admin_get_hook`, `gitea_admin_get_runners`, `gitea_admin_list_hooks`, `gitea_admin_list_packages`, `gitea_admin_list_user_badges`, `gitea_admin_list_workflow_jobs`, `gitea_admin_list_workflow_runs`, `gitea_admin_rename_user`, `gitea_admin_search_emails`, `gitea_admin_search_users`, `gitea_admin_unadopted_list`
`gitea_admin_create_hook`, `gitea_admin_create_org`, `gitea_admin_create_public_key`, `gitea_admin_create_repo`, `gitea_admin_create_runner_registration_token`, `gitea_admin_create_user`, `gitea_admin_cron_list`, `gitea_admin_cron_run`, `gitea_admin_delete_hook`, `gitea_admin_delete_runner`, `gitea_admin_delete_user`, `gitea_admin_delete_user_badges`, `gitea_admin_delete_user_public_key`, `gitea_admin_edit_hook`, `gitea_admin_edit_user`, `gitea_admin_get_all_emails`, `gitea_admin_get_all_orgs`, `gitea_admin_get_hook`, `gitea_admin_get_runner`, `gitea_admin_get_runners`, `gitea_admin_list_hooks`, `gitea_admin_list_packages`, `gitea_admin_list_user_badges`, `gitea_admin_list_workflow_jobs`, `gitea_admin_list_workflow_runs`, `gitea_admin_rename_user`, `gitea_admin_search_emails`, `gitea_admin_search_users`, `gitea_admin_unadopted_list`, `gitea_admin_update_runner`
Tools with a documented non-2xx status (4):
Tools with a documented non-2xx status (1):
| Tool | Status |
| --- | --- |
| `gitea_admin_adopt_repository` | 404 |
| `gitea_admin_delete_runner` | 404 |
| `gitea_admin_get_runner` | 404 |
| `gitea_admin_update_runner` | 404 |
Allowances (2):
@@ -174,15 +158,13 @@ none
## pull
Tools with a 2xx (25):
Tools with a 2xx (26):
`gitea_pull_create_pull_request`, `gitea_pull_create_pull_review`, `gitea_pull_create_pull_review_comment_reply`, `gitea_pull_create_pull_review_requests`, `gitea_pull_delete_pull_review`, `gitea_pull_delete_pull_review_requests`, `gitea_pull_dismiss_pull_review`, `gitea_pull_download_pull_diff_or_patch`, `gitea_pull_edit_pull_request`, `gitea_pull_get_pull_request`, `gitea_pull_get_pull_request_by_base_head`, `gitea_pull_get_pull_request_commits`, `gitea_pull_get_pull_request_files`, `gitea_pull_get_pull_review`, `gitea_pull_get_pull_review_comments`, `gitea_pull_list_pinned_pull_requests`, `gitea_pull_list_pull_requests`, `gitea_pull_list_pull_reviews`, `gitea_pull_merge_pull_request`, `gitea_pull_pull_request_is_merged`, `gitea_pull_resolve_pull_review_comment`, `gitea_pull_submit_pull_review`, `gitea_pull_undismiss_pull_review`, `gitea_pull_unresolve_pull_review_comment`, `gitea_pull_update_pull_request`
`gitea_pull_cancel_scheduled_auto_merge`, `gitea_pull_create_pull_request`, `gitea_pull_create_pull_review`, `gitea_pull_create_pull_review_comment_reply`, `gitea_pull_create_pull_review_requests`, `gitea_pull_delete_pull_review`, `gitea_pull_delete_pull_review_requests`, `gitea_pull_dismiss_pull_review`, `gitea_pull_download_pull_diff_or_patch`, `gitea_pull_edit_pull_request`, `gitea_pull_get_pull_request`, `gitea_pull_get_pull_request_by_base_head`, `gitea_pull_get_pull_request_commits`, `gitea_pull_get_pull_request_files`, `gitea_pull_get_pull_review`, `gitea_pull_get_pull_review_comments`, `gitea_pull_list_pinned_pull_requests`, `gitea_pull_list_pull_requests`, `gitea_pull_list_pull_reviews`, `gitea_pull_merge_pull_request`, `gitea_pull_pull_request_is_merged`, `gitea_pull_resolve_pull_review_comment`, `gitea_pull_submit_pull_review`, `gitea_pull_undismiss_pull_review`, `gitea_pull_unresolve_pull_review_comment`, `gitea_pull_update_pull_request`
Tools with a documented non-2xx status (1):
Tools with a documented non-2xx status (0):
| Tool | Status |
| --- | --- |
| `gitea_pull_cancel_scheduled_auto_merge` | 404 |
none
Allowances (0):
@@ -204,18 +186,15 @@ none
## repo
Tools with a 2xx (118):
Tools with a 2xx (121):
`gitea_repo_add_collaborator`, `gitea_repo_add_issue_to_project_column`, `gitea_repo_add_push_mirror`, `gitea_repo_add_team`, `gitea_repo_add_topic`, `gitea_repo_apply_diff_patch`, `gitea_repo_change_files`, `gitea_repo_check_assignee`, `gitea_repo_check_collaborator`, `gitea_repo_check_subscription`, `gitea_repo_check_team`, `gitea_repo_compare_diff`, `gitea_repo_create_branch`, `gitea_repo_create_branch_protection`, `gitea_repo_create_deploy_token`, `gitea_repo_create_file`, `gitea_repo_create_fork`, `gitea_repo_create_key`, `gitea_repo_create_project`, `gitea_repo_create_project_column`, `gitea_repo_create_status`, `gitea_repo_create_tag`, `gitea_repo_create_tag_protection`, `gitea_repo_delete`, `gitea_repo_delete_avatar`, `gitea_repo_delete_branch`, `gitea_repo_delete_branch_protection`, `gitea_repo_delete_collaborator`, `gitea_repo_delete_file`, `gitea_repo_delete_key`, `gitea_repo_delete_project`, `gitea_repo_delete_project_column`, `gitea_repo_delete_push_mirror`, `gitea_repo_delete_subscription`, `gitea_repo_delete_tag`, `gitea_repo_delete_tag_protection`, `gitea_repo_delete_team`, `gitea_repo_delete_topic`, `gitea_repo_download_commit_diff_or_patch`, `gitea_repo_edit`, `gitea_repo_edit_branch_protection`, `gitea_repo_edit_project`, `gitea_repo_edit_project_column`, `gitea_repo_edit_tag_protection`, `gitea_repo_generate_repo`, `gitea_repo_get`, `gitea_repo_get_all_commits`, `gitea_repo_get_annotated_tag`, `gitea_repo_get_archive`, `gitea_repo_get_assignees`, `gitea_repo_get_blob`, `gitea_repo_get_branch`, `gitea_repo_get_branch_protection`, `gitea_repo_get_by_id`, `gitea_repo_get_combined_status_by_ref`, `gitea_repo_get_contents`, `gitea_repo_get_contents_ext`, `gitea_repo_get_contents_list`, `gitea_repo_get_editor_config`, `gitea_repo_get_file_contents`, `gitea_repo_get_file_contents_post`, `gitea_repo_get_issue_config`, `gitea_repo_get_issue_templates`, `gitea_repo_get_key`, `gitea_repo_get_languages`, `gitea_repo_get_licenses`, `gitea_repo_get_project`, `gitea_repo_get_project_column`, `gitea_repo_get_push_mirror_by_remote_name`, `gitea_repo_get_raw_file`, `gitea_repo_get_raw_file_or_lfs`, `gitea_repo_get_repo_permissions`, `gitea_repo_get_reviewers`, `gitea_repo_get_single_commit`, `gitea_repo_get_tag`, `gitea_repo_get_tag_protection`, `gitea_repo_get_tree`, `gitea_repo_list_activity_feeds`, `gitea_repo_list_all_git_refs`, `gitea_repo_list_branch_protection`, `gitea_repo_list_branches`, `gitea_repo_list_collaborators`, `gitea_repo_list_forks`, `gitea_repo_list_git_refs`, `gitea_repo_list_keys`, `gitea_repo_list_project_column_issues`, `gitea_repo_list_project_columns`, `gitea_repo_list_projects`, `gitea_repo_list_push_mirrors`, `gitea_repo_list_stargazers`, `gitea_repo_list_statuses`, `gitea_repo_list_statuses_by_ref`, `gitea_repo_list_subscribers`, `gitea_repo_list_tag_protection`, `gitea_repo_list_tags`, `gitea_repo_list_teams`, `gitea_repo_list_topics`, `gitea_repo_list_user_tracked_times`, `gitea_repo_merge_upstream`, `gitea_repo_migrate`, `gitea_repo_mirror_sync`, `gitea_repo_move_project_columns`, `gitea_repo_move_project_issue`, `gitea_repo_new_pin_allowed`, `gitea_repo_push_mirror_sync`, `gitea_repo_put_subscription`, `gitea_repo_remove_issue_from_project_column`, `gitea_repo_rename_branch`, `gitea_repo_search`, `gitea_repo_set_default_project_column`, `gitea_repo_tracked_times`, `gitea_repo_transfer`, `gitea_repo_update_avatar`, `gitea_repo_update_branch`, `gitea_repo_update_branch_protection_priorities`, `gitea_repo_update_file`, `gitea_repo_update_topics`, `gitea_repo_validate_issue_config`
`gitea_repo_accept_repo_transfer`, `gitea_repo_add_collaborator`, `gitea_repo_add_issue_to_project_column`, `gitea_repo_add_push_mirror`, `gitea_repo_add_team`, `gitea_repo_add_topic`, `gitea_repo_apply_diff_patch`, `gitea_repo_change_files`, `gitea_repo_check_assignee`, `gitea_repo_check_collaborator`, `gitea_repo_check_subscription`, `gitea_repo_check_team`, `gitea_repo_compare_diff`, `gitea_repo_create_branch`, `gitea_repo_create_branch_protection`, `gitea_repo_create_deploy_token`, `gitea_repo_create_file`, `gitea_repo_create_fork`, `gitea_repo_create_key`, `gitea_repo_create_project`, `gitea_repo_create_project_column`, `gitea_repo_create_status`, `gitea_repo_create_tag`, `gitea_repo_create_tag_protection`, `gitea_repo_delete`, `gitea_repo_delete_avatar`, `gitea_repo_delete_branch`, `gitea_repo_delete_branch_protection`, `gitea_repo_delete_collaborator`, `gitea_repo_delete_file`, `gitea_repo_delete_key`, `gitea_repo_delete_project`, `gitea_repo_delete_project_column`, `gitea_repo_delete_push_mirror`, `gitea_repo_delete_subscription`, `gitea_repo_delete_tag`, `gitea_repo_delete_tag_protection`, `gitea_repo_delete_team`, `gitea_repo_delete_topic`, `gitea_repo_download_commit_diff_or_patch`, `gitea_repo_edit`, `gitea_repo_edit_branch_protection`, `gitea_repo_edit_project`, `gitea_repo_edit_project_column`, `gitea_repo_edit_tag_protection`, `gitea_repo_generate_repo`, `gitea_repo_get`, `gitea_repo_get_all_commits`, `gitea_repo_get_annotated_tag`, `gitea_repo_get_archive`, `gitea_repo_get_assignees`, `gitea_repo_get_blob`, `gitea_repo_get_branch`, `gitea_repo_get_branch_protection`, `gitea_repo_get_by_id`, `gitea_repo_get_combined_status_by_ref`, `gitea_repo_get_commit_pull_request`, `gitea_repo_get_contents`, `gitea_repo_get_contents_ext`, `gitea_repo_get_contents_list`, `gitea_repo_get_editor_config`, `gitea_repo_get_file_contents`, `gitea_repo_get_file_contents_post`, `gitea_repo_get_issue_config`, `gitea_repo_get_issue_templates`, `gitea_repo_get_key`, `gitea_repo_get_languages`, `gitea_repo_get_licenses`, `gitea_repo_get_project`, `gitea_repo_get_project_column`, `gitea_repo_get_push_mirror_by_remote_name`, `gitea_repo_get_raw_file`, `gitea_repo_get_raw_file_or_lfs`, `gitea_repo_get_repo_permissions`, `gitea_repo_get_reviewers`, `gitea_repo_get_single_commit`, `gitea_repo_get_tag`, `gitea_repo_get_tag_protection`, `gitea_repo_get_tree`, `gitea_repo_list_activity_feeds`, `gitea_repo_list_all_git_refs`, `gitea_repo_list_branch_protection`, `gitea_repo_list_branches`, `gitea_repo_list_collaborators`, `gitea_repo_list_forks`, `gitea_repo_list_git_refs`, `gitea_repo_list_keys`, `gitea_repo_list_project_column_issues`, `gitea_repo_list_project_columns`, `gitea_repo_list_projects`, `gitea_repo_list_push_mirrors`, `gitea_repo_list_stargazers`, `gitea_repo_list_statuses`, `gitea_repo_list_statuses_by_ref`, `gitea_repo_list_subscribers`, `gitea_repo_list_tag_protection`, `gitea_repo_list_tags`, `gitea_repo_list_teams`, `gitea_repo_list_topics`, `gitea_repo_list_user_tracked_times`, `gitea_repo_merge_upstream`, `gitea_repo_migrate`, `gitea_repo_mirror_sync`, `gitea_repo_move_project_columns`, `gitea_repo_move_project_issue`, `gitea_repo_new_pin_allowed`, `gitea_repo_push_mirror_sync`, `gitea_repo_put_subscription`, `gitea_repo_reject_repo_transfer`, `gitea_repo_remove_issue_from_project_column`, `gitea_repo_rename_branch`, `gitea_repo_search`, `gitea_repo_set_default_project_column`, `gitea_repo_tracked_times`, `gitea_repo_transfer`, `gitea_repo_update_avatar`, `gitea_repo_update_branch`, `gitea_repo_update_branch_protection_priorities`, `gitea_repo_update_file`, `gitea_repo_update_topics`, `gitea_repo_validate_issue_config`
Tools with a documented non-2xx status (4):
Tools with a documented non-2xx status (1):
| Tool | Status |
| --- | --- |
| `gitea_repo_accept_repo_transfer` | 404 |
| `gitea_repo_get_commit_pull_request` | 404 |
| `gitea_repo_get_note` | 404 |
| `gitea_repo_reject_repo_transfer` | 404 |
Allowances (2):
@@ -240,16 +219,14 @@ none
## user
Tools with a 2xx (71):
Tools with a 2xx (73):
`gitea_user_add_email`, `gitea_user_block_user`, `gitea_user_check_following`, `gitea_user_check_user_block`, `gitea_user_create_oauth2_application`, `gitea_user_current_add_issue_to_project_column`, `gitea_user_current_check_following`, `gitea_user_current_check_starring`, `gitea_user_current_create_project`, `gitea_user_current_create_project_column`, `gitea_user_current_create_repo`, `gitea_user_current_delete_follow`, `gitea_user_current_delete_gpg_key`, `gitea_user_current_delete_key`, `gitea_user_current_delete_project`, `gitea_user_current_delete_project_column`, `gitea_user_current_delete_star`, `gitea_user_current_edit_project`, `gitea_user_current_edit_project_column`, `gitea_user_current_get_key`, `gitea_user_current_get_project`, `gitea_user_current_get_project_column`, `gitea_user_current_list_followers`, `gitea_user_current_list_following`, `gitea_user_current_list_gpg_keys`, `gitea_user_current_list_keys`, `gitea_user_current_list_orgs`, `gitea_user_current_list_project_column_issues`, `gitea_user_current_list_project_columns`, `gitea_user_current_list_projects`, `gitea_user_current_list_repos`, `gitea_user_current_list_starred`, `gitea_user_current_list_subscriptions`, `gitea_user_current_move_project_columns`, `gitea_user_current_move_project_issue`, `gitea_user_current_post_key`, `gitea_user_current_put_follow`, `gitea_user_current_put_star`, `gitea_user_current_remove_issue_from_project_column`, `gitea_user_current_set_default_project_column`, `gitea_user_current_tracked_times`, `gitea_user_delete_avatar`, `gitea_user_delete_email`, `gitea_user_delete_oauth2_application`, `gitea_user_get`, `gitea_user_get_current`, `gitea_user_get_heatmap_data`, `gitea_user_get_oauth2_application`, `gitea_user_get_org_permissions`, `gitea_user_get_settings`, `gitea_user_get_stopwatches`, `gitea_user_get_verification_token`, `gitea_user_list_activity_feeds`, `gitea_user_list_blocks`, `gitea_user_list_emails`, `gitea_user_list_followers`, `gitea_user_list_following`, `gitea_user_list_gpg_keys`, `gitea_user_list_keys`, `gitea_user_list_oauth2_applications`, `gitea_user_list_orgs`, `gitea_user_list_projects`, `gitea_user_list_repos`, `gitea_user_list_starred`, `gitea_user_list_subscriptions`, `gitea_user_list_teams`, `gitea_user_search`, `gitea_user_unblock_user`, `gitea_user_update_avatar`, `gitea_user_update_oauth2_application`, `gitea_user_update_settings`
`gitea_user_add_email`, `gitea_user_block_user`, `gitea_user_check_following`, `gitea_user_check_user_block`, `gitea_user_create_oauth2_application`, `gitea_user_current_add_issue_to_project_column`, `gitea_user_current_check_following`, `gitea_user_current_check_starring`, `gitea_user_current_create_project`, `gitea_user_current_create_project_column`, `gitea_user_current_create_repo`, `gitea_user_current_delete_follow`, `gitea_user_current_delete_gpg_key`, `gitea_user_current_delete_key`, `gitea_user_current_delete_project`, `gitea_user_current_delete_project_column`, `gitea_user_current_delete_star`, `gitea_user_current_edit_project`, `gitea_user_current_edit_project_column`, `gitea_user_current_get_gpg_key`, `gitea_user_current_get_key`, `gitea_user_current_get_project`, `gitea_user_current_get_project_column`, `gitea_user_current_list_followers`, `gitea_user_current_list_following`, `gitea_user_current_list_gpg_keys`, `gitea_user_current_list_keys`, `gitea_user_current_list_orgs`, `gitea_user_current_list_project_column_issues`, `gitea_user_current_list_project_columns`, `gitea_user_current_list_projects`, `gitea_user_current_list_repos`, `gitea_user_current_list_starred`, `gitea_user_current_list_subscriptions`, `gitea_user_current_move_project_columns`, `gitea_user_current_move_project_issue`, `gitea_user_current_post_gpg_key`, `gitea_user_current_post_key`, `gitea_user_current_put_follow`, `gitea_user_current_put_star`, `gitea_user_current_remove_issue_from_project_column`, `gitea_user_current_set_default_project_column`, `gitea_user_current_tracked_times`, `gitea_user_delete_avatar`, `gitea_user_delete_email`, `gitea_user_delete_oauth2_application`, `gitea_user_get`, `gitea_user_get_current`, `gitea_user_get_heatmap_data`, `gitea_user_get_oauth2_application`, `gitea_user_get_org_permissions`, `gitea_user_get_settings`, `gitea_user_get_stopwatches`, `gitea_user_get_verification_token`, `gitea_user_list_activity_feeds`, `gitea_user_list_blocks`, `gitea_user_list_emails`, `gitea_user_list_followers`, `gitea_user_list_following`, `gitea_user_list_gpg_keys`, `gitea_user_list_keys`, `gitea_user_list_oauth2_applications`, `gitea_user_list_orgs`, `gitea_user_list_projects`, `gitea_user_list_repos`, `gitea_user_list_starred`, `gitea_user_list_subscriptions`, `gitea_user_list_teams`, `gitea_user_search`, `gitea_user_unblock_user`, `gitea_user_update_avatar`, `gitea_user_update_oauth2_application`, `gitea_user_update_settings`
Tools with a documented non-2xx status (3):
Tools with a documented non-2xx status (1):
| Tool | Status |
| --- | --- |
| `gitea_user_current_get_gpg_key` | 404 |
| `gitea_user_current_post_gpg_key` | 422 |
| `gitea_user_verify_gpg_key` | 422 |
Allowances (3):
+30 -12
View File
@@ -44,28 +44,46 @@ Dependencies are pinned in `uv.lock`; `uv sync` reproduces the environment.
## Install from the Gitea registry
Index URL: `https://git.hiddenden.cafe/api/packages/Hiddenden/pypi/simple/`
Stable:
```bash
uv pip install \
--extra-index-url https://git.hiddenden.cafe/api/packages/Hiddenden/pypi/simple/ \
aegis-gitea-mcp
# or run it without installing into an environment:
# run it without installing into an environment (recommended):
uvx --index https://git.hiddenden.cafe/api/packages/Hiddenden/pypi/simple/ aegis-gitea-mcp
# or install it into an environment:
uv pip install --index https://git.hiddenden.cafe/api/packages/Hiddenden/pypi/simple/ aegis-gitea-mcp
```
Dev (newest pre-release build):
Dev (newest pre-release build, package `aegis-gitea-mcp-dev`):
```bash
uv pip install --pre \
--extra-index-url https://git.hiddenden.cafe/api/packages/Hiddenden/pypi/simple/ \
aegis-gitea-mcp-dev
uvx --index https://git.hiddenden.cafe/api/packages/Hiddenden/pypi/simple/ --prerelease allow aegis-gitea-mcp-dev
```
The registry holds only this project's packages and does not proxy PyPI, so it is added as an
extra index and the dependencies (`mcp`, `httpx`, ...) still come from PyPI. With `pip` use
`--extra-index-url` the same way.
The registry holds only this project's packages and does not proxy PyPI, so it is added with
`--index` and the dependencies (`mcp`, `httpx`, ...) still come from PyPI. uv's default index
strategy (`first-index`) takes a package from the first index that has it and never mixes
versions across indexes, so with `--index` the name `aegis-gitea-mcp` is always taken from
this registry, even if somebody registers it on PyPI.
Do not use `--extra-index-url` with `pip`. It merges the indexes, and `pip` picks the highest
version from either: whoever publishes `aegis-gitea-mcp` on PyPI with a higher version number
wins (dependency confusion), and the package would run with your Gitea token in its
environment. With `pip`, fetch the wheel from this registry alone, then install it:
```bash
pip download --no-deps --index-url https://git.hiddenden.cafe/api/packages/Hiddenden/pypi/simple/ aegis-gitea-mcp -d wheels/
pip install wheels/aegis_gitea_mcp-*.whl
```
The name is not registered on public PyPI. Publishing there is intentionally not configured, but
the owner should reserve both names (`aegis-gitea-mcp`, `aegis-gitea-mcp-dev`) on PyPI so nobody
else can take them. Until then every install command above must name the index.
To run from a clone instead of a registry:
`uv run --directory /path/to/AegisGitea-MCP aegis-gitea-mcp serve`.
## Publishing
+2 -2
View File
@@ -26,12 +26,12 @@ because credentials belong in the identity, not in the URL.
## Behaviour
- It goes through the same executor as every generated tool: the identity's token, no redirects,
response limits, redaction and secret masking, the result envelope and one audit event.
response limits, redaction and secret masking, the result envelope and the same audit entries (`tool_call_started`, then `tool_invocation`).
- There is no allow list of paths and no write classification: with full access, the token's
scopes and the identity's Gitea permissions are the limit.
- `READ_ONLY=true` keeps the tool listed but it accepts only `GET`.
- `TOOLSETS` without `misc`, or `DISABLED_TOOLS=gitea_api_request`, removes it.
- The audit event records the method and the path, so it is visible which endpoint was called.
- The audit entry records the method and the path, so it is visible which endpoint was called.
## Example
+5 -3
View File
@@ -1,7 +1,7 @@
# Troubleshooting
Start with the result of the failing call: errors carry `error.status`, `error.kind` and
Gitea's sanitized message, and the audit log has one event per call (see
Gitea's sanitized message, and the audit log has a result entry (`tool_invocation`) per call, preceded by a `tool_call_started` entry (see
[audit-logging.md](audit-logging.md)).
## The server does not start
@@ -22,8 +22,10 @@ Gitea's sanitized message, and the audit log has one event per call (see
stdout carries only JSON-RPC. If a wrapper script prints to stdout before the server starts, the
client fails to parse the stream. Logs go to stderr; look at the client's MCP server log.
`uvx` needs access to the package index that hosts `aegis-gitea-mcp`, see
[packaging.md](packaging.md).
`uvx` needs access to the package index that hosts `aegis-gitea-mcp`: pass
`--index https://git.hiddenden.cafe/api/packages/Hiddenden/pypi/simple/`, see
[packaging.md](packaging.md). If you register the server with `claude mcp add`, put `--env`
before `--transport stdio` and the server name, as `setup` prints it.
## Tool calls fail
+7 -5
View File
@@ -10,10 +10,12 @@
# network only), plus the containers still attached to those networks;
# * containers started from the per-run image refs given as arguments, and
# those refs. Per-run refs are names containing "aegis-ci-" (named after the
# run id) and immutable ":sha-<commit>" tags;
# * for any other ref (shared tags such as :latest, :main, :dev, which a
# deployment on the same daemon may use) only the tag is removed. Containers
# are never touched, and an image still used by a container simply stays.
# run id), such as the local build tag aegis-ci-<run id>-build:<sha>;
# * for any other ref (registry tags such as :sha-<12>, :latest, :main, :dev,
# which a deployment on the same daemon may use) only the tag is removed.
# Containers are never touched, and an image still used by a container simply
# stays. Pass registry tags before the per-run ref: the image itself goes away
# with its last tag.
# Everything is best effort (a failing removal never fails the job).
set -uo pipefail
@@ -46,7 +48,7 @@ fi
is_per_run_ref() {
case "$1" in
*aegis-ci-* | *:sha-*) return 0 ;;
*aegis-ci-*) return 0 ;;
*) return 1 ;;
esac
}
+14 -5
View File
@@ -19,14 +19,22 @@ TOKEN_PLACEHOLDER = "<your-gitea-token>" # noqa: S105
BEARER_PLACEHOLDER = "<MCP_BEARER_TOKEN>"
#: Loopback base URL used when no public base URL is configured.
LOCAL_BASE_URL = "http://127.0.0.1:8080"
#: The project's own package index. The package name is not registered on public PyPI, so
#: ``uvx`` must always be pointed at this index (otherwise a squatted PyPI package would
#: receive the Gitea token from the environment).
PACKAGE_INDEX = "https://git.hiddenden.cafe/api/packages/Hiddenden/pypi/simple/"
PACKAGE_NAME = "aegis-gitea-mcp"
def claude_code_stdio(gitea_url: str) -> str:
"""Return the ``claude mcp add`` command for the local stdio server."""
"""Return the ``claude mcp add`` command for the local stdio server.
``--env`` takes several values, so the transport option must follow it: otherwise the
server name is swallowed as one more environment entry.
"""
return (
"claude mcp add --transport stdio "
f"--env GITEA_URL={gitea_url} --env GITEA_TOKEN={TOKEN_PLACEHOLDER} "
f"{SERVER_NAME} -- uvx aegis-gitea-mcp"
f"claude mcp add --env GITEA_URL={gitea_url} --env GITEA_TOKEN={TOKEN_PLACEHOLDER} "
f"--transport stdio {SERVER_NAME} -- uvx --index {PACKAGE_INDEX} {PACKAGE_NAME}"
)
@@ -36,7 +44,7 @@ def mcp_servers_json(gitea_url: str) -> str:
"mcpServers": {
SERVER_NAME: {
"command": "uvx",
"args": ["aegis-gitea-mcp"],
"args": ["--index", PACKAGE_INDEX, PACKAGE_NAME],
"env": {"GITEA_URL": gitea_url, "GITEA_TOKEN": TOKEN_PLACEHOLDER},
}
}
@@ -88,6 +96,7 @@ def le_chat_connector(base_url: str, mcp_path: str = "/mcp", *, oauth: bool) ->
" or OAuth 2.1 with dynamic client registration (users sign in with their "
"own Gitea account)."
)
lines.append(" Adding connectors in Le Chat may require workspace admin rights.")
return "\n".join(lines)
+17 -2
View File
@@ -67,7 +67,14 @@ log and the executor are all exercised. State lives in `tmp_path`.
Order: read-only first, then creates, updates and deletes (leaf resources first). A call
passes with a 2xx, a status the swagger documents for that operation, or an entry of
`ALLOWANCES` (each with a reason). All failing tools are listed at once.
`test_sweep_isolated.py` covers the tools the sweep must not call.
`sweep_baseline.json` pins the outcome class of every tool (`2xx`, or the exact status of
a documented non-2xx or allowance). A downgrade (2xx to anything else, or a different
status) fails the run and all of them are reported together, so an argument or path
regression cannot hide behind a documented 404. An upgrade only prints a note to refresh
the file. Regenerate it with `AEGIS_IT_WRITE_BASELINE=1` (only for an intended change,
and review the diff). `test_sweep_isolated.py` covers the tools the sweep must not call.
- `test_failure_paths.py`: write tools Gitea refuses (missing repository, invalid color,
duplicate repository, stale file sha) keep their status and error kind and are audited.
- `test_per_user_oauth.py`: the HTTP app with per-user OAuth, driven in process with a
seeded OAuth store; calls run as the signed-in user, admin tools and private repositories
of others are refused by Gitea, the static bearer still runs as admin.
@@ -85,6 +92,14 @@ network, registered with a token from `gitea_actions_repo_create_runner_registra
label `aegis-host:host`. Jobs run inside the runner container itself: no Docker socket, no
bind mounts. The runner reaches Gitea by container name, so it works in both network modes.
It is removed with the rest of the run. If it cannot start or come online, the test is
marked xfail with the reason; a failure after the run completed is a real failure.
marked xfail with the reason, unless `CI` or `AEGIS_IT_REQUIRE_RUNNER=1` is set (the CI job
sets it), in which case it fails: a pipeline must not silently skip the Actions scenario. A
failure after the run completed is always a real failure.
With the runner the scenario also exercises the completed run: the runner get, update and
delete tools at repository scope, the logs archive, rerun of the run and of one job, and
deleting the run. `test_runner_management_at_every_scope` registers one more runner for the
organization, the user and the instance (each serving its own label) and manages it through
the matching tools.
Runtime: about two minutes locally (Gitea start 15 s, sweep 40 s, runner 10 s).
+19
View File
@@ -35,6 +35,25 @@ _HERE = Path(__file__).parent.resolve()
ENABLE_ENV = "AEGIS_INTEGRATION"
REQUIRE_RUNNER_ENV = "AEGIS_IT_REQUIRE_RUNNER"
def runner_required() -> bool:
"""True when a runner that cannot start must fail the test instead of xfailing it.
Set by the CI job (``AEGIS_IT_REQUIRE_RUNNER=1``) and implied by ``CI``: a pipeline that
silently skips the Actions scenario would hide a broken runner setup.
"""
return os.environ.get(REQUIRE_RUNNER_ENV) == "1" or bool(os.environ.get("CI"))
def runner_unavailable(reason: str) -> None:
"""Report that the act_runner could not start or come online (fails or xfails)."""
if runner_required():
pytest.fail(f"act_runner unavailable: {reason}", pytrace=False)
pytest.xfail(reason)
def integration_enabled() -> bool:
"""Return True when the opt-in env var is set."""
return os.environ.get(ENABLE_ENV) == "1"
+13 -3
View File
@@ -561,9 +561,19 @@ def stop_gitea(instance: GiteaInstance) -> None:
cleanup_resources(res)
def start_runner(instance: GiteaInstance, registration_token: str) -> str:
def start_runner(
instance: GiteaInstance,
registration_token: str,
*,
suffix: str = "runner",
label: str = RUNNER_LABEL,
) -> str:
"""Start an ``act_runner`` container registered with ``registration_token``.
``suffix`` names the container (``aegis-it-<run id>-<suffix>``, also the runner's name in
Gitea) and ``label`` is the single label it serves, so extra runners at other scopes
never pick up the jobs of the Actions scenario.
The runner joins the run's private network, reaches Gitea by its container name
(so it works in local and in container network mode) and runs jobs labelled
``aegis-host`` in host mode inside its own container. The token travels in the
@@ -575,7 +585,7 @@ def start_runner(instance: GiteaInstance, registration_token: str) -> str:
if res is None:
raise HarnessError("the Gitea instance is not tracked (already stopped?)")
ensure_image(ACT_RUNNER_IMAGE)
name = f"{NAME_PREFIX}{instance.run_id}-runner"
name = f"{NAME_PREFIX}{instance.run_id}-{suffix}"
res.extra_containers.append(name)
run_docker(
"run",
@@ -593,7 +603,7 @@ def start_runner(instance: GiteaInstance, registration_token: str) -> str:
"-e",
f"GITEA_RUNNER_NAME={name}",
"-e",
f"GITEA_RUNNER_LABELS={RUNNER_LABEL}:host",
f"GITEA_RUNNER_LABELS={label}:host",
ACT_RUNNER_IMAGE,
env={RUNNER_REGISTRATION_ENV: registration_token},
timeout=300,
+41
View File
@@ -0,0 +1,41 @@
"""Static fixtures for the integration suite: throwaway OpenPGP public keys.
Both keys were generated offline for a fake identity (``Aegis IT Fixture N`` with an
``example.test`` address); only the public halves are committed. Gitea accepts a GPG key
only when one of its identities carries an activated email address of the account, so the
tests first add the matching address to the account (``GPG_EMAILS``).
"""
from __future__ import annotations
GPG_EMAILS = (
"aegis-it-fixture-1@example.test",
"aegis-it-fixture-2@example.test",
)
GPG_PUBLIC_KEYS = (
"""\
-----BEGIN PGP PUBLIC KEY BLOCK-----
mDMEas+oDBYJKwYBBAHaRw8BAQdA7yjFLeF4uUtwBS9tSHRfz1p3CO3zX5G01SqY
Kq4Ebuu0NEFlZ2lzIElUIEZpeHR1cmUgMSA8YWVnaXMtaXQtZml4dHVyZS0xQGV4
YW1wbGUudGVzdD6IkwQTFgoAOxYhBLEDZkoa8oLNuNJlafBqtfhHw71yBQJqz6gM
AhsDBQsJCAcCAiICBhUKCQgLAgQWAgMBAh4HAheAAAoJEPBqtfhHw71yXN8BAPZ7
ShczPrCQqX+YN/YP5gU+Cnfnopt3yo66chupK6ADAQD3fdC2kwi8DQQGPdeLb7qC
TPcxXQWSnXeVTr3EQKFFAQ==
=KK0/
-----END PGP PUBLIC KEY BLOCK-----
""",
"""\
-----BEGIN PGP PUBLIC KEY BLOCK-----
mDMEas+oDBYJKwYBBAHaRw8BAQdAfUIIJtFAtjCsr39V+TnKqtUrWINy+9KsJdgq
4CWgjnS0NEFlZ2lzIElUIEZpeHR1cmUgMiA8YWVnaXMtaXQtZml4dHVyZS0yQGV4
YW1wbGUudGVzdD6IkwQTFgoAOxYhBFk8Jp2uNta24tbmPees4IAGb9G9BQJqz6gM
AhsDBQsJCAcCAiICBhUKCQgLAgQWAgMBAh4HAheAAAoJEOes4IAGb9G9880BAJwB
Kl2G944vYVF2XXS3S/PspgOj9Gcm6Q8T/07MPLTIAP0TUTvS2gg2CF3GXp7YCgUf
0hZziR0B882mz4IWtfroAQ==
=teCc
-----END PGP PUBLIC KEY BLOCK-----
""",
)
+543
View File
@@ -0,0 +1,543 @@
{
"gitea": "28.1.0",
"comment": "Expected outcome class per tool in the sweep: 2xx, or the exact status of a documented non-2xx or allowance. Regenerate with AEGIS_IT_WRITE_BASELINE=1.",
"tools": {
"gitea_actions_approve_workflow_run": 409,
"gitea_actions_cancel_workflow_run": "2xx",
"gitea_actions_create_org_variable": "2xx",
"gitea_actions_create_repo_variable": "2xx",
"gitea_actions_create_user_variable": "2xx",
"gitea_actions_delete_action_run": 400,
"gitea_actions_delete_artifact": 404,
"gitea_actions_delete_org_runner": 404,
"gitea_actions_delete_org_secret": "2xx",
"gitea_actions_delete_org_variable": "2xx",
"gitea_actions_delete_repo_runner": 404,
"gitea_actions_delete_repo_secret": "2xx",
"gitea_actions_delete_repo_variable": "2xx",
"gitea_actions_delete_user_runner": 404,
"gitea_actions_delete_user_secret": "2xx",
"gitea_actions_delete_user_variable": "2xx",
"gitea_actions_disable_workflow": "2xx",
"gitea_actions_dispatch_workflow": "2xx",
"gitea_actions_download_artifact": 404,
"gitea_actions_download_run_job_logs": 404,
"gitea_actions_enable_workflow": "2xx",
"gitea_actions_force_cancel_workflow_run": 409,
"gitea_actions_get_artifact": 404,
"gitea_actions_get_artifacts": "2xx",
"gitea_actions_get_artifacts_of_run": "2xx",
"gitea_actions_get_org_runner": 404,
"gitea_actions_get_org_runners": "2xx",
"gitea_actions_get_org_variable": "2xx",
"gitea_actions_get_org_variables_list": "2xx",
"gitea_actions_get_org_workflow_jobs": "2xx",
"gitea_actions_get_org_workflow_runs": "2xx",
"gitea_actions_get_repo_runner": 404,
"gitea_actions_get_repo_runners": "2xx",
"gitea_actions_get_repo_variable": "2xx",
"gitea_actions_get_repo_variables_list": "2xx",
"gitea_actions_get_user_runner": 404,
"gitea_actions_get_user_runners": "2xx",
"gitea_actions_get_user_variable": "2xx",
"gitea_actions_get_user_variables_list": "2xx",
"gitea_actions_get_user_workflow_jobs": "2xx",
"gitea_actions_get_user_workflow_runs": "2xx",
"gitea_actions_get_workflow": "2xx",
"gitea_actions_get_workflow_job": "2xx",
"gitea_actions_get_workflow_run": "2xx",
"gitea_actions_get_workflow_run_attempt": "2xx",
"gitea_actions_get_workflow_run_logs": 404,
"gitea_actions_get_workflow_runs": "2xx",
"gitea_actions_list_action_tasks": "2xx",
"gitea_actions_list_repository_workflows": "2xx",
"gitea_actions_list_workflow_jobs": "2xx",
"gitea_actions_list_workflow_run_attempt_jobs": "2xx",
"gitea_actions_list_workflow_run_jobs": "2xx",
"gitea_actions_list_workflow_runs": "2xx",
"gitea_actions_org_create_runner_registration_token": "2xx",
"gitea_actions_org_list_secrets": "2xx",
"gitea_actions_repo_create_runner_registration_token": "2xx",
"gitea_actions_repo_list_secrets": "2xx",
"gitea_actions_rerun_failed_workflow_run": "2xx",
"gitea_actions_rerun_workflow_job": 404,
"gitea_actions_rerun_workflow_run": 400,
"gitea_actions_update_org_runner": 404,
"gitea_actions_update_org_secret": "2xx",
"gitea_actions_update_org_variable": "2xx",
"gitea_actions_update_repo_runner": 404,
"gitea_actions_update_repo_secret": "2xx",
"gitea_actions_update_repo_variable": "2xx",
"gitea_actions_update_user_runner": 404,
"gitea_actions_update_user_secret": "2xx",
"gitea_actions_update_user_variable": "2xx",
"gitea_actions_user_create_runner_registration_token": "2xx",
"gitea_admin_add_user_badges": 500,
"gitea_admin_adopt_repository": 404,
"gitea_admin_create_hook": "2xx",
"gitea_admin_create_org": "2xx",
"gitea_admin_create_public_key": "2xx",
"gitea_admin_create_repo": "2xx",
"gitea_admin_create_runner_registration_token": "2xx",
"gitea_admin_create_user": "2xx",
"gitea_admin_cron_list": "2xx",
"gitea_admin_cron_run": "2xx",
"gitea_admin_delete_hook": "2xx",
"gitea_admin_delete_runner": 404,
"gitea_admin_delete_unadopted_repository": 404,
"gitea_admin_delete_user": "2xx",
"gitea_admin_delete_user_badges": "2xx",
"gitea_admin_delete_user_public_key": "2xx",
"gitea_admin_edit_hook": "2xx",
"gitea_admin_edit_user": "2xx",
"gitea_admin_get_all_emails": "2xx",
"gitea_admin_get_all_orgs": "2xx",
"gitea_admin_get_hook": "2xx",
"gitea_admin_get_runner": 404,
"gitea_admin_get_runners": "2xx",
"gitea_admin_list_hooks": "2xx",
"gitea_admin_list_packages": "2xx",
"gitea_admin_list_user_badges": "2xx",
"gitea_admin_list_workflow_jobs": "2xx",
"gitea_admin_list_workflow_runs": "2xx",
"gitea_admin_rename_user": "2xx",
"gitea_admin_search_emails": "2xx",
"gitea_admin_search_users": "2xx",
"gitea_admin_unadopted_list": "2xx",
"gitea_admin_update_runner": 404,
"gitea_api_request": "2xx",
"gitea_hook_org_create_hook": "2xx",
"gitea_hook_org_delete_hook": "2xx",
"gitea_hook_org_edit_hook": "2xx",
"gitea_hook_org_get_hook": "2xx",
"gitea_hook_org_list_hooks": "2xx",
"gitea_hook_repo_create_hook": "2xx",
"gitea_hook_repo_delete_git_hook": "2xx",
"gitea_hook_repo_delete_hook": "2xx",
"gitea_hook_repo_edit_git_hook": "2xx",
"gitea_hook_repo_edit_hook": "2xx",
"gitea_hook_repo_get_git_hook": "2xx",
"gitea_hook_repo_get_hook": "2xx",
"gitea_hook_repo_list_git_hooks": "2xx",
"gitea_hook_repo_list_hooks": "2xx",
"gitea_hook_repo_test_hook": "2xx",
"gitea_hook_user_create_hook": "2xx",
"gitea_hook_user_delete_hook": "2xx",
"gitea_hook_user_edit_hook": "2xx",
"gitea_hook_user_get_hook": "2xx",
"gitea_hook_user_list_hooks": "2xx",
"gitea_issue_add_assignees": "2xx",
"gitea_issue_add_label": "2xx",
"gitea_issue_add_subscription": "2xx",
"gitea_issue_add_time": "2xx",
"gitea_issue_check_assignee": "2xx",
"gitea_issue_check_subscription": "2xx",
"gitea_issue_clear_labels": "2xx",
"gitea_issue_create_comment": "2xx",
"gitea_issue_create_issue": "2xx",
"gitea_issue_create_issue_attachment": "2xx",
"gitea_issue_create_issue_blocking": "2xx",
"gitea_issue_create_issue_comment_attachment": "2xx",
"gitea_issue_create_issue_dependencies": "2xx",
"gitea_issue_create_label": "2xx",
"gitea_issue_create_milestone": "2xx",
"gitea_issue_delete": "2xx",
"gitea_issue_delete_comment": "2xx",
"gitea_issue_delete_comment_deprecated": "2xx",
"gitea_issue_delete_comment_reaction": "2xx",
"gitea_issue_delete_issue_attachment": "2xx",
"gitea_issue_delete_issue_comment_attachment": "2xx",
"gitea_issue_delete_issue_reaction": "2xx",
"gitea_issue_delete_label": "2xx",
"gitea_issue_delete_milestone": "2xx",
"gitea_issue_delete_stopwatch": "2xx",
"gitea_issue_delete_subscription": "2xx",
"gitea_issue_delete_time": "2xx",
"gitea_issue_edit_comment": "2xx",
"gitea_issue_edit_comment_deprecated": "2xx",
"gitea_issue_edit_issue": "2xx",
"gitea_issue_edit_issue_attachment": "2xx",
"gitea_issue_edit_issue_comment_attachment": "2xx",
"gitea_issue_edit_issue_deadline": "2xx",
"gitea_issue_edit_label": "2xx",
"gitea_issue_edit_milestone": "2xx",
"gitea_issue_get_comment": "2xx",
"gitea_issue_get_comment_reactions": "2xx",
"gitea_issue_get_comments": "2xx",
"gitea_issue_get_comments_and_timeline": "2xx",
"gitea_issue_get_issue": "2xx",
"gitea_issue_get_issue_attachment": "2xx",
"gitea_issue_get_issue_comment_attachment": "2xx",
"gitea_issue_get_issue_reactions": "2xx",
"gitea_issue_get_label": "2xx",
"gitea_issue_get_labels": "2xx",
"gitea_issue_get_milestone": "2xx",
"gitea_issue_get_milestones_list": "2xx",
"gitea_issue_get_repo_comments": "2xx",
"gitea_issue_list_blocks": "2xx",
"gitea_issue_list_issue_attachments": "2xx",
"gitea_issue_list_issue_comment_attachments": "2xx",
"gitea_issue_list_issue_dependencies": "2xx",
"gitea_issue_list_issues": "2xx",
"gitea_issue_list_labels": "2xx",
"gitea_issue_list_pinned_issues": "2xx",
"gitea_issue_lock_issue": "2xx",
"gitea_issue_move_issue_pin": "2xx",
"gitea_issue_pin_issue": "2xx",
"gitea_issue_post_comment_reaction": "2xx",
"gitea_issue_post_issue_reaction": "2xx",
"gitea_issue_remove_assignees": "2xx",
"gitea_issue_remove_issue_blocking": "2xx",
"gitea_issue_remove_issue_dependencies": "2xx",
"gitea_issue_remove_label": "2xx",
"gitea_issue_replace_labels": "2xx",
"gitea_issue_reset_time": "2xx",
"gitea_issue_search_issues": "2xx",
"gitea_issue_start_stopwatch": "2xx",
"gitea_issue_stop_stopwatch": "2xx",
"gitea_issue_subscriptions": "2xx",
"gitea_issue_tracked_times": "2xx",
"gitea_issue_unlock_issue": "2xx",
"gitea_issue_unpin_issue": "2xx",
"gitea_misc_get_current_token": "2xx",
"gitea_misc_get_general_api_settings": "2xx",
"gitea_misc_get_general_attachment_settings": "2xx",
"gitea_misc_get_general_repository_settings": "2xx",
"gitea_misc_get_general_ui_settings": "2xx",
"gitea_misc_get_gitignore_template_info": "2xx",
"gitea_misc_get_label_template_info": "2xx",
"gitea_misc_get_license_template_info": "2xx",
"gitea_misc_get_signing_key": 404,
"gitea_misc_get_signing_key_ssh": 404,
"gitea_misc_get_version": "2xx",
"gitea_misc_list_gitignores_templates": "2xx",
"gitea_misc_list_label_templates": "2xx",
"gitea_misc_list_license_templates": "2xx",
"gitea_misc_render_markdown": "2xx",
"gitea_misc_render_markdown_raw": "2xx",
"gitea_misc_render_markup": "2xx",
"gitea_misc_topic_search": "2xx",
"gitea_notification_get_list": "2xx",
"gitea_notification_get_repo_list": "2xx",
"gitea_notification_get_thread": "2xx",
"gitea_notification_new_available": "2xx",
"gitea_notification_read_list": "2xx",
"gitea_notification_read_repo_list": "2xx",
"gitea_notification_read_thread": "2xx",
"gitea_org_add_issue_to_project_column": "2xx",
"gitea_org_block_user": "2xx",
"gitea_org_check_user_block": "2xx",
"gitea_org_conceal_member": "2xx",
"gitea_org_create": "2xx",
"gitea_org_create_label": "2xx",
"gitea_org_create_org_repo": "2xx",
"gitea_org_create_org_repo_deprecated": "2xx",
"gitea_org_create_project": "2xx",
"gitea_org_create_project_column": "2xx",
"gitea_org_delete": "2xx",
"gitea_org_delete_avatar": "2xx",
"gitea_org_delete_label": "2xx",
"gitea_org_delete_member": "2xx",
"gitea_org_delete_project": "2xx",
"gitea_org_delete_project_column": "2xx",
"gitea_org_delete_repos": "2xx",
"gitea_org_edit": "2xx",
"gitea_org_edit_label": "2xx",
"gitea_org_edit_project": "2xx",
"gitea_org_edit_project_column": "2xx",
"gitea_org_get": "2xx",
"gitea_org_get_all": "2xx",
"gitea_org_get_label": "2xx",
"gitea_org_get_project": "2xx",
"gitea_org_get_project_column": "2xx",
"gitea_org_is_member": "2xx",
"gitea_org_is_public_member": "2xx",
"gitea_org_list_activity_feeds": "2xx",
"gitea_org_list_blocks": "2xx",
"gitea_org_list_labels": "2xx",
"gitea_org_list_members": "2xx",
"gitea_org_list_project_column_issues": "2xx",
"gitea_org_list_project_columns": "2xx",
"gitea_org_list_projects": "2xx",
"gitea_org_list_public_members": "2xx",
"gitea_org_list_repos": "2xx",
"gitea_org_move_project_columns": "2xx",
"gitea_org_move_project_issue": "2xx",
"gitea_org_publicize_member": "2xx",
"gitea_org_remove_issue_from_project_column": "2xx",
"gitea_org_rename": "2xx",
"gitea_org_set_default_project_column": "2xx",
"gitea_org_unblock_user": "2xx",
"gitea_org_update_avatar": "2xx",
"gitea_package_delete_package": "2xx",
"gitea_package_delete_package_version": "2xx",
"gitea_package_get_latest_package_version": "2xx",
"gitea_package_get_package": "2xx",
"gitea_package_link_package": "2xx",
"gitea_package_list_package_files": "2xx",
"gitea_package_list_package_versions": "2xx",
"gitea_package_list_packages": "2xx",
"gitea_package_unlink_package": "2xx",
"gitea_pull_cancel_scheduled_auto_merge": 404,
"gitea_pull_create_pull_request": "2xx",
"gitea_pull_create_pull_review": "2xx",
"gitea_pull_create_pull_review_comment_reply": "2xx",
"gitea_pull_create_pull_review_requests": "2xx",
"gitea_pull_delete_pull_review": "2xx",
"gitea_pull_delete_pull_review_requests": "2xx",
"gitea_pull_dismiss_pull_review": "2xx",
"gitea_pull_download_pull_diff_or_patch": "2xx",
"gitea_pull_edit_pull_request": "2xx",
"gitea_pull_get_pull_request": "2xx",
"gitea_pull_get_pull_request_by_base_head": "2xx",
"gitea_pull_get_pull_request_commits": "2xx",
"gitea_pull_get_pull_request_files": "2xx",
"gitea_pull_get_pull_review": "2xx",
"gitea_pull_get_pull_review_comments": "2xx",
"gitea_pull_list_pinned_pull_requests": "2xx",
"gitea_pull_list_pull_requests": "2xx",
"gitea_pull_list_pull_reviews": "2xx",
"gitea_pull_merge_pull_request": "2xx",
"gitea_pull_pull_request_is_merged": 404,
"gitea_pull_resolve_pull_review_comment": "2xx",
"gitea_pull_submit_pull_review": "2xx",
"gitea_pull_undismiss_pull_review": "2xx",
"gitea_pull_unresolve_pull_review_comment": "2xx",
"gitea_pull_update_pull_request": "2xx",
"gitea_release_create_release": "2xx",
"gitea_release_create_release_attachment": "2xx",
"gitea_release_delete_release": "2xx",
"gitea_release_delete_release_attachment": "2xx",
"gitea_release_delete_release_by_tag": "2xx",
"gitea_release_edit_release": "2xx",
"gitea_release_edit_release_attachment": "2xx",
"gitea_release_get_latest_release": "2xx",
"gitea_release_get_release": "2xx",
"gitea_release_get_release_attachment": "2xx",
"gitea_release_get_release_by_tag": "2xx",
"gitea_release_list_release_attachments": "2xx",
"gitea_release_list_releases": "2xx",
"gitea_repo_accept_repo_transfer": 404,
"gitea_repo_add_collaborator": "2xx",
"gitea_repo_add_issue_to_project_column": "2xx",
"gitea_repo_add_push_mirror": "2xx",
"gitea_repo_add_team": "2xx",
"gitea_repo_add_topic": "2xx",
"gitea_repo_apply_diff_patch": "2xx",
"gitea_repo_change_files": "2xx",
"gitea_repo_check_assignee": "2xx",
"gitea_repo_check_collaborator": "2xx",
"gitea_repo_check_subscription": "2xx",
"gitea_repo_check_team": "2xx",
"gitea_repo_compare_diff": "2xx",
"gitea_repo_create_branch": "2xx",
"gitea_repo_create_branch_protection": "2xx",
"gitea_repo_create_deploy_token": "2xx",
"gitea_repo_create_file": "2xx",
"gitea_repo_create_fork": "2xx",
"gitea_repo_create_key": "2xx",
"gitea_repo_create_project": "2xx",
"gitea_repo_create_project_column": "2xx",
"gitea_repo_create_status": "2xx",
"gitea_repo_create_tag": "2xx",
"gitea_repo_create_tag_protection": "2xx",
"gitea_repo_delete": "2xx",
"gitea_repo_delete_avatar": "2xx",
"gitea_repo_delete_branch": "2xx",
"gitea_repo_delete_branch_protection": "2xx",
"gitea_repo_delete_collaborator": "2xx",
"gitea_repo_delete_file": "2xx",
"gitea_repo_delete_key": "2xx",
"gitea_repo_delete_project": "2xx",
"gitea_repo_delete_project_column": "2xx",
"gitea_repo_delete_push_mirror": "2xx",
"gitea_repo_delete_subscription": "2xx",
"gitea_repo_delete_tag": "2xx",
"gitea_repo_delete_tag_protection": "2xx",
"gitea_repo_delete_team": "2xx",
"gitea_repo_delete_topic": "2xx",
"gitea_repo_download_commit_diff_or_patch": "2xx",
"gitea_repo_edit": "2xx",
"gitea_repo_edit_branch_protection": "2xx",
"gitea_repo_edit_project": "2xx",
"gitea_repo_edit_project_column": "2xx",
"gitea_repo_edit_tag_protection": "2xx",
"gitea_repo_generate_repo": "2xx",
"gitea_repo_get": "2xx",
"gitea_repo_get_all_commits": "2xx",
"gitea_repo_get_annotated_tag": "2xx",
"gitea_repo_get_archive": "2xx",
"gitea_repo_get_assignees": "2xx",
"gitea_repo_get_blob": "2xx",
"gitea_repo_get_branch": "2xx",
"gitea_repo_get_branch_protection": "2xx",
"gitea_repo_get_by_id": "2xx",
"gitea_repo_get_combined_status_by_ref": "2xx",
"gitea_repo_get_commit_pull_request": 404,
"gitea_repo_get_contents": "2xx",
"gitea_repo_get_contents_ext": "2xx",
"gitea_repo_get_contents_list": "2xx",
"gitea_repo_get_editor_config": "2xx",
"gitea_repo_get_file_contents": "2xx",
"gitea_repo_get_file_contents_post": "2xx",
"gitea_repo_get_issue_config": "2xx",
"gitea_repo_get_issue_templates": "2xx",
"gitea_repo_get_key": "2xx",
"gitea_repo_get_languages": "2xx",
"gitea_repo_get_licenses": "2xx",
"gitea_repo_get_note": 404,
"gitea_repo_get_project": "2xx",
"gitea_repo_get_project_column": "2xx",
"gitea_repo_get_push_mirror_by_remote_name": "2xx",
"gitea_repo_get_raw_file": "2xx",
"gitea_repo_get_raw_file_or_lfs": "2xx",
"gitea_repo_get_repo_permissions": "2xx",
"gitea_repo_get_reviewers": "2xx",
"gitea_repo_get_single_commit": "2xx",
"gitea_repo_get_tag": "2xx",
"gitea_repo_get_tag_protection": "2xx",
"gitea_repo_get_tree": "2xx",
"gitea_repo_list_activity_feeds": "2xx",
"gitea_repo_list_all_git_refs": "2xx",
"gitea_repo_list_branch_protection": "2xx",
"gitea_repo_list_branches": "2xx",
"gitea_repo_list_collaborators": "2xx",
"gitea_repo_list_forks": "2xx",
"gitea_repo_list_git_refs": "2xx",
"gitea_repo_list_keys": "2xx",
"gitea_repo_list_project_column_issues": "2xx",
"gitea_repo_list_project_columns": "2xx",
"gitea_repo_list_projects": "2xx",
"gitea_repo_list_push_mirrors": "2xx",
"gitea_repo_list_stargazers": "2xx",
"gitea_repo_list_statuses": "2xx",
"gitea_repo_list_statuses_by_ref": "2xx",
"gitea_repo_list_subscribers": "2xx",
"gitea_repo_list_tag_protection": "2xx",
"gitea_repo_list_tags": "2xx",
"gitea_repo_list_teams": "2xx",
"gitea_repo_list_topics": "2xx",
"gitea_repo_list_user_tracked_times": "2xx",
"gitea_repo_merge_upstream": "2xx",
"gitea_repo_migrate": "2xx",
"gitea_repo_mirror_sync": "2xx",
"gitea_repo_move_project_columns": "2xx",
"gitea_repo_move_project_issue": "2xx",
"gitea_repo_new_pin_allowed": "2xx",
"gitea_repo_push_mirror_sync": "2xx",
"gitea_repo_put_subscription": "2xx",
"gitea_repo_reject_repo_transfer": 404,
"gitea_repo_remove_issue_from_project_column": "2xx",
"gitea_repo_rename_branch": "2xx",
"gitea_repo_search": "2xx",
"gitea_repo_set_default_project_column": "2xx",
"gitea_repo_signing_key": 404,
"gitea_repo_signing_key_ssh": 404,
"gitea_repo_tracked_times": "2xx",
"gitea_repo_transfer": "2xx",
"gitea_repo_update_avatar": "2xx",
"gitea_repo_update_branch": "2xx",
"gitea_repo_update_branch_protection_priorities": "2xx",
"gitea_repo_update_file": "2xx",
"gitea_repo_update_topics": "2xx",
"gitea_repo_validate_issue_config": "2xx",
"gitea_team_add_team_member": "2xx",
"gitea_team_add_team_repository": "2xx",
"gitea_team_create_team": "2xx",
"gitea_team_delete_team": "2xx",
"gitea_team_edit_team": "2xx",
"gitea_team_get_team": "2xx",
"gitea_team_list_team_activity_feeds": "2xx",
"gitea_team_list_team_member": "2xx",
"gitea_team_list_team_members": "2xx",
"gitea_team_list_team_repo": "2xx",
"gitea_team_list_team_repos": "2xx",
"gitea_team_list_teams": "2xx",
"gitea_team_remove_team_member": "2xx",
"gitea_team_remove_team_repository": "2xx",
"gitea_team_search": "2xx",
"gitea_user_add_email": "2xx",
"gitea_user_block_user": "2xx",
"gitea_user_check_following": "2xx",
"gitea_user_check_user_block": "2xx",
"gitea_user_create_oauth2_application": "2xx",
"gitea_user_create_token": 401,
"gitea_user_current_add_issue_to_project_column": "2xx",
"gitea_user_current_check_following": "2xx",
"gitea_user_current_check_starring": "2xx",
"gitea_user_current_create_project": "2xx",
"gitea_user_current_create_project_column": "2xx",
"gitea_user_current_create_repo": "2xx",
"gitea_user_current_delete_follow": "2xx",
"gitea_user_current_delete_gpg_key": "2xx",
"gitea_user_current_delete_key": "2xx",
"gitea_user_current_delete_project": "2xx",
"gitea_user_current_delete_project_column": "2xx",
"gitea_user_current_delete_star": "2xx",
"gitea_user_current_edit_project": "2xx",
"gitea_user_current_edit_project_column": "2xx",
"gitea_user_current_get_gpg_key": "2xx",
"gitea_user_current_get_key": "2xx",
"gitea_user_current_get_project": "2xx",
"gitea_user_current_get_project_column": "2xx",
"gitea_user_current_list_followers": "2xx",
"gitea_user_current_list_following": "2xx",
"gitea_user_current_list_gpg_keys": "2xx",
"gitea_user_current_list_keys": "2xx",
"gitea_user_current_list_orgs": "2xx",
"gitea_user_current_list_project_column_issues": "2xx",
"gitea_user_current_list_project_columns": "2xx",
"gitea_user_current_list_projects": "2xx",
"gitea_user_current_list_repos": "2xx",
"gitea_user_current_list_starred": "2xx",
"gitea_user_current_list_subscriptions": "2xx",
"gitea_user_current_move_project_columns": "2xx",
"gitea_user_current_move_project_issue": "2xx",
"gitea_user_current_post_gpg_key": "2xx",
"gitea_user_current_post_key": "2xx",
"gitea_user_current_put_follow": "2xx",
"gitea_user_current_put_star": "2xx",
"gitea_user_current_remove_issue_from_project_column": "2xx",
"gitea_user_current_set_default_project_column": "2xx",
"gitea_user_current_tracked_times": "2xx",
"gitea_user_delete_access_token": 401,
"gitea_user_delete_avatar": "2xx",
"gitea_user_delete_email": "2xx",
"gitea_user_delete_oauth2_application": "2xx",
"gitea_user_get": "2xx",
"gitea_user_get_current": "2xx",
"gitea_user_get_heatmap_data": "2xx",
"gitea_user_get_oauth2_application": "2xx",
"gitea_user_get_org_permissions": "2xx",
"gitea_user_get_settings": "2xx",
"gitea_user_get_stopwatches": "2xx",
"gitea_user_get_tokens": 401,
"gitea_user_get_verification_token": "2xx",
"gitea_user_list_activity_feeds": "2xx",
"gitea_user_list_blocks": "2xx",
"gitea_user_list_emails": "2xx",
"gitea_user_list_followers": "2xx",
"gitea_user_list_following": "2xx",
"gitea_user_list_gpg_keys": "2xx",
"gitea_user_list_keys": "2xx",
"gitea_user_list_oauth2_applications": "2xx",
"gitea_user_list_orgs": "2xx",
"gitea_user_list_projects": "2xx",
"gitea_user_list_repos": "2xx",
"gitea_user_list_starred": "2xx",
"gitea_user_list_subscriptions": "2xx",
"gitea_user_list_teams": "2xx",
"gitea_user_search": "2xx",
"gitea_user_unblock_user": "2xx",
"gitea_user_update_avatar": "2xx",
"gitea_user_update_oauth2_application": "2xx",
"gitea_user_update_settings": "2xx",
"gitea_user_verify_gpg_key": 422,
"gitea_wiki_create_wiki_page": "2xx",
"gitea_wiki_delete_wiki_page": "2xx",
"gitea_wiki_edit_wiki_page": "2xx",
"gitea_wiki_get_wiki_page": "2xx",
"gitea_wiki_get_wiki_page_revisions": "2xx",
"gitea_wiki_get_wiki_pages": "2xx"
}
}
+92 -1
View File
@@ -12,6 +12,9 @@ that is data or pure logic, so it can be checked without Docker
then deletes (leaf resources before their parents).
* :func:`classify` decides whether a result is acceptable: a 2xx, a status the swagger
documents for that operation, or an explicit entry of :data:`ALLOWANCES` with a reason.
* :func:`compare_baseline` pins the outcome class of every tool (``2xx`` or the exact
status of a documented non-2xx or an allowance) in ``sweep_baseline.json`` and reports
every downgrade at once; ``AEGIS_IT_WRITE_BASELINE=1`` regenerates the file.
* :func:`render_report` writes ``docs/integration-coverage.md``.
"""
@@ -27,12 +30,15 @@ from pathlib import Path
from typing import Any
from aegis_gitea_mcp.catalog import Catalog, Operation
from tests.integration.static_keys import GPG_PUBLIC_KEYS
from tests.integration.sweep_seed import PNG_1X1, Ctx, b64, ssh_public_key
REPO_ROOT = Path(__file__).resolve().parents[2]
SPEC_PATH = REPO_ROOT / "spec" / "gitea-swagger.v1.json"
REPORT_PATH = REPO_ROOT / "docs" / "integration-coverage.md"
REPORT_ENV = "AEGIS_IT_WRITE_REPORT"
BASELINE_PATH = Path(__file__).resolve().parent / "sweep_baseline.json"
BASELINE_ENV = "AEGIS_IT_WRITE_BASELINE"
RAW_TOOL = "gitea_api_request"
#: Tools the generic sweep never calls, with the test that covers them in isolation.
@@ -172,6 +178,7 @@ ID_RULES: list[tuple[str, str]] = [
(r"^/orgs/\{org\}/projects/\{id\}", "org_project"),
(r"^/user/hooks/\{id\}", "user_hook"),
(r"^/user/keys/\{id\}", "user_key"),
(r"^/user/gpg_keys/\{id\}", "gpg_key"),
(r"^/user/projects/\{id\}", "user_project"),
(r"^/user/applications/oauth2/\{id\}", "oauth_app"),
(r"^/admin/hooks/\{id\}", "admin_hook"),
@@ -605,7 +612,9 @@ OVERRIDES: dict[str, Override] = {
"gitea_user_current_post_key": lambda c: {
"body": {"title": fresh(c, "key"), "key": ssh_public_key()}
},
"gitea_user_current_post_gpg_key": lambda c: {"body": {"armored_public_key": "not a key"}},
"gitea_user_current_post_gpg_key": lambda c: {
"body": {"armored_public_key": GPG_PUBLIC_KEYS[1]}
},
"gitea_user_delete_access_token": lambda c: {
"username": c["admin"],
"token": c["throwaway_token"],
@@ -858,6 +867,88 @@ def write_report_if_enabled(catalog: Catalog, gitea_version: str) -> bool:
return True
# ---------------------------------------------------------------------------
# Baseline: the pinned outcome class per tool
# ---------------------------------------------------------------------------
SUCCESS = "2xx"
NO_STATUS = "no-status"
Outcome = str | int
def outcome_class(record: SweepRecord) -> Outcome:
"""``"2xx"`` for a success, else the HTTP status Gitea answered (or ``"no-status"``)."""
if record.status is not None and 200 <= record.status < 300 and not record.is_error:
return SUCCESS
return record.status if record.status is not None else NO_STATUS
def baseline_snapshot(records: Iterable[SweepRecord], gitea_version: str) -> dict[str, Any]:
"""The baseline document for ``records`` (what ``AEGIS_IT_WRITE_BASELINE=1`` writes)."""
return {
"gitea": gitea_version,
"comment": (
"Expected outcome class per tool in the sweep: 2xx, or the exact status of a "
"documented non-2xx or allowance. Regenerate with AEGIS_IT_WRITE_BASELINE=1."
),
"tools": {record.tool: outcome_class(record) for record in records},
}
def load_baseline(path: Path = BASELINE_PATH) -> dict[str, Outcome]:
"""Read the pinned outcome per tool."""
data = json.loads(path.read_text(encoding="utf-8"))
tools: dict[str, Outcome] = data["tools"]
return tools
def write_baseline(
records: Iterable[SweepRecord], gitea_version: str, path: Path = BASELINE_PATH
) -> None:
"""Write the baseline file (tools sorted by name, so diffs show only real changes)."""
document = baseline_snapshot(records, gitea_version)
document["tools"] = dict(sorted(document["tools"].items()))
path.write_text(json.dumps(document, indent=2) + "\n", encoding="utf-8")
@dataclass
class BaselineDiff:
"""Result of comparing a sweep with the pinned baseline."""
regressions: list[str] = field(default_factory=list)
upgrades: list[str] = field(default_factory=list)
def compare_baseline(records: Iterable[SweepRecord], baseline: dict[str, Outcome]) -> BaselineDiff:
"""Compare outcomes with the baseline.
A regression is a 2xx that is no longer a 2xx, a documented non-2xx that answers a
different status, a tool without a baseline entry or a baseline entry without a tool.
A non-2xx that became a 2xx is an upgrade: reported, but it does not fail the run.
"""
diff = BaselineDiff()
seen: set[str] = set()
for record in records:
seen.add(record.tool)
actual = outcome_class(record)
if record.tool not in baseline:
diff.regressions.append(f"{record.tool}: no baseline entry (got {actual})")
continue
expected = baseline[record.tool]
if actual == expected:
continue
if actual == SUCCESS:
diff.upgrades.append(f"{record.tool}: baseline {expected}, now 2xx")
continue
detail = f"{record.tool}: baseline {expected}, got {actual} ({record.method} {record.path}"
if record.message:
detail += f" :: {record.message}"
diff.regressions.append(detail + ")")
for tool in sorted(set(baseline) - seen):
diff.regressions.append(f"{tool}: in the baseline but not called by the sweep")
return diff
def failing(records: Iterable[SweepRecord]) -> list[SweepRecord]:
"""The records whose outcome is ``unexpected``."""
return [record for record in records if record.outcome == "unexpected"]
+16 -7
View File
@@ -16,6 +16,7 @@ from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from tests.integration.harness import GiteaInstance, HarnessError
from tests.integration.static_keys import GPG_EMAILS, GPG_PUBLIC_KEYS
Ctx = dict[str, Any]
@@ -370,7 +371,11 @@ def seed(gitea: GiteaInstance, user: str, user_token: str) -> Ctx:
# Current user: email, follow, star, watch, block.
c["email"] = f"sweep-{sfx}@example.test"
s.call("POST", "/user/emails", json={"emails": [c["email"]]})
s.call("POST", "/user/emails", json={"emails": [c["email"], *GPG_EMAILS]})
# Gitea only accepts a GPG key whose identity matches an email of the account.
c["gpg_key"] = s.call(
"POST", "/user/gpg_keys", json={"armored_public_key": GPG_PUBLIC_KEYS[0]}
)["id"]
s.call("PUT", f"/user/following/{user}")
s.call("PUT", f"/user/starred/{admin}/{repo}")
s.call("PUT", f"{base}/subscription")
@@ -419,8 +424,12 @@ def _project(s: Seeder, c: Ctx, scope: str, path: str) -> None:
def _find_run(s: Seeder, base: str) -> tuple[Any, Any]:
"""Return ``(run id, job id)`` of the first workflow run, or ``(1, 1)`` if none appeared."""
deadline = time.monotonic() + 10
"""Return ``(run id, job id)`` of the first workflow run; fail when none appears.
A made-up id would only turn every dependent tool into a 404, which the sweep would
accept as documented, so a missing run is a seeding failure.
"""
deadline = time.monotonic() + 30
while time.monotonic() < deadline:
runs = s.call("GET", f"{base}/actions/runs")
entries = runs.get("workflow_runs") or []
@@ -431,15 +440,15 @@ def _find_run(s: Seeder, base: str) -> tuple[Any, Any]:
if job_entries:
return run_id, job_entries[0]["id"]
time.sleep(0.5)
return 1, 1
raise HarnessError("seed: the push of the workflow file did not create a workflow run")
def _find_notification(s: Seeder, admin: str) -> Any:
"""Return the id of a notification of the admin if one exists, else 1."""
deadline = time.monotonic() + 5
"""Return the id of a notification of the admin; fail when none appears."""
deadline = time.monotonic() + 30
while time.monotonic() < deadline:
listing = s.call("GET", "/notifications", params={"all": "true"})
if listing:
return listing[0]["id"]
time.sleep(0.5)
return 1
raise HarnessError(f"seed: no notification arrived for {admin}")
+79
View File
@@ -0,0 +1,79 @@
"""Write tools that Gitea refuses: the status and error kind reach the client intact."""
from __future__ import annotations
import secrets
from tests.integration.conftest import McpSession, RepoRef, b64
from tests.integration.harness import GiteaInstance
async def test_creating_in_a_missing_repository_is_not_found(
mcp: McpSession, gitea: GiteaInstance
) -> None:
missing = {"owner": gitea.admin_login, "repo": "missing-" + secrets.token_hex(4)}
issue = await mcp.fail("gitea_issue_create_issue", 404, body={"title": "x"}, **missing)
assert issue.error_kind == "not_found"
label = await mcp.fail(
"gitea_issue_create_label", 404, body={"name": "x", "color": "#ffffff"}, **missing
)
assert label.error_kind == "not_found"
# The audit log records the failure with the same kind.
failed = [e for e in mcp.audit_entries() if e["tool_name"] == "gitea_issue_create_issue"]
assert failed and failed[-1]["outcome"] == "not_found"
assert failed[-1]["result_status"] == "error"
async def test_invalid_values_are_validation_errors(mcp: McpSession, fresh_repo: RepoRef) -> None:
repo = fresh_repo.args
bad_color = await mcp.call(
"gitea_issue_create_label", body={"name": "bad", "color": "not-a-color"}, **repo
)
assert bad_color.is_error
assert bad_color.error_kind in ("validation_failed", "bad_request")
assert bad_color.status in (400, 422)
# Gitea itself refuses a missing body.
missing_body = await mcp.call("gitea_issue_create_issue", **repo)
assert missing_body.is_error and missing_body.status == 422
assert missing_body.error_kind == "validation_failed"
# A field of the wrong type never reaches Gitea: the schema rejects it first.
wrong_type = await mcp.call(
"gitea_issue_create_issue", body={"title": ["not", "a", "string"]}, **repo
)
assert wrong_type.is_error and wrong_type.status is None
assert wrong_type.error_kind == "invalid_argument"
async def test_conflicting_writes_are_conflicts(
mcp: McpSession, gitea: GiteaInstance, fresh_repo: RepoRef
) -> None:
repo = fresh_repo.args
duplicate = await mcp.fail(
"gitea_user_current_create_repo", 409, body={"name": fresh_repo.name}
)
assert duplicate.error_kind == "conflict"
await mcp.ok(
"gitea_repo_create_file",
status=201,
filepath="a.txt",
body={"content": b64("one"), "message": "one"},
**repo,
)
again = await mcp.call(
"gitea_repo_create_file",
filepath="a.txt",
body={"content": b64("two"), "message": "two"},
**repo,
)
assert again.is_error and again.status in (409, 422)
stale = await mcp.call(
"gitea_repo_update_file",
filepath="a.txt",
body={"content": b64("three"), "message": "three", "sha": "0" * 40},
**repo,
)
assert stale.is_error and stale.status in (409, 422)
+47 -5
View File
@@ -47,6 +47,7 @@ class OAuthStack:
bearer: str
user_login: str
user_access_token: str
expired_access_token: str
secrets: list[str]
@@ -96,6 +97,15 @@ async def build_stack(
scopes=[MCP_SCOPE],
expires_at=time.time() + ACCESS_TOKEN_TTL,
)
# A token of the same valid session whose lifetime ended a minute ago.
expired = TOKEN_PREFIX + secrets.token_urlsafe(32)
store.tokens[token_hash(expired)] = TokenRecord(
kind="access",
session_id=session_id,
client_id=OAUTH_CLIENT_ID,
scopes=[MCP_SCOPE],
expires_at=time.time() - 60,
)
await store.save()
audit = AuditLogger(settings.resolved_audit_log_path)
@@ -117,9 +127,11 @@ async def build_stack(
bearer=bearer,
user_login=user.login,
user_access_token=access,
expired_access_token=expired,
secrets=[
bearer,
access,
expired,
user.token,
gitea.admin_token,
oauth_client_secret,
@@ -192,11 +204,32 @@ async def test_calls_run_as_the_signed_in_user(
)
).status in (403, 404)
# Admin tools and sudo are refused by Gitea with 403, not by the server.
admin_only = await mcp.call("gitea_admin_cron_list")
assert admin_only.is_error and admin_only.status == 403
assert admin_only.error_kind == "forbidden"
assert admin_only.identity["mode"] == "oauth"
# Admin tools and sudo are refused by Gitea with 403, not by the server: every
# admin list endpoint and an admin create, all for this non-admin user.
for tool in (
"gitea_admin_cron_list",
"gitea_admin_search_users",
"gitea_admin_get_all_orgs",
"gitea_admin_get_all_emails",
"gitea_admin_list_hooks",
"gitea_admin_list_packages",
"gitea_admin_get_runners",
"gitea_admin_unadopted_list",
):
refused = await mcp.call(tool)
assert refused.is_error and refused.status == 403, tool
assert refused.error_kind == "forbidden", tool
assert refused.identity["mode"] == "oauth"
created = await mcp.call(
"gitea_admin_create_user",
body={
"username": gitea.unique("it-nope"),
"email": f"{gitea.unique('nope')}@example.test",
"password": secrets.token_urlsafe(24),
},
)
assert created.is_error and created.status == 403
assert created.error_kind == "forbidden"
sudo = await mcp.call("gitea_user_get_current", sudo=gitea.admin_login)
assert sudo.is_error and sudo.status == 403
@@ -206,6 +239,7 @@ async def test_calls_run_as_the_signed_in_user(
assert {e["tool_name"] for e in entries} >= {
"gitea_user_get_current",
"gitea_admin_cron_list",
"gitea_admin_create_user",
}
gitea.api("DELETE", f"/repos/{regular_user.login}/{own}")
@@ -254,6 +288,7 @@ async def test_unknown_and_expired_tokens_never_reach_gitea(
):
for token in (
TOKEN_PREFIX + secrets.token_urlsafe(32), # looks like ours, unknown
stack.expired_access_token, # ours and known, but past its expiry
regular_user.token, # the user's own Gitea token is not an MCP token
secrets.token_urlsafe(36),
):
@@ -264,3 +299,10 @@ async def test_unknown_and_expired_tokens_never_reach_gitea(
assert token not in response.text
anonymous = await http_client.post("/mcp", json=body, headers=headers)
assert anonymous.status_code == 401
# The expiry is what excluded the token above: the live one of the same session works.
live = await http_client.post(
"/mcp",
json=body,
headers={**headers, "Authorization": f"Bearer {stack.user_access_token}"},
)
assert live.status_code == 200
+129 -5
View File
@@ -6,9 +6,7 @@ import asyncio
import time
from typing import Any
import pytest
from tests.integration.conftest import McpSession, RepoRef, b64
from tests.integration.conftest import McpSession, RepoRef, b64, drop_org, runner_unavailable
from tests.integration.harness import GiteaInstance, HarnessError, runner_logs, start_runner
WORKFLOW_PATH = ".gitea/workflows/it.yml"
@@ -45,7 +43,7 @@ async def test_workflow_run_on_act_runner(
try:
runner = start_runner(gitea, token.data["token"])
except HarnessError as exc:
pytest.xfail(f"the act_runner container could not be started: {exc}")
runner_unavailable(f"the act_runner container could not be started: {exc}")
async def online() -> dict[str, Any] | None:
runners = await mcp.ok("gitea_actions_get_repo_runners", **repo)
@@ -58,7 +56,7 @@ async def test_workflow_run_on_act_runner(
registered = await _wait_for(online, timeout=RUNNER_ONLINE_TIMEOUT)
if not registered:
pytest.xfail(
runner_unavailable(
"the act_runner did not come online within "
f"{RUNNER_ONLINE_TIMEOUT:.0f}s; runner log tail: {runner_logs(runner, 15)}"
)
@@ -115,3 +113,129 @@ async def test_workflow_run_on_act_runner(
assert (await mcp.ok("gitea_actions_get_artifacts", **repo)).data["total_count"] == 0
tasks = await mcp.ok("gitea_actions_list_action_tasks", **repo)
assert tasks.data["total_count"] >= 2
# The runner registered above, managed through the repository-scope runner tools.
runner_id = registered["id"]
got = await mcp.ok("gitea_actions_get_repo_runner", runner_id=str(runner_id), **repo)
assert got.data["id"] == runner_id and got.data["name"] == registered["name"]
off = await mcp.ok(
"gitea_actions_update_repo_runner",
runner_id=str(runner_id),
body={"disabled": True},
**repo,
)
assert off.data["disabled"] is True
on = await mcp.ok(
"gitea_actions_update_repo_runner",
runner_id=str(runner_id),
body={"disabled": False},
**repo,
)
assert on.data["disabled"] is False
# The completed run: logs archive, rerun of the run and of one job, then delete the run.
archive = await mcp.ok("gitea_actions_get_workflow_run_logs", run=run_id, **repo)
assert archive.data
await mcp.ok("gitea_actions_rerun_workflow_run", status=201, run=run_id, **repo)
await _wait_for_attempt(mcp, repo, run_id, attempt=2)
rerun_jobs = await mcp.ok("gitea_actions_list_workflow_run_jobs", run=run_id, **repo)
await mcp.ok(
"gitea_actions_rerun_workflow_job",
status=201,
run=run_id,
job_id=rerun_jobs.data["jobs"][0]["id"],
**repo,
)
await _wait_for_attempt(mcp, repo, run_id, attempt=3)
await mcp.ok("gitea_actions_delete_action_run", run=run_id, **repo)
await mcp.fail("gitea_actions_get_workflow_run", 404, run=run_id, **repo)
await mcp.ok("gitea_actions_delete_repo_runner", runner_id=str(runner_id), **repo)
await mcp.fail("gitea_actions_get_repo_runner", 404, runner_id=str(runner_id), **repo)
async def _wait_for_attempt(
mcp: McpSession, repo: dict[str, str], run_id: int, *, attempt: int
) -> None:
"""Wait until ``run_id`` reaches ``attempt`` and is completed again."""
async def done() -> bool:
run = (await mcp.ok("gitea_actions_get_workflow_run", run=run_id, **repo)).data
return bool(run["status"] == "completed" and run.get("run_attempt", 1) >= attempt)
assert await _wait_for(done, timeout=RUN_TIMEOUT), (
f"run {run_id} did not finish attempt {attempt}"
)
async def test_runner_management_at_every_scope(mcp: McpSession, gitea: GiteaInstance) -> None:
"""Register one runner per scope (org, user, admin) and manage it through the tools."""
org = gitea.unique("it-runner")
await mcp.ok("gitea_org_create", status=201, body={"username": org})
try:
scopes: list[tuple[str, str, dict[str, str], str]] = [
("org", "gitea_actions_org_create_runner_registration_token", {"org": org}, "org"),
("user", "gitea_actions_user_create_runner_registration_token", {}, "user"),
("admin", "gitea_admin_create_runner_registration_token", {}, "admin"),
]
for scope, token_tool, args, _ in scopes:
token = await mcp.ok(token_tool, **args)
suffix = f"{scope}-runner"
try:
container = start_runner(
gitea, token.data["token"], suffix=suffix, label=f"aegis-{scope}"
)
except HarnessError as exc:
runner_unavailable(f"the {scope} runner could not be started: {exc}")
return
listing = (
"gitea_admin_get_runners"
if scope == "admin"
else (f"gitea_actions_get_{scope}_runners")
)
getter = (
"gitea_admin_get_runner"
if scope == "admin"
else (f"gitea_actions_get_{scope}_runner")
)
updater = (
"gitea_admin_update_runner"
if scope == "admin"
else (f"gitea_actions_update_{scope}_runner")
)
deleter = (
"gitea_admin_delete_runner"
if scope == "admin"
else (f"gitea_actions_delete_{scope}_runner")
)
async def registered(
listing: str = listing, args: dict[str, str] = args, container: str = container
) -> dict[str, Any] | None:
runners = await mcp.ok(listing, **args)
entries = (
runners.data["runners"] if isinstance(runners.data, dict) else runners.data
)
for entry in entries or []:
if entry["name"] == container:
found: dict[str, Any] = entry
return found
return None
runner = await _wait_for(registered, timeout=RUNNER_ONLINE_TIMEOUT)
if not runner:
runner_unavailable(
f"the {scope} runner did not register; log tail: {runner_logs(container, 15)}"
)
return
assert token.data["token"] not in runner_logs(container, 200)
got = await mcp.ok(getter, runner_id=str(runner["id"]), **args)
assert got.data["name"] == container
changed = await mcp.ok(
updater, runner_id=str(runner["id"]), body={"disabled": True}, **args
)
assert changed.data["disabled"] is True
await mcp.ok(deleter, runner_id=str(runner["id"]), **args)
await mcp.fail(getter, 404, runner_id=str(runner["id"]), **args)
finally:
drop_org(gitea, org)
@@ -221,5 +221,75 @@ async def test_pull_request_review_and_merge(
assert merged.data["state"] == "closed"
readme = await mcp.ok("gitea_repo_get_contents", filepath="greeting.txt", **repo)
assert readme.data["path"] == "greeting.txt"
# The merge commit resolves back to the pull request.
merge_sha = merged.data["merge_commit_sha"]
by_commit = await mcp.ok("gitea_repo_get_commit_pull_request", sha=merge_sha, **repo)
assert by_commit.data["number"] == index
# Merging it again is refused with the documented status, not silently accepted.
again = await mcp.fail(
"gitea_pull_merge_pull_request", 405, index=index, body={"do": "merge"}, **repo
)
assert again.error_kind == "method_not_allowed"
assert "already merged" in again.payload["error"]["message"]
finally:
gitea.api("DELETE", f"/repos/{fresh_repo.owner}/{fresh_repo.name}/collaborators/{reviewer}")
async def test_scheduled_auto_merge_can_be_cancelled(
mcp: McpSession, gitea: GiteaInstance, fresh_repo: RepoRef
) -> None:
"""A required status check that is still pending turns the merge into a scheduled one."""
repo = fresh_repo.args
await mcp.ok(
"gitea_repo_create_branch",
status=201,
body={"new_branch_name": "feat/auto", "old_branch_name": "main"},
**repo,
)
await mcp.ok(
"gitea_repo_create_file",
status=201,
filepath="auto.txt",
body={"content": b64("auto\n"), "message": "add auto", "branch": "feat/auto"},
**repo,
)
await mcp.ok(
"gitea_repo_create_branch_protection",
status=201,
body={
"rule_name": "main",
"enable_status_check": True,
"status_check_contexts": ["ci/required"],
},
**repo,
)
pull = await mcp.ok(
"gitea_pull_create_pull_request",
status=201,
body={"title": "Auto merge", "base": "main", "head": "feat/auto"},
**repo,
)
index = pull.data["number"]
await mcp.ok(
"gitea_repo_create_status",
status=201,
sha=pull.data["head"]["sha"],
body={"state": "pending", "context": "ci/required"},
**repo,
)
scheduled = await mcp.ok(
"gitea_pull_merge_pull_request",
status=201,
index=index,
body={"do": "merge", "merge_when_checks_succeed": True},
**repo,
)
assert scheduled.status == 201 # scheduled, not merged
assert (await mcp.ok("gitea_pull_get_pull_request", index=index, **repo)).data[
"merged"
] is False
await mcp.ok("gitea_pull_cancel_scheduled_auto_merge", index=index, **repo)
# Nothing is scheduled any more.
await mcp.fail("gitea_pull_cancel_scheduled_auto_merge", 404, index=index, **repo)
+60 -1
View File
@@ -7,7 +7,14 @@ import base64
import time
from typing import Any
from tests.integration.conftest import McpSession, RegularUser, RepoRef, b64, drop_org
from tests.integration.conftest import (
McpSession,
RegularUser,
RepoRef,
b64,
drop_org,
drop_user,
)
from tests.integration.harness import GiteaInstance
@@ -230,3 +237,55 @@ async def test_repo_collaborators_fork_transfer_archive_search(
assert (await mcp.ok("gitea_repo_get", owner=org, repo=fresh_repo.name)).data["archived"]
finally:
drop_org(gitea, org)
async def test_repository_transfer_accept_and_reject(
mcp: McpSession, gitea: GiteaInstance, regular_user: RegularUser
) -> None:
"""A transfer started by a non-admin stays pending until the receiving user answers it."""
owner = regular_user.login
recipient, _ = gitea.create_user(gitea.unique("it-recv"))
accepted = gitea.unique("it-xfer-ok")
rejected = gitea.unique("it-xfer-no")
gitea.create_repo(owner, accepted)
gitea.create_repo(owner, rejected)
try:
for name in (accepted, rejected):
# Started by the owner (through sudo): not an admin, so Gitea keeps the
# transfer pending (answering 201 although the swagger lists 202).
await mcp.ok(
"gitea_repo_transfer",
status=(201, 202),
sudo=owner,
owner=owner,
repo=name,
body={"new_owner": recipient},
)
# Still the owner's repository until the recipient answers.
assert (await mcp.ok("gitea_repo_get", owner=owner, repo=name)).data["owner"][
"login"
] == owner
took = await mcp.ok(
"gitea_repo_accept_repo_transfer",
status=202,
sudo=recipient,
owner=owner,
repo=accepted,
)
assert took.identity["sudo"] == recipient
assert (await mcp.ok("gitea_repo_get", owner=recipient, repo=accepted)).data["owner"][
"login"
] == recipient
await mcp.ok("gitea_repo_reject_repo_transfer", sudo=recipient, owner=owner, repo=rejected)
assert (await mcp.ok("gitea_repo_get", owner=owner, repo=rejected)).data["owner"][
"login"
] == owner
# The transfer is gone: answering it again finds nothing.
await mcp.fail(
"gitea_repo_accept_repo_transfer", 404, sudo=recipient, owner=owner, repo=rejected
)
finally:
gitea.api("DELETE", f"/repos/{owner}/{rejected}")
gitea.api("DELETE", f"/repos/{owner}/{accepted}")
gitea.api("DELETE", f"/repos/{recipient}/{accepted}")
drop_user(gitea, recipient)
@@ -238,10 +238,15 @@ async def test_webhook_lifecycle(
f"{prefix}_edit_hook",
f"{prefix}_delete_hook",
)
# The signing secret and the Authorization header are credentials: neither may come
# back in any result or reach the audit log (Gitea itself omits the secret).
secret = f"hook-secret-{secrets.token_hex(12)}"
header = f"Bearer hook-auth-{secrets.token_hex(12)}"
config = {**HOOK_CONFIG, "secret": secret, "authorization_header": header}
hook = await mcp.ok(
create,
status=201,
body={"type": "gitea", "config": HOOK_CONFIG, "events": ["push"], "active": True},
body={"type": "gitea", "config": config, "events": ["push"], "active": True},
**scope,
)
hook_id = hook.data["id"]
@@ -252,7 +257,10 @@ async def test_webhook_lifecycle(
assert hook_id in [item["id"] for item in hooks.data]
assert (await mcp.ok(get, id=hook_id, **scope)).data["config"]["url"] == HOOK_CONFIG["url"]
edited = await mcp.ok(
edit, id=hook_id, body={"active": False, "events": ["push", "issues"]}, **scope
edit,
id=hook_id,
body={"active": False, "events": ["push", "issues"], "config": config},
**scope,
)
assert edited.data["active"] is False
assert "issues" in edited.data["events"]
@@ -260,6 +268,9 @@ async def test_webhook_lifecycle(
await mcp.ok("gitea_hook_repo_test_hook", id=hook_id, **scope)
await mcp.ok(delete, id=hook_id, **scope)
await mcp.fail(get, 404, id=hook_id, **scope)
for credential in (secret, header, header.removeprefix("Bearer ")):
assert all(credential not in text for text in mcp.texts), "a webhook credential leaked"
assert credential not in mcp.audit_text(), "a webhook credential reached the audit log"
finally:
if level == "org":
drop_org(gitea, org)
+23 -6
View File
@@ -2,6 +2,8 @@
from __future__ import annotations
import os
import pytest
from aegis_gitea_mcp.audit import validate_audit_log_integrity
@@ -9,20 +11,20 @@ from aegis_gitea_mcp.catalog import load_catalog
from tests.integration.conftest import McpSession, RegularUser
from tests.integration.harness import GiteaInstance
from tests.integration.sweep_plan import (
BASELINE_ENV,
EXCLUDED,
LOG,
RAW_TOOL,
compare_baseline,
failing,
load_baseline,
load_spec,
run_sweep,
summarize,
write_baseline,
)
from tests.integration.sweep_seed import Ctx, seed
#: At least this share of the tools must get a real 2xx, so a systematic argument problem
#: cannot hide behind documented 404 responses.
MIN_SUCCESS_SHARE = 0.55
@pytest.fixture(scope="module")
def sweep_context(gitea: GiteaInstance, regular_user: RegularUser) -> Ctx:
@@ -53,8 +55,23 @@ async def test_every_tool_reaches_gitea(
for r in bad
)
assert not bad, f"{len(bad)} tool(s) with an unexpected result ({summary}):\n{report}"
success_share = summary.get("success", 0) / len(records)
assert success_share >= MIN_SUCCESS_SHARE, f"only {success_share:.0%} of tools got a 2xx"
# The pinned outcome per tool: an argument or path regression cannot hide behind a
# documented 404, because a 2xx that turns into any other status fails here.
if os.environ.get(BASELINE_ENV) == "1":
write_baseline(records, load_catalog().spec_version)
else:
diff = compare_baseline(records, load_baseline())
if diff.upgrades:
print(
f"\n{len(diff.upgrades)} tool(s) improved on the baseline; refresh it with "
f"{BASELINE_ENV}=1:\n " + "\n ".join(diff.upgrades)
)
assert not diff.regressions, (
f"{len(diff.regressions)} tool(s) differ from sweep_baseline.json "
f"(regenerate with {BASELINE_ENV}=1 only when the change is intended):\n "
+ "\n ".join(diff.regressions)
)
# Whatever the 538 calls did, no credential or secret value came back in a result.
hidden = [
+35 -5
View File
@@ -275,19 +275,25 @@ def run_cleanup(tmp_path: Path, *refs: str) -> list[str]:
def test_cleanup_removes_containers_only_for_per_run_refs(tmp_path: Path) -> None:
registry = "git.hiddenden.cafe/hiddenden/aegisgitea-mcp"
per_run = f"{registry}:sha-0123456789ab"
shared = [f"{registry}:latest", f"{registry}:main", f"{registry}:dev"]
per_run = "aegis-ci-4242-build:0123456789abcdef"
shared = [
f"{registry}:sha-0123456789ab",
f"{registry}:latest",
f"{registry}:main",
f"{registry}:dev",
]
calls = run_cleanup(tmp_path, per_run, *shared)
calls = run_cleanup(tmp_path, *shared, per_run)
removed = [call for call in calls if call.startswith("rm -f")]
assert removed == [f"rm -f -v cid-of-{per_run}"]
queried = [call for call in calls if call.startswith("ps ")]
assert all("sha-0123456789ab" in call for call in queried) and len(queried) == 1
# Shared tags are only untagged.
assert len(queried) == 1 and per_run in queried[0]
# Registry tags (the immutable sha tag included) are only untagged.
for ref in shared:
assert f"image rm {ref}" in calls
assert f"rmi {ref}" not in calls
assert f"rmi {per_run}" in calls
def test_cleanup_treats_names_with_the_run_prefix_as_per_run(tmp_path: Path) -> None:
@@ -324,3 +330,27 @@ def test_scripts_are_executable_and_parse() -> None:
for script in (ROOT / "scripts" / "ci").glob("*.sh"):
assert os.access(script, os.X_OK), script.name
subprocess.run(["bash", "-n", str(script)], check=True, timeout=30) # noqa: S603, S607
def test_build_and_push_builds_under_a_per_run_tag_and_gates_on_every_test_job() -> None:
jobs = {job.name: job for job in jobs_of(ROOT / ".gitea" / "workflows" / "ci.yml")}
build = jobs["build-and-push"]
assert {"lint", "test-unit", "test-integration", "test-mcp", "security"} <= set(build.needs)
text = build.text
assert 'BUILD_TAG="aegis-ci-${GITHUB_RUN_ID}-build:${GITHUB_SHA}"' in text
# Nothing is built, scanned or smoke-tested under a registry tag.
assert '-t "${{ steps.meta.outputs.build_tag }}"' in text
assert 'scan_image.sh "${{ steps.meta.outputs.build_tag }}"' in text
assert '-t "${{ steps.meta.outputs.sha_tag }}"' not in text
# Registry tags are applied only in the push step, and cleanup removes the build tag last.
assert text.index("docker build") < text.index("docker tag") < text.index("docker push")
assert text.count("docker tag") == 1
cleanup = text[text.index("scripts/ci/cleanup_job_resources.sh") :]
assert cleanup.index("sha_tag") < cleanup.index("build_tag")
def test_the_integration_job_requires_the_actions_runner() -> None:
jobs = {job.name: job for job in jobs_of(ROOT / ".gitea" / "workflows" / "ci.yml")}
assert 'AEGIS_IT_REQUIRE_RUNNER: "1"' in jobs["test-integration"].text
+8 -2
View File
@@ -173,8 +173,14 @@ def test_printed_configs(httpx_mock: HTTPXMock, capsys: pytest.CaptureFixture[st
mock_create(httpx_mock)
assert run_setup() == 0
out = capsys.readouterr().out
assert f"--env GITEA_URL={GITEA} --env GITEA_TOKEN=<your-gitea-token>" in out
assert "uvx aegis-gitea-mcp" in out
index = "https://git.hiddenden.cafe/api/packages/Hiddenden/pypi/simple/"
assert (
f"claude mcp add --env GITEA_URL={GITEA} --env GITEA_TOKEN=<your-gitea-token> "
f"--transport stdio aegis-gitea -- uvx --index {index} aegis-gitea-mcp"
) in out
# The bare name would resolve from public PyPI, where it is not registered.
assert "uvx aegis-gitea-mcp" not in out
assert f'"--index",\n "{index}"' in out
assert '"mcpServers"' in out
assert (
f"claude mcp add --transport http aegis-gitea {PUBLIC}/mcp "
+46
View File
@@ -271,3 +271,49 @@ def test_report_is_only_written_when_enabled(
]
assert sweep_plan.write_report_if_enabled(CATALOG, "28.1.0") is True
assert target.read_text(encoding="utf-8").startswith("# Integration coverage")
def _record(tool: str, status: int | None, is_error: bool = False) -> SweepRecord:
return SweepRecord(tool, "repo", "GET", "/x", status, is_error, None, "", "success")
def test_outcome_class_is_2xx_or_the_exact_status() -> None:
assert sweep_plan.outcome_class(_record("t", 200)) == "2xx"
assert sweep_plan.outcome_class(_record("t", 204)) == "2xx"
assert sweep_plan.outcome_class(_record("t", 404, True)) == 404
assert sweep_plan.outcome_class(_record("t", 200, True)) == 200 # an error flag wins
assert sweep_plan.outcome_class(_record("t", None, True)) == "no-status"
def test_baseline_reports_every_regression_at_once_and_notes_upgrades() -> None:
baseline: dict[str, Any] = {"a": "2xx", "b": 404, "c": 404, "d": "2xx", "gone": "2xx"}
records = [
_record("a", 404, True), # 2xx -> 404: regression
_record("b", 200), # 404 -> 2xx: upgrade
_record("c", 403, True), # 404 -> 403: a different status is a regression
_record("d", 200), # unchanged
_record("new", 200), # not in the baseline
]
diff = sweep_plan.compare_baseline(records, baseline)
assert diff.upgrades == ["b: baseline 404, now 2xx"]
assert len(diff.regressions) == 4
assert diff.regressions[0].startswith("a: baseline 2xx, got 404")
assert diff.regressions[1].startswith("c: baseline 404, got 403")
assert "new: no baseline entry" in diff.regressions[2]
assert diff.regressions[3] == "gone: in the baseline but not called by the sweep"
def test_baseline_round_trips_and_covers_the_whole_sweep(tmp_path: Any) -> None:
target = tmp_path / "baseline.json"
records = [_record("b", 404, True), _record("a", 200)]
sweep_plan.write_baseline(records, "28.1.0", target)
assert sweep_plan.load_baseline(target) == {"a": "2xx", "b": 404}
assert list(sweep_plan.load_baseline(target)) == ["a", "b"]
# The committed baseline names exactly the tools the sweep calls.
committed = sweep_plan.load_baseline()
swept = {op.tool_name for op in ordered_operations(CATALOG)} | {RAW_TOOL}
assert set(committed) == swept
assert all(value == "2xx" or isinstance(value, int) for value in committed.values())