Compare commits
11
Commits
8786502b8c
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4800951ef
|
||
|
|
3fead4e121 | ||
|
|
79bce1d210 | ||
|
|
d1b9a4845e | ||
|
|
84fa29e380 | ||
|
|
7dc749d7cd | ||
|
|
9e51f72ec9 | ||
|
|
84b361962e | ||
|
|
4e108771f8 | ||
|
|
8ff67f5859 | ||
|
|
59d0e43aae |
No files matched your search
+25
-11
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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/`;
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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).
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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-----
|
||||
""",
|
||||
)
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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,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}")
|
||||
@@ -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,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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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
@@ -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 "
|
||||
|
||||
@@ -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())
|
||||
Reference in new issue
Block a user