5455 lines
227 KiB
Python
5455 lines
227 KiB
Python
"""Project-scoped Gitea connector with bounded PR-first writes."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import copy
|
|
import difflib
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
import time
|
|
from collections.abc import Callable
|
|
from datetime import UTC, datetime
|
|
from threading import Lock
|
|
from typing import Any, Literal
|
|
from urllib.parse import quote, unquote, urlparse
|
|
|
|
import yaml # type: ignore[import-untyped]
|
|
|
|
from packages.connector_kit import (
|
|
ConnectorContext,
|
|
GiteaClient,
|
|
GiteaError,
|
|
GiteaRepositoryRegistry,
|
|
PageRequest,
|
|
RegisteredRepository,
|
|
ResourcePublication,
|
|
ToolPublication,
|
|
decode_cursor,
|
|
decode_file_payload,
|
|
encode_cursor,
|
|
is_secret_like,
|
|
redact_sensitive_text,
|
|
safe_content,
|
|
safe_snippet,
|
|
)
|
|
from packages.hub_policy import Governance, governed_tool
|
|
from packages.hub_runtime.supervisor import runtime_snapshot
|
|
|
|
from .changesets import ChangesetWorkspace, apply_unified_patch, validate_operations
|
|
from .lifecycle import cleanup_basis
|
|
from .managed_validation import (
|
|
MANAGED_VALIDATION_CONTEXT,
|
|
MANAGED_VALIDATION_DIGEST,
|
|
MANAGED_VALIDATION_PATH,
|
|
MANAGED_VALIDATION_WORKFLOW,
|
|
)
|
|
|
|
_SAFE_REF = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]{0,299}$")
|
|
_SAFE_CHANGE_BRANCH = _SAFE_REF
|
|
_SAFE_REPOSITORY_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,119}$")
|
|
_SAFE_ACCOUNT_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,119}$")
|
|
_SHA = re.compile(r"^[0-9a-fA-F]{40,64}$")
|
|
_ORCHESTRATION_SUFFIX = re.compile(r"^[a-z0-9][a-z0-9._-]{0,79}$")
|
|
_WORKSPACE_PROFILES = ("test", "lint", "typecheck", "build", "security", "full")
|
|
_GOVERNANCE_BATCH_LIMIT = 5
|
|
_FAILED_CONCLUSIONS = frozenset({"failure", "failed", "cancelled", "timed_out", "timed-out"})
|
|
_FAILURE_LINE = re.compile(
|
|
r"(?i)(error|failed|failure|fatal|traceback|exception|"
|
|
r"exit(?:ed)?(?:\s+with)?(?:\s+code)?\s*[=: ]\s*\d+)"
|
|
)
|
|
_EXIT_CODE = re.compile(r"(?i)exit(?:ed)?(?:\s+with)?(?:\s+code)?\s*[=: ]\s*(\d{1,3})")
|
|
_FAILURE_MAX_JOBS = 20
|
|
_FAILURE_MAX_LINES = 100
|
|
_FAILURE_MAX_BYTES = 180_000
|
|
_VALIDATION_REUSE_SECONDS = 2 * 60 * 60
|
|
_DISCOVERY_CACHE_SECONDS = 5 * 60
|
|
_CODE_SEARCH_MAX_FILES = 40
|
|
_CODE_SEARCH_BUDGET_SECONDS = 4.0
|
|
_CODE_SEARCH_BLOB_TIMEOUT_SECONDS = 0.75
|
|
_REPOSITORY_CONTEXT_TREE_LIMIT = 64
|
|
_REPOSITORY_CONTEXT_DOCUMENT_LIMIT = 4
|
|
_REPOSITORY_CONTEXT_DOCUMENT_BYTES = 4_096
|
|
_REPOSITORY_CONTEXT_DOCUMENT_TOTAL_BYTES = 8_192
|
|
|
|
_PROJECTBRAIN_MANAGED_VALIDATION_REPOSITORY = "jens/projectbrain"
|
|
_PROJECTBRAIN_QUALITY_VALIDATION_CONTEXT = "Pull request / quality (pull_request)"
|
|
_PROJECTBRAIN_REQUIRED_VALIDATION_CONTEXTS = (
|
|
MANAGED_VALIDATION_CONTEXT,
|
|
_PROJECTBRAIN_QUALITY_VALIDATION_CONTEXT,
|
|
)
|
|
_PROJECTBRAIN_REQUIRED_PROTECTION_LOCKS = {
|
|
"enable_push": False,
|
|
"enable_push_whitelist": False,
|
|
"enable_force_push": False,
|
|
"enable_force_push_allowlist": False,
|
|
"enable_bypass_allowlist": False,
|
|
"enable_status_check": True,
|
|
"block_admin_merge_override": True,
|
|
}
|
|
# Original source commit for the ProjectBrain-specific profile lineage.
|
|
_PROJECTBRAIN_VALIDATION_PROFILE_SOURCE_SHA = "57157b1b24d3679018b54970c934fc35d1735df7"
|
|
# SHA-256 of JSON-dumped jobs.full (sort_keys, compact separators), parsed with
|
|
# BaseLoader. These two exact jobs pin the reviewed ProjectBrain workflow copies:
|
|
# the original validation profile and the bounded browser-runner profile.
|
|
# Original frozen workflow source and copied fixture raw SHA-256:
|
|
# 2f78d37ff51f38f0a3b0c9e222eb2dbf83d5108aee3615eb53f2c01283f37f34.
|
|
# Bounded browser fixture raw SHA-256:
|
|
# 6baaf4ceb2e493f90cc8210953a92f8f2c6da723e9c85446f38670efa285d9cc.
|
|
# Refresh only after review.
|
|
_PROJECTBRAIN_VALIDATION_FULL_JOB_DIGESTS = frozenset(
|
|
{
|
|
"a3fefb8865f0bd0d4a8c10af79eeb5e22367c5e82e9333a2fe04bdaf97d0193b",
|
|
"d2ee4d6b1c727b6e11286274728783533a293b47f23da81b70bbd052c6e8e11a",
|
|
}
|
|
)
|
|
_PROJECTBRAIN_VALIDATION_MAX_YAML_CHARS = 200_000
|
|
_PROJECTBRAIN_VALIDATION_MAX_YAML_TOKENS = 10_000
|
|
_PROJECTBRAIN_VALIDATION_MAX_YAML_DEPTH = 64
|
|
_PROJECTBRAIN_VALIDATION_RUN_NAME = "Managed validation / ${{ inputs.profile || 'full' }}"
|
|
_PROJECTBRAIN_VALIDATION_DISPATCH_OPTIONS = [
|
|
"test",
|
|
"lint",
|
|
"typecheck",
|
|
"build",
|
|
"security",
|
|
"full",
|
|
]
|
|
_PROJECTBRAIN_VALIDATION_TOP_LEVEL_KEYS = frozenset(
|
|
{"name", "run-name", "on", "permissions", "concurrency", "jobs"}
|
|
)
|
|
_PROJECTBRAIN_VALIDATION_CONCURRENCY = {
|
|
"group": "managed-validation-${{ gitea.repository }}-${{ gitea.ref }}",
|
|
"cancel-in-progress": "true",
|
|
}
|
|
|
|
|
|
class _UniqueWorkflowLoader(yaml.BaseLoader): # type: ignore[misc]
|
|
"""Parse workflow YAML without YAML 1.1's special treatment of the `on` key."""
|
|
|
|
|
|
def _construct_unique_workflow_mapping(loader: Any, node: Any) -> dict[str, Any]:
|
|
mapping: dict[str, Any] = {}
|
|
for key_node, value_node in node.value:
|
|
key = loader.construct_object(key_node)
|
|
if not isinstance(key, str) or key in mapping:
|
|
raise yaml.constructor.ConstructorError(
|
|
"while constructing a workflow mapping",
|
|
node.start_mark,
|
|
"workflow mapping keys must be unique strings",
|
|
key_node.start_mark,
|
|
)
|
|
mapping[key] = loader.construct_object(value_node)
|
|
return mapping
|
|
|
|
|
|
_UniqueWorkflowLoader.add_constructor(
|
|
yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG,
|
|
_construct_unique_workflow_mapping,
|
|
)
|
|
|
|
|
|
def _workflow_mapping(value: Any) -> dict[str, Any] | None:
|
|
return value if isinstance(value, dict) else None
|
|
|
|
|
|
def _workflow_yaml_is_bounded_and_alias_free(workflow_content: str) -> bool:
|
|
if len(workflow_content) > _PROJECTBRAIN_VALIDATION_MAX_YAML_CHARS:
|
|
return False
|
|
container_starts = (
|
|
yaml.tokens.BlockMappingStartToken,
|
|
yaml.tokens.BlockSequenceStartToken,
|
|
yaml.tokens.FlowMappingStartToken,
|
|
yaml.tokens.FlowSequenceStartToken,
|
|
)
|
|
container_ends = (
|
|
yaml.tokens.BlockEndToken,
|
|
yaml.tokens.FlowMappingEndToken,
|
|
yaml.tokens.FlowSequenceEndToken,
|
|
)
|
|
depth = 0
|
|
try:
|
|
for token_count, token in enumerate(
|
|
yaml.scan(workflow_content, Loader=_UniqueWorkflowLoader), start=1
|
|
):
|
|
if token_count > _PROJECTBRAIN_VALIDATION_MAX_YAML_TOKENS:
|
|
return False
|
|
if isinstance(token, yaml.tokens.AnchorToken | yaml.tokens.AliasToken):
|
|
return False
|
|
if isinstance(token, container_starts):
|
|
depth += 1
|
|
if depth > _PROJECTBRAIN_VALIDATION_MAX_YAML_DEPTH:
|
|
return False
|
|
elif isinstance(token, container_ends):
|
|
depth -= 1
|
|
if depth < 0:
|
|
return False
|
|
except (yaml.YAMLError, RecursionError, TypeError, ValueError):
|
|
return False
|
|
return depth == 0
|
|
|
|
|
|
def _projectbrain_managed_validation_is_strong(workflow_content: str) -> bool:
|
|
if not _workflow_yaml_is_bounded_and_alias_free(workflow_content):
|
|
return False
|
|
try:
|
|
# BaseLoader constructs only strings/lists/maps; it never creates Python objects.
|
|
workflow = yaml.load(workflow_content, Loader=_UniqueWorkflowLoader) # noqa: S506
|
|
except (yaml.YAMLError, RecursionError, TypeError, ValueError):
|
|
return False
|
|
document = _workflow_mapping(workflow)
|
|
if document is None or document.get("name") != "Managed validation":
|
|
return False
|
|
if (
|
|
not set(document).issubset(_PROJECTBRAIN_VALIDATION_TOP_LEVEL_KEYS)
|
|
or document.get("run-name") != _PROJECTBRAIN_VALIDATION_RUN_NAME
|
|
or document.get("permissions") != {"contents": "read"}
|
|
or document.get("concurrency") != _PROJECTBRAIN_VALIDATION_CONCURRENCY
|
|
):
|
|
return False
|
|
|
|
triggers = _workflow_mapping(document.get("on"))
|
|
if triggers is None or set(triggers) != {"push", "pull_request", "workflow_dispatch"}:
|
|
return False
|
|
dispatch = _workflow_mapping(triggers.get("workflow_dispatch"))
|
|
if dispatch is None or set(dispatch) != {"inputs"}:
|
|
return False
|
|
dispatch_inputs = _workflow_mapping(dispatch.get("inputs"))
|
|
profile_input = (
|
|
_workflow_mapping(dispatch_inputs.get("profile")) if dispatch_inputs is not None else None
|
|
)
|
|
if (
|
|
dispatch_inputs is None
|
|
or set(dispatch_inputs) != {"profile"}
|
|
or profile_input is None
|
|
or set(profile_input) != {"description", "required", "default", "type", "options"}
|
|
or not isinstance(profile_input.get("description"), str)
|
|
or profile_input.get("required") != "true"
|
|
or profile_input.get("default") != "full"
|
|
or profile_input.get("type") != "choice"
|
|
or profile_input.get("options") != _PROJECTBRAIN_VALIDATION_DISPATCH_OPTIONS
|
|
):
|
|
return False
|
|
push = _workflow_mapping(triggers.get("push"))
|
|
if push is None or set(push) != {"branches"} or push.get("branches") != ["main"]:
|
|
return False
|
|
pull_request = triggers.get("pull_request")
|
|
if pull_request not in (None, ""):
|
|
pull_request_mapping = _workflow_mapping(pull_request)
|
|
if pull_request_mapping is None:
|
|
return False
|
|
if set(pull_request_mapping) - {"branches"}:
|
|
return False
|
|
branches = pull_request_mapping.get("branches")
|
|
if branches is not None and branches != ["main"]:
|
|
return False
|
|
|
|
jobs = _workflow_mapping(document.get("jobs"))
|
|
if jobs is None or set(jobs) != {"full"}:
|
|
return False
|
|
full_job = _workflow_mapping(jobs.get("full"))
|
|
if full_job is None:
|
|
return False
|
|
try:
|
|
full_job_digest = hashlib.sha256(
|
|
json.dumps(full_job, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
).hexdigest()
|
|
except (RecursionError, TypeError, ValueError):
|
|
return False
|
|
return full_job_digest in _PROJECTBRAIN_VALIDATION_FULL_JOB_DIGESTS
|
|
|
|
|
|
def _managed_validation_profile(
|
|
repository_qualified_name: str, workflow_content: str | None
|
|
) -> str | None:
|
|
"""Accept only the canonical workflow, except for ProjectBrain's stronger bounded profile."""
|
|
if workflow_content is None:
|
|
return None
|
|
if repository_qualified_name.casefold() == _PROJECTBRAIN_MANAGED_VALIDATION_REPOSITORY:
|
|
if _projectbrain_managed_validation_is_strong(workflow_content):
|
|
return "projectbrain"
|
|
return None
|
|
digest = hashlib.sha256(workflow_content.encode("utf-8")).hexdigest()
|
|
return "canonical" if digest == MANAGED_VALIDATION_DIGEST else None
|
|
|
|
|
|
def _select_validation_workflow(workflows: list[dict[str, Any]]) -> dict[str, Any] | None:
|
|
"""Prefer the bounded managed contract over legacy validation workflows."""
|
|
managed: list[dict[str, Any]] = []
|
|
legacy: list[dict[str, Any]] = []
|
|
for item in workflows:
|
|
path = str(item.get("path", "")).casefold()
|
|
name = str(item.get("name", "")).casefold()
|
|
if path.endswith("managed-validation.yml") or name == "managed validation":
|
|
managed.append(item)
|
|
elif path.endswith("chatgpt-validation.yml") or name == "chatgpt validation":
|
|
legacy.append(item)
|
|
candidates = managed or legacy
|
|
return (
|
|
min(candidates, key=lambda item: str(item.get("path", item.get("id", ""))))
|
|
if candidates
|
|
else None
|
|
)
|
|
|
|
|
|
def _validation_polling(
|
|
latest_run: dict[str, Any] | None, expected_head_sha: str
|
|
) -> dict[str, Any]:
|
|
if latest_run is None:
|
|
state, terminal, poll_after_ms, next_action = "not-started", True, 0, "run-validation"
|
|
elif expected_head_sha and latest_run.get("headSha") != expected_head_sha:
|
|
state, terminal, poll_after_ms, next_action = "stale", True, 0, "run-validation"
|
|
else:
|
|
status = str(latest_run.get("status", "")).casefold()
|
|
conclusion = str(latest_run.get("conclusion", "")).casefold()
|
|
if status == "completed":
|
|
state = "succeeded" if conclusion == "success" else "failed"
|
|
terminal, poll_after_ms = True, 0
|
|
next_action = "continue" if state == "succeeded" else "inspect-or-retry-validation"
|
|
elif status in {"queued", "pending", "waiting", "requested"}:
|
|
state, terminal, poll_after_ms, next_action = "queued", False, 5_000, "wait-for-change"
|
|
else:
|
|
state, terminal, poll_after_ms, next_action = "running", False, 4_000, "wait-for-change"
|
|
version_input = {
|
|
"expectedHeadSha": expected_head_sha,
|
|
"id": latest_run.get("id") if latest_run else None,
|
|
"headSha": latest_run.get("headSha") if latest_run else None,
|
|
"status": latest_run.get("status") if latest_run else None,
|
|
"conclusion": latest_run.get("conclusion") if latest_run else None,
|
|
"updatedAt": latest_run.get("updatedAt") if latest_run else None,
|
|
}
|
|
return {
|
|
"validationState": state,
|
|
"terminal": terminal,
|
|
"pollAfterMs": poll_after_ms,
|
|
"nextAction": next_action,
|
|
"validationStateVersion": hashlib.sha256(
|
|
json.dumps(version_input, sort_keys=True, separators=(",", ":")).encode()
|
|
).hexdigest()[:24],
|
|
}
|
|
|
|
|
|
def _desired_branch_protection(default_branch: str) -> dict[str, Any]:
|
|
return {
|
|
"rule_name": default_branch,
|
|
"branch_name": default_branch,
|
|
"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,
|
|
}
|
|
|
|
|
|
def _branch_protection_projection(payload: dict[str, Any] | None) -> dict[str, Any] | None:
|
|
if payload is None:
|
|
return None
|
|
desired_keys = _desired_branch_protection(
|
|
str(payload.get("rule_name") or payload.get("branch_name") or "main")
|
|
)
|
|
return {key: payload.get(key) for key in desired_keys}
|
|
|
|
|
|
def _branch_protection_effective_projection(
|
|
payload: dict[str, Any] | None,
|
|
) -> dict[str, Any] | None:
|
|
if payload is None:
|
|
return None
|
|
volatile_metadata = {"id", "created_at", "updated_at"}
|
|
return {
|
|
key: value
|
|
for key, value in payload.items()
|
|
if key not in volatile_metadata and key != "status_check_contexts"
|
|
}
|
|
|
|
|
|
def _validate_ref(value: str, *, change_branch: bool = False) -> str:
|
|
pattern = _SAFE_CHANGE_BRANCH if change_branch else _SAFE_REF
|
|
if (
|
|
not pattern.fullmatch(value)
|
|
or ".." in value
|
|
or "//" in value
|
|
or value.endswith(("/", ".", ".lock"))
|
|
):
|
|
raise GiteaError("GITEA_TARGET_INVALID", "Gitea ref is outside the safe write policy")
|
|
return value
|
|
|
|
|
|
def _validate_work_branch(value: str, default_branch: str) -> str:
|
|
"""Accept any valid Git branch except the repository default branch."""
|
|
branch = _validate_ref(value, change_branch=True)
|
|
if not default_branch:
|
|
raise GiteaError("GITEA_REPOSITORY_NOT_READY", "repository has no default branch")
|
|
if branch == default_branch:
|
|
raise GiteaError(
|
|
"GITEA_TARGET_INVALID",
|
|
"direct writes to the repository default branch are not allowed; use a pull request",
|
|
)
|
|
return branch
|
|
|
|
|
|
def _validate_file_path(path: str) -> tuple[str, ...]:
|
|
if not 1 <= len(path) <= 2048 or path.startswith(("/", "\\")):
|
|
raise GiteaError("GITEA_PATH_INVALID", "repository file path is invalid")
|
|
parts = tuple(path.split("/"))
|
|
if any(
|
|
not part or part in {".", ".."} or "\\" in part or any(ord(char) < 0x20 for char in part)
|
|
for part in parts
|
|
):
|
|
raise GiteaError("GITEA_PATH_INVALID", "repository file path is invalid")
|
|
return parts
|
|
|
|
|
|
def _validate_text(value: str, *, field: str, minimum: int, maximum: int) -> str:
|
|
encoded = value.encode("utf-8")
|
|
if not minimum <= len(encoded) <= maximum or "\x00" in value:
|
|
raise GiteaError("GITEA_INPUT_INVALID", f"{field} is outside the connector budget")
|
|
return value
|
|
|
|
|
|
class GiteaConnector:
|
|
"""Expose only repositories registered for the caller's project."""
|
|
|
|
connector_type = "gitea"
|
|
version = "1.14.0"
|
|
|
|
def __init__(
|
|
self,
|
|
client: GiteaClient,
|
|
registry: GiteaRepositoryRegistry,
|
|
governance: Governance | None = None,
|
|
*,
|
|
write_client: GiteaClient | None = None,
|
|
write_owner: str | None = None,
|
|
repository_create_project: tuple[Any, str] | None = None,
|
|
changeset_workspace: ChangesetWorkspace | None = None,
|
|
discovery_projects: dict[Any, str] | None = None,
|
|
) -> None:
|
|
self.client = client
|
|
self.write_client = write_client
|
|
if write_owner is not None and not _SAFE_ACCOUNT_NAME.fullmatch(write_owner):
|
|
raise ValueError("Gitea write owner is invalid")
|
|
self.write_owner = write_owner
|
|
self.repository_create_project = repository_create_project
|
|
self.changeset_workspace = changeset_workspace
|
|
self.registry = registry
|
|
self.governance = governance
|
|
self.discovery_projects = discovery_projects or {}
|
|
self._last_discovery: dict[Any, float] = {}
|
|
self._discovery_lock = Lock()
|
|
self._tree_cache: dict[tuple[str, str, int], tuple[float, dict[str, Any]]] = {}
|
|
self._tree_cache_lock = Lock()
|
|
self._readiness_cache: tuple[float, dict[str, Any]] | None = None
|
|
self._readiness_lock = Lock()
|
|
self._validation_dispatches: dict[tuple[str, str, str, str], tuple[float, int]] = {}
|
|
self._validation_dispatch_lock = Lock()
|
|
|
|
def _git_tree_cached(
|
|
self,
|
|
repository: RegisteredRepository,
|
|
ref: str,
|
|
*,
|
|
page: int = 1,
|
|
per_page: int = 1_000,
|
|
) -> tuple[dict[str, Any], bool]:
|
|
key = (repository.qualified_name, ref, page)
|
|
now = time.monotonic()
|
|
with self._tree_cache_lock:
|
|
cached = self._tree_cache.get(key)
|
|
if cached is not None and now - cached[0] < 30:
|
|
return copy.deepcopy(cached[1]), True
|
|
payload = self.client.git_tree(
|
|
repository, ref, recursive=True, page=page, per_page=per_page
|
|
)
|
|
with self._tree_cache_lock:
|
|
self._tree_cache[key] = (now, copy.deepcopy(payload))
|
|
if len(self._tree_cache) > 64:
|
|
oldest = min(self._tree_cache, key=lambda item: self._tree_cache[item][0])
|
|
self._tree_cache.pop(oldest, None)
|
|
return payload, False
|
|
|
|
def _invalidate_repository_cache(self, repository: RegisteredRepository) -> None:
|
|
with self._tree_cache_lock:
|
|
for key in tuple(self._tree_cache):
|
|
if key[0] == repository.qualified_name:
|
|
self._tree_cache.pop(key, None)
|
|
|
|
def _refresh_discovered(self, project_id: Any) -> None:
|
|
project_slug = self.discovery_projects.get(project_id)
|
|
if project_slug is None:
|
|
return
|
|
with self._discovery_lock:
|
|
now = time.monotonic()
|
|
if now - self._last_discovery.get(project_id, 0.0) < _DISCOVERY_CACHE_SECONDS:
|
|
return
|
|
discovered: list[dict[str, Any]] = []
|
|
seen: set[str] = set()
|
|
identity_client = self.write_client or self.client
|
|
loaders = (
|
|
(self.client.accessible_private_repositories, False),
|
|
(identity_client.authenticated_user_repositories, True),
|
|
)
|
|
for loader, optional in loaders:
|
|
for page in range(1, 101):
|
|
try:
|
|
items = loader(page, 100)
|
|
except GiteaError as error:
|
|
if optional and error.code in {
|
|
"GITEA_ACCESS_DENIED",
|
|
"GITEA_NOT_FOUND",
|
|
"GITEA_UNSUPPORTED",
|
|
}:
|
|
break
|
|
raise
|
|
for item in items:
|
|
full_name = str(item.get("full_name", "")).casefold()
|
|
if full_name and full_name not in seen:
|
|
seen.add(full_name)
|
|
discovered.append(item)
|
|
if len(discovered) > 10_000:
|
|
raise GiteaError(
|
|
"GITEA_DISCOVERY_LIMIT",
|
|
"repository discovery exceeded 10000 items",
|
|
)
|
|
if not items:
|
|
break
|
|
else:
|
|
raise GiteaError(
|
|
"GITEA_DISCOVERY_LIMIT", "repository discovery exceeded 100 pages"
|
|
)
|
|
self.registry.add_discovered(project_id, project_slug, discovered)
|
|
self._last_discovery[project_id] = now
|
|
|
|
def _writer(self) -> GiteaClient:
|
|
if self.write_client is None:
|
|
raise GiteaError(
|
|
"GITEA_WRITES_DISABLED", "Gitea write capability is not enabled for this runtime"
|
|
)
|
|
return self.write_client
|
|
|
|
def _repository_write_owner(self, context: ConnectorContext) -> str:
|
|
"""Resolve the fixed create namespace without broadening the token's read scope."""
|
|
|
|
if self.write_owner is not None:
|
|
return self.write_owner
|
|
project_id, _ = self.repository_create_project or (
|
|
context.project_id,
|
|
context.project_slug,
|
|
)
|
|
owners = {item.owner for item in self.registry.list(project_id)}
|
|
if len(owners) == 1:
|
|
return next(iter(owners))
|
|
raise GiteaError(
|
|
"GITEA_CONFIGURATION_INVALID",
|
|
"Gitea repository creation requires one configured write owner",
|
|
)
|
|
|
|
def _envelope(
|
|
self,
|
|
context: ConnectorContext,
|
|
data: Any,
|
|
*,
|
|
source: str = "gitea",
|
|
truncated: bool = False,
|
|
next_cursor: str | None = None,
|
|
warnings: tuple[str, ...] = (),
|
|
) -> dict[str, Any]:
|
|
return {
|
|
"data": data,
|
|
"meta": {
|
|
"project": context.project_slug,
|
|
"environment": context.environment,
|
|
"source": source,
|
|
"truncated": truncated,
|
|
"nextCursor": next_cursor,
|
|
"generatedAt": datetime.now(UTC).isoformat(),
|
|
},
|
|
"warnings": list(warnings),
|
|
}
|
|
|
|
@staticmethod
|
|
def _snapshot_id(value: Any) -> str:
|
|
return hashlib.sha256(
|
|
json.dumps(value, sort_keys=True, separators=(",", ":")).encode()
|
|
).hexdigest()[:24]
|
|
|
|
@staticmethod
|
|
def _compact_comparison(
|
|
payload: dict[str, Any],
|
|
*,
|
|
base: str,
|
|
head: str,
|
|
reverse_payload: dict[str, Any] | None = None,
|
|
) -> dict[str, Any]:
|
|
raw_commits = payload.get("commits")
|
|
raw_files = payload.get("files")
|
|
commits = raw_commits if isinstance(raw_commits, list) else []
|
|
files = raw_files if isinstance(raw_files, list) else []
|
|
truncated = len(commits) > 50 or len(files) > 100
|
|
return {
|
|
"base": base,
|
|
"head": head,
|
|
"aheadBy": max(int(payload.get("ahead_by", payload.get("total_commits", 0)) or 0), 0),
|
|
"behindBy": max(
|
|
int(
|
|
payload.get(
|
|
"behind_by",
|
|
(reverse_payload or {}).get("total_commits", 0),
|
|
)
|
|
or 0
|
|
),
|
|
0,
|
|
),
|
|
"totalCommits": max(int(payload.get("total_commits", len(commits)) or 0), 0),
|
|
"commits": [
|
|
{
|
|
"sha": str(item.get("sha", item.get("id", "")))[:64],
|
|
"message": redact_sensitive_text(
|
|
str((item.get("commit") or {}).get("message", item.get("message", ""))),
|
|
max_bytes=1_000,
|
|
),
|
|
}
|
|
for item in commits[:50]
|
|
if isinstance(item, dict)
|
|
],
|
|
"files": [
|
|
{
|
|
"path": str(item.get("filename", ""))[:2_048],
|
|
"status": str(item.get("status", ""))[:32],
|
|
}
|
|
for item in files[:100]
|
|
if isinstance(item, dict)
|
|
],
|
|
"truncated": truncated,
|
|
}
|
|
|
|
@staticmethod
|
|
def _failure_excerpt(logs: str) -> tuple[list[str], int | None, str | None, str]:
|
|
lines = logs.splitlines()
|
|
matches = [index for index, line in enumerate(lines) if _FAILURE_LINE.search(line)]
|
|
selected: list[int] = []
|
|
for index in matches:
|
|
selected.extend(range(max(index - 3, 0), min(index + 4, len(lines))))
|
|
selected = sorted(set(selected))[-100:]
|
|
excerpt = [redact_sensitive_text(lines[index], max_bytes=2_000) for index in selected]
|
|
exit_code = None
|
|
for line in reversed(excerpt):
|
|
match = _EXIT_CODE.search(line)
|
|
if match is not None:
|
|
exit_code = int(match.group(1))
|
|
break
|
|
command = next(
|
|
(
|
|
redact_sensitive_text(line.strip(), max_bytes=1_000)
|
|
for line in reversed(lines[: (matches[-1] + 1 if matches else len(lines))])
|
|
if line.strip().startswith(("Run ", "$ ", "+ "))
|
|
),
|
|
None,
|
|
)
|
|
error_code = "VALIDATION_COMMAND_FAILED" if exit_code is not None else "VALIDATION_FAILED"
|
|
return excerpt, exit_code, command, error_code
|
|
|
|
def _repo(self, context: ConnectorContext, repository_ref: str) -> RegisteredRepository:
|
|
self._refresh_discovered(context.project_id)
|
|
return self.registry.resolve(context.project_id, repository_ref)
|
|
|
|
@staticmethod
|
|
def _page(page: PageRequest | None) -> tuple[int, int, str | None]:
|
|
request = page or PageRequest(limit=25)
|
|
cursor = decode_cursor(request.cursor, request.limit.__str__())
|
|
return request.limit, cursor.offset, request.cursor
|
|
|
|
def _collection(
|
|
self,
|
|
context: ConnectorContext,
|
|
repository_ref: str,
|
|
page: PageRequest | None,
|
|
loader: Any,
|
|
mapper: Any,
|
|
kind: str,
|
|
) -> dict[str, Any]:
|
|
repository = self._repo(context, repository_ref)
|
|
request = page or PageRequest(limit=25)
|
|
cursor = decode_cursor(
|
|
request.cursor, str(context.project_id), repository_ref, kind, str(request.limit)
|
|
)
|
|
page_number = cursor.offset // request.limit + 1
|
|
items = mapper(loader(repository, page_number, request.limit), repository)
|
|
next_cursor = None
|
|
if len(items) == request.limit:
|
|
next_cursor = encode_cursor(
|
|
cursor.offset + len(items),
|
|
str(context.project_id),
|
|
repository_ref,
|
|
kind,
|
|
str(request.limit),
|
|
)
|
|
data = {"items": items, "nextCursor": next_cursor, "truncated": next_cursor is not None}
|
|
return self._envelope(
|
|
context,
|
|
data,
|
|
source=f"gitea:{repository_ref}",
|
|
truncated=next_cursor is not None,
|
|
next_cursor=next_cursor,
|
|
)
|
|
|
|
@governed_tool("gitea.repository.list")
|
|
def repository_list(
|
|
self, context: ConnectorContext, *, page: PageRequest | None = None
|
|
) -> dict[str, Any]:
|
|
self._refresh_discovered(context.project_id)
|
|
request = page or PageRequest(limit=25)
|
|
cursor = decode_cursor(
|
|
request.cursor, str(context.project_id), "repositories", str(request.limit)
|
|
)
|
|
repositories = self.registry.list(context.project_id)
|
|
selected = repositories[cursor.offset : cursor.offset + request.limit]
|
|
items = []
|
|
for item in selected:
|
|
payload = self.client.repository(item)
|
|
default_branch = payload.get("default_branch")
|
|
if not payload.get("empty", False) and not (
|
|
isinstance(default_branch, str) and default_branch.strip()
|
|
):
|
|
raise GiteaError(
|
|
"GITEA_INVALID_RESPONSE",
|
|
"Gitea returned a non-empty repository without a default branch",
|
|
)
|
|
items.append(
|
|
{
|
|
"repositoryRef": item.repository_ref,
|
|
"name": str(payload.get("name", item.name))[:120],
|
|
"owner": str((payload.get("owner") or {}).get("login", item.owner))[:120],
|
|
"private": bool(payload.get("private", item.private)),
|
|
"defaultBranch": (
|
|
default_branch[:300]
|
|
if isinstance(default_branch, str) and default_branch.strip()
|
|
else None
|
|
),
|
|
"sourceUri": item.source_uri,
|
|
}
|
|
)
|
|
next_cursor = None
|
|
if cursor.offset + len(items) < len(repositories):
|
|
next_cursor = encode_cursor(
|
|
cursor.offset + len(items),
|
|
str(context.project_id),
|
|
"repositories",
|
|
str(request.limit),
|
|
)
|
|
data = {"items": items, "nextCursor": next_cursor, "truncated": next_cursor is not None}
|
|
return self._envelope(
|
|
context,
|
|
data,
|
|
source="gitea:registry",
|
|
truncated=next_cursor is not None,
|
|
next_cursor=next_cursor,
|
|
)
|
|
|
|
@governed_tool("gitea.repository.get")
|
|
def repository_get(self, context: ConnectorContext, repository_ref: str) -> dict[str, Any]:
|
|
repository = self._repo(context, repository_ref)
|
|
payload = self.client.repository(repository)
|
|
owner_value = payload.get("owner")
|
|
owner = owner_value if isinstance(owner_value, dict) else {}
|
|
data = {
|
|
"repositoryRef": repository.repository_ref,
|
|
"name": str(payload.get("name", repository.name))[:120],
|
|
"owner": str(owner.get("login", repository.owner))[:120],
|
|
"fullName": str(payload.get("full_name", repository.qualified_name))[:241],
|
|
"description": safe_snippet(str(payload.get("description", "")), max_bytes=2000),
|
|
"private": bool(payload.get("private", repository.private)),
|
|
"defaultBranch": str(payload.get("default_branch", ""))[:300],
|
|
"sourceUri": repository.source_uri,
|
|
"updatedAt": str(payload.get("updated_at", ""))[:64],
|
|
}
|
|
return self._envelope(context, data, source=f"gitea:{repository_ref}")
|
|
|
|
@governed_tool("gitea.repository.context")
|
|
def repository_context(
|
|
self, context: ConnectorContext, repository_ref: str, ref: str | None = None
|
|
) -> dict[str, Any]:
|
|
repository = self._repo(context, repository_ref)
|
|
metadata = self.client.repository(repository)
|
|
selected_ref = _validate_ref(ref) if ref else str(metadata.get("default_branch", ""))
|
|
if not selected_ref:
|
|
raise GiteaError("GITEA_TARGET_INVALID", "repository has no usable default branch")
|
|
tree_payload, cache_hit = self._git_tree_cached(repository, selected_ref)
|
|
raw_tree = tree_payload.get("tree")
|
|
tree = raw_tree if isinstance(raw_tree, list) else []
|
|
files = [item for item in tree if isinstance(item, dict) and item.get("type") == "blob"]
|
|
important_names = {
|
|
"AGENTS.md",
|
|
"CLAUDE.md",
|
|
"README.md",
|
|
"README_NL.md",
|
|
"pyproject.toml",
|
|
"package.json",
|
|
"Cargo.toml",
|
|
"go.mod",
|
|
"pom.xml",
|
|
"build.gradle",
|
|
"Dockerfile",
|
|
"docker-compose.yml",
|
|
"docker-compose.yaml",
|
|
}
|
|
important_paths = [
|
|
str(item.get("path", ""))
|
|
for item in files
|
|
if str(item.get("path", "")).split("/")[-1] in important_names
|
|
]
|
|
documents: list[dict[str, Any]] = []
|
|
remaining_document_bytes = _REPOSITORY_CONTEXT_DOCUMENT_TOTAL_BYTES
|
|
for path in important_paths[:_REPOSITORY_CONTEXT_DOCUMENT_LIMIT]:
|
|
if remaining_document_bytes <= 0:
|
|
break
|
|
payload = self.client.file(repository, _validate_file_path(path), selected_ref)
|
|
document_limit = min(_REPOSITORY_CONTEXT_DOCUMENT_BYTES, remaining_document_bytes)
|
|
content, truncated, sha = decode_file_payload(payload, document_limit)
|
|
filtered = safe_content(content)
|
|
emitted_bytes = len((filtered or "").encode("utf-8"))
|
|
remaining_document_bytes = max(remaining_document_bytes - emitted_bytes, 0)
|
|
documents.append(
|
|
{
|
|
"path": path,
|
|
"sha": sha[:64],
|
|
"content": filtered or "",
|
|
"excluded": filtered is None,
|
|
"truncated": truncated,
|
|
}
|
|
)
|
|
path_set = {str(item.get("path", "")) for item in files}
|
|
stacks = []
|
|
for marker, label in (
|
|
("pyproject.toml", "python"),
|
|
("package.json", "node"),
|
|
("Cargo.toml", "rust"),
|
|
("go.mod", "go"),
|
|
("pom.xml", "java-maven"),
|
|
("Dockerfile", "container"),
|
|
):
|
|
if marker in path_set:
|
|
stacks.append(label)
|
|
tree_truncated = (
|
|
bool(tree_payload.get("truncated", False)) or len(tree) > _REPOSITORY_CONTEXT_TREE_LIMIT
|
|
)
|
|
documents_truncated = len(important_paths) > len(documents) or any(
|
|
bool(item["truncated"]) for item in documents
|
|
)
|
|
context_truncated = tree_truncated or documents_truncated
|
|
data = {
|
|
"repositoryRef": repository_ref,
|
|
"fullName": repository.qualified_name,
|
|
"ref": selected_ref,
|
|
"defaultBranch": str(metadata.get("default_branch", ""))[:300],
|
|
"description": safe_snippet(str(metadata.get("description", "")), max_bytes=2_000),
|
|
"stacks": stacks,
|
|
"tree": [
|
|
{
|
|
"path": str(item.get("path", ""))[:2_048],
|
|
"type": str(item.get("type", ""))[:16],
|
|
"sha": str(item.get("sha", ""))[:64],
|
|
"size": max(int(item.get("size", 0) or 0), 0),
|
|
}
|
|
for item in tree[:_REPOSITORY_CONTEXT_TREE_LIMIT]
|
|
if isinstance(item, dict)
|
|
],
|
|
"documents": documents,
|
|
"treeTruncated": tree_truncated,
|
|
"documentsTruncated": documents_truncated,
|
|
}
|
|
result = self._envelope(
|
|
context,
|
|
data,
|
|
source=f"gitea:{repository_ref}",
|
|
truncated=context_truncated,
|
|
)
|
|
result["meta"]["cacheHit"] = cache_hit
|
|
result["meta"]["cacheTtlSeconds"] = 30
|
|
return result
|
|
|
|
@governed_tool("gitea.repository.health")
|
|
def repository_health(self, context: ConnectorContext, repository_ref: str) -> dict[str, Any]:
|
|
repository = self._repo(context, repository_ref)
|
|
metadata = self.client.repository(repository)
|
|
branches = self.client.branches(repository, 1, 100)
|
|
pulls = self.client.pull_requests(repository, 1, 100)
|
|
warnings: list[str] = []
|
|
try:
|
|
workflows = self.client.action_workflows(repository)
|
|
except GiteaError as exc:
|
|
if exc.code not in {
|
|
"GITEA_NOT_FOUND",
|
|
"GITEA_UNSUPPORTED",
|
|
"GITEA_REQUEST_FAILED",
|
|
"GITEA_INVALID_RESPONSE",
|
|
}:
|
|
raise
|
|
workflows = []
|
|
warnings.append("Gitea Actions workflow discovery is unavailable for this repository.")
|
|
default_branch = str(metadata.get("default_branch", ""))[:300]
|
|
default_payload = next(
|
|
(item for item in branches if str(item.get("name", "")) == default_branch), None
|
|
)
|
|
change_branches = [
|
|
str(item.get("name", ""))[:300]
|
|
for item in branches
|
|
if str(item.get("name", "")) != default_branch
|
|
and _SAFE_CHANGE_BRANCH.fullmatch(str(item.get("name", "")))
|
|
]
|
|
open_pulls = [item for item in pulls if str(item.get("state", "")) == "open"]
|
|
source_revision = str(((default_payload or {}).get("commit") or {}).get("id", ""))[:64]
|
|
snapshot_material = {
|
|
"repositoryRef": repository_ref,
|
|
"sourceRevision": source_revision,
|
|
"branches": [
|
|
{
|
|
"name": str(item.get("name", ""))[:300],
|
|
"protected": bool(item.get("protected", False)),
|
|
}
|
|
for item in branches
|
|
],
|
|
"pullRequests": [
|
|
{
|
|
"number": max(int(item.get("number", 0) or 0), 0),
|
|
"state": str(item.get("state", ""))[:32],
|
|
}
|
|
for item in pulls
|
|
],
|
|
}
|
|
snapshot_id = hashlib.sha256(
|
|
json.dumps(snapshot_material, sort_keys=True, separators=(",", ":")).encode()
|
|
).hexdigest()[:24]
|
|
validation_workflow = any(
|
|
str(item.get("path", "")).endswith("chatgpt-validation.yml")
|
|
or str(item.get("path", "")).endswith("managed-validation.yml")
|
|
or str(item.get("name", "")).casefold() == "chatgpt validation"
|
|
or str(item.get("name", "")).casefold() == "managed validation"
|
|
for item in workflows
|
|
)
|
|
findings = []
|
|
if default_payload is None:
|
|
findings.append("default-branch-not-listed")
|
|
elif not bool(default_payload.get("protected", False)):
|
|
findings.append("default-branch-unprotected")
|
|
if not validation_workflow:
|
|
findings.append("validation-workflow-missing")
|
|
if len(branches) >= 100:
|
|
findings.append("branch-inventory-truncated")
|
|
if len(pulls) >= 100:
|
|
findings.append("pull-request-inventory-truncated")
|
|
return self._envelope(
|
|
context,
|
|
{
|
|
"repositoryRef": repository_ref,
|
|
"defaultBranch": default_branch,
|
|
"defaultBranchProtected": bool(
|
|
isinstance(default_payload, dict) and default_payload.get("protected", False)
|
|
),
|
|
"branchCount": len(branches),
|
|
"changeBranches": change_branches,
|
|
"safeBranchCount": len(change_branches),
|
|
"openPullRequests": [self._pull_request_projection(item) for item in open_pulls],
|
|
"openPullRequestCount": len(open_pulls),
|
|
"validationWorkflowAvailable": validation_workflow,
|
|
"findings": findings,
|
|
"healthy": not findings,
|
|
"snapshotId": snapshot_id,
|
|
"sourceRevision": source_revision,
|
|
},
|
|
source=f"gitea:{repository_ref}",
|
|
truncated=len(branches) >= 100 or len(pulls) >= 100,
|
|
warnings=tuple(warnings),
|
|
)
|
|
|
|
@staticmethod
|
|
def _public_governance_plan(plan: dict[str, Any]) -> dict[str, Any]:
|
|
return {key: value for key, value in plan.items() if not key.startswith("_")}
|
|
|
|
def _governance_plan_data(
|
|
self, context: ConnectorContext, repository_ref: str
|
|
) -> dict[str, Any]:
|
|
repository = self._repo(context, repository_ref)
|
|
metadata = self.client.repository(repository)
|
|
default_branch = str(metadata.get("default_branch", ""))[:300]
|
|
blockers: list[str] = []
|
|
if bool(metadata.get("empty", False)) or not default_branch:
|
|
blockers.append("repository-empty")
|
|
|
|
protections = self.client.branch_protections(repository)
|
|
current = next(
|
|
(
|
|
item
|
|
for item in protections
|
|
if str(item.get("rule_name") or item.get("branch_name")) == default_branch
|
|
),
|
|
None,
|
|
)
|
|
desired = _desired_branch_protection(default_branch or "main")
|
|
required_contexts = (
|
|
_PROJECTBRAIN_REQUIRED_VALIDATION_CONTEXTS
|
|
if repository.qualified_name.casefold() == _PROJECTBRAIN_MANAGED_VALIDATION_REPOSITORY
|
|
else (MANAGED_VALIDATION_CONTEXT,)
|
|
)
|
|
existing_contexts = current.get("status_check_contexts") if current is not None else None
|
|
preserved_contexts = (
|
|
{str(item) for item in existing_contexts if str(item).strip()}
|
|
if isinstance(existing_contexts, list)
|
|
else set()
|
|
)
|
|
desired["status_check_contexts"] = sorted(set(required_contexts).union(preserved_contexts))
|
|
current_projection = _branch_protection_projection(current)
|
|
protection_diff = {
|
|
key: {
|
|
"current": None if current_projection is None else current_projection.get(key),
|
|
"desired": value,
|
|
}
|
|
for key, value in desired.items()
|
|
if current_projection is None or current_projection.get(key) != value
|
|
}
|
|
|
|
workflow_content: str | None = None
|
|
workflow_sha = ""
|
|
if default_branch and "repository-empty" not in blockers:
|
|
try:
|
|
workflow_payload = self.client.file(
|
|
repository, tuple(MANAGED_VALIDATION_PATH.split("/")), default_branch
|
|
)
|
|
workflow_content, workflow_truncated, workflow_sha = decode_file_payload(
|
|
workflow_payload, 200_000
|
|
)
|
|
if workflow_truncated:
|
|
blockers.append("managed-validation-workflow-too-large")
|
|
except GiteaError as exc:
|
|
if exc.code != "GITEA_NOT_FOUND":
|
|
raise
|
|
workflow_digest = (
|
|
hashlib.sha256(workflow_content.encode("utf-8")).hexdigest()
|
|
if workflow_content is not None
|
|
else None
|
|
)
|
|
workflow_profile = _managed_validation_profile(repository.qualified_name, workflow_content)
|
|
is_projectbrain = (
|
|
repository.qualified_name.casefold() == _PROJECTBRAIN_MANAGED_VALIDATION_REPOSITORY
|
|
)
|
|
if is_projectbrain and workflow_profile is None:
|
|
blockers.append("managed-validation-projectbrain-profile-invalid")
|
|
# Fail closed: governance must never replace ProjectBrain's
|
|
# repository-specific database/E2E gate with the generic baseline.
|
|
workflow_action = "none"
|
|
desired_workflow_digest = None
|
|
else:
|
|
workflow_action = (
|
|
"create"
|
|
if workflow_content is None
|
|
else "none"
|
|
if workflow_profile is not None
|
|
else "update"
|
|
)
|
|
desired_workflow_digest = (
|
|
workflow_digest if workflow_profile == "projectbrain" else MANAGED_VALIDATION_DIGEST
|
|
)
|
|
if is_projectbrain and current_projection is not None:
|
|
if any(
|
|
current_projection.get(key) != expected
|
|
for key, expected in _PROJECTBRAIN_REQUIRED_PROTECTION_LOCKS.items()
|
|
):
|
|
blockers.append("managed-validation-projectbrain-protection-safety-locks-invalid")
|
|
if workflow_profile == "projectbrain":
|
|
desired = dict(current_projection)
|
|
desired["status_check_contexts"] = sorted(
|
|
set(required_contexts).union(preserved_contexts)
|
|
)
|
|
protection_diff = {
|
|
key: {
|
|
"current": None if current_projection is None else current_projection.get(key),
|
|
"desired": value,
|
|
}
|
|
for key, value in desired.items()
|
|
if current_projection is None or current_projection.get(key) != value
|
|
}
|
|
protection_action = "create" if current is None else "update" if protection_diff else "none"
|
|
plan_payload = {
|
|
"repositoryRef": repository_ref,
|
|
"defaultBranch": default_branch,
|
|
"workflow": {
|
|
"path": MANAGED_VALIDATION_PATH,
|
|
"currentDigest": workflow_digest,
|
|
"desiredDigest": desired_workflow_digest,
|
|
"profile": workflow_profile
|
|
or ("projectbrain-required" if is_projectbrain else "canonical"),
|
|
"action": workflow_action,
|
|
},
|
|
"branchProtection": {
|
|
"current": current_projection,
|
|
"desired": desired,
|
|
"diff": protection_diff,
|
|
"action": protection_action,
|
|
},
|
|
"blockers": blockers,
|
|
}
|
|
effective_protection = _branch_protection_effective_projection(current)
|
|
plan_hash_source: dict[str, Any] = plan_payload
|
|
if is_projectbrain:
|
|
plan_hash_source = {
|
|
"plan": plan_payload,
|
|
"effectiveProtection": effective_protection,
|
|
}
|
|
plan_hash = hashlib.sha256(
|
|
json.dumps(plan_hash_source, sort_keys=True, separators=(",", ":")).encode()
|
|
).hexdigest()
|
|
protection_only_available = (
|
|
is_projectbrain
|
|
and current is not None
|
|
and current.get("enable_status_check") is True
|
|
and default_branch == "main"
|
|
and isinstance(desired.get("status_check_contexts"), list)
|
|
and set(blockers) == {"managed-validation-projectbrain-profile-invalid"}
|
|
)
|
|
protection_only_plan_hash = (
|
|
hashlib.sha256(
|
|
json.dumps(
|
|
{
|
|
"applyScope": "protection_only",
|
|
"plan": plan_payload,
|
|
"effectiveProtection": effective_protection,
|
|
},
|
|
sort_keys=True,
|
|
separators=(",", ":"),
|
|
).encode()
|
|
).hexdigest()
|
|
if protection_only_available
|
|
else None
|
|
)
|
|
return {
|
|
**plan_payload,
|
|
"planHash": plan_hash,
|
|
"protectionOnlyPlanHash": protection_only_plan_hash,
|
|
"protectionOnlyAvailable": protection_only_available,
|
|
"compliant": not blockers and workflow_action == "none" and protection_action == "none",
|
|
"_repository": repository,
|
|
"_workflowContent": workflow_content,
|
|
"_workflowSha": workflow_sha,
|
|
"_currentProtection": current,
|
|
"_protectionOnlyAvailable": protection_only_available,
|
|
}
|
|
|
|
@governed_tool("gitea.branchprotection.get")
|
|
def branch_protection_get(
|
|
self, context: ConnectorContext, repository_ref: str
|
|
) -> dict[str, Any]:
|
|
plan = self._governance_plan_data(context, repository_ref)
|
|
return self._envelope(
|
|
context,
|
|
{
|
|
"repositoryRef": repository_ref,
|
|
"defaultBranch": plan["defaultBranch"],
|
|
"protection": plan["branchProtection"]["current"],
|
|
},
|
|
source=f"gitea:{repository_ref}",
|
|
)
|
|
|
|
@governed_tool("gitea.branchprotection.preview")
|
|
def branch_protection_preview(
|
|
self, context: ConnectorContext, repository_ref: str
|
|
) -> dict[str, Any]:
|
|
plan = self._governance_plan_data(context, repository_ref)
|
|
return self._envelope(
|
|
context,
|
|
self._public_governance_plan(plan),
|
|
source=f"gitea:{repository_ref}",
|
|
)
|
|
|
|
def _write_managed_validation_workflow(
|
|
self,
|
|
context: ConnectorContext,
|
|
repository: RegisteredRepository,
|
|
default_branch: str,
|
|
workflow_action: str,
|
|
workflow_sha: str,
|
|
current_protection: dict[str, Any] | None,
|
|
) -> bool:
|
|
writer = self._writer()
|
|
|
|
def write_workflow() -> None:
|
|
if workflow_action == "create":
|
|
writer.create_file(
|
|
repository,
|
|
tuple(MANAGED_VALIDATION_PATH.split("/")),
|
|
branch=default_branch,
|
|
content=MANAGED_VALIDATION_WORKFLOW,
|
|
message="ci: add managed validation contract [skip ci]",
|
|
)
|
|
elif workflow_action == "update":
|
|
writer.update_file(
|
|
repository,
|
|
tuple(MANAGED_VALIDATION_PATH.split("/")),
|
|
branch=default_branch,
|
|
content=MANAGED_VALIDATION_WORKFLOW,
|
|
message="ci: align managed validation contract [skip ci]",
|
|
sha=workflow_sha,
|
|
)
|
|
|
|
try:
|
|
write_workflow()
|
|
return False
|
|
except GiteaError as exc:
|
|
if exc.code == "GITEA_ACCESS_DENIED" and current_protection is not None:
|
|
raise GiteaError(
|
|
"GITEA_GOVERNANCE_BLOCKED",
|
|
"protected workflow changes require a validated pull request",
|
|
) from exc
|
|
raise
|
|
|
|
@governed_tool("gitea.branchprotection.ensure")
|
|
def branch_protection_ensure(
|
|
self,
|
|
context: ConnectorContext,
|
|
repository_ref: str,
|
|
expected_plan_hash: str,
|
|
apply_scope: Literal["all", "protection_only"] = "all",
|
|
) -> dict[str, Any]:
|
|
if apply_scope not in {"all", "protection_only"}:
|
|
raise GiteaError("GITEA_INPUT_INVALID", "governance apply scope is not allowlisted")
|
|
plan = self._governance_plan_data(context, repository_ref)
|
|
expected_hash = plan["planHash"] if apply_scope == "all" else plan["protectionOnlyPlanHash"]
|
|
if (
|
|
not isinstance(expected_hash, str)
|
|
or not re.fullmatch(r"[0-9a-f]{64}", expected_plan_hash)
|
|
or expected_plan_hash != expected_hash
|
|
):
|
|
raise GiteaError(
|
|
"GITEA_PLAN_STALE",
|
|
"governance state changed; preview the repository again before applying",
|
|
)
|
|
|
|
if apply_scope == "protection_only":
|
|
allowed_blockers = {"managed-validation-projectbrain-profile-invalid"}
|
|
if not plan["_protectionOnlyAvailable"] or any(
|
|
blocker not in allowed_blockers for blocker in plan["blockers"]
|
|
):
|
|
raise GiteaError(
|
|
"GITEA_GOVERNANCE_BLOCKED",
|
|
"ProjectBrain protection-only apply requires an existing "
|
|
"enabled status-check rule",
|
|
)
|
|
current_protection = plan["_currentProtection"]
|
|
if current_protection is None:
|
|
raise GiteaError(
|
|
"GITEA_GOVERNANCE_BLOCKED",
|
|
"ProjectBrain protection-only apply requires an existing "
|
|
"enabled status-check rule",
|
|
)
|
|
current_contexts = current_protection.get("status_check_contexts")
|
|
expected_contexts = list(plan["branchProtection"]["desired"]["status_check_contexts"])
|
|
protection_changed = current_contexts != expected_contexts
|
|
if not protection_changed:
|
|
return self._envelope(
|
|
context,
|
|
{
|
|
**self._public_governance_plan(plan),
|
|
"applyScope": apply_scope,
|
|
"changed": False,
|
|
"verified": True,
|
|
"temporaryPushAllowlistUsed": False,
|
|
},
|
|
source=f"gitea:{repository_ref}",
|
|
)
|
|
self._writer().update_branch_protection(
|
|
plan["_repository"],
|
|
str(plan["defaultBranch"]),
|
|
{"status_check_contexts": expected_contexts},
|
|
)
|
|
verified_plan = self._governance_plan_data(context, repository_ref)
|
|
verified_protection = verified_plan["branchProtection"]["current"]
|
|
expected_effective_protection = _branch_protection_effective_projection(
|
|
current_protection
|
|
)
|
|
actual_effective_protection = _branch_protection_effective_projection(
|
|
verified_plan["_currentProtection"]
|
|
)
|
|
actual_contexts = (
|
|
set(verified_protection.get("status_check_contexts") or [])
|
|
if verified_protection is not None
|
|
else set()
|
|
)
|
|
if (
|
|
verified_protection is None
|
|
or verified_protection.get("enable_status_check") is not True
|
|
or actual_contexts != set(expected_contexts)
|
|
or actual_effective_protection != expected_effective_protection
|
|
or verified_plan["workflow"]["currentDigest"] != plan["workflow"]["currentDigest"]
|
|
):
|
|
raise GiteaError(
|
|
"GITEA_GOVERNANCE_VERIFY_FAILED",
|
|
"Gitea did not retain the requested ProjectBrain protection-only contract",
|
|
)
|
|
return self._envelope(
|
|
context,
|
|
{
|
|
**self._public_governance_plan(verified_plan),
|
|
"applyScope": apply_scope,
|
|
"changed": True,
|
|
"verified": True,
|
|
"temporaryPushAllowlistUsed": False,
|
|
},
|
|
source=f"gitea:{repository_ref}",
|
|
)
|
|
|
|
if plan["blockers"]:
|
|
raise GiteaError(
|
|
"GITEA_GOVERNANCE_BLOCKED",
|
|
"repository governance has unresolved blockers",
|
|
)
|
|
if plan["compliant"]:
|
|
return self._envelope(
|
|
context,
|
|
{
|
|
**self._public_governance_plan(plan),
|
|
"applyScope": apply_scope,
|
|
"changed": False,
|
|
"verified": True,
|
|
},
|
|
source=f"gitea:{repository_ref}",
|
|
)
|
|
|
|
writer = self._writer()
|
|
repository = plan["_repository"]
|
|
default_branch = str(plan["defaultBranch"])
|
|
workflow_action = str(plan["workflow"]["action"])
|
|
workflow_changed = False
|
|
temporary_push_allowlist_used = False
|
|
try:
|
|
if workflow_action in {"create", "update"}:
|
|
temporary_push_allowlist_used = self._write_managed_validation_workflow(
|
|
context,
|
|
repository,
|
|
default_branch,
|
|
workflow_action,
|
|
str(plan["_workflowSha"]),
|
|
plan["_currentProtection"],
|
|
)
|
|
workflow_changed = True
|
|
|
|
desired = dict(plan["branchProtection"]["desired"])
|
|
protection_action = str(plan["branchProtection"]["action"])
|
|
if protection_action == "create":
|
|
writer.create_branch_protection(repository, desired)
|
|
elif protection_action == "update":
|
|
if plan["workflow"]["profile"] == "projectbrain":
|
|
edit_payload = {"status_check_contexts": desired["status_check_contexts"]}
|
|
else:
|
|
edit_payload = {
|
|
key: value
|
|
for key, value in desired.items()
|
|
if key not in {"rule_name", "branch_name"}
|
|
}
|
|
writer.update_branch_protection(repository, default_branch, edit_payload)
|
|
except GiteaError:
|
|
if workflow_changed:
|
|
try:
|
|
current_file = writer.file(
|
|
repository, tuple(MANAGED_VALIDATION_PATH.split("/")), default_branch
|
|
)
|
|
_, _, current_sha = decode_file_payload(current_file, 200_000)
|
|
if workflow_action == "create":
|
|
writer.delete_file(
|
|
repository,
|
|
tuple(MANAGED_VALIDATION_PATH.split("/")),
|
|
branch=default_branch,
|
|
message="ci: roll back incomplete governance [skip ci]",
|
|
sha=current_sha,
|
|
)
|
|
elif plan["_workflowContent"] is not None:
|
|
writer.update_file(
|
|
repository,
|
|
tuple(MANAGED_VALIDATION_PATH.split("/")),
|
|
branch=default_branch,
|
|
content=str(plan["_workflowContent"]),
|
|
message="ci: roll back incomplete governance [skip ci]",
|
|
sha=current_sha,
|
|
)
|
|
except GiteaError:
|
|
pass
|
|
raise
|
|
|
|
verified_plan = self._governance_plan_data(context, repository_ref)
|
|
is_projectbrain_profile = plan["workflow"]["profile"] == "projectbrain"
|
|
verified_contexts = set(
|
|
(verified_plan["branchProtection"]["current"] or {}).get("status_check_contexts") or []
|
|
)
|
|
verified_expected_contexts = set(
|
|
plan["branchProtection"]["desired"].get("status_check_contexts") or []
|
|
)
|
|
projectbrain_protection_changed = is_projectbrain_profile and (
|
|
verified_contexts != verified_expected_contexts
|
|
or (
|
|
plan["_currentProtection"] is not None
|
|
and _branch_protection_effective_projection(verified_plan["_currentProtection"])
|
|
!= _branch_protection_effective_projection(plan["_currentProtection"])
|
|
)
|
|
)
|
|
projectbrain_workflow_changed = (
|
|
is_projectbrain_profile
|
|
and verified_plan["workflow"]["currentDigest"] != plan["workflow"]["currentDigest"]
|
|
)
|
|
if (
|
|
not verified_plan["compliant"]
|
|
or projectbrain_protection_changed
|
|
or projectbrain_workflow_changed
|
|
):
|
|
raise GiteaError(
|
|
"GITEA_GOVERNANCE_VERIFY_FAILED",
|
|
"Gitea did not retain the requested governance contract",
|
|
)
|
|
return self._envelope(
|
|
context,
|
|
{
|
|
**self._public_governance_plan(verified_plan),
|
|
"applyScope": apply_scope,
|
|
"changed": True,
|
|
"verified": True,
|
|
"temporaryPushAllowlistUsed": temporary_push_allowlist_used,
|
|
},
|
|
source=f"gitea:{repository_ref}",
|
|
)
|
|
|
|
def _portfolio_governance(
|
|
self, context: ConnectorContext, repository_refs: list[str]
|
|
) -> dict[str, Any]:
|
|
if not 1 <= len(repository_refs) <= 25 or len(set(repository_refs)) != len(repository_refs):
|
|
raise GiteaError(
|
|
"GITEA_INPUT_INVALID", "repositoryRefs must contain 1-25 unique references"
|
|
)
|
|
items: list[dict[str, Any]] = []
|
|
for repository_ref in repository_refs:
|
|
try:
|
|
items.append(
|
|
self._public_governance_plan(
|
|
self._governance_plan_data(context, repository_ref)
|
|
)
|
|
)
|
|
except GiteaError as exc:
|
|
items.append(
|
|
{"repositoryRef": repository_ref, "compliant": False, "errorCode": exc.code}
|
|
)
|
|
return {
|
|
"items": items,
|
|
"summary": {
|
|
"total": len(items),
|
|
"compliant": sum(bool(item.get("compliant")) for item in items),
|
|
"attention": sum(not bool(item.get("compliant")) for item in items),
|
|
},
|
|
}
|
|
|
|
@governed_tool("gitea.portfolio.governance.preview")
|
|
def portfolio_governance_preview(
|
|
self, context: ConnectorContext, repository_refs: list[str]
|
|
) -> dict[str, Any]:
|
|
return self._envelope(
|
|
context,
|
|
self._portfolio_governance(context, repository_refs),
|
|
source="gitea:portfolio-governance",
|
|
)
|
|
|
|
@governed_tool("gitea.portfolio.governance.verify")
|
|
def portfolio_governance_verify(
|
|
self, context: ConnectorContext, repository_refs: list[str]
|
|
) -> dict[str, Any]:
|
|
return self.portfolio_governance_preview(context, repository_refs)
|
|
|
|
@governed_tool("gitea.portfolio.governance.apply")
|
|
def portfolio_governance_apply(
|
|
self, context: ConnectorContext, plans: list[dict[str, str]]
|
|
) -> dict[str, Any]:
|
|
if not 1 <= len(plans) <= _GOVERNANCE_BATCH_LIMIT:
|
|
raise GiteaError("GITEA_INPUT_INVALID", "plans must contain 1-5 governance plans")
|
|
repository_refs = [str(item.get("repositoryRef", "")) for item in plans]
|
|
if len(set(repository_refs)) != len(repository_refs):
|
|
raise GiteaError("GITEA_INPUT_INVALID", "governance repositoryRefs must be unique")
|
|
items: list[dict[str, Any]] = []
|
|
for item in plans:
|
|
repository_ref = str(item.get("repositoryRef", ""))
|
|
try:
|
|
apply_scope = str(item.get("applyScope", "all"))
|
|
result = self.branch_protection_ensure(
|
|
context,
|
|
repository_ref,
|
|
str(item.get("planHash", "")),
|
|
apply_scope=apply_scope, # type: ignore[arg-type]
|
|
)["data"]
|
|
items.append(
|
|
{
|
|
"repositoryRef": repository_ref,
|
|
"applied": True,
|
|
"changed": bool(result.get("changed")),
|
|
"verified": bool(result.get("verified")),
|
|
"applyScope": str(result.get("applyScope", "all")),
|
|
}
|
|
)
|
|
except GiteaError as exc:
|
|
items.append(
|
|
{"repositoryRef": repository_ref, "applied": False, "errorCode": exc.code}
|
|
)
|
|
return self._envelope(
|
|
context,
|
|
{
|
|
"items": items,
|
|
"summary": {
|
|
"total": len(items),
|
|
"applied": sum(bool(item.get("applied")) for item in items),
|
|
"failed": sum(not bool(item.get("applied")) for item in items),
|
|
},
|
|
},
|
|
source="gitea:portfolio-governance",
|
|
)
|
|
|
|
@governed_tool("gitea.branch.list")
|
|
def branch_list(
|
|
self, context: ConnectorContext, repository_ref: str, *, page: PageRequest | None = None
|
|
) -> dict[str, Any]:
|
|
def map_items(items: list[dict[str, Any]], repository: Any) -> list[dict[str, Any]]:
|
|
return [
|
|
{
|
|
"name": str(item.get("name", ""))[:300],
|
|
"commitSha": str((item.get("commit") or {}).get("id", ""))[:64],
|
|
"protected": bool(item.get("protected", False)),
|
|
}
|
|
for item in items
|
|
]
|
|
|
|
return self._collection(
|
|
context, repository_ref, page, self.client.branches, map_items, "branches"
|
|
)
|
|
|
|
@governed_tool("gitea.commit.list")
|
|
def commit_list(
|
|
self, context: ConnectorContext, repository_ref: str, *, page: PageRequest | None = None
|
|
) -> dict[str, Any]:
|
|
def map_items(items: list[dict[str, Any]], repository: Any) -> list[dict[str, Any]]:
|
|
result = []
|
|
for item in items:
|
|
commit_value = item.get("commit")
|
|
commit = commit_value if isinstance(commit_value, dict) else {}
|
|
author_value = commit.get("author")
|
|
author = author_value if isinstance(author_value, dict) else {}
|
|
result.append(
|
|
{
|
|
"sha": str(item.get("sha", ""))[:64],
|
|
"summary": safe_snippet(str(commit.get("message", "")), max_bytes=500),
|
|
"authorName": str(author.get("name", ""))[:200],
|
|
"committedAt": str(author.get("date", ""))[:64],
|
|
}
|
|
)
|
|
return result
|
|
|
|
return self._collection(
|
|
context, repository_ref, page, self.client.commits, map_items, "commits"
|
|
)
|
|
|
|
@staticmethod
|
|
def _commit_projection(item: dict[str, Any]) -> dict[str, Any]:
|
|
commit_value = item.get("commit")
|
|
commit = commit_value if isinstance(commit_value, dict) else {}
|
|
author_value = commit.get("author")
|
|
author = author_value if isinstance(author_value, dict) else {}
|
|
verification_value = item.get("verification")
|
|
verification = verification_value if isinstance(verification_value, dict) else {}
|
|
stats_value = item.get("stats")
|
|
stats = stats_value if isinstance(stats_value, dict) else {}
|
|
files_value = item.get("files")
|
|
files = files_value if isinstance(files_value, list) else []
|
|
return {
|
|
"sha": str(item.get("sha", ""))[:64],
|
|
"summary": safe_snippet(str(commit.get("message", "")), max_bytes=2_000),
|
|
"authorName": str(author.get("name", ""))[:200],
|
|
"committedAt": str(author.get("date", ""))[:64],
|
|
"verified": bool(verification.get("verified", False)),
|
|
"verificationReason": str(verification.get("reason", ""))[:300],
|
|
"stats": {
|
|
"additions": max(int(stats.get("additions", 0) or 0), 0),
|
|
"deletions": max(int(stats.get("deletions", 0) or 0), 0),
|
|
"total": max(int(stats.get("total", 0) or 0), 0),
|
|
},
|
|
"files": [
|
|
{
|
|
"path": str(file.get("filename", ""))[:2_048],
|
|
"status": str(file.get("status", ""))[:32],
|
|
}
|
|
for file in files[:100]
|
|
if isinstance(file, dict)
|
|
],
|
|
"filesTruncated": len(files) > 100,
|
|
"sourceUri": str(item.get("html_url", ""))[:2_048],
|
|
}
|
|
|
|
@governed_tool("gitea.commit.get")
|
|
def commit_get(
|
|
self, context: ConnectorContext, repository_ref: str, ref: str
|
|
) -> dict[str, Any]:
|
|
repository = self._repo(context, repository_ref)
|
|
safe_ref = _validate_ref(ref)
|
|
data = self._commit_projection(self.client.commit(repository, safe_ref))
|
|
data["repositoryRef"] = repository_ref
|
|
return self._envelope(
|
|
context,
|
|
data,
|
|
source=f"gitea:{repository_ref}",
|
|
truncated=bool(data["filesTruncated"]),
|
|
)
|
|
|
|
@governed_tool("gitea.ref.compare")
|
|
def ref_compare(
|
|
self, context: ConnectorContext, repository_ref: str, base: str, head: str
|
|
) -> dict[str, Any]:
|
|
repository = self._repo(context, repository_ref)
|
|
safe_base = _validate_ref(base)
|
|
safe_head = _validate_ref(head)
|
|
if safe_base == safe_head:
|
|
raise GiteaError("GITEA_TARGET_INVALID", "comparison refs must differ")
|
|
payload = self.client.compare(repository, safe_base, safe_head)
|
|
raw_commits = payload.get("commits")
|
|
raw_files = payload.get("files")
|
|
commits = raw_commits if isinstance(raw_commits, list) else []
|
|
files = raw_files if isinstance(raw_files, list) else []
|
|
truncated = len(commits) > 100 or len(files) > 200
|
|
return self._envelope(
|
|
context,
|
|
{
|
|
"repositoryRef": repository_ref,
|
|
"base": safe_base,
|
|
"head": safe_head,
|
|
"totalCommits": max(int(payload.get("total_commits", len(commits)) or 0), 0),
|
|
"commits": [
|
|
self._commit_projection(item)
|
|
for item in commits[:100]
|
|
if isinstance(item, dict)
|
|
],
|
|
"files": [
|
|
{
|
|
"path": str(item.get("filename", ""))[:2_048],
|
|
"status": str(item.get("status", ""))[:32],
|
|
}
|
|
for item in files[:200]
|
|
if isinstance(item, dict)
|
|
],
|
|
"truncated": truncated,
|
|
},
|
|
source=f"gitea:{repository_ref}",
|
|
truncated=truncated,
|
|
)
|
|
|
|
@governed_tool("gitea.release.list")
|
|
def release_list(
|
|
self, context: ConnectorContext, repository_ref: str, *, page: PageRequest | None = None
|
|
) -> dict[str, Any]:
|
|
def map_items(items: list[dict[str, Any]], repository: Any) -> list[dict[str, Any]]:
|
|
return [
|
|
{
|
|
"tag": str(item.get("tag_name", ""))[:300],
|
|
"title": safe_snippet(str(item.get("name", "")), max_bytes=500),
|
|
"draft": bool(item.get("draft", False)),
|
|
"prerelease": bool(item.get("prerelease", False)),
|
|
}
|
|
for item in items
|
|
]
|
|
|
|
return self._collection(
|
|
context, repository_ref, page, self.client.releases, map_items, "releases"
|
|
)
|
|
|
|
@governed_tool("gitea.issue.list")
|
|
def issue_list(
|
|
self, context: ConnectorContext, repository_ref: str, *, page: PageRequest | None = None
|
|
) -> dict[str, Any]:
|
|
def map_items(items: list[dict[str, Any]], repository: Any) -> list[dict[str, Any]]:
|
|
return [
|
|
{
|
|
"number": int(item.get("number", 0)),
|
|
"title": safe_snippet(str(item.get("title", "")), max_bytes=500),
|
|
"state": str(item.get("state", ""))[:32],
|
|
"sourceUri": str(item.get("html_url", ""))[:2048],
|
|
}
|
|
for item in items
|
|
]
|
|
|
|
return self._collection(
|
|
context, repository_ref, page, self.client.issues, map_items, "issues"
|
|
)
|
|
|
|
@governed_tool("gitea.issue.search")
|
|
def issue_search(
|
|
self,
|
|
context: ConnectorContext,
|
|
state: str = "open",
|
|
exclude_repository_names: list[str] | None = None,
|
|
*,
|
|
page: PageRequest | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Return a portfolio issue page without per-repository metadata calls."""
|
|
if state not in {"open", "closed", "all"}:
|
|
raise GiteaError("GITEA_INPUT_INVALID", "issue state is invalid")
|
|
excluded = {
|
|
name.strip().casefold() for name in (exclude_repository_names or []) if name.strip()
|
|
}
|
|
if len(excluded) > 20 or any(len(name) > 120 for name in excluded):
|
|
raise GiteaError("GITEA_INPUT_INVALID", "issue repository exclusion is invalid")
|
|
self._refresh_discovered(context.project_id)
|
|
repositories = self.registry.list(context.project_id)
|
|
repository_by_name = {f"{item.owner}/{item.name}".casefold(): item for item in repositories}
|
|
request = page or PageRequest(limit=100)
|
|
cursor = decode_cursor(
|
|
request.cursor,
|
|
str(context.project_id),
|
|
"portfolio-issues",
|
|
state,
|
|
",".join(sorted(excluded)),
|
|
str(request.limit),
|
|
)
|
|
page_number = cursor.offset // request.limit + 1
|
|
raw_items = self.client.search_issues(state=state, page=page_number, limit=request.limit)
|
|
items: list[dict[str, Any]] = []
|
|
for item in raw_items:
|
|
raw_repository = item.get("repository")
|
|
repository_data = raw_repository if isinstance(raw_repository, dict) else {}
|
|
full_name = str(repository_data.get("full_name", ""))
|
|
repository = repository_by_name.get(full_name.casefold())
|
|
if repository is None or repository.name.casefold() in excluded:
|
|
continue
|
|
raw_labels = item.get("labels")
|
|
labels = raw_labels if isinstance(raw_labels, list) else []
|
|
items.append(
|
|
{
|
|
"repositoryRef": repository.repository_ref,
|
|
"repositoryFullName": full_name[:242],
|
|
"number": max(int(item.get("number", 0)), 0),
|
|
"title": safe_snippet(str(item.get("title", "")), max_bytes=500),
|
|
"state": str(item.get("state", ""))[:32],
|
|
"updatedAt": str(item.get("updated_at", ""))[:64],
|
|
"labels": [
|
|
safe_snippet(str(label.get("name", "")), max_bytes=100)
|
|
for label in labels[:20]
|
|
if isinstance(label, dict)
|
|
],
|
|
"sourceUri": str(item.get("html_url", ""))[:2048],
|
|
}
|
|
)
|
|
has_more = len(raw_items) >= request.limit
|
|
next_cursor = (
|
|
encode_cursor(
|
|
cursor.offset + request.limit,
|
|
str(context.project_id),
|
|
"portfolio-issues",
|
|
state,
|
|
",".join(sorted(excluded)),
|
|
str(request.limit),
|
|
)
|
|
if has_more
|
|
else None
|
|
)
|
|
return self._envelope(
|
|
context,
|
|
{"items": items, "nextCursor": next_cursor, "truncated": has_more},
|
|
source="gitea:portfolio-issues",
|
|
truncated=has_more,
|
|
next_cursor=next_cursor,
|
|
)
|
|
|
|
@governed_tool("gitea.pullrequest.list")
|
|
def pull_request_list(
|
|
self, context: ConnectorContext, repository_ref: str, *, page: PageRequest | None = None
|
|
) -> dict[str, Any]:
|
|
def map_items(items: list[dict[str, Any]], repository: Any) -> list[dict[str, Any]]:
|
|
return [self._pull_request_projection(item) for item in items]
|
|
|
|
return self._collection(
|
|
context,
|
|
repository_ref,
|
|
page,
|
|
self.client.pull_requests,
|
|
map_items,
|
|
"pull-requests",
|
|
)
|
|
|
|
@staticmethod
|
|
def _pull_request_projection(item: dict[str, Any]) -> dict[str, Any]:
|
|
raw_head = item.get("head")
|
|
raw_base = item.get("base")
|
|
head: dict[str, Any] = raw_head if isinstance(raw_head, dict) else {}
|
|
base: dict[str, Any] = raw_base if isinstance(raw_base, dict) else {}
|
|
return {
|
|
"number": max(int(item.get("number", 0)), 0),
|
|
"title": safe_snippet(str(item.get("title", "")), max_bytes=500),
|
|
"state": str(item.get("state", ""))[:32],
|
|
"draft": bool(item.get("draft", False)),
|
|
"merged": bool(item.get("merged", False)),
|
|
"mergeable": item.get("mergeable") if isinstance(item.get("mergeable"), bool) else None,
|
|
"mergeCommitSha": str(item.get("merge_commit_sha", ""))[:64],
|
|
"headRef": str(head.get("ref", ""))[:300],
|
|
"headSha": str(head.get("sha", ""))[:64],
|
|
"baseRef": str(base.get("ref", ""))[:300],
|
|
"baseSha": str(base.get("sha", ""))[:64],
|
|
"updatedAt": str(item.get("updated_at", ""))[:64],
|
|
"sourceUri": str(item.get("html_url", ""))[:2048],
|
|
}
|
|
|
|
@governed_tool("gitea.pullrequest.get")
|
|
def pull_request_get(
|
|
self, context: ConnectorContext, repository_ref: str, number: int
|
|
) -> dict[str, Any]:
|
|
if not 1 <= number <= 2_147_483_647:
|
|
raise GiteaError("GITEA_TARGET_INVALID", "pull request number is invalid")
|
|
repository = self._repo(context, repository_ref)
|
|
pull = self._pull_request_projection(self.client.pull_request(repository, number))
|
|
files = self.client.pull_request_files(repository, number)
|
|
pull["changedFiles"] = [
|
|
{
|
|
"path": str(item.get("filename", ""))[:2048],
|
|
"status": str(item.get("status", ""))[:32],
|
|
"additions": max(int(item.get("additions", 0)), 0),
|
|
"deletions": max(int(item.get("deletions", 0)), 0),
|
|
}
|
|
for item in files[:100]
|
|
]
|
|
pull["filesTruncated"] = len(files) > 100
|
|
return self._envelope(
|
|
context,
|
|
pull,
|
|
source=f"gitea:{repository_ref}",
|
|
truncated=len(files) > 100,
|
|
)
|
|
|
|
@governed_tool("gitea.pullrequest.feedback")
|
|
def pull_request_feedback(
|
|
self, context: ConnectorContext, repository_ref: str, number: int
|
|
) -> dict[str, Any]:
|
|
if not 1 <= number <= 2_147_483_647:
|
|
raise GiteaError("GITEA_TARGET_INVALID", "pull request number is invalid")
|
|
repository = self._repo(context, repository_ref)
|
|
reviews = self.client.pull_request_reviews(repository, number, 1, 100)
|
|
output: list[dict[str, Any]] = []
|
|
truncated = len(reviews) > 100
|
|
for review in reviews[:100]:
|
|
review_id = max(int(review.get("id", 0)), 0)
|
|
comments = (
|
|
self.client.pull_request_review_comments(repository, number, review_id)
|
|
if review_id
|
|
else []
|
|
)
|
|
output.append(
|
|
{
|
|
"id": review_id,
|
|
"state": str(review.get("state", ""))[:32],
|
|
"body": safe_snippet(str(review.get("body", "")), max_bytes=4_000),
|
|
"reviewer": str((review.get("user") or {}).get("login", ""))[:120],
|
|
"submittedAt": str(review.get("submitted_at", ""))[:64],
|
|
"comments": [
|
|
{
|
|
"id": max(int(item.get("id", 0)), 0),
|
|
"path": str(item.get("path", ""))[:2_048],
|
|
"line": max(int(item.get("new_position", 0) or 0), 0),
|
|
"body": safe_snippet(str(item.get("body", "")), max_bytes=4_000),
|
|
}
|
|
for item in comments[:100]
|
|
],
|
|
"commentsTruncated": len(comments) > 100,
|
|
}
|
|
)
|
|
truncated = truncated or len(comments) > 100
|
|
return self._envelope(
|
|
context,
|
|
{"repositoryRef": repository_ref, "number": number, "reviews": output},
|
|
source=f"gitea:{repository_ref}",
|
|
truncated=truncated,
|
|
)
|
|
|
|
@staticmethod
|
|
def _action_run_projection(item: dict[str, Any]) -> dict[str, Any]:
|
|
return {
|
|
"id": max(int(item.get("id", item.get("workflow_run_id", 0))), 0),
|
|
"name": str(item.get("name", item.get("display_title", "")))[:300],
|
|
"status": str(item.get("status", ""))[:32],
|
|
"conclusion": str(item.get("conclusion", ""))[:32],
|
|
"event": str(item.get("event", ""))[:64],
|
|
"headBranch": str("" if item.get("head_branch") is None else item["head_branch"])[:300],
|
|
"headSha": str(item.get("head_sha", ""))[:64],
|
|
"workflowId": str(item.get("workflow_id") or "")[:2_048],
|
|
"workflowPath": str(item.get("path") or "")[:2_048],
|
|
"runNumber": max(int(item.get("run_number", item.get("id", 0))), 0),
|
|
"runAttempt": max(int(item.get("run_attempt", 0)), 0),
|
|
"displayTitle": safe_snippet(str(item.get("display_title", "")), max_bytes=500),
|
|
"createdAt": str(item.get("created_at", ""))[:64],
|
|
"updatedAt": str(item.get("updated_at", ""))[:64],
|
|
"sourceUri": str(item.get("html_url", item.get("url", "")))[:2_048],
|
|
}
|
|
|
|
@staticmethod
|
|
def _workflow_identity_values(workflow: dict[str, Any]) -> set[str]:
|
|
values: set[str] = set()
|
|
for key in ("id", "path"):
|
|
value = str(workflow.get(key, "")).strip().casefold()
|
|
if value:
|
|
values.add(value)
|
|
values.add(value.rsplit("/", 1)[-1])
|
|
return values
|
|
|
|
@classmethod
|
|
def _runs_for_workflow(
|
|
cls,
|
|
runs: list[dict[str, Any]],
|
|
workflow: dict[str, Any] | None,
|
|
*,
|
|
allow_legacy_without_workflow_id: bool = True,
|
|
) -> list[dict[str, Any]]:
|
|
if workflow is None:
|
|
return []
|
|
identities = cls._workflow_identity_values(workflow)
|
|
if not identities:
|
|
return []
|
|
output: list[dict[str, Any]] = []
|
|
for item in runs:
|
|
raw_path = item.get("path")
|
|
workflow_id = str(item.get("workflow_id") or "").strip().casefold()
|
|
if raw_path is not None:
|
|
if not isinstance(raw_path, str) or not raw_path or len(raw_path) > 512:
|
|
continue
|
|
parts = raw_path.split("@")
|
|
if len(parts) > 2 or parts[0].casefold() not in identities:
|
|
continue
|
|
if len(parts) == 2 and not re.fullmatch(
|
|
r"refs/(?:pull/[1-9][0-9]*/head|heads/[A-Za-z0-9][A-Za-z0-9._/-]*)",
|
|
parts[1],
|
|
):
|
|
continue
|
|
elif not workflow_id:
|
|
if (
|
|
allow_legacy_without_workflow_id
|
|
and isinstance(item.get("head_branch"), str)
|
|
and bool(item["head_branch"])
|
|
):
|
|
output.append(item)
|
|
continue
|
|
if workflow_id and workflow_id not in identities:
|
|
continue
|
|
output.append(item)
|
|
return output
|
|
|
|
def _find_reusable_validation_run(
|
|
self,
|
|
repository: RegisteredRepository,
|
|
branch: str,
|
|
head_sha: str,
|
|
profile: str,
|
|
workflow: dict[str, Any],
|
|
) -> dict[str, Any] | None:
|
|
runs = self.client.action_runs(repository, branch=branch, page=1, limit=100)
|
|
identities = self._workflow_identity_values(workflow)
|
|
for raw in runs:
|
|
workflow_id = str(raw.get("workflow_id", "")).strip().casefold()
|
|
if not workflow_id:
|
|
continue
|
|
if workflow_id not in identities and workflow_id.rsplit("/", 1)[-1] not in identities:
|
|
continue
|
|
candidate = self._action_run_projection(raw)
|
|
if (
|
|
str(candidate.get("headSha", "")).casefold() != head_sha.casefold()
|
|
or str(candidate.get("event", "")).casefold() != "workflow_dispatch"
|
|
):
|
|
continue
|
|
status = str(candidate.get("status", "")).casefold()
|
|
conclusion = str(candidate.get("conclusion", "")).casefold()
|
|
if status == "completed" and conclusion != "success":
|
|
continue
|
|
run_id = max(int(candidate.get("id", 0) or 0), 0)
|
|
if not run_id:
|
|
continue
|
|
expected_title = f"Managed validation / {profile}"
|
|
if str(candidate.get("displayTitle", "")).casefold() == expected_title.casefold():
|
|
return candidate
|
|
return None
|
|
|
|
@governed_tool("gitea.validation.get")
|
|
def validation_get(
|
|
self,
|
|
context: ConnectorContext,
|
|
repository_ref: str,
|
|
run_id: int,
|
|
include_logs: bool = False,
|
|
) -> dict[str, Any]:
|
|
if not 1 <= run_id <= 9_223_372_036_854_775_807:
|
|
raise GiteaError("GITEA_TARGET_INVALID", "workflow run id is invalid")
|
|
repository = self._repo(context, repository_ref)
|
|
run = self._action_run_projection(self.client.action_run(repository, run_id))
|
|
jobs = self.client.action_run_jobs(repository, run_id)
|
|
projected_jobs: list[dict[str, Any]] = []
|
|
any_truncated = len(jobs) > 100
|
|
for job in jobs[:100]:
|
|
job_id = max(int(job.get("id", 0)), 0)
|
|
logs = ""
|
|
logs_truncated = False
|
|
logs_unavailable = False
|
|
if include_logs and job_id:
|
|
if str(job.get("status", "")).casefold() in {
|
|
"requested",
|
|
"queued",
|
|
"waiting",
|
|
"pending",
|
|
}:
|
|
# Gitea returns 404 before a job has an effective task/log.
|
|
logs = "[job not started; logs unavailable]"
|
|
logs_unavailable = True
|
|
elif job.get("conclusion") == "skipped" and not job.get("started_at"):
|
|
logs_unavailable = True
|
|
else:
|
|
try:
|
|
logs, logs_truncated = self.client.action_job_logs(repository, job_id)
|
|
except GiteaError as exc:
|
|
if exc.code != "GITEA_NOT_FOUND":
|
|
raise
|
|
logs_unavailable = True
|
|
else:
|
|
filtered_logs = safe_content(logs)
|
|
logs = filtered_logs or "[log excluded: secret-like content detected]"
|
|
projected_jobs.append(
|
|
{
|
|
"id": job_id,
|
|
"name": str(job.get("name", ""))[:300],
|
|
"status": str(job.get("status", ""))[:32],
|
|
"conclusion": str(job.get("conclusion", ""))[:32],
|
|
"startedAt": str(job.get("started_at", ""))[:64],
|
|
"completedAt": str(job.get("completed_at", ""))[:64],
|
|
"logs": logs,
|
|
"logsTruncated": logs_truncated,
|
|
"logsUnavailable": logs_unavailable,
|
|
}
|
|
)
|
|
any_truncated = any_truncated or logs_truncated
|
|
run["jobs"] = projected_jobs
|
|
run.update(_validation_polling(run, str(run.get("headSha", ""))))
|
|
return self._envelope(
|
|
context,
|
|
{"repositoryRef": repository_ref, "run": run},
|
|
source=f"gitea:{repository_ref}",
|
|
truncated=any_truncated,
|
|
)
|
|
|
|
@governed_tool("gitea.validation.failure.get")
|
|
def validation_failure_get(
|
|
self,
|
|
context: ConnectorContext,
|
|
repository_ref: str,
|
|
run_id: int,
|
|
) -> dict[str, Any]:
|
|
"""Return only bounded, failure-relevant validation evidence."""
|
|
|
|
if not 1 <= run_id <= 9_223_372_036_854_775_807:
|
|
raise GiteaError("GITEA_TARGET_INVALID", "workflow run id is invalid")
|
|
repository = self._repo(context, repository_ref)
|
|
run = self._action_run_projection(self.client.action_run(repository, run_id))
|
|
jobs = self.client.action_run_jobs(repository, run_id)
|
|
failures: list[dict[str, Any]] = []
|
|
truncated = len(jobs) > 100
|
|
remaining_lines = _FAILURE_MAX_LINES
|
|
remaining_bytes = _FAILURE_MAX_BYTES
|
|
for job in jobs[:100]:
|
|
conclusion = str(job.get("conclusion", "")).casefold()
|
|
status = str(job.get("status", "")).casefold()
|
|
if conclusion not in _FAILED_CONCLUSIONS and status not in _FAILED_CONCLUSIONS:
|
|
continue
|
|
if len(failures) >= _FAILURE_MAX_JOBS or remaining_lines <= 0 or remaining_bytes <= 0:
|
|
truncated = True
|
|
break
|
|
job_id = max(int(job.get("id", 0) or 0), 0)
|
|
logs, logs_truncated = self.client.action_job_logs(
|
|
repository, job_id, max_bytes=200_000
|
|
)
|
|
filtered_logs = safe_content(logs)
|
|
if filtered_logs is None:
|
|
excerpt, exit_code, command, error_code = (
|
|
["[log excluded: secret-like content detected]"],
|
|
None,
|
|
None,
|
|
"VALIDATION_LOG_EXCLUDED",
|
|
)
|
|
else:
|
|
excerpt, exit_code, command, error_code = self._failure_excerpt(filtered_logs)
|
|
bounded_excerpt: list[str] = []
|
|
for line in excerpt[:remaining_lines]:
|
|
encoded_size = len(line.encode("utf-8"))
|
|
if encoded_size > remaining_bytes:
|
|
truncated = True
|
|
break
|
|
bounded_excerpt.append(line)
|
|
remaining_bytes -= encoded_size
|
|
remaining_lines -= len(bounded_excerpt)
|
|
if len(bounded_excerpt) < len(excerpt):
|
|
truncated = True
|
|
excerpt = bounded_excerpt
|
|
raw_steps = job.get("steps")
|
|
steps = raw_steps if isinstance(raw_steps, list) else []
|
|
failed_step = next(
|
|
(
|
|
str(item.get("name", ""))[:300]
|
|
for item in steps
|
|
if isinstance(item, dict)
|
|
and str(item.get("conclusion", "")).casefold() in _FAILED_CONCLUSIONS
|
|
),
|
|
None,
|
|
)
|
|
failures.append(
|
|
{
|
|
"jobId": job_id,
|
|
"job": str(job.get("name", ""))[:300],
|
|
"step": failed_step,
|
|
"command": command,
|
|
"exitCode": exit_code,
|
|
"errorCode": error_code,
|
|
"relevantLogs": excerpt,
|
|
"logsTruncated": logs_truncated or len(excerpt) >= 100,
|
|
"artifactRefs": [run["sourceUri"]] if run.get("sourceUri") else [],
|
|
}
|
|
)
|
|
truncated = truncated or logs_truncated or remaining_lines <= 0 or remaining_bytes <= 0
|
|
return self._envelope(
|
|
context,
|
|
{
|
|
"repositoryRef": repository_ref,
|
|
"runId": run_id,
|
|
"headSha": run.get("headSha"),
|
|
"conclusion": run.get("conclusion"),
|
|
"failures": failures,
|
|
"failureCount": len(failures),
|
|
"fullLogsIncluded": False,
|
|
},
|
|
source=f"gitea:{repository_ref}",
|
|
truncated=truncated,
|
|
)
|
|
|
|
@governed_tool("gitea.validation.list")
|
|
def validation_list(
|
|
self,
|
|
context: ConnectorContext,
|
|
repository_ref: str,
|
|
branch: str,
|
|
*,
|
|
page: PageRequest | None = None,
|
|
) -> dict[str, Any]:
|
|
repository = self._repo(context, repository_ref)
|
|
safe_branch = _validate_ref(branch)
|
|
request = page or PageRequest(limit=25)
|
|
cursor = decode_cursor(
|
|
request.cursor,
|
|
str(context.project_id),
|
|
repository_ref,
|
|
f"validations:{safe_branch}",
|
|
str(request.limit),
|
|
)
|
|
page_number = cursor.offset // request.limit + 1
|
|
raw_runs = self.client.action_runs(
|
|
repository, branch=safe_branch, page=page_number, limit=request.limit
|
|
)
|
|
workflow = _select_validation_workflow(self.client.action_workflows(repository))
|
|
runs = self._runs_for_workflow(raw_runs, workflow)
|
|
next_cursor = None
|
|
if len(raw_runs) == request.limit and workflow is not None:
|
|
next_cursor = encode_cursor(
|
|
cursor.offset + len(runs),
|
|
str(context.project_id),
|
|
repository_ref,
|
|
f"validations:{safe_branch}",
|
|
str(request.limit),
|
|
)
|
|
return self._envelope(
|
|
context,
|
|
{
|
|
"repositoryRef": repository_ref,
|
|
"branch": safe_branch,
|
|
"items": [self._action_run_projection(item) for item in runs],
|
|
"nextCursor": next_cursor,
|
|
"truncated": next_cursor is not None,
|
|
},
|
|
source=f"gitea:{repository_ref}",
|
|
truncated=next_cursor is not None,
|
|
next_cursor=next_cursor,
|
|
)
|
|
|
|
@governed_tool("gitea.change.status")
|
|
def change_status(
|
|
self, context: ConnectorContext, repository_ref: str, branch: str
|
|
) -> dict[str, Any]:
|
|
safe_branch = _validate_ref(branch)
|
|
repository = self._repo(context, repository_ref)
|
|
branches = self.client.branches(repository, 1, 100)
|
|
branch_payload = next(
|
|
(item for item in branches if str(item.get("name", "")) == safe_branch), None
|
|
)
|
|
if branch_payload is None:
|
|
raise GiteaError("GITEA_NOT_FOUND", "change branch was not found")
|
|
pulls = self.client.pull_requests(repository, 1, 100)
|
|
pull = next(
|
|
(
|
|
self._pull_request_projection(item)
|
|
for item in pulls
|
|
if str((item.get("head") or {}).get("ref", "")) == safe_branch
|
|
),
|
|
None,
|
|
)
|
|
workflow = _select_validation_workflow(self.client.action_workflows(repository))
|
|
commit_sha = str((branch_payload.get("commit") or {}).get("id", ""))[:64]
|
|
runs = self._runs_for_workflow(
|
|
self.client.exact_head_action_runs(
|
|
repository,
|
|
branch=safe_branch,
|
|
head_sha=commit_sha,
|
|
pull_request_number=int(pull["number"]) if pull is not None else None,
|
|
),
|
|
workflow,
|
|
)
|
|
if not runs:
|
|
# Preserve honest stale branch history, never synthetic or current
|
|
# contradictory PR evidence, when the exact-head inventory is empty.
|
|
history = self.client.action_runs(repository, branch=safe_branch, page=1, limit=100)
|
|
history = [
|
|
item
|
|
for item in history
|
|
if (
|
|
type(item.get("id")) is int
|
|
and item["id"] > 0
|
|
and item.get("head_branch") == safe_branch
|
|
and isinstance(item.get("head_sha"), str)
|
|
and re.fullmatch(r"(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})", item["head_sha"])
|
|
and item["head_sha"] != commit_sha
|
|
and self.client.matches_exact_head_action_run(
|
|
item,
|
|
branch=safe_branch,
|
|
head_sha=item["head_sha"],
|
|
pull_request_number=int(pull["number"]) if pull is not None else None,
|
|
)
|
|
)
|
|
]
|
|
runs = self._runs_for_workflow(history, workflow)
|
|
projected_runs = [self._action_run_projection(item) for item in runs]
|
|
latest_run = projected_runs[0] if projected_runs else None
|
|
merge_eligibility: dict[str, Any] = {
|
|
"eligible": False,
|
|
"headSha": commit_sha,
|
|
"reason": "pull_request_missing" if pull is None else "not_evaluated",
|
|
}
|
|
if pull is not None:
|
|
try:
|
|
_, base_sha, run_id, _ = self._require_merge_eligible(
|
|
context, repository_ref, int(pull["number"]), commit_sha
|
|
)
|
|
except GiteaError as exc:
|
|
merge_eligibility.update(
|
|
{"reason": exc.code, "detail": str(exc), "baseSha": pull.get("baseSha")}
|
|
)
|
|
else:
|
|
merge_eligibility.update(
|
|
{
|
|
"eligible": True,
|
|
"reason": "all_machine_verifiable_gates_passed",
|
|
"baseSha": base_sha,
|
|
"validationContext": MANAGED_VALIDATION_CONTEXT,
|
|
"validationRunId": run_id,
|
|
}
|
|
)
|
|
ready_to_merge = bool(merge_eligibility["eligible"])
|
|
next_actions = []
|
|
if pull is None:
|
|
next_actions.append("create-pull-request")
|
|
if latest_run is None or latest_run.get("headSha") != commit_sha:
|
|
next_actions.append("run-validation")
|
|
elif latest_run.get("status") != "completed":
|
|
next_actions.append("wait-for-validation")
|
|
elif latest_run.get("conclusion") != "success":
|
|
next_actions.append("fix-validation")
|
|
if pull is not None and not ready_to_merge:
|
|
if merge_eligibility["reason"] in {
|
|
"GITEA_PR_CHECK_MISSING",
|
|
"GITEA_PR_CHECK_PENDING",
|
|
"GITEA_PR_CHECK_FAILED",
|
|
"GITEA_PR_VALIDATION_REQUIRED",
|
|
}:
|
|
next_actions.append("ensure-pr-validation")
|
|
elif merge_eligibility["reason"] in {"GITEA_BRANCH_OUTDATED", "GITEA_BASE_CHANGED"}:
|
|
next_actions.append("sync-pull-request")
|
|
if ready_to_merge:
|
|
next_actions.append("review-and-merge")
|
|
snapshot_at = datetime.now(UTC).isoformat()
|
|
snapshot_material = {
|
|
"repositoryRef": repository_ref,
|
|
"branch": safe_branch,
|
|
"commitSha": commit_sha,
|
|
"pullRequest": pull,
|
|
"validationRuns": projected_runs,
|
|
}
|
|
return self._envelope(
|
|
context,
|
|
{
|
|
"repositoryRef": repository_ref,
|
|
"branch": safe_branch,
|
|
"commitSha": commit_sha,
|
|
"pullRequest": pull,
|
|
"validationRuns": projected_runs,
|
|
**_validation_polling(latest_run, commit_sha),
|
|
"mergeEligibility": merge_eligibility,
|
|
"requiredPrValidationState": (
|
|
"success" if ready_to_merge else str(merge_eligibility["reason"])
|
|
),
|
|
"validationState": (
|
|
"pr_check_missing"
|
|
if merge_eligibility["reason"]
|
|
in {"GITEA_PR_CHECK_MISSING", "GITEA_PR_VALIDATION_REQUIRED"}
|
|
else "pr_check_pending"
|
|
if merge_eligibility["reason"] == "GITEA_PR_CHECK_PENDING"
|
|
else "pr_check_failed"
|
|
if merge_eligibility["reason"] == "GITEA_PR_CHECK_FAILED"
|
|
else _validation_polling(latest_run, commit_sha)["validationState"]
|
|
),
|
|
"nextAction": (
|
|
"ensure-pr-validation"
|
|
if "ensure-pr-validation" in next_actions
|
|
else _validation_polling(latest_run, commit_sha)["nextAction"]
|
|
),
|
|
"readyToMerge": ready_to_merge,
|
|
"nextActions": next_actions,
|
|
"resumeRef": f"{repository_ref}:{safe_branch}",
|
|
"snapshotId": self._snapshot_id(snapshot_material),
|
|
"snapshotAt": snapshot_at,
|
|
"giteaRevision": commit_sha,
|
|
},
|
|
source=f"gitea:{repository_ref}",
|
|
)
|
|
|
|
@governed_tool("gitea.change.review")
|
|
def change_review(
|
|
self, context: ConnectorContext, repository_ref: str, branch: str
|
|
) -> dict[str, Any]:
|
|
repository = self._repo(context, repository_ref)
|
|
status: dict[str, Any] = {}
|
|
pull: dict[str, Any] | None = None
|
|
comparison: dict[str, Any] | None = None
|
|
verified: dict[str, Any] = {}
|
|
stable = False
|
|
observed_actual = ""
|
|
for _ in range(2):
|
|
status = self.change_status(context, repository_ref, branch)["data"]
|
|
candidate = status.get("pullRequest")
|
|
if not isinstance(candidate, dict):
|
|
pull = None
|
|
comparison = None
|
|
else:
|
|
pull = self._pull_request_projection(
|
|
self.client.pull_request(repository, max(int(candidate.get("number", 0)), 0))
|
|
)
|
|
if pull.get("headSha") != status.get("commitSha"):
|
|
observed_actual = str(pull.get("headSha", ""))
|
|
continue
|
|
status["pullRequest"] = pull
|
|
comparison = None
|
|
if pull.get("baseSha") and pull.get("headSha"):
|
|
payload = self.client.compare(
|
|
repository, str(pull["baseSha"]), str(pull["headSha"])
|
|
)
|
|
reverse_payload = self.client.compare(
|
|
repository, str(pull["headSha"]), str(pull["baseSha"])
|
|
)
|
|
comparison = self._compact_comparison(
|
|
payload,
|
|
base=str(pull.get("baseRef", "")),
|
|
head=str(pull.get("headRef", "")),
|
|
reverse_payload=reverse_payload,
|
|
)
|
|
verified = self.change_status(context, repository_ref, branch)["data"]
|
|
verified_candidate = verified.get("pullRequest")
|
|
stable = (
|
|
verified.get("commitSha") == status.get("commitSha")
|
|
and verified.get("validationRuns") == status.get("validationRuns")
|
|
and isinstance(verified_candidate, dict) == isinstance(candidate, dict)
|
|
)
|
|
if stable and pull is not None and isinstance(verified_candidate, dict):
|
|
verified_pull = self._pull_request_projection(
|
|
self.client.pull_request(
|
|
repository, max(int(verified_candidate.get("number", 0)), 0)
|
|
)
|
|
)
|
|
stable = (
|
|
verified_pull.get("headSha") == pull.get("headSha")
|
|
and verified_pull.get("baseSha") == pull.get("baseSha")
|
|
and verified_pull.get("state") == pull.get("state")
|
|
)
|
|
if stable:
|
|
pull = verified_pull
|
|
verified["pullRequest"] = verified_pull
|
|
if stable:
|
|
status = verified
|
|
break
|
|
if not stable:
|
|
raise GiteaError(
|
|
"GITEA_CONCURRENT_MODIFICATION",
|
|
"change state moved while the review snapshot was being created",
|
|
retryable=True,
|
|
safe_to_retry=True,
|
|
expected_state=str(status.get("commitSha", status.get("snapshotId", ""))),
|
|
actual_state=(
|
|
observed_actual
|
|
or str(verified.get("commitSha", verified.get("snapshotId", "")))
|
|
),
|
|
recovery_hint="Retry change.review to obtain one consistent snapshot.",
|
|
)
|
|
snapshot_material = {**status, "comparison": comparison}
|
|
snapshot_at = datetime.now(UTC).isoformat()
|
|
return self._envelope(
|
|
context,
|
|
{
|
|
**status,
|
|
"comparison": comparison,
|
|
"snapshotId": self._snapshot_id(snapshot_material),
|
|
"snapshotAt": snapshot_at,
|
|
"giteaRevision": status.get("commitSha"),
|
|
"snapshotConsistent": True,
|
|
"snapshotConsistency": "optimistic-double-read",
|
|
},
|
|
source=f"gitea:{repository_ref}",
|
|
truncated=bool(comparison and comparison.get("truncated")),
|
|
)
|
|
|
|
@governed_tool("gitea.change.reconcile")
|
|
def change_reconcile(
|
|
self, context: ConnectorContext, repository_ref: str, branch: str
|
|
) -> dict[str, Any]:
|
|
"""Compute one non-mutating delivery recommendation from a consistent snapshot."""
|
|
|
|
review = self.change_review(context, repository_ref, branch)["data"]
|
|
pull = review.get("pullRequest") if isinstance(review.get("pullRequest"), dict) else None
|
|
comparison = review.get("comparison") if isinstance(review.get("comparison"), dict) else {}
|
|
recommendation = "create-pull-request"
|
|
if pull is not None:
|
|
if pull.get("mergeable") is False or int(comparison.get("behindBy", 0) or 0) > 0:
|
|
recommendation = "sync-or-resolve-conflicts"
|
|
elif review.get("validationState") == "failed":
|
|
recommendation = "inspect-validation-failure"
|
|
elif review.get("validationState") in {"not-started", "stale"}:
|
|
recommendation = "run-validation"
|
|
elif not review.get("terminal", True):
|
|
recommendation = "wait-for-validation"
|
|
elif review.get("readyToMerge"):
|
|
recommendation = "review-and-merge"
|
|
else:
|
|
recommendation = "review-pull-request"
|
|
return self._envelope(
|
|
context,
|
|
{
|
|
"repositoryRef": repository_ref,
|
|
"branch": branch,
|
|
"snapshotId": review["snapshotId"],
|
|
"snapshotAt": review["snapshotAt"],
|
|
"giteaRevision": review["giteaRevision"],
|
|
"pullRequest": pull,
|
|
"branchBehindBy": int(comparison.get("behindBy", 0) or 0),
|
|
"validationState": review.get("validationState"),
|
|
"readyToMerge": bool(review.get("readyToMerge", False)),
|
|
"recommendedNextAction": recommendation,
|
|
"validationRequired": recommendation
|
|
in {
|
|
"run-validation",
|
|
"sync-or-resolve-conflicts",
|
|
},
|
|
"mutationPerformed": False,
|
|
},
|
|
source=f"gitea:{repository_ref}",
|
|
)
|
|
|
|
def _capability_data(self, context: ConnectorContext) -> dict[str, Any]:
|
|
publications = list(self.tools())
|
|
if self.write_client is not None:
|
|
publications.extend(self.write_tools())
|
|
descriptors = sorted(
|
|
[
|
|
{
|
|
"name": item.canonical_name,
|
|
"version": item.version,
|
|
"riskClass": item.risk_class,
|
|
"inputSchema": item.input_schema,
|
|
}
|
|
for item in publications
|
|
],
|
|
key=lambda item: str(item["name"]),
|
|
)
|
|
encoded = json.dumps(descriptors, sort_keys=True, separators=(",", ":")).encode()
|
|
risk_counts = {
|
|
risk: sum(item.risk_class == risk for item in publications)
|
|
for risk in ("R1", "R2", "R3")
|
|
}
|
|
published_names = {str(item["name"]) for item in descriptors}
|
|
handler_names = set(self._tool_handlers())
|
|
runtime = runtime_snapshot()
|
|
upstream = (self.write_client or self.client).upstream_compatibility()
|
|
merge_supported = upstream["compatibility"] == "SUPPORTED"
|
|
return {
|
|
"server": "itworx-gitea",
|
|
"version": self.version,
|
|
"connectorVersion": self.version,
|
|
"upstreamGitea": upstream,
|
|
"buildSha": os.environ.get("GITEA_CONNECTOR_BUILD_SHA", "unknown")[:64],
|
|
"capabilityFingerprint": hashlib.sha256(encoded).hexdigest(),
|
|
"toolCount": len(publications),
|
|
"registeredHandlerCount": len(handler_names),
|
|
"missingHandlers": sorted(published_names - handler_names),
|
|
"extraHandlers": sorted(handler_names - published_names),
|
|
"riskCounts": risk_counts,
|
|
"tools": [item["name"] for item in descriptors],
|
|
"repositoryCount": len(self.registry.list(context.project_id)),
|
|
"writeMode": "pr-first" if self.write_client is not None else "disabled",
|
|
"workspaceProfiles": list(_WORKSPACE_PROFILES),
|
|
"changeBranchPrefixes": [],
|
|
"workBranchPolicy": "any-valid-non-default-branch",
|
|
"arbitraryExecution": False,
|
|
"durableStateSource": "gitea-branches-pullrequests-actions",
|
|
"selfTestAvailable": True,
|
|
"selfTestOperation": "gitea.selftest.run",
|
|
"selfTestModes": ["read", "guarded-write"],
|
|
"runtime": runtime,
|
|
"subsystems": {
|
|
"runtime": "READY" if published_names == handler_names else "DEGRADED",
|
|
"catalog": "READY" if published_names == handler_names else "DEGRADED",
|
|
"dispatcher": "READY",
|
|
"worker": "READY" if runtime["circuitState"] == "CLOSED" else "DEGRADED",
|
|
"upstream_gitea": (
|
|
"READY"
|
|
if merge_supported
|
|
else "UNAVAILABLE"
|
|
if upstream["compatibility"] == "UNAVAILABLE"
|
|
else "UNVERIFIED"
|
|
),
|
|
"merge": "READY" if merge_supported and self.write_client else "UNAVAILABLE",
|
|
"search": "READY",
|
|
"write": "READY" if self.write_client is not None else "DISABLED",
|
|
"actions": "READY" if self.write_client is not None else "READ_ONLY",
|
|
"projectbrain": "EXTERNAL",
|
|
"database": "NOT_APPLICABLE",
|
|
"queue": "READY",
|
|
"session": "RESUMABLE",
|
|
},
|
|
}
|
|
|
|
@governed_tool("gitea.capabilities.get")
|
|
def capabilities_get(self, context: ConnectorContext) -> dict[str, Any]:
|
|
"""Return a stable fingerprint for client-side tool catalog refresh checks."""
|
|
return self._envelope(context, self._capability_data(context), source="gitea:capabilities")
|
|
|
|
@governed_tool("gitea.connection.check")
|
|
def connection_check(self, context: ConnectorContext) -> dict[str, Any]:
|
|
"""Verify scoped upstream access and discovery without returning credentials."""
|
|
self._refresh_discovered(context.project_id)
|
|
repositories = self.registry.list(context.project_id)
|
|
if not repositories:
|
|
raise GiteaError("GITEA_REPOSITORY_NOT_REGISTERED", "no repositories are registered")
|
|
probe = self.client.repository(repositories[0])
|
|
capability = self._capability_data(context)
|
|
return self._envelope(
|
|
context,
|
|
{
|
|
"connected": True,
|
|
"upstream": "gitea",
|
|
"repositoryCount": len(repositories),
|
|
"probeRepositoryRef": repositories[0].repository_ref,
|
|
"probePrivate": bool(probe.get("private", False)),
|
|
"capabilityFingerprint": capability["capabilityFingerprint"],
|
|
"toolCount": capability["toolCount"],
|
|
"credentialMode": "mounted-secret",
|
|
"connectorVersion": capability["connectorVersion"],
|
|
"upstreamGitea": capability["upstreamGitea"],
|
|
},
|
|
source="gitea:connection",
|
|
)
|
|
|
|
@governed_tool("gitea.selftest.run")
|
|
def selftest_run(self, context: ConnectorContext, mode: str = "read") -> dict[str, Any]:
|
|
"""Run bounded, non-mutating runtime and upstream registration probes."""
|
|
|
|
if mode not in {"read", "guarded-write"}:
|
|
raise GiteaError("GITEA_INPUT_INVALID", "self-test mode is not supported")
|
|
|
|
capability = self._capability_data(context)
|
|
connection = self.connection_check(context)["data"]
|
|
checks = [
|
|
{
|
|
"name": "handler-parity",
|
|
"status": "PASS"
|
|
if not capability["missingHandlers"] and not capability["extraHandlers"]
|
|
else "FAIL",
|
|
"published": capability["toolCount"],
|
|
"registered": capability["registeredHandlerCount"],
|
|
},
|
|
{
|
|
"name": "gitea-connection",
|
|
"status": "PASS" if connection["connected"] else "FAIL",
|
|
"repositoryCount": connection["repositoryCount"],
|
|
},
|
|
{
|
|
"name": "upstream-merge-compatibility",
|
|
"status": "PASS"
|
|
if capability["upstreamGitea"]["compatibility"] == "SUPPORTED"
|
|
else "FAIL",
|
|
"diagnostics": capability["upstreamGitea"],
|
|
},
|
|
{
|
|
"name": "write-runtime",
|
|
"status": "PASS" if self.write_client is not None else "DISABLED",
|
|
"mode": capability["writeMode"],
|
|
},
|
|
{
|
|
"name": "managed-actions",
|
|
"status": "PASS" if self.write_client is not None else "READ_ONLY",
|
|
"profiles": list(_WORKSPACE_PROFILES),
|
|
},
|
|
]
|
|
failed = [item["name"] for item in checks if item["status"] == "FAIL"]
|
|
return self._envelope(
|
|
context,
|
|
{
|
|
"status": "PASS" if not failed else "DEGRADED",
|
|
"checks": checks,
|
|
"failedChecks": failed,
|
|
"probeOnly": True,
|
|
"mutationsAttempted": 0,
|
|
"mode": mode,
|
|
"availableModes": capability["selfTestModes"],
|
|
"canonicalOperation": capability["selfTestOperation"],
|
|
"sessionRecovery": capability["runtime"],
|
|
"capabilityFingerprint": capability["capabilityFingerprint"],
|
|
"buildSha": capability["buildSha"],
|
|
},
|
|
source="gitea:selftest",
|
|
)
|
|
|
|
@governed_tool("gitea.workspace.status")
|
|
def workspace_status(
|
|
self, context: ConnectorContext, repository_ref: str, branch: str
|
|
) -> dict[str, Any]:
|
|
"""Reconstruct a durable repository workspace from Gitea source-of-truth state."""
|
|
safe_branch = _validate_ref(branch)
|
|
status = self.change_status(context, repository_ref, safe_branch)["data"]
|
|
workspace_id = hashlib.sha256(
|
|
f"{context.project_id}:{repository_ref}:{safe_branch}".encode()
|
|
).hexdigest()[:24]
|
|
durable_status = {key: value for key, value in status.items() if key != "snapshotAt"}
|
|
state_version = hashlib.sha256(
|
|
json.dumps(durable_status, sort_keys=True, separators=(",", ":")).encode()
|
|
).hexdigest()[:24]
|
|
return self._envelope(
|
|
context,
|
|
{
|
|
**status,
|
|
"workspaceId": workspace_id,
|
|
"stateVersion": state_version,
|
|
"execution": {
|
|
"mode": "managed-actions",
|
|
"profiles": list(_WORKSPACE_PROFILES),
|
|
"timeoutMinutes": 30,
|
|
"repositoryPermission": "read",
|
|
"arbitraryCommands": False,
|
|
},
|
|
},
|
|
source=f"gitea:{repository_ref}",
|
|
)
|
|
|
|
@governed_tool("gitea.portfolio.health")
|
|
def portfolio_health(
|
|
self, context: ConnectorContext, repository_refs: list[str]
|
|
) -> dict[str, Any]:
|
|
"""Run bounded health checks across an explicit repository set."""
|
|
if not 1 <= len(repository_refs) <= 25 or len(set(repository_refs)) != len(repository_refs):
|
|
raise GiteaError(
|
|
"GITEA_INPUT_INVALID", "repositoryRefs must contain 1-25 unique references"
|
|
)
|
|
items: list[dict[str, Any]] = []
|
|
for repository_ref in repository_refs:
|
|
try:
|
|
health = self.repository_health(context, repository_ref)["data"]
|
|
items.append(
|
|
{
|
|
"repositoryRef": repository_ref,
|
|
"healthy": bool(health.get("healthy", False)),
|
|
"defaultBranch": health.get("defaultBranch"),
|
|
"defaultBranchProtected": health.get("defaultBranchProtected"),
|
|
"validationWorkflowAvailable": health.get("validationWorkflowAvailable"),
|
|
"openPullRequestCount": health.get("openPullRequestCount", 0),
|
|
"safeBranchCount": health.get("safeBranchCount", 0),
|
|
"snapshotId": health.get("snapshotId"),
|
|
"sourceRevision": health.get("sourceRevision"),
|
|
"findings": health.get("findings", [])[:20],
|
|
}
|
|
)
|
|
except GiteaError as exc:
|
|
items.append(
|
|
{
|
|
"repositoryRef": repository_ref,
|
|
"healthy": False,
|
|
"errorCode": exc.code,
|
|
}
|
|
)
|
|
snapshot_at = datetime.now(UTC).isoformat()
|
|
portfolio_snapshot_id = self._snapshot_id(
|
|
{
|
|
"items": [
|
|
{
|
|
"repositoryRef": item.get("repositoryRef"),
|
|
"snapshotId": item.get("snapshotId"),
|
|
"sourceRevision": item.get("sourceRevision"),
|
|
"errorCode": item.get("errorCode"),
|
|
}
|
|
for item in items
|
|
],
|
|
}
|
|
)
|
|
return self._envelope(
|
|
context,
|
|
{
|
|
"items": items,
|
|
"snapshotId": portfolio_snapshot_id,
|
|
"snapshotAt": snapshot_at,
|
|
"cacheAgeMs": 0,
|
|
"summary": {
|
|
"total": len(items),
|
|
"healthy": sum(bool(item.get("healthy")) for item in items),
|
|
"attention": sum(not bool(item.get("healthy")) for item in items),
|
|
},
|
|
},
|
|
source="gitea:portfolio-health",
|
|
)
|
|
|
|
def _orchestration_plan_data(
|
|
self,
|
|
context: ConnectorContext,
|
|
repository_refs: list[str],
|
|
branch_suffix: str,
|
|
) -> dict[str, Any]:
|
|
if not 1 <= len(repository_refs) <= 10 or len(set(repository_refs)) != len(repository_refs):
|
|
raise GiteaError(
|
|
"GITEA_INPUT_INVALID", "repositoryRefs must contain 1-10 unique references"
|
|
)
|
|
if not _ORCHESTRATION_SUFFIX.fullmatch(branch_suffix):
|
|
raise GiteaError("GITEA_INPUT_INVALID", "branchSuffix is outside the safe policy")
|
|
branch = _validate_ref(f"codex/{branch_suffix}", change_branch=True)
|
|
items: list[dict[str, Any]] = []
|
|
for repository_ref in repository_refs:
|
|
repository = self._repo(context, repository_ref)
|
|
metadata = self.client.repository(repository)
|
|
default_branch = str(metadata.get("default_branch", ""))
|
|
if not default_branch:
|
|
raise GiteaError(
|
|
"GITEA_TARGET_INVALID", "repository does not have a default branch"
|
|
)
|
|
base = self.client.branch(repository, default_branch)
|
|
items.append(
|
|
{
|
|
"repositoryRef": repository_ref,
|
|
"baseRef": default_branch,
|
|
"baseSha": str((base.get("commit") or {}).get("id", ""))[:64],
|
|
"branch": branch,
|
|
}
|
|
)
|
|
orchestration_id = hashlib.sha256(
|
|
json.dumps(items, sort_keys=True, separators=(",", ":")).encode()
|
|
).hexdigest()[:24]
|
|
return {
|
|
"orchestrationId": orchestration_id,
|
|
"branchSuffix": branch_suffix,
|
|
"items": items,
|
|
"repositoryCount": len(items),
|
|
"nextAction": "confirm-and-start",
|
|
}
|
|
|
|
@governed_tool("gitea.orchestration.plan")
|
|
def orchestration_plan(
|
|
self,
|
|
context: ConnectorContext,
|
|
repository_refs: list[str],
|
|
branch_suffix: str,
|
|
) -> dict[str, Any]:
|
|
"""Preview exact bases and safe branches for one bounded multi-repository task."""
|
|
return self._envelope(
|
|
context,
|
|
self._orchestration_plan_data(context, repository_refs, branch_suffix),
|
|
source="gitea:orchestration",
|
|
)
|
|
|
|
@governed_tool("gitea.workspace.run")
|
|
def workspace_run(
|
|
self,
|
|
context: ConnectorContext,
|
|
repository_ref: str,
|
|
branch: str,
|
|
profile: str,
|
|
) -> dict[str, Any]:
|
|
"""Dispatch an isolated fixed-profile workspace job with durable Gitea state."""
|
|
if profile not in _WORKSPACE_PROFILES:
|
|
raise GiteaError("GITEA_INPUT_INVALID", "workspace profile is not allowlisted")
|
|
result = self.validation_run(context, repository_ref, branch, profile)
|
|
workspace = self.workspace_status(context, repository_ref, branch)["data"]
|
|
result["data"].update(
|
|
{
|
|
"workspaceId": workspace["workspaceId"],
|
|
"executionMode": "managed-actions",
|
|
"timeoutMinutes": 30,
|
|
"arbitraryCommands": False,
|
|
}
|
|
)
|
|
return result
|
|
|
|
@governed_tool("gitea.orchestration.start")
|
|
def orchestration_start(
|
|
self,
|
|
context: ConnectorContext,
|
|
repository_refs: list[str],
|
|
branch_suffix: str,
|
|
expected_orchestration_id: str,
|
|
) -> dict[str, Any]:
|
|
"""Create or resume one safe branch in every preflighted repository."""
|
|
if not re.fullmatch(r"^[0-9a-f]{24}$", expected_orchestration_id):
|
|
raise GiteaError("GITEA_INPUT_INVALID", "expectedOrchestrationId is invalid")
|
|
plan = self._orchestration_plan_data(context, repository_refs, branch_suffix)
|
|
if plan["orchestrationId"] != expected_orchestration_id:
|
|
raise GiteaError(
|
|
"GITEA_CONFLICT", "orchestration plan changed; refresh before starting"
|
|
)
|
|
results: list[dict[str, Any]] = []
|
|
for item in plan["items"]:
|
|
repository = self._repo(context, str(item["repositoryRef"]))
|
|
try:
|
|
existing = self.client.branch(repository, str(item["branch"]))
|
|
except GiteaError as exc:
|
|
if exc.code != "GITEA_NOT_FOUND":
|
|
raise
|
|
created = self.branch_create(
|
|
context,
|
|
str(item["repositoryRef"]),
|
|
str(item["branch"]),
|
|
str(item["baseRef"]),
|
|
)["data"]
|
|
results.append({**item, "state": "created", "headSha": created["commitSha"]})
|
|
else:
|
|
results.append(
|
|
{
|
|
**item,
|
|
"state": "resumed",
|
|
"headSha": str((existing.get("commit") or {}).get("id", ""))[:64],
|
|
}
|
|
)
|
|
return self._envelope(
|
|
context,
|
|
{
|
|
**plan,
|
|
"items": results,
|
|
"started": True,
|
|
"nextAction": "apply-reviewed-changesets",
|
|
},
|
|
source="gitea:orchestration",
|
|
)
|
|
|
|
@governed_tool("gitea.commitstatus.list")
|
|
def commit_status_list(
|
|
self,
|
|
context: ConnectorContext,
|
|
repository_ref: str,
|
|
ref: str,
|
|
*,
|
|
page: PageRequest | None = None,
|
|
) -> dict[str, Any]:
|
|
if not 1 <= len(ref) <= 300 or any(char in ref for char in "\\?#\x00\r\n"):
|
|
raise GiteaError("GITEA_TARGET_INVALID", "commit status ref is invalid")
|
|
|
|
def loader(repository: RegisteredRepository, page_number: int, limit: int) -> Any:
|
|
return self.client.commit_statuses(repository, ref, page_number, limit)
|
|
|
|
def mapper(items: list[dict[str, Any]], repository: Any) -> list[dict[str, Any]]:
|
|
return [
|
|
{
|
|
"state": str(item.get("status", ""))[:32],
|
|
"context": str(item.get("context", ""))[:200],
|
|
"description": safe_snippet(str(item.get("description", "")), max_bytes=500),
|
|
"targetUrl": str(item.get("target_url", ""))[:2048],
|
|
"createdAt": str(item.get("created_at", ""))[:64],
|
|
}
|
|
for item in items
|
|
]
|
|
|
|
return self._collection(
|
|
context, repository_ref, page, loader, mapper, f"commit-statuses:{ref}"
|
|
)
|
|
|
|
@governed_tool("gitea.file.read")
|
|
def file_read(
|
|
self, context: ConnectorContext, file_uri: str, ref: str | None = None
|
|
) -> dict[str, Any]:
|
|
parsed = urlparse(file_uri)
|
|
if (
|
|
parsed.scheme != "itworx-git"
|
|
or parsed.query
|
|
or parsed.fragment
|
|
or parsed.username
|
|
or parsed.password
|
|
):
|
|
raise GiteaError("GITEA_FILE_URI_INVALID", "file URI is not a registered Gitea URI")
|
|
if parsed.netloc != context.project_slug:
|
|
raise GiteaError("GITEA_FILE_URI_INVALID", "file URI is outside the caller project")
|
|
segments = [unquote(part) for part in parsed.path.split("/") if part]
|
|
if len(segments) < 2 or any(
|
|
"/" in part or "\\" in part or part in {".", ".."} for part in segments
|
|
):
|
|
raise GiteaError("GITEA_PATH_INVALID", "file URI path is invalid")
|
|
repository = self._repo(context, segments[0])
|
|
safe_ref = _validate_ref(ref) if ref else None
|
|
payload = self.client.file(repository, tuple(segments[1:]), safe_ref)
|
|
content, truncated, commit_sha = decode_file_payload(payload, 200_000)
|
|
data = {
|
|
"fileUri": file_uri,
|
|
"mimeType": "text/plain",
|
|
"content": content,
|
|
"commitSha": commit_sha,
|
|
"truncated": truncated,
|
|
"generatedAt": datetime.now(UTC).isoformat(),
|
|
}
|
|
return self._envelope(
|
|
context, data, source=f"gitea:{repository.repository_ref}", truncated=truncated
|
|
)
|
|
|
|
@governed_tool("gitea.tree.list")
|
|
def tree_list(
|
|
self,
|
|
context: ConnectorContext,
|
|
repository_ref: str,
|
|
path: str = "",
|
|
ref: str | None = None,
|
|
) -> dict[str, Any]:
|
|
repository = self._repo(context, repository_ref)
|
|
safe_path = _validate_file_path(path) if path else ()
|
|
safe_ref = _validate_ref(ref) if ref else None
|
|
payload = self.client.contents(repository, safe_path, safe_ref)
|
|
entries = payload if isinstance(payload, list) else [payload]
|
|
if not all(isinstance(item, dict) for item in entries):
|
|
raise GiteaError("GITEA_INVALID_RESPONSE", "Gitea returned an invalid directory")
|
|
items = [
|
|
{
|
|
"name": str(item.get("name", ""))[:512],
|
|
"path": str(item.get("path", ""))[:2048],
|
|
"type": str(item.get("type", ""))[:32],
|
|
"sha": str(item.get("sha", ""))[:64],
|
|
"size": max(int(item.get("size", 0)), 0),
|
|
"fileUri": (
|
|
f"itworx-git://{context.project_slug}/{repository_ref}/"
|
|
f"{str(item.get('path', ''))}"
|
|
),
|
|
}
|
|
for item in entries[:1000]
|
|
]
|
|
return self._envelope(
|
|
context,
|
|
{"items": items, "truncated": len(entries) > 1000},
|
|
source=f"gitea:{repository_ref}",
|
|
truncated=len(entries) > 1000,
|
|
)
|
|
|
|
@governed_tool("gitea.code.search")
|
|
def code_search(
|
|
self,
|
|
context: ConnectorContext,
|
|
repository_ref: str,
|
|
query: str,
|
|
ref: str | None = None,
|
|
path_prefix: str = "",
|
|
*,
|
|
page: PageRequest | None = None,
|
|
) -> dict[str, Any]:
|
|
if not 1 <= len(query) <= 500:
|
|
raise GiteaError(
|
|
"GITEA_QUERY_INVALID", "code search query is outside the connector budget"
|
|
)
|
|
repository = self._repo(context, repository_ref)
|
|
metadata = self.client.repository(repository)
|
|
selected_ref = _validate_ref(ref) if ref else str(metadata.get("default_branch", ""))
|
|
safe_prefix = "/".join(_validate_file_path(path_prefix)) + "/" if path_prefix else ""
|
|
request = page or PageRequest(limit=25)
|
|
cursor = decode_cursor(
|
|
request.cursor,
|
|
str(context.project_id),
|
|
repository_ref,
|
|
selected_ref,
|
|
query,
|
|
safe_prefix,
|
|
str(request.limit),
|
|
)
|
|
tree_page_size = 1_000
|
|
tree_page = cursor.offset // tree_page_size + 1
|
|
page_offset = cursor.offset % tree_page_size
|
|
tree_payload, cache_hit = self._git_tree_cached(
|
|
repository, selected_ref, page=tree_page, per_page=tree_page_size
|
|
)
|
|
raw_tree = tree_payload.get("tree")
|
|
tree = raw_tree if isinstance(raw_tree, list) else []
|
|
mapped: list[dict[str, Any]] = []
|
|
scanned = 0
|
|
consumed = 0
|
|
needle = query.casefold()
|
|
deadline = time.monotonic() + _CODE_SEARCH_BUDGET_SECONDS
|
|
for candidate in tree[page_offset:]:
|
|
if (
|
|
scanned >= _CODE_SEARCH_MAX_FILES
|
|
or len(mapped) >= request.limit
|
|
or time.monotonic() >= deadline
|
|
):
|
|
break
|
|
consumed += 1
|
|
if (
|
|
not isinstance(candidate, dict)
|
|
or candidate.get("type") != "blob"
|
|
or not str(candidate.get("path", "")).startswith(safe_prefix)
|
|
or not 0 <= int(candidate.get("size", 0) or 0) <= 200_000
|
|
):
|
|
continue
|
|
scanned += 1
|
|
path = str(candidate.get("path", ""))
|
|
sha = str(candidate.get("sha", ""))
|
|
try:
|
|
payload = self.client.git_blob(
|
|
repository,
|
|
sha,
|
|
timeout_seconds=_CODE_SEARCH_BLOB_TIMEOUT_SECONDS,
|
|
max_attempts=1,
|
|
)
|
|
except GiteaError as exc:
|
|
# A single slow or transient blob must not make the whole MCP
|
|
# request exceed the client deadline. Return the bounded partial
|
|
# page and let its cursor continue from the next candidate.
|
|
if exc.code == "GITEA_UNAVAILABLE":
|
|
continue
|
|
raise
|
|
encoded = payload.get("content")
|
|
if not isinstance(encoded, str):
|
|
continue
|
|
try:
|
|
raw = base64.b64decode(encoded.encode("ascii"), validate=False)
|
|
content = raw.decode("utf-8")
|
|
except (ValueError, UnicodeDecodeError, UnicodeEncodeError):
|
|
continue
|
|
matches = []
|
|
for line_number, line in enumerate(content.splitlines(), 1):
|
|
if needle in line.casefold() and not is_secret_like(line):
|
|
matches.append(
|
|
{"line": line_number, "snippet": safe_snippet(line, max_bytes=500)}
|
|
)
|
|
if len(matches) == 10:
|
|
break
|
|
if matches:
|
|
mapped.append(
|
|
{
|
|
"fileUri": f"itworx-git://{context.project_slug}/{repository_ref}/{path}",
|
|
"repositoryRef": repository_ref,
|
|
"path": path[:2048],
|
|
"sha": sha[:64],
|
|
"matches": matches,
|
|
}
|
|
)
|
|
absolute_offset = (tree_page - 1) * tree_page_size + page_offset + consumed
|
|
more_results = page_offset + consumed < len(tree) or bool(
|
|
tree_payload.get("truncated", False)
|
|
)
|
|
next_cursor = (
|
|
encode_cursor(
|
|
absolute_offset,
|
|
str(context.project_id),
|
|
repository_ref,
|
|
selected_ref,
|
|
query,
|
|
safe_prefix,
|
|
str(request.limit),
|
|
)
|
|
if more_results
|
|
else None
|
|
)
|
|
search_truncated = next_cursor is not None
|
|
data = {
|
|
"items": mapped,
|
|
"nextCursor": next_cursor,
|
|
"truncated": search_truncated,
|
|
"scannedFiles": scanned,
|
|
}
|
|
result = self._envelope(
|
|
context,
|
|
data,
|
|
source=f"gitea:{repository_ref}:code-search",
|
|
truncated=search_truncated,
|
|
next_cursor=next_cursor,
|
|
)
|
|
result["meta"]["cacheHit"] = cache_hit
|
|
result["meta"]["cacheTtlSeconds"] = 30
|
|
return result
|
|
|
|
@staticmethod
|
|
def _research_id(context: ConnectorContext, repository_ref: str, path: str, ref: str) -> str:
|
|
payload = json.dumps(
|
|
{
|
|
"project": context.project_slug,
|
|
"repositoryRef": repository_ref,
|
|
"path": path,
|
|
"ref": ref,
|
|
},
|
|
sort_keys=True,
|
|
separators=(",", ":"),
|
|
).encode()
|
|
return "gitea:" + base64.urlsafe_b64encode(payload).decode().rstrip("=")
|
|
|
|
@staticmethod
|
|
def _decode_research_id(context: ConnectorContext, identifier: str) -> dict[str, str]:
|
|
if not identifier.startswith("gitea:") or len(identifier) > 4096:
|
|
raise GiteaError("GITEA_DOCUMENT_INVALID", "research document ID is invalid")
|
|
encoded = identifier.removeprefix("gitea:")
|
|
try:
|
|
raw = base64.urlsafe_b64decode(encoded + "=" * (-len(encoded) % 4))
|
|
value = json.loads(raw)
|
|
except (ValueError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
raise GiteaError("GITEA_DOCUMENT_INVALID", "research document ID is invalid") from exc
|
|
if not isinstance(value, dict) or set(value) != {
|
|
"project",
|
|
"repositoryRef",
|
|
"path",
|
|
"ref",
|
|
}:
|
|
raise GiteaError("GITEA_DOCUMENT_INVALID", "research document ID is invalid")
|
|
if not all(isinstance(item, str) for item in value.values()):
|
|
raise GiteaError("GITEA_DOCUMENT_INVALID", "research document ID is invalid")
|
|
if value["project"] != context.project_slug:
|
|
raise GiteaError("GITEA_DOCUMENT_INVALID", "research document is outside the project")
|
|
_validate_file_path(value["path"])
|
|
_validate_ref(value["ref"])
|
|
return value
|
|
|
|
def _research_url(self, repository: RegisteredRepository, path: str, ref: str) -> str:
|
|
base = self.client.settings.base_url.rstrip("/")
|
|
return (
|
|
f"{base}/{quote(repository.owner, safe='')}/{quote(repository.name, safe='')}"
|
|
f"/src/commit/{quote(ref, safe='')}/{quote(path, safe='/')}"
|
|
)
|
|
|
|
@governed_tool("gitea.research.search")
|
|
def research_search(self, context: ConnectorContext, query: str) -> dict[str, Any]:
|
|
"""Return citable, immutable repository documents using the standard search shape."""
|
|
normalized = _validate_text(query.strip(), field="query", minimum=1, maximum=500)
|
|
self._refresh_discovered(context.project_id)
|
|
terms = tuple(
|
|
dict.fromkeys(
|
|
part for part in re.findall(r"[a-z0-9_-]+", normalized.casefold()) if len(part) > 1
|
|
)
|
|
)[:12]
|
|
repositories = self.registry.list(context.project_id)[:10]
|
|
ranked: list[tuple[int, dict[str, str]]] = []
|
|
remaining_blob_budget = 30
|
|
important_names = {
|
|
"AGENTS.md",
|
|
"CLAUDE.md",
|
|
"README.md",
|
|
"README_NL.md",
|
|
"SECURITY.md",
|
|
"pyproject.toml",
|
|
"package.json",
|
|
}
|
|
for repository in repositories:
|
|
if remaining_blob_budget <= 0:
|
|
break
|
|
metadata = self.client.repository(repository)
|
|
default_branch = str(metadata.get("default_branch", ""))
|
|
if not default_branch:
|
|
continue
|
|
branch = self.client.branch(repository, default_branch)
|
|
commit = branch.get("commit") if isinstance(branch, dict) else None
|
|
ref = str(commit.get("id", "")) if isinstance(commit, dict) else ""
|
|
if not _SHA.fullmatch(ref):
|
|
continue
|
|
tree_payload, _ = self._git_tree_cached(repository, ref)
|
|
raw_tree = tree_payload.get("tree")
|
|
files = [
|
|
item
|
|
for item in (raw_tree if isinstance(raw_tree, list) else [])
|
|
if isinstance(item, dict)
|
|
and item.get("type") == "blob"
|
|
and 0 <= int(item.get("size", 0) or 0) <= 200_000
|
|
]
|
|
files.sort(
|
|
key=lambda item: (
|
|
-sum(term in str(item.get("path", "")).casefold() for term in terms),
|
|
str(item.get("path", "")).split("/")[-1] not in important_names,
|
|
str(item.get("path", "")),
|
|
)
|
|
)
|
|
for candidate in files[: min(8, remaining_blob_budget)]:
|
|
remaining_blob_budget -= 1
|
|
path = str(candidate.get("path", ""))
|
|
sha = str(candidate.get("sha", ""))
|
|
blob = self.client.git_blob(repository, sha)
|
|
encoded = blob.get("content")
|
|
if not isinstance(encoded, str):
|
|
continue
|
|
try:
|
|
content = base64.b64decode(encoded.encode("ascii"), validate=False).decode(
|
|
"utf-8"
|
|
)
|
|
except (ValueError, UnicodeDecodeError, UnicodeEncodeError):
|
|
continue
|
|
filtered = safe_content(content)
|
|
if filtered is None:
|
|
continue
|
|
haystack = f"{repository.qualified_name}\n{path}\n{filtered}".casefold()
|
|
score = sum(haystack.count(term) for term in terms)
|
|
if score <= 0:
|
|
continue
|
|
ranked.append(
|
|
(
|
|
score,
|
|
{
|
|
"id": self._research_id(context, repository.repository_ref, path, ref),
|
|
"title": f"{repository.qualified_name} · {path}"[:512],
|
|
"url": self._research_url(repository, path, ref),
|
|
},
|
|
)
|
|
)
|
|
ranked.sort(key=lambda item: (-item[0], item[1]["title"]))
|
|
return {"results": [item for _, item in ranked[:20]]}
|
|
|
|
@governed_tool("gitea.research.fetch")
|
|
def research_fetch(self, context: ConnectorContext, identifier: str) -> dict[str, Any]:
|
|
"""Fetch one immutable search result using the standard fetch shape."""
|
|
document = self._decode_research_id(context, identifier)
|
|
repository = self._repo(context, document["repositoryRef"])
|
|
result = self.file_read(
|
|
context,
|
|
f"itworx-git://{context.project_slug}/{repository.repository_ref}/{document['path']}",
|
|
document["ref"],
|
|
)["data"]
|
|
return {
|
|
"id": identifier,
|
|
"title": f"{repository.qualified_name} · {document['path']}"[:512],
|
|
"text": str(result["content"]),
|
|
"url": self._research_url(repository, document["path"], document["ref"]),
|
|
"metadata": {
|
|
"repositoryRef": repository.repository_ref,
|
|
"path": document["path"],
|
|
"ref": document["ref"],
|
|
"truncated": bool(result["truncated"]),
|
|
},
|
|
}
|
|
|
|
def _preview_changeset(
|
|
self,
|
|
context: ConnectorContext,
|
|
repository_ref: str,
|
|
branch: str,
|
|
operations: list[dict[str, Any]],
|
|
) -> dict[str, Any]:
|
|
candidate_branch = _validate_ref(branch)
|
|
normalized = validate_operations(operations)
|
|
repository = self._repo(context, repository_ref)
|
|
metadata = self.client.repository(repository)
|
|
safe_branch = _validate_work_branch(
|
|
candidate_branch, str(metadata.get("default_branch", ""))
|
|
)
|
|
previews: list[dict[str, Any]] = []
|
|
total_diff = 0
|
|
for operation in normalized:
|
|
kind = operation["kind"]
|
|
path = "/".join(_validate_file_path(operation["path"]))
|
|
destination = operation.get("destinationPath")
|
|
if destination is not None:
|
|
destination = "/".join(_validate_file_path(str(destination)))
|
|
original = ""
|
|
actual_sha: str | None = None
|
|
if kind == "create":
|
|
try:
|
|
self.client.file(repository, tuple(path.split("/")), safe_branch)
|
|
except GiteaError as exc:
|
|
if exc.code != "GITEA_NOT_FOUND":
|
|
raise
|
|
else:
|
|
raise GiteaError("GITEA_CHANGESET_CONFLICT", f"file already exists: {path}")
|
|
else:
|
|
payload = self.client.file(repository, tuple(path.split("/")), safe_branch)
|
|
original, truncated, actual_sha = decode_file_payload(payload, 200_000)
|
|
if truncated:
|
|
raise GiteaError("GITEA_CHANGESET_INVALID", "source file exceeds budget")
|
|
if actual_sha.casefold() != str(operation.get("expectedSha", "")).casefold():
|
|
raise GiteaError("GITEA_CHANGESET_CONFLICT", f"stale expectedSha for {path}")
|
|
if destination is not None:
|
|
try:
|
|
self.client.file(repository, tuple(destination.split("/")), safe_branch)
|
|
except GiteaError as exc:
|
|
if exc.code != "GITEA_NOT_FOUND":
|
|
raise
|
|
else:
|
|
raise GiteaError(
|
|
"GITEA_CHANGESET_CONFLICT",
|
|
f"destination already exists: {destination}",
|
|
)
|
|
if kind == "create" or kind == "update":
|
|
updated = _validate_text(
|
|
str(operation["content"]), field="file content", minimum=0, maximum=200_000
|
|
)
|
|
elif kind == "patch":
|
|
updated = apply_unified_patch(original, str(operation["patch"]))
|
|
elif kind == "move":
|
|
updated = original
|
|
else:
|
|
updated = ""
|
|
diff = "".join(
|
|
difflib.unified_diff(
|
|
original.splitlines(keepends=True),
|
|
updated.splitlines(keepends=True),
|
|
fromfile=f"a/{path}",
|
|
tofile=f"b/{destination or path}",
|
|
n=3,
|
|
)
|
|
)
|
|
encoded_diff = diff.encode("utf-8")
|
|
total_diff += len(encoded_diff)
|
|
previews.append(
|
|
{
|
|
"kind": kind,
|
|
"path": path,
|
|
"destinationPath": destination,
|
|
"expectedSha": actual_sha,
|
|
"diff": diff[:100_000],
|
|
"diffTruncated": len(encoded_diff) > 100_000,
|
|
}
|
|
)
|
|
return self._envelope(
|
|
context,
|
|
{
|
|
"repositoryRef": repository_ref,
|
|
"branch": safe_branch,
|
|
"valid": True,
|
|
"operationCount": len(previews),
|
|
"operations": previews,
|
|
"totalDiffBytes": total_diff,
|
|
},
|
|
source=f"gitea:{repository_ref}",
|
|
truncated=any(item["diffTruncated"] for item in previews),
|
|
)
|
|
|
|
@governed_tool("gitea.changeset.preview")
|
|
def changeset_preview(
|
|
self,
|
|
context: ConnectorContext,
|
|
repository_ref: str,
|
|
branch: str,
|
|
operations: list[dict[str, Any]],
|
|
) -> dict[str, Any]:
|
|
return self._preview_changeset(context, repository_ref, branch, operations)
|
|
|
|
@governed_tool("gitea.changeset.apply")
|
|
def changeset_apply(
|
|
self,
|
|
context: ConnectorContext,
|
|
repository_ref: str,
|
|
branch: str,
|
|
message: str,
|
|
operations: list[dict[str, Any]],
|
|
) -> dict[str, Any]:
|
|
preview = self._preview_changeset(context, repository_ref, branch, operations)
|
|
safe_message = _validate_text(message, field="commit message", minimum=1, maximum=1_000)
|
|
if self.changeset_workspace is None:
|
|
raise GiteaError("GITEA_WRITES_DISABLED", "atomic changeset capability is not enabled")
|
|
repository = self._repo(context, repository_ref)
|
|
result = self.changeset_workspace.apply(repository, branch, safe_message, operations)
|
|
self._invalidate_repository_cache(repository)
|
|
result["previewedOperationCount"] = preview["data"]["operationCount"]
|
|
return self._envelope(context, result, source=f"gitea:{repository_ref}")
|
|
|
|
@governed_tool("gitea.repository.create")
|
|
def repository_create(
|
|
self,
|
|
context: ConnectorContext,
|
|
name: str,
|
|
description: str = "",
|
|
private: bool = True,
|
|
auto_init: bool = True,
|
|
default_branch: str = "main",
|
|
) -> dict[str, Any]:
|
|
safe_name = name.strip()
|
|
if not _SAFE_REPOSITORY_NAME.fullmatch(safe_name) or ".." in safe_name:
|
|
raise GiteaError("GITEA_TARGET_INVALID", "repository name is outside the safe policy")
|
|
safe_description = _validate_text(
|
|
description, field="repository description", minimum=0, maximum=2_000
|
|
)
|
|
if not auto_init:
|
|
raise GiteaError(
|
|
"GITEA_REPOSITORY_INIT_REQUIRED",
|
|
"governed repository creation requires an initialized default branch",
|
|
)
|
|
safe_default_branch = _validate_ref(default_branch)
|
|
if safe_default_branch != "main":
|
|
raise GiteaError(
|
|
"GITEA_DEFAULT_BRANCH_INVALID",
|
|
"governed repository creation requires the canonical main branch",
|
|
)
|
|
writer = self._writer()
|
|
owner = self._repository_write_owner(context)
|
|
registration_project_id, registration_project_slug = self.repository_create_project or (
|
|
context.project_id,
|
|
context.project_slug,
|
|
)
|
|
|
|
candidate = RegisteredRepository(
|
|
repository_ref="temporary-created-repository",
|
|
project_id=registration_project_id,
|
|
owner=owner,
|
|
name=safe_name,
|
|
source_uri=(f"itworx-git://{registration_project_slug}/temporary-created-repository"),
|
|
private=private,
|
|
)
|
|
created = False
|
|
try:
|
|
payload = writer.repository(candidate)
|
|
except GiteaError as error:
|
|
if error.code != "GITEA_NOT_FOUND":
|
|
raise
|
|
payload = writer.create_repository(
|
|
name=safe_name,
|
|
description=safe_description,
|
|
private=private,
|
|
auto_init=auto_init,
|
|
default_branch=safe_default_branch,
|
|
)
|
|
created = True
|
|
|
|
payload_owner = payload.get("owner")
|
|
returned_owner = (
|
|
str(payload_owner.get("login", "")) if isinstance(payload_owner, dict) else ""
|
|
)
|
|
returned_name = str(payload.get("name", ""))
|
|
if (
|
|
returned_owner.casefold() != owner.casefold()
|
|
or returned_name.casefold() != safe_name.casefold()
|
|
):
|
|
raise GiteaError(
|
|
"GITEA_INVALID_RESPONSE", "Gitea returned unexpected repository identity"
|
|
)
|
|
if not created and bool(payload.get("private", False)) != private:
|
|
raise GiteaError(
|
|
"GITEA_REPOSITORY_CONFLICT",
|
|
"an existing repository with this name has different visibility",
|
|
)
|
|
|
|
repaired_empty_repository = False
|
|
if bool(payload.get("empty", False)) or not str(payload.get("default_branch", "")):
|
|
writer.create_file(
|
|
candidate,
|
|
("README.md",),
|
|
branch=safe_default_branch,
|
|
content=(
|
|
f"# {safe_name}\n\n"
|
|
f"{safe_description or 'Repository initialized through governed Gitea MCP.'}\n"
|
|
),
|
|
message="chore: initialize repository",
|
|
)
|
|
repaired_empty_repository = True
|
|
payload = writer.repository(candidate)
|
|
|
|
actual_default_branch = str(payload.get("default_branch", safe_default_branch))[:300]
|
|
if actual_default_branch != safe_default_branch:
|
|
raise GiteaError(
|
|
"GITEA_REPOSITORY_CONFLICT",
|
|
"an existing repository with this name has a different default branch",
|
|
)
|
|
branch_payload = writer.branch(candidate, safe_default_branch)
|
|
branch_commit = branch_payload.get("commit")
|
|
head_sha = str(branch_commit.get("id", "")) if isinstance(branch_commit, dict) else ""
|
|
if not head_sha:
|
|
raise GiteaError(
|
|
"GITEA_REPOSITORY_NOT_READY",
|
|
"Gitea did not return a usable default-branch head commit",
|
|
)
|
|
|
|
self.registry.add_discovered(registration_project_id, registration_project_slug, [payload])
|
|
repository = next(
|
|
(
|
|
item
|
|
for item in self.registry.list(registration_project_id)
|
|
if item.qualified_name.casefold() == f"{owner}/{safe_name}".casefold()
|
|
),
|
|
None,
|
|
)
|
|
if repository is None:
|
|
raise GiteaError("GITEA_REGISTRATION_FAILED", "created repository was not registered")
|
|
governance_context = context.model_copy(
|
|
update={
|
|
"project_id": registration_project_id,
|
|
"project_slug": registration_project_slug,
|
|
}
|
|
)
|
|
try:
|
|
governance_preview = self._governance_plan_data(
|
|
governance_context, repository.repository_ref
|
|
)
|
|
governance_result = self.branch_protection_ensure(
|
|
governance_context,
|
|
repository.repository_ref,
|
|
str(governance_preview["planHash"]),
|
|
)["data"]
|
|
except GiteaError as error:
|
|
# Creation is already durable. A later governance failure must not
|
|
# destroy a repository (or commits added by another caller). Keep
|
|
# its registration so the unchanged idempotent request can recover.
|
|
raise GiteaError(
|
|
"GITEA_REPOSITORY_GOVERNANCE_PENDING",
|
|
"Repository exists and is retained; governance setup is incomplete",
|
|
category="configuration",
|
|
upstream_status=error.upstream_status,
|
|
expected_state="repository_governance_ready",
|
|
actual_state="repository_retained_governance_pending",
|
|
recovery_hint=(
|
|
f"Do not create another repository. Repair governance access/configuration "
|
|
f"for {owner}/{safe_name}, then repeat the unchanged "
|
|
"repository-create request. "
|
|
f"Cause: {error.code}."
|
|
),
|
|
) from error
|
|
return self._envelope(
|
|
context,
|
|
{
|
|
"repositoryRef": repository.repository_ref,
|
|
"owner": owner,
|
|
"name": returned_name,
|
|
"fullName": f"{owner}/{returned_name}",
|
|
"private": bool(payload.get("private", private)),
|
|
"defaultBranch": actual_default_branch,
|
|
"headSha": head_sha[:64],
|
|
"initialized": True,
|
|
"branchReady": True,
|
|
"repairedEmptyRepository": repaired_empty_repository,
|
|
"sourceUri": str(payload.get("html_url", repository.source_uri))[:2048],
|
|
"created": created,
|
|
"availableImmediately": True,
|
|
"governanceReady": bool(governance_result.get("verified")),
|
|
"managedValidationReady": bool(governance_result.get("compliant")),
|
|
"defaultBranchProtected": bool(governance_result.get("compliant")),
|
|
"governancePlanHash": str(governance_result.get("planHash", ""))[:64],
|
|
},
|
|
source=f"gitea:{repository.repository_ref}",
|
|
)
|
|
|
|
@governed_tool("gitea.branch.create")
|
|
def branch_create(
|
|
self,
|
|
context: ConnectorContext,
|
|
repository_ref: str,
|
|
branch: str,
|
|
from_ref: str,
|
|
) -> dict[str, Any]:
|
|
candidate_branch = _validate_ref(branch)
|
|
safe_from_ref = _validate_ref(from_ref)
|
|
repository = self._repo(context, repository_ref)
|
|
metadata = self.client.repository(repository)
|
|
safe_branch = _validate_work_branch(
|
|
candidate_branch, str(metadata.get("default_branch", ""))
|
|
)
|
|
if safe_branch == safe_from_ref:
|
|
raise GiteaError("GITEA_TARGET_INVALID", "new and source branch must differ")
|
|
payload = self._writer().create_branch(repository, safe_branch, safe_from_ref)
|
|
data = {
|
|
"repositoryRef": repository_ref,
|
|
"name": str(payload.get("name", safe_branch))[:300],
|
|
"commitSha": str((payload.get("commit") or {}).get("id", ""))[:64],
|
|
"created": True,
|
|
}
|
|
return self._envelope(context, data, source=f"gitea:{repository_ref}")
|
|
|
|
@governed_tool("gitea.issue.create")
|
|
def issue_create(
|
|
self,
|
|
context: ConnectorContext,
|
|
repository_ref: str,
|
|
title: str,
|
|
body: str = "",
|
|
) -> dict[str, Any]:
|
|
repository = self._repo(context, repository_ref)
|
|
payload = self._writer().create_issue(
|
|
repository,
|
|
_validate_text(title, field="issue title", minimum=1, maximum=500),
|
|
_validate_text(body, field="issue body", minimum=0, maximum=32_000),
|
|
)
|
|
data = {
|
|
"repositoryRef": repository_ref,
|
|
"number": max(int(payload.get("number", 0)), 0),
|
|
"title": safe_snippet(str(payload.get("title", title)), max_bytes=500),
|
|
"state": str(payload.get("state", "open"))[:32],
|
|
"sourceUri": str(payload.get("html_url", ""))[:2048],
|
|
"created": True,
|
|
}
|
|
return self._envelope(context, data, source=f"gitea:{repository_ref}")
|
|
|
|
@governed_tool("gitea.issue.update")
|
|
def issue_update(
|
|
self,
|
|
context: ConnectorContext,
|
|
repository_ref: str,
|
|
number: int,
|
|
title: str | None = None,
|
|
body: str | None = None,
|
|
state: str | None = None,
|
|
) -> dict[str, Any]:
|
|
if not 1 <= number <= 2_147_483_647:
|
|
raise GiteaError("GITEA_TARGET_INVALID", "issue number is invalid")
|
|
if title is None and body is None and state is None:
|
|
raise GiteaError("GITEA_INPUT_INVALID", "title, body or state must be provided")
|
|
if state is not None and state not in {"open", "closed"}:
|
|
raise GiteaError("GITEA_INPUT_INVALID", "issue state must be open or closed")
|
|
repository = self._repo(context, repository_ref)
|
|
current = self.client.issue(repository, number)
|
|
if current.get("pull_request"):
|
|
raise GiteaError(
|
|
"GITEA_TARGET_INVALID",
|
|
"pull requests must be changed with pull request tools",
|
|
)
|
|
payload = self._writer().update_issue(
|
|
repository,
|
|
number,
|
|
title=(
|
|
_validate_text(title, field="issue title", minimum=1, maximum=500)
|
|
if title is not None
|
|
else None
|
|
),
|
|
body=(
|
|
_validate_text(body, field="issue body", minimum=0, maximum=32_000)
|
|
if body is not None
|
|
else None
|
|
),
|
|
state=state,
|
|
)
|
|
data = {
|
|
"repositoryRef": repository_ref,
|
|
"number": max(int(payload.get("number", number)), 0),
|
|
"title": safe_snippet(str(payload.get("title", title or "")), max_bytes=500),
|
|
"state": str(payload.get("state", state or current.get("state", "open")))[:32],
|
|
"sourceUri": str(payload.get("html_url", current.get("html_url", "")))[:2048],
|
|
"updated": True,
|
|
}
|
|
return self._envelope(context, data, source=f"gitea:{repository_ref}")
|
|
|
|
@governed_tool("gitea.issue.comment.create")
|
|
def issue_comment_create(
|
|
self,
|
|
context: ConnectorContext,
|
|
repository_ref: str,
|
|
number: int,
|
|
body: str,
|
|
) -> dict[str, Any]:
|
|
if not 1 <= number <= 2_147_483_647:
|
|
raise GiteaError("GITEA_TARGET_INVALID", "issue number is invalid")
|
|
repository = self._repo(context, repository_ref)
|
|
payload = self._writer().create_issue_comment(
|
|
repository,
|
|
number,
|
|
_validate_text(body, field="comment body", minimum=1, maximum=32_000),
|
|
)
|
|
data = {
|
|
"repositoryRef": repository_ref,
|
|
"issueNumber": number,
|
|
"commentId": max(int(payload.get("id", 0)), 0),
|
|
"sourceUri": str(payload.get("html_url", ""))[:2048],
|
|
"created": True,
|
|
}
|
|
return self._envelope(context, data, source=f"gitea:{repository_ref}")
|
|
|
|
def _file_write(
|
|
self,
|
|
context: ConnectorContext,
|
|
repository_ref: str,
|
|
path: str,
|
|
branch: str,
|
|
content: str,
|
|
message: str,
|
|
*,
|
|
expected_sha: str | None,
|
|
) -> dict[str, Any]:
|
|
candidate_branch = _validate_ref(branch)
|
|
safe_path = _validate_file_path(path)
|
|
safe_content = _validate_text(content, field="file content", minimum=0, maximum=200_000)
|
|
safe_message = _validate_text(message, field="commit message", minimum=1, maximum=1_000)
|
|
if expected_sha is not None and not _SHA.fullmatch(expected_sha):
|
|
raise GiteaError("GITEA_TARGET_INVALID", "expected file SHA is invalid")
|
|
repository = self._repo(context, repository_ref)
|
|
metadata = self.client.repository(repository)
|
|
safe_branch = _validate_work_branch(
|
|
candidate_branch, str(metadata.get("default_branch", ""))
|
|
)
|
|
if expected_sha is None:
|
|
payload = self._writer().create_file(
|
|
repository,
|
|
safe_path,
|
|
branch=safe_branch,
|
|
content=safe_content,
|
|
message=safe_message,
|
|
)
|
|
operation = "created"
|
|
else:
|
|
payload = self._writer().update_file(
|
|
repository,
|
|
safe_path,
|
|
branch=safe_branch,
|
|
content=safe_content,
|
|
message=safe_message,
|
|
sha=expected_sha,
|
|
)
|
|
operation = "updated"
|
|
content_payload = payload.get("content")
|
|
commit_payload = payload.get("commit")
|
|
content_result = content_payload if isinstance(content_payload, dict) else {}
|
|
commit_result = commit_payload if isinstance(commit_payload, dict) else {}
|
|
data = {
|
|
"repositoryRef": repository_ref,
|
|
"path": "/".join(safe_path),
|
|
"branch": safe_branch,
|
|
"contentSha": str(content_result.get("sha", ""))[:64],
|
|
"commitSha": str(commit_result.get("sha", ""))[:64],
|
|
"operation": operation,
|
|
}
|
|
self._invalidate_repository_cache(repository)
|
|
return self._envelope(context, data, source=f"gitea:{repository_ref}")
|
|
|
|
@governed_tool("gitea.file.create")
|
|
def file_create(
|
|
self,
|
|
context: ConnectorContext,
|
|
repository_ref: str,
|
|
path: str,
|
|
branch: str,
|
|
content: str,
|
|
message: str,
|
|
) -> dict[str, Any]:
|
|
return self._file_write(
|
|
context, repository_ref, path, branch, content, message, expected_sha=None
|
|
)
|
|
|
|
@governed_tool("gitea.file.update")
|
|
def file_update(
|
|
self,
|
|
context: ConnectorContext,
|
|
repository_ref: str,
|
|
path: str,
|
|
branch: str,
|
|
content: str,
|
|
message: str,
|
|
expected_sha: str,
|
|
) -> dict[str, Any]:
|
|
return self._file_write(
|
|
context,
|
|
repository_ref,
|
|
path,
|
|
branch,
|
|
content,
|
|
message,
|
|
expected_sha=expected_sha,
|
|
)
|
|
|
|
@governed_tool("gitea.file.delete")
|
|
def file_delete(
|
|
self,
|
|
context: ConnectorContext,
|
|
repository_ref: str,
|
|
path: str,
|
|
branch: str,
|
|
message: str,
|
|
expected_sha: str,
|
|
) -> dict[str, Any]:
|
|
candidate_branch = _validate_ref(branch)
|
|
safe_path = _validate_file_path(path)
|
|
safe_message = _validate_text(message, field="commit message", minimum=1, maximum=1_000)
|
|
if not _SHA.fullmatch(expected_sha):
|
|
raise GiteaError("GITEA_TARGET_INVALID", "expected file SHA is invalid")
|
|
repository = self._repo(context, repository_ref)
|
|
metadata = self.client.repository(repository)
|
|
safe_branch = _validate_work_branch(
|
|
candidate_branch, str(metadata.get("default_branch", ""))
|
|
)
|
|
payload = self._writer().delete_file(
|
|
repository,
|
|
safe_path,
|
|
branch=safe_branch,
|
|
message=safe_message,
|
|
sha=expected_sha,
|
|
)
|
|
commit_payload = payload.get("commit")
|
|
commit_result = commit_payload if isinstance(commit_payload, dict) else {}
|
|
self._invalidate_repository_cache(repository)
|
|
return self._envelope(
|
|
context,
|
|
{
|
|
"repositoryRef": repository_ref,
|
|
"path": "/".join(safe_path),
|
|
"branch": safe_branch,
|
|
"commitSha": str(commit_result.get("sha", ""))[:64],
|
|
"operation": "deleted",
|
|
},
|
|
source=f"gitea:{repository_ref}",
|
|
)
|
|
|
|
@governed_tool("gitea.validation.run")
|
|
def validation_run(
|
|
self,
|
|
context: ConnectorContext,
|
|
repository_ref: str,
|
|
branch: str,
|
|
profile: str,
|
|
) -> dict[str, Any]:
|
|
if profile not in {"test", "lint", "typecheck", "build", "security", "full"}:
|
|
raise GiteaError("GITEA_INPUT_INVALID", "validation profile is not allowlisted")
|
|
repository = self._repo(context, repository_ref)
|
|
safe_branch = _validate_ref(branch)
|
|
workflows = self.client.action_workflows(repository)
|
|
workflow = _select_validation_workflow(workflows)
|
|
if workflow is None:
|
|
raise GiteaError(
|
|
"GITEA_VALIDATION_UNAVAILABLE",
|
|
"repository has no allowlisted managed validation workflow",
|
|
)
|
|
workflow_id = str(workflow.get("id", workflow.get("path", "")))
|
|
source_repository_id = max(
|
|
int(workflow.get("source_repository_id", workflow.get("source_repo_id", 0)) or 0), 0
|
|
)
|
|
if not workflow_id or len(workflow_id) > 2_048:
|
|
raise GiteaError("GITEA_INVALID_RESPONSE", "workflow id is invalid")
|
|
branch_payload = self.client.branch(repository, safe_branch)
|
|
branch_commit = branch_payload.get("commit")
|
|
branch_head_sha = str(
|
|
branch_commit.get("id", "") if isinstance(branch_commit, dict) else ""
|
|
)
|
|
if not _SHA.fullmatch(branch_head_sha):
|
|
raise GiteaError("GITEA_INVALID_RESPONSE", "branch head SHA is invalid")
|
|
cache_key = (repository.qualified_name, branch_head_sha.casefold(), profile, workflow_id)
|
|
|
|
# A single connector can receive the same retry from multiple MCP clients.
|
|
# Serialize the small dispatch decision so those retries cannot create
|
|
# duplicate workflow runs before the first response reaches the caller.
|
|
with self._validation_dispatch_lock:
|
|
now = time.monotonic()
|
|
deduplication_source = "dispatch"
|
|
for key, (created_at, _) in tuple(self._validation_dispatches.items()):
|
|
if now - created_at >= _VALIDATION_REUSE_SECONDS:
|
|
self._validation_dispatches.pop(key, None)
|
|
cached = self._validation_dispatches.get(cache_key)
|
|
if cached is not None:
|
|
current = self._action_run_projection(self.client.action_run(repository, cached[1]))
|
|
status = str(current.get("status", "")).casefold()
|
|
conclusion = str(current.get("conclusion", "")).casefold()
|
|
reusable = status != "completed" or conclusion == "success"
|
|
if reusable and str(current.get("headSha", "")).casefold() == cache_key[1]:
|
|
data = current
|
|
dispatched = False
|
|
deduplication_source = "memory"
|
|
else:
|
|
self._validation_dispatches.pop(cache_key, None)
|
|
cached = None
|
|
if cached is None:
|
|
durable = self._find_reusable_validation_run(
|
|
repository, safe_branch, branch_head_sha, profile, workflow
|
|
)
|
|
if durable is not None:
|
|
data = durable
|
|
run_id = max(int(data.get("id", 0)), 0)
|
|
if run_id:
|
|
self._validation_dispatches[cache_key] = (now, run_id)
|
|
dispatched = False
|
|
deduplication_source = "gitea-actions"
|
|
cached = (now, run_id)
|
|
if cached is None:
|
|
payload = self._writer().dispatch_action_workflow(
|
|
repository,
|
|
workflow_id,
|
|
ref=safe_branch,
|
|
inputs={"profile": profile},
|
|
source_repository_id=source_repository_id,
|
|
)
|
|
data = self._action_run_projection(payload)
|
|
run_id = max(int(data.get("id", 0)), 0)
|
|
if run_id:
|
|
actual_head = str(data.get("headSha", branch_head_sha)).casefold()
|
|
actual_key = (repository.qualified_name, actual_head, profile, workflow_id)
|
|
self._validation_dispatches[actual_key] = (now, run_id)
|
|
dispatched = True
|
|
data.update(
|
|
{
|
|
"repositoryRef": repository_ref,
|
|
"branch": safe_branch,
|
|
"profile": profile,
|
|
"workflowId": workflow_id,
|
|
"sourceRepositoryId": source_repository_id,
|
|
"dispatched": dispatched,
|
|
"reused": not dispatched,
|
|
"deduplicatedRunId": None if dispatched else max(int(data.get("id", 0)), 0),
|
|
"deduplicationSource": deduplication_source,
|
|
"reuseWindowSeconds": _VALIDATION_REUSE_SECONDS,
|
|
**_validation_polling(data, str(data.get("headSha", ""))),
|
|
}
|
|
)
|
|
return self._envelope(context, data, source=f"gitea:{repository_ref}")
|
|
|
|
@governed_tool("gitea.pullrequest.create")
|
|
def pull_request_create(
|
|
self,
|
|
context: ConnectorContext,
|
|
repository_ref: str,
|
|
head: str,
|
|
base: str,
|
|
title: str,
|
|
body: str = "",
|
|
) -> dict[str, Any]:
|
|
repository = self._repo(context, repository_ref)
|
|
safe_head = _validate_ref(head)
|
|
safe_base = _validate_ref(base)
|
|
if safe_head == safe_base:
|
|
raise GiteaError("GITEA_TARGET_INVALID", "pull request head and base must differ")
|
|
payload = self._writer().create_pull_request(
|
|
repository,
|
|
head=safe_head,
|
|
base=safe_base,
|
|
title=_validate_text(title, field="pull request title", minimum=1, maximum=500),
|
|
body=_validate_text(body, field="pull request body", minimum=0, maximum=32_000),
|
|
)
|
|
data = self._pull_request_projection(payload)
|
|
data["repositoryRef"] = repository_ref
|
|
data["created"] = True
|
|
return self._envelope(context, data, source=f"gitea:{repository_ref}")
|
|
|
|
@governed_tool("gitea.pullrequest.update")
|
|
def pull_request_update(
|
|
self,
|
|
context: ConnectorContext,
|
|
repository_ref: str,
|
|
number: int,
|
|
expected_head_sha: str,
|
|
title: str | None = None,
|
|
body: str | None = None,
|
|
) -> dict[str, Any]:
|
|
if not 1 <= number <= 2_147_483_647:
|
|
raise GiteaError("GITEA_TARGET_INVALID", "pull request number is invalid")
|
|
if not _SHA.fullmatch(expected_head_sha):
|
|
raise GiteaError("GITEA_TARGET_INVALID", "expected pull request head SHA is invalid")
|
|
if title is None and body is None:
|
|
raise GiteaError("GITEA_INPUT_INVALID", "title or body must be provided")
|
|
repository = self._repo(context, repository_ref)
|
|
current = self.client.pull_request(repository, number)
|
|
head_value = current.get("head")
|
|
head: dict[str, Any] = head_value if isinstance(head_value, dict) else {}
|
|
_validate_ref(str(head.get("ref", "")))
|
|
if str(head.get("sha", "")) != expected_head_sha:
|
|
raise GiteaError("GITEA_CONFLICT", "pull request head changed; refresh before updating")
|
|
if str(current.get("state", "")) != "open":
|
|
raise GiteaError("GITEA_TARGET_INVALID", "only an open pull request can be updated")
|
|
payload = self._writer().update_pull_request(
|
|
repository,
|
|
number,
|
|
title=(
|
|
_validate_text(title, field="pull request title", minimum=1, maximum=500)
|
|
if title is not None
|
|
else None
|
|
),
|
|
body=(
|
|
_validate_text(body, field="pull request body", minimum=0, maximum=32_000)
|
|
if body is not None
|
|
else None
|
|
),
|
|
)
|
|
data = self._pull_request_projection(payload)
|
|
data.update({"repositoryRef": repository_ref, "updated": True})
|
|
return self._envelope(context, data, source=f"gitea:{repository_ref}")
|
|
|
|
@governed_tool("gitea.pullrequest.validation.ensure")
|
|
def pull_request_validation_ensure(
|
|
self,
|
|
context: ConnectorContext,
|
|
repository_ref: str,
|
|
number: int,
|
|
expected_head_sha: str,
|
|
) -> dict[str, Any]:
|
|
"""Reuse or retrigger a real full pull_request run for the exact PR head."""
|
|
if not 1 <= number <= 2_147_483_647 or not _SHA.fullmatch(expected_head_sha):
|
|
raise GiteaError("GITEA_TARGET_INVALID", "PR number or expected head is invalid")
|
|
repository = self._repo(context, repository_ref)
|
|
pull = self.client.pull_request(repository, number)
|
|
head = pull.get("head") or {}
|
|
base = pull.get("base") or {}
|
|
if pull.get("state") != "open" or pull.get("draft"):
|
|
raise GiteaError("GITEA_TARGET_INVALID", "PR must be open and non-draft")
|
|
if head.get("sha") != expected_head_sha:
|
|
raise GiteaError("GITEA_CONFLICT", "PR head changed before validation ensure")
|
|
default_branch = str(self.client.repository(repository).get("default_branch"))
|
|
if str(base.get("ref", "")) != default_branch:
|
|
raise GiteaError("GITEA_TARGET_INVALID", "PR does not target the default branch")
|
|
protection = self.client.branch_protection(repository, str(base.get("ref", "")))
|
|
if not protection.get("enable_status_check") or MANAGED_VALIDATION_CONTEXT not in (
|
|
protection.get("status_check_contexts") or []
|
|
):
|
|
raise GiteaError("GITEA_PROTECTION_DRIFT", "canonical PR check is not required")
|
|
|
|
workflow = _select_validation_workflow(self.client.action_workflows(repository))
|
|
safe_branch = _validate_ref(str(head.get("ref", "")))
|
|
|
|
def exact_runs() -> list[dict[str, Any]]:
|
|
runs = self._runs_for_workflow(
|
|
self.client.exact_head_action_runs(
|
|
repository,
|
|
branch=safe_branch,
|
|
head_sha=expected_head_sha,
|
|
pull_request_number=number,
|
|
),
|
|
workflow,
|
|
)
|
|
return [
|
|
self._action_run_projection(item)
|
|
for item in runs
|
|
if item.get("event") == "pull_request"
|
|
]
|
|
|
|
runs = exact_runs()
|
|
statuses = self.client.commit_statuses(repository, expected_head_sha, 1, 100)
|
|
current_status = next(
|
|
(item for item in statuses if item.get("context") == MANAGED_VALIDATION_CONTEXT),
|
|
None,
|
|
)
|
|
for run in runs:
|
|
run_id = int(run["id"])
|
|
if (
|
|
current_status
|
|
and current_status.get("status") == "success"
|
|
and re.search(
|
|
rf"/actions/runs/{run_id}(?:/|$)", str(current_status.get("target_url", ""))
|
|
)
|
|
and run.get("status") == "completed"
|
|
and run.get("conclusion") == "success"
|
|
and any(
|
|
job.get("name") == "full" and job.get("conclusion") == "success"
|
|
for job in self.client.action_run_jobs(repository, run_id)
|
|
)
|
|
):
|
|
return self._envelope(
|
|
context,
|
|
{
|
|
"repositoryRef": repository_ref,
|
|
"pr": number,
|
|
"headSha": expected_head_sha,
|
|
"requiredContext": MANAGED_VALIDATION_CONTEXT,
|
|
"state": "success",
|
|
"runId": run_id,
|
|
"event": "pull_request",
|
|
"reused": True,
|
|
},
|
|
source=f"gitea:{repository_ref}",
|
|
)
|
|
if run.get("status") != "completed":
|
|
return self._envelope(
|
|
context,
|
|
{
|
|
"repositoryRef": repository_ref,
|
|
"pr": number,
|
|
"headSha": expected_head_sha,
|
|
"requiredContext": MANAGED_VALIDATION_CONTEXT,
|
|
"state": "pending",
|
|
"runId": run_id,
|
|
"event": "pull_request",
|
|
"reused": True,
|
|
},
|
|
source=f"gitea:{repository_ref}",
|
|
)
|
|
break
|
|
|
|
if runs:
|
|
run_id = int(runs[0]["id"])
|
|
self._writer().rerun_action_run(repository, run_id)
|
|
triggered = self.client.action_run(repository, run_id)
|
|
if (
|
|
triggered.get("event") != "pull_request"
|
|
or triggered.get("head_sha") != expected_head_sha
|
|
):
|
|
raise GiteaError(
|
|
"GITEA_PR_VALIDATION_REQUIRED",
|
|
"rerun did not preserve the exact pull_request validation identity",
|
|
)
|
|
trigger_method = "rerun_pull_request"
|
|
else:
|
|
# Gitea's default pull_request trigger includes reopened, but not edited.
|
|
# Reopening creates a genuine PR event without changing the head commit.
|
|
before_close = self.client.pull_request(repository, number)
|
|
if (
|
|
before_close.get("state") != "open"
|
|
or (before_close.get("head") or {}).get("sha") != expected_head_sha
|
|
or (before_close.get("base") or {}).get("sha") != base.get("sha")
|
|
):
|
|
raise GiteaError(
|
|
"GITEA_CONCURRENT_MODIFICATION",
|
|
"PR moved before validation trigger",
|
|
)
|
|
self._writer().close_pull_request(repository, number)
|
|
try:
|
|
self._writer().reopen_pull_request(repository, number)
|
|
except GiteaError as exc:
|
|
raise GiteaError(
|
|
"GITEA_PR_REOPEN_FAILED",
|
|
"PR was closed but could not be reopened; retry the exact PR reopen",
|
|
retryable=True,
|
|
safe_to_retry=True,
|
|
) from exc
|
|
trigger_method = "reopen_pull_request"
|
|
run_id = 0
|
|
for _ in range(10):
|
|
refreshed = self.client.pull_request(repository, number)
|
|
if (
|
|
refreshed.get("state") != "open"
|
|
or (refreshed.get("head") or {}).get("sha") != expected_head_sha
|
|
):
|
|
raise GiteaError(
|
|
"GITEA_CONCURRENT_MODIFICATION",
|
|
"PR changed while canonical validation was triggered",
|
|
)
|
|
found = exact_runs()
|
|
if found:
|
|
run_id = int(found[0]["id"])
|
|
break
|
|
time.sleep(0.5)
|
|
if not run_id:
|
|
raise GiteaError(
|
|
"GITEA_PR_RUN_NOT_VISIBLE",
|
|
"PR reopened but its validation run is not visible yet; retry ensure",
|
|
retryable=True,
|
|
safe_to_retry=True,
|
|
)
|
|
return self._envelope(
|
|
context,
|
|
{
|
|
"repositoryRef": repository_ref,
|
|
"pr": number,
|
|
"headSha": expected_head_sha,
|
|
"requiredContext": MANAGED_VALIDATION_CONTEXT,
|
|
"state": "pending",
|
|
"runId": run_id,
|
|
"event": "pull_request",
|
|
"reused": False,
|
|
"triggerMethod": trigger_method,
|
|
},
|
|
source=f"gitea:{repository_ref}",
|
|
)
|
|
|
|
@governed_tool("gitea.pullrequest.close")
|
|
def pull_request_close(
|
|
self,
|
|
context: ConnectorContext,
|
|
repository_ref: str,
|
|
number: int,
|
|
expected_head_sha: str,
|
|
) -> dict[str, Any]:
|
|
if not 1 <= number <= 2_147_483_647:
|
|
raise GiteaError("GITEA_TARGET_INVALID", "pull request number is invalid")
|
|
if not _SHA.fullmatch(expected_head_sha):
|
|
raise GiteaError("GITEA_TARGET_INVALID", "expected pull request head SHA is invalid")
|
|
repository = self._repo(context, repository_ref)
|
|
current = self.client.pull_request(repository, number)
|
|
head_value = current.get("head")
|
|
head: dict[str, Any] = head_value if isinstance(head_value, dict) else {}
|
|
_validate_ref(str(head.get("ref", "")))
|
|
if str(head.get("sha", "")) != expected_head_sha:
|
|
raise GiteaError("GITEA_CONFLICT", "pull request head changed; refresh before closing")
|
|
if str(current.get("state", "")) != "open":
|
|
raise GiteaError("GITEA_TARGET_INVALID", "only an open pull request can be closed")
|
|
payload = self._writer().close_pull_request(repository, number)
|
|
data = self._pull_request_projection(payload)
|
|
data.update({"repositoryRef": repository_ref, "closed": True})
|
|
return self._envelope(context, data, source=f"gitea:{repository_ref}")
|
|
|
|
@governed_tool("gitea.pullrequest.sync")
|
|
def pull_request_sync(
|
|
self,
|
|
context: ConnectorContext,
|
|
repository_ref: str,
|
|
number: int,
|
|
expected_head_sha: str,
|
|
expected_base_sha: str,
|
|
strategy: str,
|
|
dry_run: bool,
|
|
) -> dict[str, Any]:
|
|
"""Merge or rebase the exact current base into a PR branch without inventing conflicts."""
|
|
|
|
if not 1 <= number <= 2_147_483_647:
|
|
raise GiteaError("GITEA_TARGET_INVALID", "pull request number is invalid")
|
|
if not _SHA.fullmatch(expected_head_sha) or not _SHA.fullmatch(expected_base_sha):
|
|
raise GiteaError("GITEA_TARGET_INVALID", "expected pull request SHAs are invalid")
|
|
if strategy not in {"merge", "rebase"}:
|
|
raise GiteaError("GITEA_INPUT_INVALID", "sync strategy must be merge or rebase")
|
|
repository = self._repo(context, repository_ref)
|
|
metadata = self.client.repository(repository)
|
|
current = self._pull_request_projection(self.client.pull_request(repository, number))
|
|
head_ref = _validate_ref(str(current.get("headRef", "")))
|
|
if head_ref == str(metadata.get("default_branch", "")):
|
|
raise GiteaError("GITEA_TARGET_INVALID", "the default branch cannot be a sync target")
|
|
if current.get("state") != "open":
|
|
raise GiteaError("GITEA_TARGET_INVALID", "only an open pull request can be synced")
|
|
if current.get("headSha") != expected_head_sha:
|
|
raise GiteaError(
|
|
"GITEA_CONFLICT",
|
|
"pull request head changed; refresh before syncing",
|
|
expected_state=expected_head_sha,
|
|
actual_state=str(current.get("headSha", "")),
|
|
recovery_hint="Call gitea.change.reconcile and retry with the returned head SHA.",
|
|
)
|
|
if current.get("baseSha") != expected_base_sha:
|
|
raise GiteaError(
|
|
"GITEA_CONFLICT",
|
|
"pull request base changed; refresh before syncing",
|
|
expected_state=expected_base_sha,
|
|
actual_state=str(current.get("baseSha", "")),
|
|
recovery_hint="Call gitea.change.reconcile and retry with the returned base SHA.",
|
|
)
|
|
comparison_payload = self.client.compare(repository, expected_base_sha, expected_head_sha)
|
|
reverse_comparison_payload = self.client.compare(
|
|
repository, expected_head_sha, expected_base_sha
|
|
)
|
|
comparison = self._compact_comparison(
|
|
comparison_payload,
|
|
base=str(current.get("baseRef", "")),
|
|
head=head_ref,
|
|
reverse_payload=reverse_comparison_payload,
|
|
)
|
|
preview = {
|
|
"repositoryRef": repository_ref,
|
|
"number": number,
|
|
"branch": head_ref,
|
|
"strategy": strategy,
|
|
"dryRun": dry_run,
|
|
"expectedHeadSha": expected_head_sha,
|
|
"expectedBaseSha": expected_base_sha,
|
|
"mergeable": current.get("mergeable"),
|
|
"behindBy": comparison["behindBy"],
|
|
"conflictFiles": [],
|
|
"conflictDetailsAvailable": False,
|
|
"wouldChange": bool(comparison["behindBy"] or current.get("mergeable") is False),
|
|
"validationRequired": False,
|
|
"synced": False,
|
|
}
|
|
if dry_run:
|
|
return self._envelope(context, preview, source=f"gitea:{repository_ref}")
|
|
# Re-read immediately before the non-conditional Gitea update API. The
|
|
# upstream endpoint has no If-Match/SHA argument, so this second fence
|
|
# closes all connector-side work between check and use and fails closed
|
|
# on state observed to have moved. External writers can still race the
|
|
# upstream endpoint itself; expose that limitation as optimistic
|
|
# concurrency instead of claiming an atomic precondition.
|
|
before_update = self._pull_request_projection(self.client.pull_request(repository, number))
|
|
if (
|
|
before_update.get("headSha") != expected_head_sha
|
|
or before_update.get("baseSha") != expected_base_sha
|
|
):
|
|
raise GiteaError(
|
|
"GITEA_CONCURRENT_MODIFICATION",
|
|
"pull request state moved while synchronization was being prepared",
|
|
retryable=True,
|
|
safe_to_retry=True,
|
|
expected_state=f"{expected_head_sha}:{expected_base_sha}",
|
|
actual_state=(
|
|
f"{before_update.get('headSha', '')}:{before_update.get('baseSha', '')}"
|
|
),
|
|
recovery_hint="Call gitea.change.reconcile and request fresh confirmation.",
|
|
)
|
|
self._writer().update_pull_request_branch(repository, number, style=strategy)
|
|
self._invalidate_repository_cache(repository)
|
|
updated = self._pull_request_projection(self.client.pull_request(repository, number))
|
|
new_head_sha = str(updated.get("headSha", ""))
|
|
return self._envelope(
|
|
context,
|
|
{
|
|
**preview,
|
|
"dryRun": False,
|
|
"newHeadSha": new_head_sha,
|
|
"newBaseSha": updated.get("baseSha"),
|
|
"mergeable": updated.get("mergeable"),
|
|
"validationRequired": new_head_sha != expected_head_sha,
|
|
"synced": True,
|
|
"preconditionMode": "optimistic-double-read",
|
|
},
|
|
source=f"gitea:{repository_ref}",
|
|
)
|
|
|
|
def _require_merge_eligible(
|
|
self,
|
|
context: ConnectorContext,
|
|
repository_ref: str,
|
|
number: int,
|
|
expected_head_sha: str,
|
|
) -> tuple[RegisteredRepository, str, int, str]:
|
|
"""Authoritative server-side PR, protection, review and exact-run gate."""
|
|
repository = self._repo(context, repository_ref)
|
|
metadata = self.client.repository(repository)
|
|
current = self.client.pull_request(repository, number)
|
|
head_value = current.get("head")
|
|
base_value = current.get("base")
|
|
head: dict[str, Any] = head_value if isinstance(head_value, dict) else {}
|
|
base: dict[str, Any] = base_value if isinstance(base_value, dict) else {}
|
|
_validate_ref(str(head.get("ref", "")))
|
|
if str(head.get("sha", "")) != expected_head_sha:
|
|
raise GiteaError("GITEA_CONFLICT", "pull request head changed; refresh before merging")
|
|
if str(base.get("ref", "")) != str(metadata.get("default_branch", "")):
|
|
raise GiteaError(
|
|
"GITEA_TARGET_INVALID", "pull request must target the repository default branch"
|
|
)
|
|
if str(current.get("state", "")) != "open" or bool(current.get("draft", False)):
|
|
raise GiteaError("GITEA_TARGET_INVALID", "pull request must be open and non-draft")
|
|
if current.get("mergeable") is not True:
|
|
raise GiteaError("GITEA_CONFLICT", "pull request is not currently mergeable")
|
|
protection = self.client.branch_protection(repository, str(base.get("ref", "")))
|
|
required_contexts = set((protection or {}).get("status_check_contexts") or [])
|
|
if MANAGED_VALIDATION_CONTEXT not in required_contexts:
|
|
raise GiteaError(
|
|
"GITEA_PROTECTION_DRIFT",
|
|
"the default branch does not require the canonical full pull_request context",
|
|
)
|
|
if protection.get("enable_push") or protection.get("enable_force_push"):
|
|
raise GiteaError(
|
|
"GITEA_PROTECTION_DRIFT",
|
|
"the default branch permits direct or force pushes",
|
|
)
|
|
base_sha = str(base.get("sha", ""))
|
|
current_base = self.client.branch(repository, str(base.get("ref", "")))
|
|
current_base_sha = str((current_base.get("commit") or {}).get("id", ""))
|
|
if not _SHA.fullmatch(base_sha) or current_base_sha != base_sha:
|
|
raise GiteaError(
|
|
"GITEA_BASE_CHANGED",
|
|
"the default branch changed; refresh and synchronize the pull request",
|
|
expected_state=base_sha,
|
|
actual_state=current_base_sha,
|
|
)
|
|
if protection.get("block_on_outdated_branch"):
|
|
behind = self.client.compare(repository, expected_head_sha, current_base_sha)
|
|
if int(behind.get("total_commits", 0) or 0) > 0:
|
|
raise GiteaError(
|
|
"GITEA_BRANCH_OUTDATED",
|
|
"the pull request branch is behind the protected default branch",
|
|
)
|
|
reviews = self.client.pull_request_reviews(repository, number, 1, 100)
|
|
if len(reviews) == 100:
|
|
raise GiteaError("GITEA_REVIEW_TRUNCATED", "review history exceeds the safe bound")
|
|
latest_reviews: dict[str, dict[str, Any]] = {}
|
|
for review in sorted(
|
|
reviews,
|
|
key=lambda item: (str(item.get("submitted_at", "")), int(item.get("id", 0))),
|
|
):
|
|
reviewer = str((review.get("user") or {}).get("login", "")).casefold()
|
|
if reviewer:
|
|
latest_reviews[reviewer] = review
|
|
if protection.get("block_on_rejected_reviews") and any(
|
|
str(item.get("state", "")).upper() in {"REQUEST_CHANGES", "REJECTED"}
|
|
for item in latest_reviews.values()
|
|
):
|
|
raise GiteaError("GITEA_BLOCKING_REVIEW", "a reviewer requested changes")
|
|
required_approvals = max(int(protection.get("required_approvals", 0) or 0), 0)
|
|
approval_count = sum(
|
|
str(item.get("state", "")).upper() == "APPROVED" for item in latest_reviews.values()
|
|
)
|
|
if approval_count < required_approvals:
|
|
raise GiteaError(
|
|
"GITEA_APPROVAL_REQUIRED",
|
|
f"{required_approvals} approvals required; {approval_count} current",
|
|
)
|
|
statuses = self.client.commit_statuses(repository, expected_head_sha, 1, 100)
|
|
if len(statuses) == 100:
|
|
raise GiteaError(
|
|
"GITEA_STATUS_TRUNCATED", "commit status history exceeds the safe bound"
|
|
)
|
|
for required_context in sorted(required_contexts - {MANAGED_VALIDATION_CONTEXT}):
|
|
required_status = next(
|
|
(item for item in statuses if item.get("context") == required_context), None
|
|
)
|
|
if required_status is None or required_status.get("status") != "success":
|
|
raise GiteaError(
|
|
"GITEA_REQUIRED_CHECK_BLOCKED",
|
|
f"required status {required_context!r} is not successful on the exact head",
|
|
)
|
|
canonical_status = next(
|
|
(item for item in statuses if item.get("context") == MANAGED_VALIDATION_CONTEXT),
|
|
None,
|
|
)
|
|
if canonical_status is None or canonical_status.get("status") != "success":
|
|
raise GiteaError(
|
|
"GITEA_PR_CHECK_MISSING"
|
|
if canonical_status is None
|
|
else "GITEA_PR_CHECK_FAILED"
|
|
if canonical_status.get("status") in {"failure", "error"}
|
|
else "GITEA_PR_CHECK_PENDING",
|
|
"the canonical full pull_request status is not successful on the exact head",
|
|
)
|
|
run_url = str(canonical_status.get("target_url", ""))
|
|
run_match = re.search(r"/actions/runs/([1-9][0-9]*)(?:/|$)", run_url)
|
|
if run_match is None:
|
|
raise GiteaError(
|
|
"GITEA_PR_VALIDATION_REQUIRED",
|
|
"the required status has no verifiable canonical validation run",
|
|
)
|
|
raw_run = self.client.action_run(repository, int(run_match[1]))
|
|
run = self._action_run_projection(raw_run)
|
|
workflow = _select_validation_workflow(self.client.action_workflows(repository))
|
|
canonical_run = bool(self._runs_for_workflow([raw_run], workflow))
|
|
canonical_run = canonical_run and self.client.matches_exact_head_action_run(
|
|
raw_run,
|
|
branch=str(head.get("ref", "")),
|
|
head_sha=expected_head_sha,
|
|
pull_request_number=number,
|
|
)
|
|
jobs = self.client.action_run_jobs(repository, int(run_match[1]))
|
|
full_job = next((job for job in jobs if job.get("name") == "full"), None)
|
|
if (
|
|
int(run.get("id", 0)) != int(run_match[1])
|
|
or str(run.get("headSha", "")) != expected_head_sha
|
|
or str(run.get("event", "")) != "pull_request"
|
|
or str(run.get("status", "")) != "completed"
|
|
or str(run.get("conclusion", "")) != "success"
|
|
or not canonical_run
|
|
or not isinstance(full_job, dict)
|
|
or full_job.get("status") != "completed"
|
|
or full_job.get("conclusion") != "success"
|
|
):
|
|
raise GiteaError(
|
|
"GITEA_PR_VALIDATION_REQUIRED",
|
|
"the required status does not identify a successful canonical "
|
|
"pull_request run on the exact head",
|
|
)
|
|
return repository, base_sha, int(run_match[1]), str(base.get("ref", ""))
|
|
|
|
@governed_tool("gitea.pullrequest.merge")
|
|
def pull_request_merge(
|
|
self,
|
|
context: ConnectorContext,
|
|
repository_ref: str,
|
|
number: int,
|
|
expected_head_sha: str,
|
|
method: str = "squash",
|
|
) -> dict[str, Any]:
|
|
if not 1 <= number <= 2_147_483_647:
|
|
raise GiteaError("GITEA_TARGET_INVALID", "pull request number is invalid")
|
|
if not _SHA.fullmatch(expected_head_sha):
|
|
raise GiteaError("GITEA_TARGET_INVALID", "expected pull request head SHA is invalid")
|
|
if method not in {"merge", "rebase", "rebase-merge", "squash", "fast-forward-only"}:
|
|
raise GiteaError("GITEA_INPUT_INVALID", "merge method is not allowlisted")
|
|
repository, base_sha, run_id, base_ref = self._require_merge_eligible(
|
|
context, repository_ref, number, expected_head_sha
|
|
)
|
|
before_merge = self.client.pull_request(repository, number)
|
|
before_head = before_merge.get("head") or {}
|
|
before_base = before_merge.get("base") or {}
|
|
latest_base = self.client.branch(repository, base_ref)
|
|
if (
|
|
before_head.get("sha") != expected_head_sha
|
|
or before_base.get("sha") != base_sha
|
|
or str((latest_base.get("commit") or {}).get("id", "")) != base_sha
|
|
):
|
|
raise GiteaError(
|
|
"GITEA_CONCURRENT_MODIFICATION",
|
|
"pull request head or protected base changed before merge",
|
|
)
|
|
try:
|
|
self._writer().merge_pull_request(
|
|
repository, number, method=method, head_commit_id=expected_head_sha
|
|
)
|
|
except GiteaError as exc:
|
|
if exc.code == "GITEA_STATE_TRANSITION_DENIED":
|
|
raise GiteaError(
|
|
"GITEA_UPSTREAM_MERGE_REJECTED",
|
|
"Gitea rejected the guarded merge after eligibility verification; "
|
|
"refresh the PR and required checks",
|
|
) from exc
|
|
raise
|
|
self._invalidate_repository_cache(repository)
|
|
merged = self._pull_request_projection(self.client.pull_request(repository, number))
|
|
merge_sha = str(merged.get("mergeCommitSha", ""))
|
|
resulting_main_sha = str(
|
|
(self.client.branch(repository, base_ref).get("commit") or {}).get("id", "")
|
|
)
|
|
if not merged.get("merged") or not _SHA.fullmatch(resulting_main_sha):
|
|
raise GiteaError(
|
|
"GITEA_MERGE_RESULT_UNVERIFIED",
|
|
"Gitea accepted the merge but the resulting default branch could not be verified",
|
|
)
|
|
merged.update(
|
|
{
|
|
"repositoryRef": repository_ref,
|
|
"mergeMethod": method,
|
|
"validationRunId": run_id,
|
|
"resultingMainSha": resulting_main_sha,
|
|
"merged": True,
|
|
"auditEvidence": {
|
|
"repositoryRef": repository_ref,
|
|
"pr": number,
|
|
"headSha": expected_head_sha,
|
|
"baseSha": base_sha,
|
|
"requiredContext": MANAGED_VALIDATION_CONTEXT,
|
|
"validationRunId": run_id,
|
|
"validationEvent": "pull_request",
|
|
"validationConclusion": "success",
|
|
"mergeMethod": method,
|
|
"mergeCommitSha": merge_sha,
|
|
"resultingMainSha": resulting_main_sha,
|
|
"occurredAt": datetime.now(UTC).isoformat(),
|
|
},
|
|
}
|
|
)
|
|
return self._envelope(context, merged, source=f"gitea:{repository_ref}")
|
|
|
|
@governed_tool("gitea.branch.delete")
|
|
def branch_delete(
|
|
self,
|
|
context: ConnectorContext,
|
|
repository_ref: str,
|
|
branch: str,
|
|
expected_head_sha: str,
|
|
) -> dict[str, Any]:
|
|
safe_branch = _validate_ref(branch)
|
|
if not _SHA.fullmatch(expected_head_sha):
|
|
raise GiteaError("GITEA_TARGET_INVALID", "expected branch head SHA is invalid")
|
|
repository = self._repo(context, repository_ref)
|
|
metadata = self.client.repository(repository)
|
|
if safe_branch == str(metadata.get("default_branch", "")):
|
|
raise GiteaError("GITEA_TARGET_INVALID", "the default branch cannot be deleted")
|
|
current = self.client.branch(repository, safe_branch)
|
|
current_sha = str((current.get("commit") or {}).get("id", ""))
|
|
if current_sha != expected_head_sha:
|
|
raise GiteaError("GITEA_CONFLICT", "branch head changed; refresh before deleting")
|
|
merged_pull, closed_pull = cleanup_basis(
|
|
self.client, repository, safe_branch, expected_head_sha
|
|
)
|
|
before_delete = self.client.branch(repository, safe_branch)
|
|
before_delete_sha = str((before_delete.get("commit") or {}).get("id", ""))
|
|
if before_delete_sha != expected_head_sha:
|
|
raise GiteaError(
|
|
"GITEA_CONCURRENT_MODIFICATION",
|
|
"branch head moved while cleanup was being prepared",
|
|
expected_state=expected_head_sha,
|
|
actual_state=before_delete_sha,
|
|
recovery_hint="Refresh the branch and request fresh cleanup confirmation.",
|
|
)
|
|
merged_pull, closed_pull = cleanup_basis(
|
|
self.client, repository, safe_branch, expected_head_sha
|
|
)
|
|
cleanup_disposition = "merged" if merged_pull is not None else "closed-unmerged"
|
|
self._writer().delete_branch(repository, safe_branch)
|
|
self._invalidate_repository_cache(repository)
|
|
return self._envelope(
|
|
context,
|
|
{
|
|
"repositoryRef": repository_ref,
|
|
"branch": safe_branch,
|
|
"deletedHeadSha": expected_head_sha,
|
|
"mergedPullRequest": (
|
|
max(int(merged_pull.get("number", 0)), 0) if merged_pull is not None else 0
|
|
),
|
|
"closedPullRequest": (
|
|
max(int(closed_pull.get("number", 0)), 0) if closed_pull is not None else 0
|
|
),
|
|
"cleanupDisposition": cleanup_disposition,
|
|
"preconditionMode": "optimistic-double-read",
|
|
"deleted": True,
|
|
},
|
|
source=f"gitea:{repository_ref}",
|
|
)
|
|
|
|
def tools(self) -> tuple[ToolPublication, ...]:
|
|
return GITEA_TOOLS
|
|
|
|
def write_tools(self) -> tuple[ToolPublication, ...]:
|
|
"""Return feature-gated PR-first publications for a controlled-operations release."""
|
|
return GITEA_PR_WRITE_TOOLS + GITEA_LIFECYCLE_WRITE_TOOLS
|
|
|
|
def resources(self) -> tuple[ResourcePublication, ...]:
|
|
return GITEA_RESOURCES
|
|
|
|
def _tool_handlers(self) -> dict[str, Callable[..., dict[str, Any]]]:
|
|
return {
|
|
"gitea.capabilities.get": self.capabilities_get,
|
|
"gitea.connection.check": self.connection_check,
|
|
"gitea.selftest.run": self.selftest_run,
|
|
"gitea.repository.list": self.repository_list,
|
|
"gitea.repository.get": self.repository_get,
|
|
"gitea.repository.context": self.repository_context,
|
|
"gitea.repository.health": self.repository_health,
|
|
"gitea.branchprotection.get": self.branch_protection_get,
|
|
"gitea.branchprotection.preview": self.branch_protection_preview,
|
|
"gitea.branchprotection.ensure": self.branch_protection_ensure,
|
|
"gitea.branch.list": self.branch_list,
|
|
"gitea.commit.list": self.commit_list,
|
|
"gitea.commit.get": self.commit_get,
|
|
"gitea.ref.compare": self.ref_compare,
|
|
"gitea.release.list": self.release_list,
|
|
"gitea.issue.list": self.issue_list,
|
|
"gitea.issue.search": self.issue_search,
|
|
"gitea.pullrequest.list": self.pull_request_list,
|
|
"gitea.pullrequest.get": self.pull_request_get,
|
|
"gitea.pullrequest.feedback": self.pull_request_feedback,
|
|
"gitea.commitstatus.list": self.commit_status_list,
|
|
"gitea.file.read": self.file_read,
|
|
"gitea.tree.list": self.tree_list,
|
|
"gitea.code.search": self.code_search,
|
|
"gitea.research.search": self.research_search,
|
|
"gitea.research.fetch": self.research_fetch,
|
|
"gitea.changeset.preview": self.changeset_preview,
|
|
"gitea.validation.get": self.validation_get,
|
|
"gitea.validation.failure.get": self.validation_failure_get,
|
|
"gitea.validation.list": self.validation_list,
|
|
"gitea.change.status": self.change_status,
|
|
"gitea.change.review": self.change_review,
|
|
"gitea.change.reconcile": self.change_reconcile,
|
|
"gitea.workspace.status": self.workspace_status,
|
|
"gitea.portfolio.health": self.portfolio_health,
|
|
"gitea.portfolio.governance.preview": self.portfolio_governance_preview,
|
|
"gitea.portfolio.governance.apply": self.portfolio_governance_apply,
|
|
"gitea.portfolio.governance.verify": self.portfolio_governance_verify,
|
|
"gitea.orchestration.plan": self.orchestration_plan,
|
|
"gitea.repository.create": self.repository_create,
|
|
"gitea.branch.create": self.branch_create,
|
|
"gitea.issue.create": self.issue_create,
|
|
"gitea.issue.update": self.issue_update,
|
|
"gitea.issue.comment.create": self.issue_comment_create,
|
|
"gitea.file.create": self.file_create,
|
|
"gitea.file.update": self.file_update,
|
|
"gitea.file.delete": self.file_delete,
|
|
"gitea.changeset.apply": self.changeset_apply,
|
|
"gitea.validation.run": self.validation_run,
|
|
"gitea.workspace.run": self.workspace_run,
|
|
"gitea.orchestration.start": self.orchestration_start,
|
|
"gitea.pullrequest.create": self.pull_request_create,
|
|
"gitea.pullrequest.update": self.pull_request_update,
|
|
"gitea.pullrequest.close": self.pull_request_close,
|
|
"gitea.pullrequest.validation.ensure": self.pull_request_validation_ensure,
|
|
"gitea.pullrequest.sync": self.pull_request_sync,
|
|
"gitea.pullrequest.merge": self.pull_request_merge,
|
|
"gitea.branch.delete": self.branch_delete,
|
|
}
|
|
|
|
def invoke(self, tool: str, context: ConnectorContext, **arguments: Any) -> dict[str, Any]:
|
|
method = self._tool_handlers().get(tool)
|
|
if method is None:
|
|
raise ValueError("unknown gitea tool")
|
|
return method(context, **arguments)
|
|
|
|
def health(self) -> dict[str, Any]:
|
|
return {
|
|
"status": "healthy",
|
|
"component": "gitea-connector",
|
|
"readOnly": self.write_client is None,
|
|
"writeMode": "pr-first" if self.write_client is not None else "disabled",
|
|
"atomicChangesets": self.changeset_workspace is not None,
|
|
"discoveryProjects": len(self.discovery_projects),
|
|
"discoveryCacheSeconds": _DISCOVERY_CACHE_SECONDS,
|
|
}
|
|
|
|
def readiness(self) -> dict[str, Any]:
|
|
now = time.monotonic()
|
|
with self._readiness_lock:
|
|
if self._readiness_cache is not None and now - self._readiness_cache[0] < 30:
|
|
return copy.deepcopy(self._readiness_cache[1])
|
|
repositories = self.client.accessible_private_repositories(1, 1)
|
|
if not repositories:
|
|
raise GiteaError("GITEA_NOT_READY", "Gitea discovery returned no repositories")
|
|
publications = list(self.tools())
|
|
if self.write_client is not None:
|
|
publications.extend(self.write_tools())
|
|
descriptors = sorted(
|
|
[
|
|
{
|
|
"name": item.canonical_name,
|
|
"version": item.version,
|
|
"riskClass": item.risk_class,
|
|
"inputSchema": item.input_schema,
|
|
}
|
|
for item in publications
|
|
],
|
|
key=lambda item: str(item["name"]),
|
|
)
|
|
runtime = runtime_snapshot()
|
|
upstream = (self.write_client or self.client).upstream_compatibility()
|
|
snapshot = {
|
|
"status": "ready",
|
|
"component": "gitea-connector",
|
|
"readOnly": self.write_client is None,
|
|
"writeMode": "pr-first" if self.write_client is not None else "disabled",
|
|
"atomicChangesets": self.changeset_workspace is not None,
|
|
"upstream": "unavailable"
|
|
if upstream["compatibility"] == "UNAVAILABLE"
|
|
else "reachable",
|
|
"connectorVersion": self.version,
|
|
"upstreamGitea": upstream,
|
|
"toolCount": len(publications),
|
|
"capabilityFingerprint": hashlib.sha256(
|
|
json.dumps(descriptors, sort_keys=True, separators=(",", ":")).encode()
|
|
).hexdigest(),
|
|
"probeCacheSeconds": 30,
|
|
"runtime": runtime,
|
|
"subsystems": {
|
|
"runtime": "READY",
|
|
"dispatcher": "READY",
|
|
"worker": ("READY" if runtime["circuitState"] == "CLOSED" else "DEGRADED"),
|
|
"session": "RESUMABLE",
|
|
"upstream_gitea": (
|
|
"READY"
|
|
if upstream["compatibility"] == "SUPPORTED"
|
|
else "UNAVAILABLE"
|
|
if upstream["compatibility"] == "UNAVAILABLE"
|
|
else "UNVERIFIED"
|
|
),
|
|
"merge": "READY"
|
|
if upstream["compatibility"] == "SUPPORTED" and self.write_client
|
|
else "UNAVAILABLE",
|
|
},
|
|
}
|
|
with self._readiness_lock:
|
|
self._readiness_cache = (now, copy.deepcopy(snapshot))
|
|
return snapshot
|
|
|
|
|
|
_PAGE_INPUT: dict[str, Any] = {
|
|
"type": "object",
|
|
"additionalProperties": False,
|
|
"properties": {
|
|
"repositoryRef": {"type": "string"},
|
|
"limit": {"type": "integer", "minimum": 1, "maximum": 100},
|
|
"cursor": {"type": ["string", "null"], "maxLength": 500},
|
|
},
|
|
"required": ["repositoryRef"],
|
|
}
|
|
_PAGE_OUTPUT: dict[str, Any] = {"type": "object", "additionalProperties": False}
|
|
|
|
_CHANGESET_OPERATION_SCHEMA: dict[str, Any] = {
|
|
"type": "object",
|
|
"additionalProperties": False,
|
|
"properties": {
|
|
"kind": {"type": "string", "enum": ["create", "update", "delete", "move", "patch"]},
|
|
"path": {"type": "string", "minLength": 1, "maxLength": 2_048},
|
|
"destinationPath": {"type": "string", "minLength": 1, "maxLength": 2_048},
|
|
"content": {"type": "string", "maxLength": 200_000},
|
|
"patch": {"type": "string", "maxLength": 300_000},
|
|
"expectedSha": {"type": "string", "pattern": "^[0-9a-fA-F]{40,64}$"},
|
|
},
|
|
"required": ["kind", "path"],
|
|
}
|
|
|
|
|
|
def _changeset_schema(*, include_message: bool) -> dict[str, Any]:
|
|
properties: dict[str, Any] = {
|
|
"repositoryRef": {"type": "string", "pattern": "^[a-z0-9][a-z0-9-]{0,127}$"},
|
|
"branch": {
|
|
"type": "string",
|
|
"pattern": "^[A-Za-z0-9][A-Za-z0-9._/-]{0,299}$",
|
|
"maxLength": 300,
|
|
},
|
|
"operations": {
|
|
"type": "array",
|
|
"minItems": 1,
|
|
"maxItems": 50,
|
|
"items": _CHANGESET_OPERATION_SCHEMA,
|
|
},
|
|
}
|
|
required = ["repositoryRef", "branch", "operations"]
|
|
if include_message:
|
|
properties["message"] = {"type": "string", "minLength": 1, "maxLength": 1_000}
|
|
required.append("message")
|
|
return {
|
|
"type": "object",
|
|
"additionalProperties": False,
|
|
"properties": properties,
|
|
"required": required,
|
|
}
|
|
|
|
|
|
def _tool_input_schema(name: str) -> dict[str, Any]:
|
|
if name in {"gitea.capabilities.get", "gitea.connection.check"}:
|
|
return {"type": "object", "additionalProperties": False, "properties": {}}
|
|
if name == "gitea.selftest.run":
|
|
return {
|
|
"type": "object",
|
|
"additionalProperties": False,
|
|
"properties": {
|
|
"mode": {
|
|
"type": "string",
|
|
"enum": ["read", "guarded-write"],
|
|
"default": "read",
|
|
}
|
|
},
|
|
}
|
|
if name == "gitea.research.search":
|
|
return {
|
|
"type": "object",
|
|
"additionalProperties": False,
|
|
"properties": {"query": {"type": "string", "minLength": 1, "maxLength": 500}},
|
|
"required": ["query"],
|
|
}
|
|
if name == "gitea.research.fetch":
|
|
return {
|
|
"type": "object",
|
|
"additionalProperties": False,
|
|
"properties": {"id": {"type": "string", "minLength": 1, "maxLength": 4096}},
|
|
"required": ["id"],
|
|
}
|
|
if name in {
|
|
"gitea.portfolio.health",
|
|
"gitea.portfolio.governance.preview",
|
|
"gitea.portfolio.governance.verify",
|
|
}:
|
|
return {
|
|
"type": "object",
|
|
"additionalProperties": False,
|
|
"properties": {
|
|
"repositoryRefs": {
|
|
"type": "array",
|
|
"minItems": 1,
|
|
"maxItems": 25,
|
|
"uniqueItems": True,
|
|
"items": {"type": "string", "pattern": "^[a-z0-9][a-z0-9-]{0,127}$"},
|
|
}
|
|
},
|
|
"required": ["repositoryRefs"],
|
|
}
|
|
if name == "gitea.orchestration.plan":
|
|
return {
|
|
"type": "object",
|
|
"additionalProperties": False,
|
|
"properties": {
|
|
"repositoryRefs": {
|
|
"type": "array",
|
|
"minItems": 1,
|
|
"maxItems": 10,
|
|
"uniqueItems": True,
|
|
"items": {"type": "string", "pattern": "^[a-z0-9][a-z0-9-]{0,127}$"},
|
|
},
|
|
"branchSuffix": {
|
|
"type": "string",
|
|
"pattern": "^[a-z0-9][a-z0-9._-]{0,79}$",
|
|
},
|
|
},
|
|
"required": ["repositoryRefs", "branchSuffix"],
|
|
}
|
|
if name == "gitea.repository.list":
|
|
return {
|
|
"type": "object",
|
|
"additionalProperties": False,
|
|
"properties": {
|
|
"limit": {"type": "integer", "minimum": 1, "maximum": 100},
|
|
"cursor": {"type": ["string", "null"], "maxLength": 500},
|
|
},
|
|
}
|
|
if name == "gitea.issue.search":
|
|
return {
|
|
"type": "object",
|
|
"additionalProperties": False,
|
|
"properties": {
|
|
"state": {
|
|
"type": "string",
|
|
"enum": ["open", "closed", "all"],
|
|
"default": "open",
|
|
},
|
|
"excludeRepositoryNames": {
|
|
"type": "array",
|
|
"maxItems": 20,
|
|
"uniqueItems": True,
|
|
"items": {"type": "string", "minLength": 1, "maxLength": 120},
|
|
"default": [],
|
|
},
|
|
"limit": {"type": "integer", "minimum": 1, "maximum": 100},
|
|
"cursor": {"type": ["string", "null"], "maxLength": 500},
|
|
},
|
|
}
|
|
if name in {
|
|
"gitea.repository.get",
|
|
"gitea.repository.health",
|
|
"gitea.branchprotection.get",
|
|
"gitea.branchprotection.preview",
|
|
}:
|
|
return {
|
|
"type": "object",
|
|
"additionalProperties": False,
|
|
"properties": {"repositoryRef": {"type": "string"}},
|
|
"required": ["repositoryRef"],
|
|
}
|
|
if name == "gitea.repository.context":
|
|
return {
|
|
"type": "object",
|
|
"additionalProperties": False,
|
|
"properties": {
|
|
"repositoryRef": {"type": "string"},
|
|
"ref": {"type": ["string", "null"], "maxLength": 300},
|
|
},
|
|
"required": ["repositoryRef"],
|
|
}
|
|
if name == "gitea.commit.get":
|
|
return {
|
|
"type": "object",
|
|
"additionalProperties": False,
|
|
"properties": {
|
|
"repositoryRef": {"type": "string"},
|
|
"ref": {"type": "string", "minLength": 1, "maxLength": 300},
|
|
},
|
|
"required": ["repositoryRef", "ref"],
|
|
}
|
|
if name == "gitea.ref.compare":
|
|
return {
|
|
"type": "object",
|
|
"additionalProperties": False,
|
|
"properties": {
|
|
"repositoryRef": {"type": "string"},
|
|
"base": {"type": "string", "minLength": 1, "maxLength": 300},
|
|
"head": {"type": "string", "minLength": 1, "maxLength": 300},
|
|
},
|
|
"required": ["repositoryRef", "base", "head"],
|
|
}
|
|
if name == "gitea.file.read":
|
|
return {
|
|
"type": "object",
|
|
"additionalProperties": False,
|
|
"properties": {
|
|
"fileUri": {"type": "string", "maxLength": 2048},
|
|
"ref": {"type": ["string", "null"], "maxLength": 300},
|
|
},
|
|
"required": ["fileUri"],
|
|
}
|
|
if name == "gitea.code.search":
|
|
return {
|
|
"type": "object",
|
|
"additionalProperties": False,
|
|
"properties": {
|
|
"repositoryRef": {"type": "string"},
|
|
"query": {"type": "string", "minLength": 1, "maxLength": 500},
|
|
"ref": {"type": ["string", "null"], "maxLength": 300},
|
|
"pathPrefix": {"type": "string", "maxLength": 2_048, "default": ""},
|
|
"limit": {"type": "integer", "minimum": 1, "maximum": 100},
|
|
"cursor": {"type": ["string", "null"], "maxLength": 500},
|
|
},
|
|
"required": ["repositoryRef", "query"],
|
|
}
|
|
if name in {"gitea.pullrequest.get", "gitea.pullrequest.feedback"}:
|
|
return {
|
|
"type": "object",
|
|
"additionalProperties": False,
|
|
"properties": {
|
|
"repositoryRef": {"type": "string"},
|
|
"number": {"type": "integer", "minimum": 1, "maximum": 2_147_483_647},
|
|
},
|
|
"required": ["repositoryRef", "number"],
|
|
}
|
|
if name == "gitea.changeset.preview":
|
|
return _changeset_schema(include_message=False)
|
|
if name in {"gitea.validation.get", "gitea.validation.failure.get"}:
|
|
validation_properties: dict[str, Any] = {
|
|
"repositoryRef": {"type": "string"},
|
|
"runId": {"type": "integer", "minimum": 1},
|
|
}
|
|
if name == "gitea.validation.get":
|
|
validation_properties["includeLogs"] = {"type": "boolean", "default": False}
|
|
return {
|
|
"type": "object",
|
|
"additionalProperties": False,
|
|
"properties": validation_properties,
|
|
"required": ["repositoryRef", "runId"],
|
|
}
|
|
if name in {
|
|
"gitea.change.status",
|
|
"gitea.change.review",
|
|
"gitea.change.reconcile",
|
|
"gitea.workspace.status",
|
|
}:
|
|
return {
|
|
"type": "object",
|
|
"additionalProperties": False,
|
|
"properties": {
|
|
"repositoryRef": {"type": "string"},
|
|
"branch": {
|
|
"type": "string",
|
|
"pattern": "^[A-Za-z0-9][A-Za-z0-9._/-]{0,299}$",
|
|
},
|
|
},
|
|
"required": ["repositoryRef", "branch"],
|
|
}
|
|
if name == "gitea.validation.list":
|
|
validation_list_properties = dict(_PAGE_INPUT["properties"])
|
|
validation_list_properties["branch"] = {
|
|
"type": "string",
|
|
"pattern": "^[A-Za-z0-9][A-Za-z0-9._/-]{0,299}$",
|
|
}
|
|
return {
|
|
"type": "object",
|
|
"additionalProperties": False,
|
|
"properties": validation_list_properties,
|
|
"required": ["repositoryRef", "branch"],
|
|
}
|
|
if name == "gitea.tree.list":
|
|
return {
|
|
"type": "object",
|
|
"additionalProperties": False,
|
|
"properties": {
|
|
"repositoryRef": {"type": "string"},
|
|
"path": {"type": "string", "maxLength": 2048, "default": ""},
|
|
"ref": {"type": ["string", "null"], "maxLength": 300},
|
|
},
|
|
"required": ["repositoryRef"],
|
|
}
|
|
if name == "gitea.commitstatus.list":
|
|
commit_status_properties: dict[str, Any] = dict(_PAGE_INPUT["properties"])
|
|
commit_status_properties["ref"] = {
|
|
"type": "string",
|
|
"minLength": 1,
|
|
"maxLength": 300,
|
|
}
|
|
schema: dict[str, Any] = {
|
|
"type": "object",
|
|
"additionalProperties": False,
|
|
"properties": commit_status_properties,
|
|
"required": ["repositoryRef", "ref"],
|
|
}
|
|
return schema
|
|
return _PAGE_INPUT
|
|
|
|
|
|
GITEA_READ_TOOLS = tuple(
|
|
ToolPublication(
|
|
canonical_name=name,
|
|
version="1.12.0"
|
|
if name
|
|
in {
|
|
"gitea.selftest.run",
|
|
"gitea.validation.failure.get",
|
|
"gitea.change.reconcile",
|
|
}
|
|
else "1.9.0"
|
|
if name
|
|
in {
|
|
"gitea.changeset.preview",
|
|
"gitea.validation.list",
|
|
"gitea.change.status",
|
|
"gitea.change.review",
|
|
"gitea.workspace.status",
|
|
"gitea.branchprotection.preview",
|
|
"gitea.portfolio.governance.preview",
|
|
"gitea.portfolio.governance.verify",
|
|
}
|
|
else "1.8.0"
|
|
if name == "gitea.branchprotection.get"
|
|
else "1.5.0",
|
|
connector_type="gitea",
|
|
risk_class="R1",
|
|
input_schema=_tool_input_schema(name),
|
|
output_schema=_PAGE_OUTPUT,
|
|
required_scopes=("project:{project}:read",),
|
|
)
|
|
for name in (
|
|
"gitea.capabilities.get",
|
|
"gitea.connection.check",
|
|
"gitea.selftest.run",
|
|
"gitea.repository.list",
|
|
"gitea.repository.get",
|
|
"gitea.repository.context",
|
|
"gitea.repository.health",
|
|
"gitea.branchprotection.get",
|
|
"gitea.branchprotection.preview",
|
|
"gitea.branch.list",
|
|
"gitea.commit.list",
|
|
"gitea.commit.get",
|
|
"gitea.ref.compare",
|
|
"gitea.release.list",
|
|
"gitea.issue.list",
|
|
"gitea.issue.search",
|
|
"gitea.pullrequest.list",
|
|
"gitea.pullrequest.get",
|
|
"gitea.pullrequest.feedback",
|
|
"gitea.commitstatus.list",
|
|
"gitea.file.read",
|
|
"gitea.tree.list",
|
|
"gitea.code.search",
|
|
"gitea.research.search",
|
|
"gitea.research.fetch",
|
|
"gitea.changeset.preview",
|
|
"gitea.validation.get",
|
|
"gitea.validation.failure.get",
|
|
"gitea.validation.list",
|
|
"gitea.change.status",
|
|
"gitea.change.review",
|
|
"gitea.change.reconcile",
|
|
"gitea.workspace.status",
|
|
"gitea.portfolio.health",
|
|
"gitea.portfolio.governance.preview",
|
|
"gitea.portfolio.governance.verify",
|
|
"gitea.orchestration.plan",
|
|
)
|
|
)
|
|
|
|
|
|
def _write_tool_input_schema(name: str) -> dict[str, Any]:
|
|
if name == "gitea.repository.create":
|
|
return {
|
|
"type": "object",
|
|
"additionalProperties": False,
|
|
"properties": {
|
|
"name": {
|
|
"type": "string",
|
|
"pattern": "^[A-Za-z0-9][A-Za-z0-9_.-]{0,119}$",
|
|
},
|
|
"description": {"type": "string", "maxLength": 2_000},
|
|
"private": {"type": "boolean"},
|
|
"autoInit": {
|
|
"type": "boolean",
|
|
"const": True,
|
|
"description": (
|
|
"Must be true so the default branch exists before the tool returns."
|
|
),
|
|
},
|
|
"defaultBranch": {
|
|
"type": "string",
|
|
"const": "main",
|
|
},
|
|
},
|
|
"required": ["name", "description", "private", "autoInit", "defaultBranch"],
|
|
}
|
|
properties: dict[str, Any] = {
|
|
"repositoryRef": {
|
|
"type": "string",
|
|
"pattern": "^[a-z0-9][a-z0-9-]{0,127}$",
|
|
"description": "Opaque repositoryRef returned by gitea.repository.list.",
|
|
}
|
|
}
|
|
required = ["repositoryRef"]
|
|
change_branch = {
|
|
"type": "string",
|
|
"pattern": "^[A-Za-z0-9][A-Za-z0-9._/-]{0,299}$",
|
|
"maxLength": 300,
|
|
}
|
|
safe_ref = {
|
|
"type": "string",
|
|
"pattern": "^[A-Za-z0-9][A-Za-z0-9._/-]{0,299}$",
|
|
"maxLength": 300,
|
|
}
|
|
if name == "gitea.branchprotection.ensure":
|
|
properties["expectedPlanHash"] = {"type": "string", "pattern": "^[0-9a-f]{64}$"}
|
|
properties["applyScope"] = {
|
|
"type": "string",
|
|
"enum": ["all", "protection_only"],
|
|
"default": "all",
|
|
}
|
|
required.append("expectedPlanHash")
|
|
elif name == "gitea.portfolio.governance.apply":
|
|
return {
|
|
"type": "object",
|
|
"additionalProperties": False,
|
|
"properties": {
|
|
"plans": {
|
|
"type": "array",
|
|
"minItems": 1,
|
|
"maxItems": _GOVERNANCE_BATCH_LIMIT,
|
|
"uniqueItems": True,
|
|
"items": {
|
|
"type": "object",
|
|
"additionalProperties": False,
|
|
"properties": {
|
|
"repositoryRef": {
|
|
"type": "string",
|
|
"pattern": "^[a-z0-9][a-z0-9-]{0,127}$",
|
|
},
|
|
"planHash": {
|
|
"type": "string",
|
|
"pattern": "^[0-9a-f]{64}$",
|
|
"description": (
|
|
"Use planHash for all scope or protectionOnlyPlanHash "
|
|
"for protection_only scope."
|
|
),
|
|
},
|
|
"applyScope": {
|
|
"type": "string",
|
|
"enum": ["all", "protection_only"],
|
|
"default": "all",
|
|
},
|
|
},
|
|
"required": ["repositoryRef", "planHash"],
|
|
},
|
|
}
|
|
},
|
|
"required": ["plans"],
|
|
}
|
|
elif name == "gitea.branch.create":
|
|
properties.update({"branch": change_branch, "fromRef": safe_ref})
|
|
required.extend(("branch", "fromRef"))
|
|
elif name == "gitea.issue.create":
|
|
properties.update(
|
|
{
|
|
"title": {"type": "string", "minLength": 1, "maxLength": 500},
|
|
"body": {"type": "string", "maxLength": 32_000, "default": ""},
|
|
}
|
|
)
|
|
required.append("title")
|
|
elif name == "gitea.issue.update":
|
|
properties.update(
|
|
{
|
|
"number": {"type": "integer", "minimum": 1, "maximum": 2_147_483_647},
|
|
"title": {"type": ["string", "null"], "minLength": 1, "maxLength": 500},
|
|
"body": {"type": ["string", "null"], "maxLength": 32_000},
|
|
"state": {"type": ["string", "null"], "enum": ["open", "closed", None]},
|
|
}
|
|
)
|
|
required.append("number")
|
|
elif name == "gitea.issue.comment.create":
|
|
properties.update(
|
|
{
|
|
"number": {"type": "integer", "minimum": 1, "maximum": 2_147_483_647},
|
|
"body": {"type": "string", "minLength": 1, "maxLength": 32_000},
|
|
}
|
|
)
|
|
required.extend(("number", "body"))
|
|
elif name in {"gitea.file.create", "gitea.file.update", "gitea.file.delete"}:
|
|
properties.update(
|
|
{
|
|
"path": {"type": "string", "minLength": 1, "maxLength": 2_048},
|
|
"branch": change_branch,
|
|
"message": {"type": "string", "minLength": 1, "maxLength": 1_000},
|
|
}
|
|
)
|
|
required.extend(("path", "branch", "message"))
|
|
if name != "gitea.file.delete":
|
|
properties["content"] = {"type": "string", "maxLength": 200_000}
|
|
required.append("content")
|
|
if name in {"gitea.file.update", "gitea.file.delete"}:
|
|
properties["expectedSha"] = {
|
|
"type": "string",
|
|
"pattern": "^[0-9a-fA-F]{40,64}$",
|
|
}
|
|
required.append("expectedSha")
|
|
elif name == "gitea.pullrequest.create":
|
|
properties.update(
|
|
{
|
|
"head": change_branch,
|
|
"base": safe_ref,
|
|
"title": {"type": "string", "minLength": 1, "maxLength": 500},
|
|
"body": {"type": "string", "maxLength": 32_000, "default": ""},
|
|
}
|
|
)
|
|
required.extend(("head", "base", "title"))
|
|
elif name == "gitea.pullrequest.update":
|
|
properties.update(
|
|
{
|
|
"number": {"type": "integer", "minimum": 1, "maximum": 2_147_483_647},
|
|
"expectedHeadSha": {"type": "string", "pattern": "^[0-9a-fA-F]{40,64}$"},
|
|
"title": {"type": ["string", "null"], "minLength": 1, "maxLength": 500},
|
|
"body": {"type": ["string", "null"], "maxLength": 32_000},
|
|
}
|
|
)
|
|
required.extend(("number", "expectedHeadSha"))
|
|
elif name in {"gitea.pullrequest.close", "gitea.pullrequest.validation.ensure"}:
|
|
properties.update(
|
|
{
|
|
"number": {"type": "integer", "minimum": 1, "maximum": 2_147_483_647},
|
|
"expectedHeadSha": {"type": "string", "pattern": "^[0-9a-fA-F]{40,64}$"},
|
|
}
|
|
)
|
|
required.extend(("number", "expectedHeadSha"))
|
|
elif name == "gitea.pullrequest.sync":
|
|
properties.update(
|
|
{
|
|
"number": {"type": "integer", "minimum": 1, "maximum": 2_147_483_647},
|
|
"expectedHeadSha": {"type": "string", "pattern": "^[0-9a-fA-F]{40,64}$"},
|
|
"expectedBaseSha": {"type": "string", "pattern": "^[0-9a-fA-F]{40,64}$"},
|
|
"strategy": {"type": "string", "enum": ["merge", "rebase"]},
|
|
"dryRun": {"type": "boolean"},
|
|
}
|
|
)
|
|
required.extend(("number", "expectedHeadSha", "expectedBaseSha", "strategy", "dryRun"))
|
|
elif name == "gitea.pullrequest.merge":
|
|
properties.update(
|
|
{
|
|
"number": {"type": "integer", "minimum": 1, "maximum": 2_147_483_647},
|
|
"expectedHeadSha": {"type": "string", "pattern": "^[0-9a-fA-F]{40,64}$"},
|
|
"method": {
|
|
"type": "string",
|
|
"enum": ["merge", "rebase", "rebase-merge", "squash", "fast-forward-only"],
|
|
"default": "squash",
|
|
},
|
|
}
|
|
)
|
|
required.extend(("number", "expectedHeadSha"))
|
|
elif name == "gitea.branch.delete":
|
|
properties.update(
|
|
{
|
|
"branch": change_branch,
|
|
"expectedHeadSha": {"type": "string", "pattern": "^[0-9a-fA-F]{40,64}$"},
|
|
}
|
|
)
|
|
required.extend(("branch", "expectedHeadSha"))
|
|
elif name == "gitea.changeset.apply":
|
|
return _changeset_schema(include_message=True)
|
|
elif name == "gitea.validation.run":
|
|
properties.update(
|
|
{
|
|
"branch": change_branch,
|
|
"profile": {
|
|
"type": "string",
|
|
"enum": ["test", "lint", "typecheck", "build", "security", "full"],
|
|
},
|
|
}
|
|
)
|
|
required.extend(("branch", "profile"))
|
|
elif name == "gitea.workspace.run":
|
|
properties.update(
|
|
{
|
|
"branch": change_branch,
|
|
"profile": {"type": "string", "enum": list(_WORKSPACE_PROFILES)},
|
|
}
|
|
)
|
|
required.extend(("branch", "profile"))
|
|
elif name == "gitea.orchestration.start":
|
|
properties = {
|
|
"repositoryRefs": {
|
|
"type": "array",
|
|
"minItems": 1,
|
|
"maxItems": 10,
|
|
"uniqueItems": True,
|
|
"items": {
|
|
"type": "string",
|
|
"pattern": "^[a-z0-9][a-z0-9-]{0,127}$",
|
|
},
|
|
},
|
|
"branchSuffix": {
|
|
"type": "string",
|
|
"pattern": "^[a-z0-9][a-z0-9._-]{0,79}$",
|
|
},
|
|
"expectedOrchestrationId": {"type": "string", "pattern": "^[0-9a-f]{24}$"},
|
|
}
|
|
required = ["repositoryRefs", "branchSuffix", "expectedOrchestrationId"]
|
|
else:
|
|
raise ValueError(f"unsupported Gitea write tool: {name}")
|
|
return {
|
|
"type": "object",
|
|
"additionalProperties": False,
|
|
"properties": properties,
|
|
"required": required,
|
|
}
|
|
|
|
|
|
GITEA_PR_WRITE_TOOLS = tuple(
|
|
ToolPublication(
|
|
canonical_name=name,
|
|
version=(
|
|
"1.15.0"
|
|
if name == "gitea.pullrequest.validation.ensure"
|
|
else "1.14.0"
|
|
if name == "gitea.pullrequest.close"
|
|
else "1.13.0"
|
|
if name in {"gitea.validation.run", "gitea.workspace.run"}
|
|
else "1.10.0"
|
|
if name == "gitea.issue.update"
|
|
else "1.9.0"
|
|
if name
|
|
in {
|
|
"gitea.branch.create",
|
|
"gitea.file.create",
|
|
"gitea.file.update",
|
|
"gitea.file.delete",
|
|
"gitea.pullrequest.create",
|
|
"gitea.pullrequest.update",
|
|
"gitea.changeset.apply",
|
|
}
|
|
else "1.9.0"
|
|
if name in {"gitea.branchprotection.ensure", "gitea.portfolio.governance.apply"}
|
|
else "1.8.0"
|
|
if name == "gitea.repository.create"
|
|
else "1.5.0"
|
|
),
|
|
connector_type="gitea",
|
|
read_only=False,
|
|
risk_class="R2",
|
|
input_schema=_write_tool_input_schema(name),
|
|
output_schema=_PAGE_OUTPUT,
|
|
required_scopes=("project:{project}:write",),
|
|
)
|
|
for name in (
|
|
"gitea.repository.create",
|
|
"gitea.branchprotection.ensure",
|
|
"gitea.portfolio.governance.apply",
|
|
"gitea.branch.create",
|
|
"gitea.issue.create",
|
|
"gitea.issue.update",
|
|
"gitea.issue.comment.create",
|
|
"gitea.file.create",
|
|
"gitea.file.update",
|
|
"gitea.file.delete",
|
|
"gitea.pullrequest.create",
|
|
"gitea.pullrequest.update",
|
|
"gitea.pullrequest.close",
|
|
"gitea.pullrequest.validation.ensure",
|
|
"gitea.changeset.apply",
|
|
"gitea.validation.run",
|
|
"gitea.workspace.run",
|
|
"gitea.orchestration.start",
|
|
)
|
|
)
|
|
|
|
|
|
GITEA_LIFECYCLE_WRITE_TOOLS = tuple(
|
|
ToolPublication(
|
|
canonical_name=name,
|
|
version=(
|
|
"1.14.0"
|
|
if name == "gitea.branch.delete"
|
|
else "1.12.0"
|
|
if name == "gitea.pullrequest.sync"
|
|
else "1.10.0"
|
|
),
|
|
connector_type="gitea",
|
|
read_only=False,
|
|
risk_class="R3",
|
|
input_schema=_write_tool_input_schema(name),
|
|
output_schema=_PAGE_OUTPUT,
|
|
required_scopes=("project:{project}:write",),
|
|
)
|
|
for name in ("gitea.pullrequest.sync", "gitea.pullrequest.merge", "gitea.branch.delete")
|
|
)
|
|
|
|
|
|
GITEA_TOOLS = GITEA_READ_TOOLS
|
|
|
|
GITEA_RESOURCES = (
|
|
ResourcePublication(
|
|
uri_template="itworx-git://{project}/{repositoryRef}/{path}",
|
|
connector_type="gitea",
|
|
risk_class="R1",
|
|
mime_types=("text/plain", "text/markdown", "application/json"),
|
|
max_bytes=262144,
|
|
required_scopes=("project:{project}:read",),
|
|
),
|
|
)
|