Initial curated ITWorx_MCP_Hub source from 368fba691ea8d23403b4d56b53b5ec670d7c5020
This commit is contained in:
commit
cb5be898ea
663 files changed
+82674
No files matched your search
@@ -0,0 +1,13 @@
|
||||
"""Gitea read-only connector package."""
|
||||
|
||||
from .changesets import DulwichChangesetWorkspace
|
||||
from .fake import FakeGiteaServer
|
||||
from .server import GITEA_RESOURCES, GITEA_TOOLS, GiteaConnector
|
||||
|
||||
__all__ = [
|
||||
"DulwichChangesetWorkspace",
|
||||
"FakeGiteaServer",
|
||||
"GITEA_RESOURCES",
|
||||
"GITEA_TOOLS",
|
||||
"GiteaConnector",
|
||||
]
|
||||
@@ -0,0 +1,341 @@
|
||||
"""Bounded patching and single-commit Git workspaces for Gitea changesets."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from collections.abc import Iterator, Mapping, Sequence
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from threading import Lock
|
||||
from typing import Any, Protocol
|
||||
|
||||
from dulwich import porcelain
|
||||
from dulwich.repo import Repo
|
||||
|
||||
from packages.connector_kit import GiteaError, RegisteredRepository
|
||||
from packages.connector_kit.gitea import TokenProvider
|
||||
|
||||
_HUNK = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(?: .*)?$")
|
||||
_SHA = re.compile(r"^[0-9a-fA-F]{40,64}$")
|
||||
_MAX_CHANGESET_BYTES = 1_000_000
|
||||
_MAX_OPERATIONS = 50
|
||||
_MAX_WORKSPACE_BYTES = 256 * 1024 * 1024
|
||||
_DULWICH_IDENTITY_LOCK = Lock()
|
||||
_DULWICH_IDENTITY = {"USER": "mcp-agent", "EMAIL": "mcp-agent@example.com"}
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _dulwich_clone_identity() -> Iterator[None]:
|
||||
"""Give Dulwich a bounded reflog identity without mutating process state concurrently."""
|
||||
|
||||
with _DULWICH_IDENTITY_LOCK:
|
||||
previous = {name: os.environ.get(name) for name in _DULWICH_IDENTITY}
|
||||
os.environ.update(_DULWICH_IDENTITY)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
for name, value in previous.items():
|
||||
if value is None:
|
||||
os.environ.pop(name, None)
|
||||
else:
|
||||
os.environ[name] = value
|
||||
|
||||
|
||||
def _hex_object_id(value: bytes) -> str:
|
||||
try:
|
||||
decoded = value.decode("ascii")
|
||||
except UnicodeDecodeError:
|
||||
return value.hex()
|
||||
return decoded.casefold() if _SHA.fullmatch(decoded) else value.hex()
|
||||
|
||||
|
||||
class ChangesetWorkspace(Protocol):
|
||||
def apply(
|
||||
self,
|
||||
repository: RegisteredRepository,
|
||||
branch: str,
|
||||
message: str,
|
||||
operations: Sequence[Mapping[str, Any]],
|
||||
) -> dict[str, Any]: ...
|
||||
|
||||
|
||||
def apply_unified_patch(original: str, patch: str) -> str:
|
||||
"""Apply a single-file unified diff with exact context matching."""
|
||||
if not patch or len(patch.encode("utf-8")) > 300_000 or "\x00" in patch:
|
||||
raise GiteaError("GITEA_PATCH_INVALID", "patch is outside the connector budget")
|
||||
lines = patch.splitlines(keepends=True)
|
||||
index = 0
|
||||
while index < len(lines) and not lines[index].startswith("@@ "):
|
||||
if lines[index].startswith(("GIT binary patch", "Binary files ")):
|
||||
raise GiteaError("GITEA_PATCH_INVALID", "binary patches are not supported")
|
||||
index += 1
|
||||
if index == len(lines):
|
||||
raise GiteaError("GITEA_PATCH_INVALID", "patch contains no unified diff hunk")
|
||||
|
||||
source = original.splitlines(keepends=True)
|
||||
output: list[str] = []
|
||||
source_index = 0
|
||||
seen_hunk = False
|
||||
while index < len(lines):
|
||||
header = lines[index].rstrip("\r\n")
|
||||
match = _HUNK.fullmatch(header)
|
||||
if match is None:
|
||||
raise GiteaError("GITEA_PATCH_INVALID", "patch contains an invalid hunk header")
|
||||
seen_hunk = True
|
||||
old_start = int(match.group(1))
|
||||
old_count = int(match.group(2) or "1")
|
||||
new_count = int(match.group(4) or "1")
|
||||
target_index = max(old_start - 1, 0)
|
||||
if target_index < source_index or target_index > len(source):
|
||||
raise GiteaError("GITEA_PATCH_CONFLICT", "patch hunks overlap or exceed the file")
|
||||
output.extend(source[source_index:target_index])
|
||||
source_index = target_index
|
||||
index += 1
|
||||
consumed = 0
|
||||
produced = 0
|
||||
while index < len(lines) and not lines[index].startswith("@@ "):
|
||||
line = lines[index]
|
||||
if line.startswith("\\ No newline at end of file"):
|
||||
index += 1
|
||||
continue
|
||||
if not line or line[0] not in {" ", "+", "-"}:
|
||||
raise GiteaError("GITEA_PATCH_INVALID", "patch contains an invalid hunk line")
|
||||
marker, value = line[0], line[1:]
|
||||
if marker in {" ", "-"}:
|
||||
if source_index >= len(source) or source[source_index] != value:
|
||||
raise GiteaError("GITEA_PATCH_CONFLICT", "patch context does not match")
|
||||
source_index += 1
|
||||
consumed += 1
|
||||
if marker in {" ", "+"}:
|
||||
output.append(value)
|
||||
produced += 1
|
||||
index += 1
|
||||
if consumed != old_count or produced != new_count:
|
||||
raise GiteaError("GITEA_PATCH_INVALID", "patch hunk counts do not match")
|
||||
if not seen_hunk:
|
||||
raise GiteaError("GITEA_PATCH_INVALID", "patch contains no hunks")
|
||||
output.extend(source[source_index:])
|
||||
result = "".join(output)
|
||||
if len(result.encode("utf-8")) > 200_000:
|
||||
raise GiteaError("GITEA_INPUT_INVALID", "patched file exceeds the connector budget")
|
||||
return result
|
||||
|
||||
|
||||
def validate_operations(operations: Sequence[Mapping[str, Any]]) -> tuple[dict[str, Any], ...]:
|
||||
if not 1 <= len(operations) <= _MAX_OPERATIONS:
|
||||
raise GiteaError("GITEA_CHANGESET_INVALID", "changeset operation count is invalid")
|
||||
total = 0
|
||||
normalized: list[dict[str, Any]] = []
|
||||
touched: set[str] = set()
|
||||
for raw in operations:
|
||||
kind = str(raw.get("kind", ""))
|
||||
if kind not in {"create", "update", "delete", "move", "patch"}:
|
||||
raise GiteaError("GITEA_CHANGESET_INVALID", "changeset operation kind is invalid")
|
||||
path = str(raw.get("path", ""))
|
||||
if not path or path in touched:
|
||||
raise GiteaError("GITEA_CHANGESET_INVALID", "changeset paths must be unique")
|
||||
touched.add(path)
|
||||
operation: dict[str, Any] = {"kind": kind, "path": path}
|
||||
for field in ("content", "patch", "expectedSha", "destinationPath"):
|
||||
if field in raw and raw[field] is not None:
|
||||
operation[field] = str(raw[field])
|
||||
if kind in {"update", "delete", "move", "patch"} and not _SHA.fullmatch(
|
||||
operation.get("expectedSha", "")
|
||||
):
|
||||
raise GiteaError("GITEA_CHANGESET_INVALID", "expectedSha is required and invalid")
|
||||
if kind in {"create", "update"} and "content" not in operation:
|
||||
raise GiteaError("GITEA_CHANGESET_INVALID", "content is required")
|
||||
if kind == "patch" and "patch" not in operation:
|
||||
raise GiteaError("GITEA_CHANGESET_INVALID", "patch text is required")
|
||||
if kind == "move" and not operation.get("destinationPath"):
|
||||
raise GiteaError("GITEA_CHANGESET_INVALID", "destinationPath is required")
|
||||
destination = operation.get("destinationPath")
|
||||
if destination is not None:
|
||||
if destination in touched:
|
||||
raise GiteaError("GITEA_CHANGESET_INVALID", "changeset paths must be unique")
|
||||
touched.add(destination)
|
||||
total += sum(len(str(value).encode("utf-8")) for value in operation.values())
|
||||
normalized.append(operation)
|
||||
if total > _MAX_CHANGESET_BYTES:
|
||||
raise GiteaError("GITEA_CHANGESET_INVALID", "changeset exceeds the connector budget")
|
||||
return tuple(normalized)
|
||||
|
||||
|
||||
class DulwichChangesetWorkspace:
|
||||
"""Apply an already-validated changeset through a fixed, isolated Git workflow."""
|
||||
|
||||
def __init__(self, base_url: str, token_provider: TokenProvider, token_reference: str) -> None:
|
||||
self._base_url = base_url.rstrip("/")
|
||||
self._token_provider = token_provider
|
||||
self._token_reference = token_reference
|
||||
|
||||
@staticmethod
|
||||
def _relative(root: Path, value: str) -> Path:
|
||||
if not value or value.startswith(("/", "\\")) or "\\" in value:
|
||||
raise GiteaError("GITEA_PATH_INVALID", "repository file path is invalid")
|
||||
parts = value.split("/")
|
||||
if any(not part or part in {".", ".."} or "\x00" in part for part in parts):
|
||||
raise GiteaError("GITEA_PATH_INVALID", "repository file path is invalid")
|
||||
target = root.joinpath(*parts)
|
||||
try:
|
||||
target.resolve(strict=False).relative_to(root.resolve())
|
||||
except ValueError as exc:
|
||||
raise GiteaError("GITEA_PATH_INVALID", "repository file path is invalid") from exc
|
||||
return target
|
||||
|
||||
@staticmethod
|
||||
def _blob_sha(repo: Repo, relative: str) -> str | None:
|
||||
try:
|
||||
entry = repo.open_index()[relative.encode("utf-8")]
|
||||
except KeyError:
|
||||
return None
|
||||
sha = getattr(entry, "sha", None)
|
||||
return _hex_object_id(sha) if sha is not None else None
|
||||
|
||||
def apply(
|
||||
self,
|
||||
repository: RegisteredRepository,
|
||||
branch: str,
|
||||
message: str,
|
||||
operations: Sequence[Mapping[str, Any]],
|
||||
) -> dict[str, Any]:
|
||||
normalized = validate_operations(operations)
|
||||
token = self._token_provider.get_token(self._token_reference)
|
||||
remote = f"{self._base_url}/{repository.owner}/{repository.name}.git"
|
||||
authenticated = remote.startswith(("https://", "http://"))
|
||||
stderr = io.BytesIO()
|
||||
with tempfile.TemporaryDirectory(prefix="itworx-gitea-") as temporary:
|
||||
root = Path(temporary) / "repository"
|
||||
repo: Repo | None = None
|
||||
try:
|
||||
with _dulwich_clone_identity():
|
||||
if authenticated:
|
||||
repo = porcelain.clone(
|
||||
remote,
|
||||
root,
|
||||
branch=branch,
|
||||
depth=1,
|
||||
errstream=stderr,
|
||||
username="oauth2",
|
||||
password=token,
|
||||
)
|
||||
else:
|
||||
repo = porcelain.clone(
|
||||
remote, root, branch=branch, depth=1, errstream=stderr
|
||||
)
|
||||
workspace_bytes = sum(
|
||||
entry.stat(follow_symlinks=False).st_size
|
||||
for entry in root.rglob("*")
|
||||
if entry.is_file() and not entry.is_symlink()
|
||||
)
|
||||
if workspace_bytes > _MAX_WORKSPACE_BYTES:
|
||||
raise GiteaError(
|
||||
"GITEA_CHANGESET_TOO_LARGE",
|
||||
"repository exceeds the changeset workspace budget",
|
||||
)
|
||||
staged: list[str] = []
|
||||
removed: list[str] = []
|
||||
for operation in normalized:
|
||||
kind = operation["kind"]
|
||||
relative = operation["path"]
|
||||
target = self._relative(root, relative)
|
||||
actual_sha = self._blob_sha(repo, relative)
|
||||
expected_sha = operation.get("expectedSha")
|
||||
if expected_sha is not None and actual_sha != expected_sha.casefold():
|
||||
raise GiteaError(
|
||||
"GITEA_CHANGESET_CONFLICT", f"stale expectedSha for {relative}"
|
||||
)
|
||||
if kind == "create":
|
||||
if actual_sha is not None or target.exists():
|
||||
raise GiteaError(
|
||||
"GITEA_CHANGESET_CONFLICT", f"file already exists: {relative}"
|
||||
)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(operation["content"], encoding="utf-8")
|
||||
staged.append(relative)
|
||||
elif kind == "update":
|
||||
if actual_sha is None:
|
||||
raise GiteaError(
|
||||
"GITEA_CHANGESET_CONFLICT", f"file does not exist: {relative}"
|
||||
)
|
||||
target.write_text(operation["content"], encoding="utf-8")
|
||||
staged.append(relative)
|
||||
elif kind == "patch":
|
||||
if actual_sha is None:
|
||||
raise GiteaError(
|
||||
"GITEA_CHANGESET_CONFLICT", f"file does not exist: {relative}"
|
||||
)
|
||||
original = target.read_text(encoding="utf-8")
|
||||
target.write_text(
|
||||
apply_unified_patch(original, operation["patch"]), encoding="utf-8"
|
||||
)
|
||||
staged.append(relative)
|
||||
elif kind == "delete":
|
||||
if actual_sha is None:
|
||||
raise GiteaError(
|
||||
"GITEA_CHANGESET_CONFLICT", f"file does not exist: {relative}"
|
||||
)
|
||||
target.unlink()
|
||||
removed.append(relative)
|
||||
else:
|
||||
destination = str(operation["destinationPath"])
|
||||
destination_target = self._relative(root, destination)
|
||||
if actual_sha is None or destination_target.exists():
|
||||
raise GiteaError(
|
||||
"GITEA_CHANGESET_CONFLICT", "move source/destination is invalid"
|
||||
)
|
||||
destination_target.parent.mkdir(parents=True, exist_ok=True)
|
||||
os.replace(target, destination_target)
|
||||
removed.append(relative)
|
||||
staged.append(destination)
|
||||
if removed:
|
||||
porcelain.rm(repo, removed)
|
||||
if staged:
|
||||
porcelain.add(repo, staged)
|
||||
status = porcelain.status(repo)
|
||||
if not status.staged and not status.unstaged and not status.untracked:
|
||||
raise GiteaError("GITEA_CHANGESET_EMPTY", "changeset produces no change")
|
||||
commit_sha = _hex_object_id(
|
||||
porcelain.commit(
|
||||
repo,
|
||||
message=message.encode("utf-8"),
|
||||
author=b"ChatGPT MCP <mcp-agent@example.com>",
|
||||
committer=b"ChatGPT MCP <mcp-agent@example.com>",
|
||||
)
|
||||
)
|
||||
if authenticated:
|
||||
porcelain.push(
|
||||
repo,
|
||||
remote,
|
||||
refspecs=f"refs/heads/{branch}:refs/heads/{branch}",
|
||||
force=False,
|
||||
errstream=stderr,
|
||||
username="oauth2",
|
||||
password=token,
|
||||
)
|
||||
else:
|
||||
porcelain.push(
|
||||
repo,
|
||||
remote,
|
||||
refspecs=f"refs/heads/{branch}:refs/heads/{branch}",
|
||||
force=False,
|
||||
errstream=stderr,
|
||||
)
|
||||
except GiteaError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise GiteaError(
|
||||
"GITEA_CHANGESET_FAILED", "bounded Git changeset could not be committed"
|
||||
) from exc
|
||||
finally:
|
||||
if repo is not None:
|
||||
repo.close()
|
||||
return {
|
||||
"commitSha": commit_sha,
|
||||
"branch": branch,
|
||||
"operationCount": len(normalized),
|
||||
"atomic": True,
|
||||
}
|
||||
@@ -0,0 +1,675 @@
|
||||
"""Deterministic in-memory Gitea HTTP transport for contract tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
from typing import Any
|
||||
from urllib.parse import parse_qs, unquote, urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
from .managed_validation import (
|
||||
MANAGED_VALIDATION_CONTEXT,
|
||||
MANAGED_VALIDATION_PATH,
|
||||
MANAGED_VALIDATION_WORKFLOW,
|
||||
)
|
||||
|
||||
|
||||
class FakeGiteaServer:
|
||||
"""A tiny bounded Gitea API fixture with observable requests and no network."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
unsupported_search: bool = False,
|
||||
deny_user_repositories: bool = False,
|
||||
deny_protected_workflow_writes: bool = False,
|
||||
redirect_to: str | None = None,
|
||||
gitea_version: str = "1.26.4",
|
||||
deny_merge_transition: bool = False,
|
||||
) -> None:
|
||||
self.unsupported_search = unsupported_search
|
||||
self.deny_user_repositories = deny_user_repositories
|
||||
self.deny_protected_workflow_writes = deny_protected_workflow_writes
|
||||
self.redirect_to = redirect_to
|
||||
self.gitea_version = gitea_version
|
||||
self.deny_merge_transition = deny_merge_transition
|
||||
self.recorded_methods: list[str] = []
|
||||
self.recorded_authorizations: list[str] = []
|
||||
self.recorded_paths: list[str] = []
|
||||
self.recorded_bodies: list[dict[str, Any]] = []
|
||||
self._repos = {
|
||||
"acme/hub": {
|
||||
"owner": {"login": "acme"},
|
||||
"name": "hub",
|
||||
"full_name": "acme/hub",
|
||||
"description": "ITWorx MCP Hub",
|
||||
"private": True,
|
||||
"default_branch": "main",
|
||||
"updated_at": "2026-08-01T10:00:00Z",
|
||||
"token": "fixture-token-must-not-leak",
|
||||
}
|
||||
}
|
||||
self._branches: list[dict[str, Any]] = [
|
||||
{"name": "main", "commit": {"id": "a" * 40}, "protected": True},
|
||||
{"name": "release", "commit": {"id": "b" * 40}, "protected": False},
|
||||
{"name": "feature", "commit": {"id": "c" * 40}, "protected": False},
|
||||
]
|
||||
self._branch_protections: dict[str, list[dict[str, Any]]] = {
|
||||
"acme/hub": [
|
||||
{
|
||||
"rule_name": "main",
|
||||
"branch_name": "main",
|
||||
"priority": 1,
|
||||
"enable_push": False,
|
||||
"enable_push_whitelist": False,
|
||||
"push_whitelist_usernames": [],
|
||||
"push_whitelist_teams": [],
|
||||
"push_whitelist_deploy_keys": False,
|
||||
"enable_force_push": False,
|
||||
"enable_force_push_allowlist": False,
|
||||
"force_push_allowlist_usernames": [],
|
||||
"force_push_allowlist_teams": [],
|
||||
"force_push_allowlist_deploy_keys": False,
|
||||
"enable_merge_whitelist": False,
|
||||
"merge_whitelist_usernames": [],
|
||||
"merge_whitelist_teams": [],
|
||||
"enable_bypass_allowlist": False,
|
||||
"bypass_allowlist_usernames": [],
|
||||
"bypass_allowlist_teams": [],
|
||||
"enable_status_check": True,
|
||||
"status_check_contexts": [MANAGED_VALIDATION_CONTEXT],
|
||||
"required_approvals": 0,
|
||||
"enable_approvals_whitelist": False,
|
||||
"approvals_whitelist_username": [],
|
||||
"approvals_whitelist_teams": [],
|
||||
"block_on_rejected_reviews": True,
|
||||
"block_on_official_review_requests": False,
|
||||
"block_on_outdated_branch": True,
|
||||
"dismiss_stale_approvals": True,
|
||||
"ignore_stale_approvals": False,
|
||||
"require_signed_commits": False,
|
||||
"protected_file_patterns": "",
|
||||
"unprotected_file_patterns": "",
|
||||
"block_admin_merge_override": True,
|
||||
}
|
||||
]
|
||||
}
|
||||
self._managed_workflows: dict[str, str] = {"acme/hub": MANAGED_VALIDATION_WORKFLOW}
|
||||
self._commits = [
|
||||
{
|
||||
"sha": "a" * 40,
|
||||
"commit": {"message": "Add bounded connector", "author": {"name": "Ada"}},
|
||||
},
|
||||
{"sha": "b" * 40, "commit": {"message": "Document the API", "author": {"name": "Lin"}}},
|
||||
{"sha": "c" * 40, "commit": {"message": "Add tests", "author": {"name": "Kai"}}},
|
||||
]
|
||||
self._compare_commits = [self._commits[0]]
|
||||
self._reverse_compare_commits: list[dict[str, Any]] = []
|
||||
self._releases = [
|
||||
{"tag_name": "v1.0.0", "name": "First release", "draft": False, "prerelease": False}
|
||||
]
|
||||
self._issues = [
|
||||
{
|
||||
"number": 1,
|
||||
"title": "Bounded read API",
|
||||
"state": "open",
|
||||
"html_url": "https://gitea.example/acme/hub/issues/1",
|
||||
}
|
||||
]
|
||||
self._pulls = [
|
||||
{
|
||||
"number": 7,
|
||||
"title": "Add governed Codex workflows",
|
||||
"state": "open",
|
||||
"draft": False,
|
||||
"mergeable": True,
|
||||
"html_url": "https://gitea.example/acme/hub/pulls/7",
|
||||
"head": {"ref": "codex/workflows", "sha": "e" * 40},
|
||||
"base": {"ref": "main", "sha": "a" * 40},
|
||||
"updated_at": "2026-08-21T20:00:00Z",
|
||||
}
|
||||
]
|
||||
self._pull_files = [
|
||||
{
|
||||
"filename": "services/runtime/mcp_server.py",
|
||||
"status": "modified",
|
||||
"additions": 42,
|
||||
"deletions": 2,
|
||||
}
|
||||
]
|
||||
self._statuses = [
|
||||
{
|
||||
"status": "success",
|
||||
"context": MANAGED_VALIDATION_CONTEXT,
|
||||
"description": "All checks passed",
|
||||
"target_url": "https://gitea.example/acme/hub/actions/runs/42",
|
||||
"created_at": "2026-08-21T20:05:00Z",
|
||||
}
|
||||
]
|
||||
self._file = "# ITWorx MCP Hub\n\nThis is a safe fixture.\n"
|
||||
self._reviews: list[dict[str, Any]] = []
|
||||
self._review_comments = [
|
||||
{"id": 12, "path": "README.md", "new_position": 1, "body": "Clarify this."}
|
||||
]
|
||||
self._action_run = {
|
||||
"id": 42,
|
||||
"name": "ChatGPT validation",
|
||||
"status": "completed",
|
||||
"conclusion": "success",
|
||||
"event": "pull_request",
|
||||
"head_branch": "codex/workflows",
|
||||
"head_sha": "e" * 40,
|
||||
"workflow_id": "managed-validation.yml",
|
||||
"html_url": "https://gitea.example/acme/hub/actions/runs/42",
|
||||
}
|
||||
self._action_job = {
|
||||
"id": 43,
|
||||
"name": "full",
|
||||
"status": "completed",
|
||||
"conclusion": "success",
|
||||
}
|
||||
self._action_job_logs = "all checks passed\n"
|
||||
|
||||
def transport(self) -> httpx.MockTransport:
|
||||
return httpx.MockTransport(self._handle)
|
||||
|
||||
def _handle(self, request: httpx.Request) -> httpx.Response:
|
||||
self.recorded_methods.append(request.method)
|
||||
self.recorded_authorizations.append(request.headers.get("authorization", ""))
|
||||
if self.redirect_to is not None:
|
||||
return httpx.Response(302, headers={"location": self.redirect_to}, request=request)
|
||||
parsed = urlparse(str(request.url))
|
||||
self.recorded_paths.append(parsed.path)
|
||||
if not parsed.path.startswith("/api/v1/"):
|
||||
return self._json(404, {"message": "API prefix missing"}, request)
|
||||
path = parsed.path.removeprefix("/api/v1/")
|
||||
query = parse_qs(parsed.query)
|
||||
body: dict[str, Any] = {}
|
||||
if request.content:
|
||||
decoded = json.loads(request.content.decode("utf-8"))
|
||||
body = decoded if isinstance(decoded, dict) else {}
|
||||
self.recorded_bodies.append(body)
|
||||
if path == "version" and request.method == "GET":
|
||||
return self._json(200, {"version": self.gitea_version}, request)
|
||||
if path == "repos/search":
|
||||
if self.unsupported_search:
|
||||
return self._json(501, {"message": "unsupported"}, request)
|
||||
if "q" not in query:
|
||||
repositories = list(self._repos.values())
|
||||
if query.get("private") == ["true"]:
|
||||
repositories = [item for item in repositories if item.get("private") is True]
|
||||
return self._json(200, {"ok": True, "data": repositories}, request)
|
||||
return self._json(
|
||||
200,
|
||||
{
|
||||
"ok": True,
|
||||
"data": [{"repository": {"full_name": "acme/hub"}, "path": "README.md"}],
|
||||
},
|
||||
request,
|
||||
)
|
||||
if path == "user" and request.method == "GET":
|
||||
return self._json(200, {"login": "acme"}, request)
|
||||
if path == "user/repos" and request.method == "GET":
|
||||
if self.deny_user_repositories:
|
||||
return self._json(403, {"message": "denied"}, request)
|
||||
return self._paged(list(self._repos.values()), query, request)
|
||||
if path == "user/repos" and request.method == "POST":
|
||||
name = str(body.get("name", ""))
|
||||
qualified_name = f"acme/{name}"
|
||||
if qualified_name in self._repos:
|
||||
return self._json(409, {"message": "repository already exists"}, request)
|
||||
auto_init = bool(body.get("auto_init", True))
|
||||
repository = {
|
||||
"owner": {"login": "acme"},
|
||||
"name": name,
|
||||
"full_name": qualified_name,
|
||||
"description": str(body.get("description", "")),
|
||||
"private": bool(body.get("private", True)),
|
||||
"empty": not auto_init,
|
||||
"default_branch": str(body.get("default_branch", "main")) if auto_init else "",
|
||||
"html_url": f"https://gitea.example/{qualified_name}",
|
||||
"updated_at": "2026-08-26T15:00:00Z",
|
||||
}
|
||||
self._repos[qualified_name] = repository
|
||||
return self._json(201, repository, request)
|
||||
if path == "repos/issues/search" and request.method == "GET":
|
||||
state = str(query.get("state", ["open"])[0])
|
||||
issues = [
|
||||
{
|
||||
**item,
|
||||
"repository": {
|
||||
"full_name": "acme/hub",
|
||||
"name": "hub",
|
||||
"owner": {"login": "acme"},
|
||||
},
|
||||
"updated_at": "2026-08-01T10:00:00Z",
|
||||
"labels": [],
|
||||
}
|
||||
for item in self._issues
|
||||
if state == "all" or item.get("state") == state
|
||||
]
|
||||
return self._paged(issues, query, request)
|
||||
if path.startswith("repos/"):
|
||||
path = path.removeprefix("repos/")
|
||||
parts = path.split("/")
|
||||
qualified_name = "/".join(parts[:2])
|
||||
if len(parts) >= 2 and qualified_name not in self._repos:
|
||||
return self._json(404, {"message": "not found"}, request)
|
||||
if len(parts) == 2:
|
||||
if request.method == "DELETE":
|
||||
self._repos.pop(qualified_name, None)
|
||||
self._branch_protections.pop(qualified_name, None)
|
||||
self._managed_workflows.pop(qualified_name, None)
|
||||
return httpx.Response(204, request=request)
|
||||
if request.method != "GET":
|
||||
return self._json(405, {"message": "method not allowed"}, request)
|
||||
return self._json(200, self._repos[qualified_name], request, leaked_header=True)
|
||||
if parts[2] == "branch_protections":
|
||||
protections = self._branch_protections.setdefault(qualified_name, [])
|
||||
if len(parts) == 3:
|
||||
if request.method == "POST":
|
||||
created = dict(body)
|
||||
created.setdefault("rule_name", str(body.get("branch_name", "")))
|
||||
created.setdefault("branch_name", str(body.get("rule_name", "")))
|
||||
protections.append(created)
|
||||
branch_name = str(created.get("rule_name") or created.get("branch_name"))
|
||||
for branch in self._branches:
|
||||
if branch.get("name") == branch_name:
|
||||
branch["protected"] = True
|
||||
return self._json(201, created, request)
|
||||
return self._json(200, protections, request)
|
||||
rule_name = unquote(parts[3])
|
||||
protection = next(
|
||||
(
|
||||
item
|
||||
for item in protections
|
||||
if item.get("rule_name") == rule_name or item.get("branch_name") == rule_name
|
||||
),
|
||||
None,
|
||||
)
|
||||
if protection is None:
|
||||
return self._json(404, {"message": "not found"}, request)
|
||||
if request.method == "PATCH":
|
||||
protection.update(body)
|
||||
return self._json(200, protection, request)
|
||||
if request.method == "DELETE":
|
||||
protections.remove(protection)
|
||||
return httpx.Response(204, request=request)
|
||||
return self._json(200, protection, request)
|
||||
if parts[2] == "branches":
|
||||
if request.method == "POST":
|
||||
created = {
|
||||
"name": body.get("new_branch_name", ""),
|
||||
"commit": {"id": "f" * 40},
|
||||
"protected": False,
|
||||
}
|
||||
self._branches.append(created)
|
||||
return self._json(201, created, request)
|
||||
if len(parts) == 4:
|
||||
branch_name = unquote(parts[3])
|
||||
matched_branch: dict[str, object] | None = None
|
||||
for item in self._branches:
|
||||
if item.get("name") == branch_name:
|
||||
matched_branch = item
|
||||
break
|
||||
if matched_branch is None:
|
||||
return self._json(404, {"message": "not found"}, request)
|
||||
if request.method == "DELETE":
|
||||
self._branches.remove(matched_branch)
|
||||
return httpx.Response(204, request=request)
|
||||
return self._json(200, matched_branch, request)
|
||||
return self._paged(self._branches, query, request)
|
||||
if parts[2] == "commits" and len(parts) == 3:
|
||||
return self._paged(self._commits, query, request)
|
||||
if parts[2:4] == ["git", "commits"] and len(parts) == 5:
|
||||
return self._json(
|
||||
200,
|
||||
{
|
||||
**self._commits[0],
|
||||
"sha": unquote(parts[4]),
|
||||
"verification": {"verified": True, "reason": "gpg"},
|
||||
"stats": {"additions": 42, "deletions": 2, "total": 44},
|
||||
"files": [{"filename": "README.md", "status": "modified"}],
|
||||
"html_url": "https://gitea.example/acme/hub/commit/example",
|
||||
},
|
||||
request,
|
||||
)
|
||||
if parts[2] == "compare" and len(parts) == 4:
|
||||
compare_range = unquote(parts[3])
|
||||
reverse_ranges: set[str] = set()
|
||||
for pull in self._pulls:
|
||||
head = pull.get("head")
|
||||
base = pull.get("base")
|
||||
if isinstance(head, dict) and isinstance(base, dict):
|
||||
reverse_ranges.add(f"{head.get('sha', '')}...{base.get('sha', '')}")
|
||||
compare_commits = (
|
||||
self._reverse_compare_commits
|
||||
if compare_range in reverse_ranges
|
||||
else self._compare_commits
|
||||
)
|
||||
return self._json(
|
||||
200,
|
||||
{
|
||||
"total_commits": len(compare_commits),
|
||||
"commits": compare_commits,
|
||||
"files": [{"filename": "README.md", "status": "modified"}],
|
||||
},
|
||||
request,
|
||||
)
|
||||
if parts[2] == "releases":
|
||||
return self._paged(self._releases, query, request)
|
||||
if parts[2] == "issues":
|
||||
if request.method == "POST" and len(parts) == 3:
|
||||
created_issue = {
|
||||
"number": len(self._issues) + 1,
|
||||
"title": body.get("title", ""),
|
||||
"state": "open",
|
||||
"html_url": f"https://gitea.example/acme/hub/issues/{len(self._issues) + 1}",
|
||||
}
|
||||
self._issues.append(created_issue)
|
||||
return self._json(201, created_issue, request)
|
||||
if len(parts) == 4:
|
||||
issue_number = int(parts[3])
|
||||
issue = next(
|
||||
(item for item in self._issues if item.get("number") == issue_number), None
|
||||
)
|
||||
if issue is None:
|
||||
return self._json(404, {"message": "not found"}, request)
|
||||
if request.method == "PATCH":
|
||||
issue.update(
|
||||
{
|
||||
key: value
|
||||
for key, value in body.items()
|
||||
if key in {"title", "body", "state"}
|
||||
}
|
||||
)
|
||||
return self._json(200, issue, request)
|
||||
if request.method == "POST" and len(parts) == 5 and parts[4] == "comments":
|
||||
return self._json(
|
||||
201,
|
||||
{
|
||||
"id": 99,
|
||||
"body": body.get("body", ""),
|
||||
"html_url": f"https://gitea.example/acme/hub/issues/{parts[3]}#issuecomment-99",
|
||||
},
|
||||
request,
|
||||
)
|
||||
return self._paged(self._issues, query, request)
|
||||
if parts[2] == "pulls" and len(parts) == 3:
|
||||
if request.method == "POST":
|
||||
created_pull = {
|
||||
"number": len(self._pulls) + 8,
|
||||
"title": body.get("title", ""),
|
||||
"state": "open",
|
||||
"draft": False,
|
||||
"mergeable": True,
|
||||
"html_url": "https://gitea.example/acme/hub/pulls/8",
|
||||
"head": {"ref": body.get("head", ""), "sha": "f" * 40},
|
||||
"base": {"ref": body.get("base", ""), "sha": "a" * 40},
|
||||
"updated_at": "2026-08-24T20:00:00Z",
|
||||
}
|
||||
self._pulls.append(created_pull)
|
||||
return self._json(201, created_pull, request)
|
||||
return self._paged(self._pulls, query, request)
|
||||
if parts[2] == "pulls" and len(parts) == 4 and parts[3] == "7":
|
||||
if request.method == "PATCH":
|
||||
self._pulls[0].update(
|
||||
{key: value for key, value in body.items() if key in {"title", "body", "state"}}
|
||||
)
|
||||
if body.get("state") == "open":
|
||||
self._action_run.update(
|
||||
event="pull_request", status="queued", conclusion="",
|
||||
head_sha=self._pulls[0]["head"]["sha"],
|
||||
)
|
||||
return self._json(200, self._pulls[0], request)
|
||||
if parts[2] == "pulls" and len(parts) == 5 and parts[3:] == ["7", "update"]:
|
||||
if query.get("style") not in [["merge"], ["rebase"]]:
|
||||
return self._json(422, {"message": "invalid style"}, request)
|
||||
head = self._pulls[0].get("head")
|
||||
base = self._pulls[0].get("base")
|
||||
if not isinstance(head, dict) or not isinstance(base, dict):
|
||||
return self._json(500, {"message": "invalid fake pull request"}, request)
|
||||
head["sha"] = "d" * 40
|
||||
base["sha"] = "a" * 40
|
||||
return self._json(200, {}, request)
|
||||
if parts[2] == "pulls" and len(parts) == 5 and parts[3:] == ["7", "merge"]:
|
||||
if self.deny_merge_transition:
|
||||
return self._json(405, {"message": "merge blocked by repository policy"}, request)
|
||||
strategy_key = "Do" if self.gitea_version.startswith(("1.14.", "1.25.")) else "do"
|
||||
if body.get(strategy_key) not in {
|
||||
"merge",
|
||||
"rebase",
|
||||
"rebase-merge",
|
||||
"squash",
|
||||
"fast-forward-only",
|
||||
}:
|
||||
return self._json(405, {"message": "unsupported merge strategy"}, request)
|
||||
self._pulls[0].update({"state": "closed", "merged": True, "merge_commit_sha": "6" * 40})
|
||||
for branch in self._branches:
|
||||
if branch.get("name") == "main":
|
||||
branch["commit"]["id"] = "6" * 40
|
||||
return self._json(200, {}, request)
|
||||
if parts[2] == "pulls" and len(parts) == 5 and parts[3:] == ["7", "files"]:
|
||||
return self._paged(self._pull_files, query, request)
|
||||
if parts[2] == "pulls" and len(parts) == 5 and parts[3:] == ["7", "reviews"]:
|
||||
return self._paged(self._reviews, query, request)
|
||||
if (
|
||||
parts[2] == "pulls"
|
||||
and len(parts) == 7
|
||||
and parts[3:5] == ["7", "reviews"]
|
||||
and parts[6] == "comments"
|
||||
):
|
||||
return self._json(200, self._review_comments, request)
|
||||
if parts[2] == "commits" and len(parts) == 5 and parts[4] == "statuses":
|
||||
return self._paged(self._statuses, query, request)
|
||||
if parts[2:4] == ["git", "trees"] and len(parts) == 5:
|
||||
if self.unsupported_search:
|
||||
return self._json(501, {"message": "unsupported"}, request)
|
||||
return self._json(
|
||||
200,
|
||||
{
|
||||
"sha": "a" * 40,
|
||||
"tree": [
|
||||
{
|
||||
"path": "README.md",
|
||||
"type": "blob",
|
||||
"sha": "d" * 40,
|
||||
"size": len(self._file.encode()),
|
||||
}
|
||||
],
|
||||
"truncated": False,
|
||||
},
|
||||
request,
|
||||
)
|
||||
if parts[2:4] == ["git", "blobs"] and len(parts) == 5:
|
||||
return self._json(
|
||||
200,
|
||||
{
|
||||
"sha": parts[4],
|
||||
"encoding": "base64",
|
||||
"content": base64.b64encode(self._file.encode()).decode(),
|
||||
"size": len(self._file.encode()),
|
||||
},
|
||||
request,
|
||||
)
|
||||
if parts[2:4] == ["actions", "workflows"] and len(parts) == 4:
|
||||
workflows = []
|
||||
if qualified_name in self._managed_workflows:
|
||||
workflows.append(
|
||||
{
|
||||
"id": "managed-validation.yml",
|
||||
"name": "Managed validation",
|
||||
"path": MANAGED_VALIDATION_PATH,
|
||||
}
|
||||
)
|
||||
return self._json(
|
||||
200,
|
||||
{"workflows": workflows},
|
||||
request,
|
||||
)
|
||||
if parts[2:4] == ["actions", "workflows"] and len(parts) == 6 and parts[5] == "dispatches":
|
||||
raw_inputs = body.get("inputs")
|
||||
inputs: dict[str, Any] = raw_inputs if isinstance(raw_inputs, dict) else {}
|
||||
profile = str(inputs.get("profile", "full"))
|
||||
ref = str(body.get("ref", ""))
|
||||
dispatch_branch: dict[str, Any] | None = next(
|
||||
(item for item in self._branches if item.get("name") == ref), None
|
||||
)
|
||||
head_sha = str(
|
||||
(dispatch_branch or {}).get("commit", {}).get("id", self._action_run["head_sha"])
|
||||
)
|
||||
self._action_run.update(
|
||||
{
|
||||
"event": "workflow_dispatch",
|
||||
"head_branch": ref,
|
||||
"head_sha": head_sha,
|
||||
"workflow_id": unquote(parts[4]),
|
||||
"display_title": f"Managed validation / {profile}",
|
||||
}
|
||||
)
|
||||
self._action_job["name"] = "full"
|
||||
return self._json(200, self._action_run, request)
|
||||
if parts[2:4] == ["actions", "runs"] and len(parts) == 4:
|
||||
return self._json(200, {"workflow_runs": [self._action_run]}, request)
|
||||
if parts[2:4] == ["actions", "runs"] and len(parts) == 6 and parts[5] == "rerun":
|
||||
self._action_run.update(status="queued", conclusion="")
|
||||
return httpx.Response(204, request=request)
|
||||
if parts[2:4] == ["actions", "runs"] and len(parts) == 5:
|
||||
return self._json(200, self._action_run, request)
|
||||
if parts[2:4] == ["actions", "runs"] and len(parts) == 6 and parts[5] == "jobs":
|
||||
return self._json(200, {"jobs": [self._action_job]}, request)
|
||||
if parts[2:4] == ["actions", "jobs"] and len(parts) == 6 and parts[5] == "logs":
|
||||
return httpx.Response(
|
||||
200,
|
||||
headers={"content-type": "text/plain"},
|
||||
content=self._action_job_logs.encode(),
|
||||
request=request,
|
||||
)
|
||||
if parts[2] == "contents" and len(parts) == 3:
|
||||
return self._json(
|
||||
200,
|
||||
[
|
||||
{
|
||||
"name": "README.md",
|
||||
"path": "README.md",
|
||||
"type": "file",
|
||||
"size": len(self._file.encode()),
|
||||
"sha": "d" * 40,
|
||||
}
|
||||
],
|
||||
request,
|
||||
)
|
||||
if (
|
||||
parts[2] == "contents"
|
||||
and len(parts) >= 4
|
||||
and "/".join(parts[3:]) == MANAGED_VALIDATION_PATH
|
||||
):
|
||||
existing = self._managed_workflows.get(qualified_name)
|
||||
protection = next(iter(self._branch_protections.get(qualified_name, [])), None)
|
||||
temporary_push_allowed = bool(
|
||||
protection
|
||||
and protection.get("enable_push") is True
|
||||
and protection.get("enable_push_whitelist") is True
|
||||
and "acme" in protection.get("push_whitelist_usernames", [])
|
||||
)
|
||||
if (
|
||||
request.method in {"POST", "PUT"}
|
||||
and self.deny_protected_workflow_writes
|
||||
and protection is not None
|
||||
and not temporary_push_allowed
|
||||
):
|
||||
return self._json(403, {"message": "protected branch"}, request)
|
||||
if request.method == "POST":
|
||||
if existing is not None:
|
||||
return self._json(422, {"message": "already exists"}, request)
|
||||
self._managed_workflows[qualified_name] = base64.b64decode(
|
||||
str(body.get("content", ""))
|
||||
).decode()
|
||||
return self._json(
|
||||
201,
|
||||
{"content": {"path": MANAGED_VALIDATION_PATH, "sha": "9" * 40}},
|
||||
request,
|
||||
)
|
||||
if existing is None:
|
||||
return self._json(404, {"message": "not found"}, request)
|
||||
if request.method == "PUT":
|
||||
self._managed_workflows[qualified_name] = base64.b64decode(
|
||||
str(body.get("content", ""))
|
||||
).decode()
|
||||
return self._json(
|
||||
200,
|
||||
{"content": {"path": MANAGED_VALIDATION_PATH, "sha": "9" * 40}},
|
||||
request,
|
||||
)
|
||||
if request.method == "DELETE":
|
||||
self._managed_workflows.pop(qualified_name, None)
|
||||
return self._json(200, {"commit": {"sha": "7" * 40}}, request)
|
||||
return self._json(
|
||||
200,
|
||||
{
|
||||
"name": "managed-validation.yml",
|
||||
"path": MANAGED_VALIDATION_PATH,
|
||||
"sha": "d" * 40,
|
||||
"content": base64.b64encode(existing.encode()).decode(),
|
||||
"encoding": "base64",
|
||||
},
|
||||
request,
|
||||
)
|
||||
if parts[2] == "contents" and len(parts) >= 4 and "/".join(parts[3:]) == "README.md":
|
||||
if request.method == "DELETE":
|
||||
return self._json(200, {"commit": {"sha": "7" * 40}}, request)
|
||||
if request.method in {"POST", "PUT"}:
|
||||
if request.method == "POST" and self._repos[qualified_name].get("empty"):
|
||||
branch_name = str(body.get("branch", "main"))
|
||||
self._repos[qualified_name]["empty"] = False
|
||||
self._repos[qualified_name]["default_branch"] = branch_name
|
||||
if not any(item.get("name") == branch_name for item in self._branches):
|
||||
self._branches.append(
|
||||
{
|
||||
"name": branch_name,
|
||||
"commit": {"id": "8" * 40},
|
||||
"protected": False,
|
||||
}
|
||||
)
|
||||
return self._json(
|
||||
201 if request.method == "POST" else 200,
|
||||
{
|
||||
"content": {"path": "README.md", "sha": "9" * 40},
|
||||
"commit": {"sha": "8" * 40},
|
||||
},
|
||||
request,
|
||||
)
|
||||
return self._json(
|
||||
200,
|
||||
{
|
||||
"name": "README.md",
|
||||
"path": "README.md",
|
||||
"sha": "d" * 40,
|
||||
"content": base64.b64encode(self._file.encode()).decode(),
|
||||
"encoding": "base64",
|
||||
"token": "fixture-token-must-not-leak",
|
||||
},
|
||||
request,
|
||||
)
|
||||
return self._json(404, {"message": "not found"}, request)
|
||||
|
||||
def _paged(
|
||||
self, items: list[dict[str, Any]], query: dict[str, list[str]], request: httpx.Request
|
||||
) -> httpx.Response:
|
||||
page = max(int(query.get("page", ["1"])[0]), 1)
|
||||
limit = max(int(query.get("limit", ["100"])[0]), 1)
|
||||
start = (page - 1) * limit
|
||||
return self._json(200, items[start : start + limit], request)
|
||||
|
||||
@staticmethod
|
||||
def _json(
|
||||
status: int, payload: object, request: httpx.Request, *, leaked_header: bool = False
|
||||
) -> httpx.Response:
|
||||
headers = {"content-type": "application/json"}
|
||||
if leaked_header:
|
||||
headers["x-fixture-token"] = "fixture-token-must-not-leak"
|
||||
return httpx.Response(
|
||||
status, headers=headers, content=json.dumps(payload).encode(), request=request
|
||||
)
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Bounded helpers for destructive Gitea pull-request lifecycle cleanup."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Protocol
|
||||
|
||||
from packages.connector_kit import GiteaError, RegisteredRepository
|
||||
|
||||
_VIRTUAL_PULL_HEAD = re.compile(r"refs/pull/[1-9][0-9]*/head")
|
||||
|
||||
|
||||
class PullRequestReader(Protocol):
|
||||
def pull_requests(
|
||||
self, repository: RegisteredRepository, page: int, limit: int
|
||||
) -> list[dict[str, Any]]: ...
|
||||
|
||||
|
||||
def cleanup_basis(
|
||||
client: PullRequestReader,
|
||||
repository: RegisteredRepository,
|
||||
branch: str,
|
||||
expected_head_sha: str,
|
||||
) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
|
||||
"""Return the merged/closed PR proving one exact branch is cleanup-eligible."""
|
||||
related: list[dict[str, Any]] = []
|
||||
for page_number in range(1, 11):
|
||||
pulls = client.pull_requests(repository, page_number, 100)
|
||||
for item in pulls:
|
||||
head = item.get("head") or {}
|
||||
head_ref = str(head.get("ref", ""))
|
||||
head_sha = str(head.get("sha", ""))
|
||||
if head_ref == branch or (
|
||||
head_sha == expected_head_sha and _VIRTUAL_PULL_HEAD.fullmatch(head_ref)
|
||||
):
|
||||
related.append(item)
|
||||
if len(pulls) < 100:
|
||||
break
|
||||
|
||||
if any(str(item.get("state", "")) == "open" for item in related):
|
||||
raise GiteaError(
|
||||
"GITEA_TARGET_INVALID",
|
||||
"branch is still referenced by an open pull request; close or merge it first",
|
||||
)
|
||||
merged = next((item for item in related if bool(item.get("merged", False))), None)
|
||||
closed = next(
|
||||
(
|
||||
item
|
||||
for item in related
|
||||
if str(item.get("state", "")) == "closed" and not bool(item.get("merged", False))
|
||||
),
|
||||
None,
|
||||
)
|
||||
if merged is None and closed is None:
|
||||
raise GiteaError(
|
||||
"GITEA_TARGET_INVALID",
|
||||
"only a change branch from a merged or closed pull request can be deleted",
|
||||
)
|
||||
return merged, closed
|
||||
@@ -0,0 +1,153 @@
|
||||
"""Stable managed-validation contract used by Gitea governance tools."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
|
||||
MANAGED_VALIDATION_PATH = ".gitea/workflows/managed-validation.yml"
|
||||
MANAGED_VALIDATION_CONTEXT = "Managed validation / full (pull_request)"
|
||||
|
||||
# Keep this workflow pull-request/dispatch only. Bulk governance must never create
|
||||
# a push-triggered Actions storm across the portfolio.
|
||||
MANAGED_VALIDATION_WORKFLOW = """name: Managed validation
|
||||
run-name: "Managed validation / ${{ inputs.profile || 'full' }}"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
profile:
|
||||
description: Allowlisted validation profile
|
||||
required: true
|
||||
default: full
|
||||
type: choice
|
||||
options: [test, lint, typecheck, build, security, full]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: managed-validation-${{ gitea.repository }}-${{ gitea.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
full:
|
||||
name: full
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- name: Install .NET 8 for solution repositories
|
||||
if: ${{ hashFiles('*.sln') != '' }}
|
||||
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
|
||||
with:
|
||||
dotnet-version: '8.0.x'
|
||||
- name: Validate repository with a bounded profile
|
||||
shell: bash
|
||||
env:
|
||||
REQUESTED_PROFILE: ${{ inputs.profile }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
profile="${REQUESTED_PROFILE:-full}"
|
||||
case "${profile}" in
|
||||
test|lint|typecheck|build|security|full) ;;
|
||||
*) echo "Profile is not allowlisted" >&2; exit 2 ;;
|
||||
esac
|
||||
python_tests_ran=0
|
||||
|
||||
git diff --check
|
||||
if git grep -nE '^(<<<<<<< |=======$|>>>>>>> )' -- . ':!*.lock' ':!*.patch'; then
|
||||
echo "Unresolved merge markers detected" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -f pyproject.toml || -f requirements.txt ]]; then
|
||||
# Compile only tracked Python sources. Running compileall after a
|
||||
# Node install would otherwise traverse node_modules and turn a
|
||||
# lightweight baseline into a large runner workload.
|
||||
git ls-files -z '*.py' | xargs -0 -r python -m py_compile
|
||||
if [[ -f uv.lock ]]; then
|
||||
python -m venv "${RUNNER_TEMP}/managed-uv"
|
||||
uv_python="${RUNNER_TEMP}/managed-uv/bin/python"
|
||||
"${uv_python}" -m pip install --disable-pip-version-check uv==0.10.0
|
||||
managed_uv="${RUNNER_TEMP}/managed-uv/bin/uv"
|
||||
export UV_PROJECT_ENVIRONMENT="${RUNNER_TEMP}/managed-project-venv"
|
||||
"${managed_uv}" sync --locked
|
||||
export PATH="${UV_PROJECT_ENVIRONMENT}/bin:${PATH}"
|
||||
if [[ "${profile}" == test || "${profile}" == full ]]; then
|
||||
if "${managed_uv}" run python -c 'import pytest' 2>/dev/null; then
|
||||
"${managed_uv}" run python -m pytest
|
||||
python_tests_ran=1
|
||||
fi
|
||||
fi
|
||||
if [[ "${profile}" == lint || "${profile}" == full ]]; then
|
||||
if "${managed_uv}" run python -c 'import ruff' 2>/dev/null; then
|
||||
"${managed_uv}" run python -m ruff check .
|
||||
fi
|
||||
fi
|
||||
elif [[ -f requirements.txt ]]; then
|
||||
python -m venv "${RUNNER_TEMP}/managed-python"
|
||||
managed_python="${RUNNER_TEMP}/managed-python/bin/python"
|
||||
"${managed_python}" -m pip install --disable-pip-version-check -r requirements.txt
|
||||
export PATH="${RUNNER_TEMP}/managed-python/bin:${PATH}"
|
||||
if [[ "${profile}" == test || "${profile}" == full ]]; then
|
||||
if "${managed_python}" -c 'import pytest' 2>/dev/null; then
|
||||
"${managed_python}" -m pytest
|
||||
python_tests_ran=1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Prepare Python before invoking Node scripts. Polyglot repositories
|
||||
# commonly delegate their test script to Python and need the managed
|
||||
# virtual environment to be active first.
|
||||
if [[ -f package.json ]]; then
|
||||
corepack enable
|
||||
run_node_test=1
|
||||
if [[ "${python_tests_ran}" == 1 ]] && node -e '
|
||||
const script = String(require("./package.json").scripts?.test || "");
|
||||
const delegated = /^\\s*python(?:3)?\\s+-m\\s+(?:pytest|unittest)\\b/.test(script);
|
||||
process.exit(delegated ? 0 : 1);
|
||||
'; then
|
||||
run_node_test=0
|
||||
fi
|
||||
if [[ -f pnpm-lock.yaml ]]; then
|
||||
pnpm install --frozen-lockfile
|
||||
if [[ "${run_node_test}" == 1 ]]; then
|
||||
[[ "${profile}" == test || "${profile}" == full ]] && pnpm --if-present test
|
||||
fi
|
||||
[[ "${profile}" == lint || "${profile}" == full ]] && pnpm --if-present lint
|
||||
[[ "${profile}" == typecheck || "${profile}" == full ]] && pnpm --if-present typecheck
|
||||
[[ "${profile}" == build || "${profile}" == full ]] && pnpm --if-present build
|
||||
elif [[ -f package-lock.json ]]; then
|
||||
npm ci
|
||||
if [[ "${run_node_test}" == 1 ]]; then
|
||||
[[ "${profile}" == test || "${profile}" == full ]] && npm run --if-present test
|
||||
fi
|
||||
[[ "${profile}" == lint || "${profile}" == full ]] && npm run --if-present lint
|
||||
if [[ "${profile}" == typecheck || "${profile}" == full ]]; then
|
||||
npm run --if-present typecheck
|
||||
fi
|
||||
[[ "${profile}" == build || "${profile}" == full ]] && npm run --if-present build
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -f go.mod ]]; then
|
||||
if [[ "${profile}" == test || "${profile}" == build || "${profile}" == full ]]; then
|
||||
go test ./...
|
||||
fi
|
||||
fi
|
||||
if [[ -f Cargo.toml ]]; then
|
||||
if [[ "${profile}" == test || "${profile}" == build || "${profile}" == full ]]; then
|
||||
cargo test --locked
|
||||
fi
|
||||
fi
|
||||
if compgen -G '*.sln' >/dev/null; then
|
||||
if [[ "${profile}" == test || "${profile}" == build || "${profile}" == full ]]; then
|
||||
dotnet test --configuration Release
|
||||
fi
|
||||
fi
|
||||
"""
|
||||
|
||||
MANAGED_VALIDATION_DIGEST = hashlib.sha256(MANAGED_VALIDATION_WORKFLOW.encode("utf-8")).hexdigest()
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user