Files
AegisGitea-MCP/tests/integration/sweep_plan.py
T

929 lines
40 KiB
Python

"""Plan for the all-tools sweep: arguments, ordering, expectations and the coverage report.
The sweep calls every tool (538 with ``gitea_api_request``) once through the MCP server
against a seeded Gitea (:mod:`tests.integration.sweep_seed`). This module holds everything
that is data or pure logic, so it can be checked without Docker
(``tests/test_sweep_plan.py``):
* :func:`build_arguments` turns a catalog operation plus the seed context into tool
arguments: path values from the context, required bodies generated from their schema,
and an override table (:data:`OVERRIDES`) for tools that need specific bodies or ids.
* :func:`ordered_operations` sorts the calls: read-only first, then creates and updates,
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:`render_report` writes ``docs/integration-coverage.md``.
"""
from __future__ import annotations
import json
import os
import re
import secrets
from collections.abc import Callable, Iterable, Sequence
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from aegis_gitea_mcp.catalog import Catalog, Operation
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"
RAW_TOOL = "gitea_api_request"
#: Tools the generic sweep never calls, with the test that covers them in isolation.
EXCLUDED: dict[str, str] = {
"gitea_misc_delete_current_token": (
"deletes the token the sweep itself runs with; covered by test_sweep_isolated.py "
"with a throwaway token"
),
}
@dataclass(frozen=True)
class Allowance:
"""A status that is accepted for a tool although the swagger does not document it."""
statuses: frozenset[int]
reason: str
def allow(reason: str, *statuses: int) -> Allowance:
"""Build an :class:`Allowance`."""
return Allowance(frozenset(statuses), reason)
#: Tools whose result may be a status the swagger does not list, each with its reason.
_NO_SIGNING_KEY = allow(
"the throwaway instance has no commit signing key; Gitea answers 404 although the "
"swagger lists no 404 for this operation",
404,
)
ALLOWANCES: dict[str, Allowance] = {
"gitea_misc_get_signing_key": _NO_SIGNING_KEY,
"gitea_misc_get_signing_key_ssh": _NO_SIGNING_KEY,
"gitea_repo_signing_key": _NO_SIGNING_KEY,
"gitea_repo_signing_key_ssh": _NO_SIGNING_KEY,
"gitea_admin_add_user_badges": allow(
"Gitea answers 500 for an unknown badge slug and the API cannot create badges", 500
),
"gitea_admin_delete_unadopted_repository": allow(
"no unadopted repository exists on the instance; Gitea answers 404 although the swagger "
"lists only 204 and 403",
404,
),
"gitea_user_create_token": allow(
"Gitea only accepts basic auth here; the server always sends token authentication", 401
),
"gitea_user_get_tokens": allow(
"Gitea only accepts basic auth here; the server always sends token authentication", 401
),
"gitea_user_delete_access_token": allow(
"Gitea only accepts basic auth here; the server always sends token authentication", 401
),
}
# ---------------------------------------------------------------------------
# Spec helpers
# ---------------------------------------------------------------------------
def load_spec() -> dict[str, Any]:
"""Load the pinned swagger."""
data: dict[str, Any] = json.loads(SPEC_PATH.read_text(encoding="utf-8"))
return data
def documented_statuses(spec: dict[str, Any], operation: Operation) -> frozenset[int]:
"""Status codes the swagger lists in ``responses`` of this operation."""
item = spec["paths"][operation.path][operation.method.lower()]
return frozenset(int(code) for code in item["responses"] if code.isdigit())
# ---------------------------------------------------------------------------
# Argument synthesis
# ---------------------------------------------------------------------------
DEFAULT_PATH_VALUES: dict[str, str] = {
"owner": "owner",
"repo": "repo",
"org": "org",
"username": "user",
"collaborator": "user",
"assignee": "user",
"target": "user",
"user": "user",
"template_owner": "owner",
"template_repo": "template_repo",
"repo_name": "repo",
"index": "issue",
"sha": "sha",
"run": "run",
"job_id": "job_id",
"pageName": "page_name",
"team": "team_name",
"task": "cron_task",
}
CONSTANT_PATH_VALUES: dict[str, Any] = {
"ref": "main",
"branch": "main",
"filepath": "README.md",
"archive": "main.zip",
"basehead": "main...feat",
"diffType": "diff",
"base": "main",
"head": "feat",
"type": "generic",
"version": "1.0.0",
"attempt": 1,
"artifact_id": 1,
"runner_id": 1,
"position": 1,
"workflow_id": "sweep.yml",
"topic": "extra",
"token": "none",
}
# (regex over the path template, context key) for the ``{id}`` segment, first match wins.
ID_RULES: list[tuple[str, str]] = [
(r"^/repos/\{owner\}/\{repo\}/pulls/comments/\{id\}", "review_comment"),
(r"^/repos/\{owner\}/\{repo\}/pulls/\{index\}/comments/\{id\}", "review_comment"),
(r"^/repos/\{owner\}/\{repo\}/pulls/\{index\}/reviews/\{id\}", "review"),
(r"^/repos/\{owner\}/\{repo\}/issues/comments/\{id\}", "comment"),
(r"^/repos/\{owner\}/\{repo\}/issues/\{index\}/comments/\{id\}", "comment"),
(r"^/repos/\{owner\}/\{repo\}/issues/\{index\}/labels/\{id\}", "label"),
(r"^/repos/\{owner\}/\{repo\}/issues/\{index\}/times/\{id\}", "time_id"),
(r"^/repos/\{owner\}/\{repo\}/labels/\{id\}", "label"),
(r"^/repos/\{owner\}/\{repo\}/milestones/\{id\}", "milestone"),
(r"^/repos/\{owner\}/\{repo\}/hooks/git/\{id\}", "git_hook"),
(r"^/repos/\{owner\}/\{repo\}/hooks/\{id\}", "repo_hook"),
(r"^/repos/\{owner\}/\{repo\}/keys/\{id\}", "deploy_key"),
(r"^/repos/\{owner\}/\{repo\}/releases/\{id\}", "release"),
(r"^/repos/\{owner\}/\{repo\}/tag_protections/\{id\}", "tag_protection"),
(r"^/repos/\{owner\}/\{repo\}/projects/\{id\}", "repo_project"),
(r"^/orgs/\{org\}/hooks/\{id\}", "org_hook"),
(r"^/orgs/\{org\}/labels/\{id\}", "org_label"),
(r"^/orgs/\{org\}/projects/\{id\}", "org_project"),
(r"^/user/hooks/\{id\}", "user_hook"),
(r"^/user/keys/\{id\}", "user_key"),
(r"^/user/projects/\{id\}", "user_project"),
(r"^/user/applications/oauth2/\{id\}", "oauth_app"),
(r"^/admin/hooks/\{id\}", "admin_hook"),
(r"^/admin/users/\{username\}/keys/\{id\}", "admin_user_key"),
(r"^/teams/\{id\}", "team_id"),
(r"^/repositories/\{id\}", "repo_id"),
(r"^/notifications/threads/\{id\}", "notification"),
]
def _path_value(operation: Operation, name: str, c: Ctx) -> Any:
"""Value of the path parameter ``name`` of ``operation`` from the context."""
path = operation.path
if name == "index":
return c["pr"] if "/pulls/" in path else c["issue"]
if name == "id":
for pattern, key in ID_RULES:
if re.match(pattern, path):
return c[key]
return 1
if name == "attachment_id":
if "/releases/" in path:
return c["release_asset"]
return c["comment_attachment"] if "/issues/comments/" in path else c["issue_attachment"]
if name == "column_id":
if path.startswith("/orgs/"):
return c["org_column"]
return c["user_column"] if path.startswith("/user/") else c["repo_column"]
if name == "issue_id":
return c["org_issue_id"] if path.startswith("/orgs/") else c["issue1_id"]
if name == "secretname":
scope = path.split("/")[1]
return {"orgs": "ORG_SECRET", "user": c["user_secret"]}.get(scope, "REPO_SECRET")
if name == "variablename":
scope = path.split("/")[1]
return {"orgs": "ORG_VAR", "user": c["user_var"]}.get(scope, "REPO_VAR")
if name == "name":
if path.startswith("/gitignore/"):
return "Go"
if path.startswith("/label/"):
return "Advanced"
if path.startswith("/licenses/"):
return "MIT"
if path.startswith("/packages/"):
return c["pkg"]
if "branch_protections" in path:
return c["protection"]
return "none"
if name == "tag":
return c["release_tag"] if "/releases/" in path else "v0.0.1"
if name in DEFAULT_PATH_VALUES:
return c[DEFAULT_PATH_VALUES[name]]
if name in CONSTANT_PATH_VALUES:
return CONSTANT_PATH_VALUES[name]
return 1
def _coerce(value: Any, schema: dict[str, Any]) -> Any:
"""Convert ``value`` to the JSON type the argument schema asks for."""
kind = schema.get("type")
if kind == "string":
return str(value)
if kind == "integer" and isinstance(value, str) and value.isdigit():
return int(value)
return value
def sample(schema: dict[str, Any], name: str = "") -> Any:
"""A minimal valid value for ``schema`` (required properties only)."""
if "enum" in schema:
return schema["enum"][0]
if "anyOf" in schema:
return sample(schema["anyOf"][0], name)
kind = schema.get("type")
if kind == "object" or "properties" in schema:
required = schema.get("required", [])
props = schema.get("properties", {})
return {key: sample(props[key], key) for key in required if key in props}
if kind == "array":
return []
if kind in ("integer", "number"):
return 1
if kind == "boolean":
return False
lowered = name.lower()
if "email" in lowered:
return f"sweep-{secrets.token_hex(3)}@example.test"
if "url" in lowered or "addr" in lowered:
return "http://127.0.0.1:9/sweep"
if "color" in lowered:
return "#336699"
if "base64" in lowered or lowered == "content":
return b64("sweep")
return f"sweep-{secrets.token_hex(3)}"
def fresh(c: Ctx, prefix: str = "sw") -> str:
"""A fresh name for one more resource."""
return f"{prefix}-{c['sfx']}-{secrets.token_hex(2)}"
def hook_body(c: Ctx) -> dict[str, Any]:
"""A webhook body of type gitea."""
return {
"type": "gitea",
"config": {"url": "http://127.0.0.1:9/sweep", "content_type": "json"},
"events": ["push"],
"active": True,
}
def local_url(c: Ctx) -> str:
"""Base URL of the admin's repositories as seen from inside the Gitea container."""
return f"http://127.0.0.1:3000/{c['admin']}"
def _project_overrides() -> dict[str, Override]:
"""Column and issue handling of org, repo and user projects (all three work alike)."""
result: dict[str, Override] = {}
for scope, tool_scope in (("org", "org"), ("repo", "repo"), ("user", "user_current")):
result[f"gitea_{tool_scope}_move_project_columns"] = lambda c, s=scope: {
"body": {"column_ids": c[f"{s}_columns"]}
}
result[f"gitea_{tool_scope}_move_project_issue"] = lambda c, s=scope: {
"body": {"column_id": c[f"{s}_column2"]}
}
result[f"gitea_{tool_scope}_delete_project_column"] = lambda c, s=scope: {
"column_id": c[f"{s}_column2"]
}
result[f"gitea_{tool_scope}_remove_issue_from_project_column"] = lambda c, s=scope: {
"column_id": c[f"{s}_column2"]
}
return result
def _org_repo(c: Ctx) -> dict[str, Any]:
return {"owner": c["org"], "repo": c["org_repo"]}
def _org_repo_team(c: Ctx) -> dict[str, Any]:
return {"owner": c["org"], "repo": c["org_repo"], "team": c["team_name"]}
def _team_repo(c: Ctx) -> dict[str, Any]:
return {"org": c["org"], "repo": c["org_repo"]}
PATCH_TEXT = """\
diff --git a/applied-{name}.txt b/applied-{name}.txt
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/applied-{name}.txt
@@ -0,0 +1 @@
+applied
"""
Override = Callable[[Ctx], dict[str, Any]]
OVERRIDES: dict[str, Override] = {
# ------------------------------------------------------------------ reads
"gitea_repo_get_annotated_tag": lambda c: {"sha": c["tag_sha"]},
"gitea_repo_get_push_mirror_by_remote_name": lambda c: {"name": c["push_mirror"]},
"gitea_repo_delete_push_mirror": lambda c: {"name": c["push_mirror"]},
"gitea_repo_add_push_mirror": lambda c: {
"body": {
"remote_address": f"{local_url(c)}/{c['push_target2']}.git",
"remote_username": c["admin"],
"remote_password": c["admin_password"],
"interval": "8h0m0s",
"sync_on_commit": False,
}
},
"gitea_repo_mirror_sync": lambda c: {"repo": c["mirror_repo"]},
"gitea_repo_merge_upstream": lambda c: {
"owner": c["org"],
"repo": c["fork_repo"],
"body": {"branch": "main"},
},
"gitea_repo_migrate": lambda c: {
"body": {
"clone_addr": f"{local_url(c)}/{c['template_repo']}.git",
"repo_name": fresh(c, "mig"),
"service": "git",
}
},
"gitea_repo_add_team": lambda c: {**_org_repo(c), "team": c["team2_name"]},
"gitea_repo_delete_team": lambda c: {**_org_repo(c), "team": c["team2_name"]},
"gitea_repo_update_topics": lambda c: {"body": {"topics": ["sweep", "seed", "extra"]}},
"gitea_issue_delete_stopwatch": lambda c: {"sudo": c["user"], "index": c["issue2"]},
"gitea_org_block_user": lambda c: {"username": c["user3"]},
"gitea_user_block_user": lambda c: {"username": c["user3"]},
"gitea_repo_get_file_contents": lambda c: {"query_body": json.dumps({"files": ["README.md"]})},
"gitea_repo_get_file_contents_post": lambda c: {"body": {"files": ["README.md"]}},
"gitea_repo_list_git_refs": lambda c: {"ref": "heads/main"},
"gitea_repo_get_blob": lambda c: {"sha": c["blob_sha"]},
"gitea_repo_check_team": _org_repo_team,
"gitea_repo_list_teams": _org_repo,
"gitea_team_list_team_repo": _team_repo,
"gitea_team_add_team_repository": _team_repo,
"gitea_team_remove_team_repository": _team_repo,
"gitea_user_check_following": lambda c: {"username": c["admin"], "target": c["user"]},
"gitea_misc_render_markdown": lambda c: {"body": {"Text": "# sweep", "Mode": "markdown"}},
"gitea_misc_render_markup": lambda c: {
"body": {"Text": "# sweep", "Mode": "markdown", "Context": ""}
},
"gitea_misc_render_markdown_raw": lambda c: {"body": "# sweep"},
"gitea_notification_read_thread": lambda c: {"id": str(c["notification"])},
"gitea_notification_get_thread": lambda c: {"id": str(c["notification"])},
"gitea_user_get_tokens": lambda c: {"username": c["admin"]},
# ---------------------------------------------------------------- actions
"gitea_actions_create_org_variable": lambda c: {
"variablename": "SW_NEW",
"body": {"value": "1"},
},
"gitea_actions_create_repo_variable": lambda c: {
"variablename": "SW_NEW",
"body": {"value": "1"},
},
"gitea_actions_create_user_variable": lambda c: {
"variablename": f"SW_NEW_{c['sfx'].upper()}",
"body": {"value": "1"},
},
"gitea_actions_dispatch_workflow": lambda c: {"body": {"ref": "main"}},
"gitea_actions_update_org_secret": lambda c: {"body": {"data": "rotated-org-value"}},
"gitea_actions_update_repo_secret": lambda c: {"body": {"data": "rotated-repo-value"}},
"gitea_actions_update_user_secret": lambda c: {"body": {"data": "rotated-user-value"}},
# ------------------------------------------------------------------ admin
"gitea_admin_add_user_badges": lambda c: {"body": {"badge_slugs": ["sweep"]}},
"gitea_admin_delete_user_badges": lambda c: {"body": {"badge_slugs": ["sweep"]}},
"gitea_admin_create_hook": lambda c: {"body": hook_body(c)},
"gitea_admin_create_org": lambda c: {"body": {"username": fresh(c, "sw-aorg")}},
"gitea_admin_create_public_key": lambda c: {
"body": {"title": fresh(c, "key"), "key": ssh_public_key()}
},
"gitea_admin_create_repo": lambda c: {"body": {"name": fresh(c, "sw-arepo")}},
"gitea_admin_create_user": lambda c: _new_user(c),
"gitea_admin_edit_user": lambda c: {
"username": c["user2"],
"body": {"source_id": 0, "login_name": c["user2"], "full_name": "Sweep Two"},
},
"gitea_admin_rename_user": lambda c: {
"username": c["rename_user"],
"body": {"new_username": fresh(c, "sw-renamed")},
},
"gitea_admin_delete_user": lambda c: {"username": c["del_user"]},
# ------------------------------------------------------------------ hooks
"gitea_hook_org_create_hook": lambda c: {"body": hook_body(c)},
"gitea_hook_repo_create_hook": lambda c: {"body": hook_body(c)},
"gitea_hook_user_create_hook": lambda c: {"body": hook_body(c)},
"gitea_hook_repo_edit_git_hook": lambda c: {"body": {"content": "#!/bin/sh\nexit 0\n"}},
# ----------------------------------------------------------------- issues
"gitea_issue_create_issue": lambda c: {"body": {"title": fresh(c, "issue")}},
"gitea_issue_create_comment": lambda c: {"body": {"body": "sweep comment"}},
"gitea_issue_create_label": lambda c: {"body": {"name": fresh(c, "lbl"), "color": "#00aa00"}},
"gitea_issue_create_milestone": lambda c: {"body": {"title": fresh(c, "ms")}},
"gitea_issue_add_label": lambda c: {"body": {"labels": [c["label"]]}},
"gitea_issue_replace_labels": lambda c: {"body": {"labels": [c["label"]]}},
"gitea_issue_add_assignees": lambda c: {"body": {"assignees": [c["user"]]}},
"gitea_issue_remove_assignees": lambda c: {"body": {"assignees": [c["admin"]]}},
"gitea_issue_add_time": lambda c: {"body": {"time": 30}},
"gitea_issue_edit_issue": lambda c: {"body": {"title": "Sweep main edited"}},
"gitea_issue_edit_comment": lambda c: {"body": {"body": "edited"}},
"gitea_issue_edit_comment_deprecated": lambda c: {"body": {"body": "edited again"}},
"gitea_issue_edit_label": lambda c: {"body": {"name": "bug-renamed"}},
"gitea_issue_edit_milestone": lambda c: {"body": {"title": "m1-renamed"}},
"gitea_issue_edit_issue_attachment": lambda c: {"body": {"name": "renamed.txt"}},
"gitea_issue_edit_issue_comment_attachment": lambda c: {"body": {"name": "renamed.txt"}},
"gitea_issue_edit_issue_deadline": lambda c: {"body": {"due_date": "2030-01-01T00:00:00Z"}},
"gitea_issue_lock_issue": lambda c: {"body": {"lock_reason": "too heated"}},
"gitea_issue_move_issue_pin": lambda c: {"index": c["issue3"]},
"gitea_issue_unpin_issue": lambda c: {"index": c["issue3"]},
"gitea_issue_post_issue_reaction": lambda c: {"body": {"content": "heart"}},
"gitea_issue_post_comment_reaction": lambda c: {"body": {"content": "heart"}},
"gitea_issue_delete_issue_reaction": lambda c: {"body": {"content": "+1"}},
"gitea_issue_delete_comment_reaction": lambda c: {"body": {"content": "+1"}},
"gitea_issue_create_issue_blocking": lambda c: {
"body": {"index": c["issue5"], "owner": c["owner"], "repo": c["repo"]}
},
"gitea_issue_remove_issue_blocking": lambda c: {
"body": {"index": c["issue3"], "owner": c["owner"], "repo": c["repo"]}
},
"gitea_issue_create_issue_dependencies": lambda c: {
"body": {"index": c["issue4"], "owner": c["owner"], "repo": c["repo"]}
},
"gitea_issue_remove_issue_dependencies": lambda c: {
"body": {"index": c["issue2"], "owner": c["owner"], "repo": c["repo"]}
},
"gitea_issue_delete_comment_deprecated": lambda c: {"id": c["comment2"]},
# ------------------------------------------------------------------- orgs
"gitea_org_create": lambda c: {"body": {"username": fresh(c, "sw-neworg")}},
"gitea_org_create_label": lambda c: {"body": {"name": fresh(c, "lbl"), "color": "#00aa00"}},
"gitea_org_create_org_repo": lambda c: {"body": {"name": fresh(c, "sw-orgrepo")}},
"gitea_org_create_org_repo_deprecated": lambda c: {"body": {"name": fresh(c, "sw-orgrepo")}},
"gitea_org_create_project": lambda c: {"body": {"title": fresh(c, "proj")}},
"gitea_org_create_project_column": lambda c: {
"id": c["org_project"],
"body": {"title": fresh(c, "col")},
},
"gitea_org_edit": lambda c: {"body": {"description": "edited by the sweep"}},
"gitea_org_rename": lambda c: {
"org": c["rename_org"],
"body": {"new_name": fresh(c, "sw-renorg")},
},
"gitea_org_delete": lambda c: {"org": c["del_org2"]},
"gitea_org_delete_repos": lambda c: {"org": c["del_org"]},
"gitea_org_update_avatar": lambda c: {"body": {"image": PNG_1X1}},
"gitea_org_check_user_block": lambda c: {"username": c["user2"]},
"gitea_org_unblock_user": lambda c: {"username": c["user2"]},
# ---------------------------------------------------------------- package
"gitea_package_delete_package": lambda c: {"name": c["pkg2"]},
# ------------------------------------------------------------------ pulls
"gitea_pull_create_pull_request": lambda c: {
"body": {"title": "Sweep PR three", "base": "main", "head": "feat3"}
},
"gitea_pull_create_pull_review": lambda c: {"body": {"event": "COMMENT", "body": "sweep"}},
"gitea_pull_submit_pull_review": lambda c: {
"sudo": c["user"],
"id": c["pending_review"],
"body": {"event": "COMMENT", "body": "submitted"},
},
"gitea_pull_delete_pull_review": lambda c: {"id": c["review2"]},
"gitea_pull_dismiss_pull_review": lambda c: {"body": {"message": "stale", "priors": False}},
"gitea_pull_create_pull_review_comment_reply": lambda c: {"body": {"body": "reply"}},
"gitea_pull_create_pull_review_requests": lambda c: {"body": {"reviewers": [c["user2"]]}},
"gitea_pull_delete_pull_review_requests": lambda c: {"body": {"reviewers": [c["user2"]]}},
"gitea_pull_merge_pull_request": lambda c: {"index": c["pr2"], "body": {"do": "merge"}},
"gitea_pull_edit_pull_request": lambda c: {"body": {"title": "Sweep PR edited"}},
# --------------------------------------------------------------- releases
"gitea_release_create_release": lambda c: {
"body": {"tag_name": "v3.0.0", "target_commitish": "main"}
},
"gitea_release_edit_release": lambda c: {"body": {"name": "renamed"}},
"gitea_release_edit_release_attachment": lambda c: {"body": {"name": "renamed.bin"}},
"gitea_release_delete_release_by_tag": lambda c: {"tag": c["release_tag2"]},
# ------------------------------------------------------------------ repos
"gitea_repo_create_file": lambda c: {
"filepath": f"new-{fresh(c)}.txt",
"body": {"content": b64("new"), "message": "sweep new file"},
},
"gitea_repo_update_file": lambda c: {
"filepath": "to-update.txt",
"body": {"content": b64("after"), "sha": c["update_file_sha"], "message": "sweep update"},
},
"gitea_repo_delete_file": lambda c: {
"filepath": "to-delete.txt",
"body": {"sha": c["delete_file_sha"], "message": "sweep delete"},
},
"gitea_repo_change_files": lambda c: {
"body": {
"files": [{"operation": "create", "path": f"cf-{fresh(c)}.txt", "content": b64("c")}],
"message": "sweep change files",
}
},
"gitea_repo_apply_diff_patch": lambda c: {
"body": {"content": PATCH_TEXT.format(name=fresh(c)), "message": "sweep patch"}
},
"gitea_repo_create_branch": lambda c: {
"body": {"new_branch_name": fresh(c, "br"), "old_branch_name": "main"}
},
"gitea_repo_create_branch_protection": lambda c: {"body": {"rule_name": "hotfix/*"}},
"gitea_repo_create_tag": lambda c: {"body": {"tag_name": "v0.0.2", "target": "main"}},
"gitea_repo_create_tag_protection": lambda c: {
"body": {"name_pattern": "w*", "whitelist_usernames": [c["admin"]]}
},
"gitea_repo_create_fork": lambda c: {
"body": {"organization": c["del_org"], "name": fresh(c, "fork")}
},
"gitea_repo_create_key": lambda c: {
"body": {"title": fresh(c, "key"), "key": ssh_public_key()}
},
"gitea_repo_create_deploy_token": lambda c: {"body": {"title": fresh(c, "dt")}},
"gitea_repo_create_status": lambda c: {"body": {"state": "success", "context": "ci/two"}},
"gitea_repo_create_project": lambda c: {"body": {"title": fresh(c, "proj")}},
"gitea_repo_create_project_column": lambda c: {
"id": c["repo_project"],
"body": {"title": fresh(c, "col")},
},
"gitea_repo_edit": lambda c: {"body": {"description": "edited by the sweep"}},
"gitea_repo_edit_branch_protection": lambda c: {"body": {"required_approvals": 0}},
"gitea_repo_edit_tag_protection": lambda c: {
"body": {"name_pattern": "v9*", "whitelist_usernames": [c["admin"]]}
},
"gitea_repo_edit_project": lambda c: {"body": {"title": "renamed project"}},
"gitea_repo_edit_project_column": lambda c: {"body": {"title": "renamed column"}},
"gitea_repo_generate_repo": lambda c: {
"body": {"name": fresh(c, "gen"), "owner": c["admin"], "git_content": True}
},
"gitea_repo_rename_branch": lambda c: {"branch": "rn", "body": {"name": "rn2"}},
"gitea_repo_transfer": lambda c: {"repo": c["xfer_repo"], "body": {"new_owner": c["org"]}},
"gitea_repo_update_avatar": lambda c: {"body": {"image": PNG_1X1}},
"gitea_repo_update_branch": lambda c: {
"branch": "feat3",
"body": {"new_commit_id": c["sha"], "force": True},
},
"gitea_repo_update_branch_protection_priorities": lambda c: {"body": {"ids": []}},
"gitea_repo_delete": lambda c: {"repo": c["del_repo"]},
"gitea_repo_delete_branch": lambda c: {"branch": "del-branch"},
"gitea_repo_delete_collaborator": lambda c: {"collaborator": c["user2"]},
# ------------------------------------------------------------------- wiki
"gitea_wiki_create_wiki_page": lambda c: {
"body": {"title": "Page2", "content_base64": b64("two"), "message": "page two"}
},
"gitea_wiki_edit_wiki_page": lambda c: {
"body": {"title": "Home", "content_base64": b64("# home v2\n"), "message": "edit"}
},
# ------------------------------------------------------------------ teams
"gitea_team_create_team": lambda c: {
"body": {"name": fresh(c, "team"), "permission": "read", "units": ["repo.code"]}
},
"gitea_team_edit_team": lambda c: {
"body": {"name": c["team_name"], "description": "edited by the sweep"}
},
# ------------------------------------------------------------------ users
"gitea_user_add_email": lambda c: {"body": {"emails": [f"sw-{fresh(c)}@example.test"]}},
"gitea_user_delete_email": lambda c: {"body": {"emails": [c["email"]]}},
"gitea_user_check_user_block": lambda c: {"username": c["user2"]},
"gitea_user_unblock_user": lambda c: {"username": c["user2"]},
"gitea_user_create_oauth2_application": lambda c: {
"body": {"name": fresh(c, "app"), "redirect_uris": ["http://127.0.0.1:1/cb"]}
},
"gitea_user_update_oauth2_application": lambda c: {
"body": {"name": fresh(c, "app2"), "redirect_uris": ["http://127.0.0.1:1/cb2"]}
},
"gitea_user_current_create_repo": lambda c: {"body": {"name": fresh(c, "sw-urepo")}},
"gitea_user_current_create_project": lambda c: {"body": {"title": fresh(c, "proj")}},
"gitea_user_current_create_project_column": lambda c: {
"id": c["user_project"],
"body": {"title": fresh(c, "col")},
},
"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_delete_access_token": lambda c: {
"username": c["admin"],
"token": c["throwaway_token"],
},
"gitea_user_update_avatar": lambda c: {"body": {"image": PNG_1X1}},
"gitea_user_update_settings": lambda c: {"body": {"description": "sweep"}},
# -------------------------------------------------------------------- raw
RAW_TOOL: lambda c: {"method": "GET", "path": "/version"},
}
OVERRIDES.update(_project_overrides())
def _new_user(c: Ctx) -> dict[str, Any]:
name = fresh(c, "sw-new")
return {
"body": {
"username": name,
"email": f"{name}@example.test",
"password": secrets.token_urlsafe(18),
"must_change_password": False,
}
}
def build_arguments(operation: Operation, c: Ctx) -> dict[str, Any]:
"""Synthesize the arguments of one catalog operation from the seed context."""
args: dict[str, Any] = {}
for parameter in operation.parameters:
if parameter.location == "path":
value = _path_value(operation, parameter.name, c)
args[parameter.arg_name] = _coerce(value, parameter.schema)
elif parameter.is_file:
args[parameter.arg_name] = {
"filename": "sweep.txt",
"content_base64": b64("sweep"),
"content_type": "text/plain",
}
elif parameter.location == "body":
needs_members = bool(parameter.schema.get("required"))
args[parameter.arg_name] = (
sample(parameter.schema) if parameter.required or needs_members else {}
)
elif parameter.required:
args[parameter.arg_name] = sample(parameter.schema, parameter.name)
override = OVERRIDES.get(operation.tool_name)
if override is not None:
args.update(override(c))
for name, value in list(args.items()):
parameter = next((p for p in operation.parameters if p.arg_name == name), None)
if parameter is not None and parameter.location == "path":
args[name] = _coerce(value, parameter.schema)
return args
# ---------------------------------------------------------------------------
# Ordering and classification
# ---------------------------------------------------------------------------
PHASE_BY_METHOD = {"GET": 1, "HEAD": 1, "POST": 2, "PUT": 3, "PATCH": 4, "DELETE": 5}
#: Explicit priorities inside a phase (higher runs later; default 50).
PRIORITY: dict[str, int] = {
"gitea_pull_merge_pull_request": 90,
# Moving columns must list every column, so it runs before any column is created.
"gitea_org_move_project_columns": 10,
"gitea_repo_move_project_columns": 10,
"gitea_user_current_move_project_columns": 10,
}
def phase_of(operation: Operation) -> int:
"""0 for read-only operations, then 2 (POST), 3 (PUT), 4 (PATCH) and 5 (DELETE)."""
if operation.read_only:
return 0
return PHASE_BY_METHOD.get(operation.method, 2)
def sort_key(operation: Operation) -> tuple[int, int, int, str]:
"""Sort key: phase, priority, leaf-first depth for deletes, name."""
phase = phase_of(operation)
depth = -operation.path.count("/") if phase == 5 else 0
return (phase, PRIORITY.get(operation.tool_name, 50), depth, operation.tool_name)
def ordered_operations(catalog: Catalog) -> list[Operation]:
"""Every operation that the generic sweep calls, in execution order."""
operations = [op for op in catalog.operations if op.tool_name not in EXCLUDED]
return sorted(operations, key=sort_key)
@dataclass
class SweepRecord:
"""The outcome of one sweep call."""
tool: str
domain: str
method: str
path: str
status: int | None
is_error: bool
kind: str | None
message: str
outcome: str
reason: str = ""
def classify(
tool: str,
status: int | None,
is_error: bool,
documented: frozenset[int],
) -> tuple[str, str]:
"""Return ``(outcome, reason)``: success, documented, allowance or unexpected."""
if status is not None and 200 <= status < 300 and not is_error:
return "success", ""
if status is not None and status in documented:
return "documented", ""
allowance = ALLOWANCES.get(tool)
if allowance is not None and status in allowance.statuses:
return "allowance", allowance.reason
return "unexpected", ""
# ---------------------------------------------------------------------------
# Results and report
# ---------------------------------------------------------------------------
@dataclass
class CallLog:
"""Every ``(tool, status, is_error)`` seen during the session, from every test."""
calls: list[tuple[str, int | None, bool]] = field(default_factory=list)
sweep: list[SweepRecord] = field(default_factory=list)
def add(self, tool: str, status: int | None, is_error: bool) -> None:
"""Record one call."""
self.calls.append((tool, status, is_error))
LOG = CallLog()
def _bucket(tool: str, log: CallLog) -> str:
"""``success`` when any call of the session got a 2xx, else the sweep outcome."""
for name, status, is_error in log.calls:
if name == tool and status is not None and 200 <= status < 300 and not is_error:
return "success"
for record in log.sweep:
if record.tool == tool:
return record.outcome
return "not called"
def render_report(catalog: Catalog, log: CallLog, gitea_version: str) -> str:
"""Render the coverage report as Markdown (no em dashes, no bold)."""
tool_domain = {op.tool_name: op.domain for op in catalog.operations}
tool_domain[RAW_TOOL] = "misc"
for name in EXCLUDED:
tool_domain.setdefault(name, "misc")
by_domain: dict[str, list[str]] = {}
for tool, domain in sorted(tool_domain.items()):
by_domain.setdefault(domain, []).append(tool)
sweep = {record.tool: record for record in log.sweep}
totals = {"success": 0, "documented": 0, "allowance": 0, "other": 0}
sections: list[str] = []
rows: list[str] = []
for domain in sorted(by_domain):
tools = by_domain[domain]
buckets = {tool: _bucket(tool, log) for tool in tools}
counts = {key: sum(1 for b in buckets.values() if b == key) for key in totals}
counts["other"] = (
len(tools) - counts["success"] - counts["documented"] - counts["allowance"]
)
for key in totals:
totals[key] += counts[key]
rows.append(
f"| {domain} | {len(tools)} | {counts['success']} | {counts['documented']} | "
f"{counts['allowance']} | {counts['other']} |"
)
lines = [f"## {domain}", ""]
ok = [t for t in tools if buckets[t] == "success"]
lines.append(f"Tools with a 2xx ({len(ok)}):")
lines.append("")
lines.append(", ".join(f"`{t}`" for t in ok) if ok else "none")
lines.append("")
documented = [t for t in tools if buckets[t] == "documented"]
lines.append(f"Tools with a documented non-2xx status ({len(documented)}):")
lines.append("")
if documented:
lines.extend(["| Tool | Status |", "| --- | --- |"])
lines.extend(f"| `{t}` | {sweep[t].status} |" for t in documented)
else:
lines.append("none")
lines.append("")
allowed = [t for t in tools if buckets[t] == "allowance"]
lines.append(f"Allowances ({len(allowed)}):")
lines.append("")
if allowed:
lines.extend(["| Tool | Status | Reason |", "| --- | --- | --- |"])
lines.extend(f"| `{t}` | {sweep[t].status} | {sweep[t].reason} |" for t in allowed)
else:
lines.append("none")
lines.append("")
other = [t for t in tools if buckets[t] not in ("success", "documented", "allowance")]
if other:
lines.append(f"Not covered by this run ({len(other)}):")
lines.append("")
lines.append(", ".join(f"`{t}`" for t in other))
lines.append("")
sections.append("\n".join(lines))
header = [
"# Integration coverage",
"",
"Generated by the live-Gitea integration suite; do not edit by hand. Regenerate with:",
"",
"```bash",
f"{REPORT_ENV}=1 AEGIS_INTEGRATION=1 uv run pytest tests/integration -m integration --no-cov",
"```",
"",
f"Target: Gitea {gitea_version}. Every tool of the server ({len(tool_domain)}) is called "
"at least once through the MCP server against a seeded instance (see "
"`tests/integration/test_sweep.py`). A call counts as covered when it reaches Gitea and "
"answers 2xx, answers a status the swagger documents for that operation, or answers a "
"status listed as an explicit allowance below. Scenario tests "
"(`tests/integration/test_scenario_*.py`) add real state changes; a tool counts as 2xx "
"when any call in the run got a 2xx.",
"",
"| Domain | Tools | 2xx | Documented non-2xx | Allowances | Not covered |",
"| --- | --- | --- | --- | --- | --- |",
*rows,
f"| total | {len(tool_domain)} | {totals['success']} | {totals['documented']} | "
f"{totals['allowance']} | {totals['other']} |",
"",
]
if EXCLUDED:
header.append("Excluded from the generic sweep and covered by a dedicated test:")
header.append("")
header.extend(f"- `{tool}`: {reason}" for tool, reason in sorted(EXCLUDED.items()))
header.append("")
return "\n".join(header) + "\n" + "\n".join(sections)
def write_report_if_enabled(catalog: Catalog, gitea_version: str) -> bool:
"""Write the report when ``AEGIS_IT_WRITE_REPORT=1`` and the sweep ran."""
if os.environ.get(REPORT_ENV) != "1" or not LOG.sweep:
return False
REPORT_PATH.write_text(render_report(catalog, LOG, gitea_version), encoding="utf-8")
return True
def failing(records: Iterable[SweepRecord]) -> list[SweepRecord]:
"""The records whose outcome is ``unexpected``."""
return [record for record in records if record.outcome == "unexpected"]
def summarize(records: Sequence[SweepRecord]) -> dict[str, int]:
"""Counts per outcome."""
result: dict[str, int] = {}
for record in records:
result[record.outcome] = result.get(record.outcome, 0) + 1
return result
# ---------------------------------------------------------------------------
# The sweep itself
# ---------------------------------------------------------------------------
CallTool = Callable[..., Any]
def _short(text: str, limit: int = 160) -> str:
return " ".join(text.split())[:limit]
async def run_sweep(
call: CallTool, catalog: Catalog, spec: dict[str, Any], c: Ctx
) -> list[SweepRecord]:
"""Call every tool once (in plan order) and classify the results.
``call(tool, **arguments)`` must return an object with ``status``, ``is_error``,
``error_kind`` and ``payload`` (a :class:`tests.integration.conftest.ToolResult`).
"""
records: list[SweepRecord] = []
jobs: list[tuple[str, str, str, str, frozenset[int], dict[str, Any]]] = []
for operation in ordered_operations(catalog):
jobs.append(
(
operation.tool_name,
operation.domain,
operation.method,
operation.path,
documented_statuses(spec, operation),
build_arguments(operation, c),
)
)
jobs.append((RAW_TOOL, "misc", "GET", "/version", frozenset({200}), OVERRIDES[RAW_TOOL](c)))
for tool, domain, method, path, documented, arguments in jobs:
result = await call(tool, **arguments)
error = result.payload.get("error") if result.is_error else None
message = ""
if isinstance(error, dict):
message = _short(str(error.get("message", "")))
outcome, reason = classify(tool, result.status, result.is_error, documented)
records.append(
SweepRecord(
tool=tool,
domain=domain,
method=method,
path=path,
status=result.status,
is_error=result.is_error,
kind=result.error_kind,
message=message,
outcome=outcome,
reason=reason,
)
)
return records