Files
ITWorx_MCP_Hub-Public/connectors/gitea/changesets.py
T

342 lines
15 KiB
Python

"""Bounded patching and single-commit Git workspaces for Gitea changesets."""
from __future__ import annotations
import io
import os
import re
import tempfile
from collections.abc import Iterator, Mapping, Sequence
from contextlib import contextmanager
from pathlib import Path
from threading import Lock
from typing import Any, Protocol
from dulwich import porcelain
from dulwich.repo import Repo
from packages.connector_kit import GiteaError, RegisteredRepository
from packages.connector_kit.gitea import TokenProvider
_HUNK = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(?: .*)?$")
_SHA = re.compile(r"^[0-9a-fA-F]{40,64}$")
_MAX_CHANGESET_BYTES = 1_000_000
_MAX_OPERATIONS = 50
_MAX_WORKSPACE_BYTES = 256 * 1024 * 1024
_DULWICH_IDENTITY_LOCK = Lock()
_DULWICH_IDENTITY = {"USER": "mcp-agent", "EMAIL": "mcp-agent@example.com"}
@contextmanager
def _dulwich_clone_identity() -> Iterator[None]:
"""Give Dulwich a bounded reflog identity without mutating process state concurrently."""
with _DULWICH_IDENTITY_LOCK:
previous = {name: os.environ.get(name) for name in _DULWICH_IDENTITY}
os.environ.update(_DULWICH_IDENTITY)
try:
yield
finally:
for name, value in previous.items():
if value is None:
os.environ.pop(name, None)
else:
os.environ[name] = value
def _hex_object_id(value: bytes) -> str:
try:
decoded = value.decode("ascii")
except UnicodeDecodeError:
return value.hex()
return decoded.casefold() if _SHA.fullmatch(decoded) else value.hex()
class ChangesetWorkspace(Protocol):
def apply(
self,
repository: RegisteredRepository,
branch: str,
message: str,
operations: Sequence[Mapping[str, Any]],
) -> dict[str, Any]: ...
def apply_unified_patch(original: str, patch: str) -> str:
"""Apply a single-file unified diff with exact context matching."""
if not patch or len(patch.encode("utf-8")) > 300_000 or "\x00" in patch:
raise GiteaError("GITEA_PATCH_INVALID", "patch is outside the connector budget")
lines = patch.splitlines(keepends=True)
index = 0
while index < len(lines) and not lines[index].startswith("@@ "):
if lines[index].startswith(("GIT binary patch", "Binary files ")):
raise GiteaError("GITEA_PATCH_INVALID", "binary patches are not supported")
index += 1
if index == len(lines):
raise GiteaError("GITEA_PATCH_INVALID", "patch contains no unified diff hunk")
source = original.splitlines(keepends=True)
output: list[str] = []
source_index = 0
seen_hunk = False
while index < len(lines):
header = lines[index].rstrip("\r\n")
match = _HUNK.fullmatch(header)
if match is None:
raise GiteaError("GITEA_PATCH_INVALID", "patch contains an invalid hunk header")
seen_hunk = True
old_start = int(match.group(1))
old_count = int(match.group(2) or "1")
new_count = int(match.group(4) or "1")
target_index = max(old_start - 1, 0)
if target_index < source_index or target_index > len(source):
raise GiteaError("GITEA_PATCH_CONFLICT", "patch hunks overlap or exceed the file")
output.extend(source[source_index:target_index])
source_index = target_index
index += 1
consumed = 0
produced = 0
while index < len(lines) and not lines[index].startswith("@@ "):
line = lines[index]
if line.startswith("\\ No newline at end of file"):
index += 1
continue
if not line or line[0] not in {" ", "+", "-"}:
raise GiteaError("GITEA_PATCH_INVALID", "patch contains an invalid hunk line")
marker, value = line[0], line[1:]
if marker in {" ", "-"}:
if source_index >= len(source) or source[source_index] != value:
raise GiteaError("GITEA_PATCH_CONFLICT", "patch context does not match")
source_index += 1
consumed += 1
if marker in {" ", "+"}:
output.append(value)
produced += 1
index += 1
if consumed != old_count or produced != new_count:
raise GiteaError("GITEA_PATCH_INVALID", "patch hunk counts do not match")
if not seen_hunk:
raise GiteaError("GITEA_PATCH_INVALID", "patch contains no hunks")
output.extend(source[source_index:])
result = "".join(output)
if len(result.encode("utf-8")) > 200_000:
raise GiteaError("GITEA_INPUT_INVALID", "patched file exceeds the connector budget")
return result
def validate_operations(operations: Sequence[Mapping[str, Any]]) -> tuple[dict[str, Any], ...]:
if not 1 <= len(operations) <= _MAX_OPERATIONS:
raise GiteaError("GITEA_CHANGESET_INVALID", "changeset operation count is invalid")
total = 0
normalized: list[dict[str, Any]] = []
touched: set[str] = set()
for raw in operations:
kind = str(raw.get("kind", ""))
if kind not in {"create", "update", "delete", "move", "patch"}:
raise GiteaError("GITEA_CHANGESET_INVALID", "changeset operation kind is invalid")
path = str(raw.get("path", ""))
if not path or path in touched:
raise GiteaError("GITEA_CHANGESET_INVALID", "changeset paths must be unique")
touched.add(path)
operation: dict[str, Any] = {"kind": kind, "path": path}
for field in ("content", "patch", "expectedSha", "destinationPath"):
if field in raw and raw[field] is not None:
operation[field] = str(raw[field])
if kind in {"update", "delete", "move", "patch"} and not _SHA.fullmatch(
operation.get("expectedSha", "")
):
raise GiteaError("GITEA_CHANGESET_INVALID", "expectedSha is required and invalid")
if kind in {"create", "update"} and "content" not in operation:
raise GiteaError("GITEA_CHANGESET_INVALID", "content is required")
if kind == "patch" and "patch" not in operation:
raise GiteaError("GITEA_CHANGESET_INVALID", "patch text is required")
if kind == "move" and not operation.get("destinationPath"):
raise GiteaError("GITEA_CHANGESET_INVALID", "destinationPath is required")
destination = operation.get("destinationPath")
if destination is not None:
if destination in touched:
raise GiteaError("GITEA_CHANGESET_INVALID", "changeset paths must be unique")
touched.add(destination)
total += sum(len(str(value).encode("utf-8")) for value in operation.values())
normalized.append(operation)
if total > _MAX_CHANGESET_BYTES:
raise GiteaError("GITEA_CHANGESET_INVALID", "changeset exceeds the connector budget")
return tuple(normalized)
class DulwichChangesetWorkspace:
"""Apply an already-validated changeset through a fixed, isolated Git workflow."""
def __init__(self, base_url: str, token_provider: TokenProvider, token_reference: str) -> None:
self._base_url = base_url.rstrip("/")
self._token_provider = token_provider
self._token_reference = token_reference
@staticmethod
def _relative(root: Path, value: str) -> Path:
if not value or value.startswith(("/", "\\")) or "\\" in value:
raise GiteaError("GITEA_PATH_INVALID", "repository file path is invalid")
parts = value.split("/")
if any(not part or part in {".", ".."} or "\x00" in part for part in parts):
raise GiteaError("GITEA_PATH_INVALID", "repository file path is invalid")
target = root.joinpath(*parts)
try:
target.resolve(strict=False).relative_to(root.resolve())
except ValueError as exc:
raise GiteaError("GITEA_PATH_INVALID", "repository file path is invalid") from exc
return target
@staticmethod
def _blob_sha(repo: Repo, relative: str) -> str | None:
try:
entry = repo.open_index()[relative.encode("utf-8")]
except KeyError:
return None
sha = getattr(entry, "sha", None)
return _hex_object_id(sha) if sha is not None else None
def apply(
self,
repository: RegisteredRepository,
branch: str,
message: str,
operations: Sequence[Mapping[str, Any]],
) -> dict[str, Any]:
normalized = validate_operations(operations)
token = self._token_provider.get_token(self._token_reference)
remote = f"{self._base_url}/{repository.owner}/{repository.name}.git"
authenticated = remote.startswith(("https://", "http://"))
stderr = io.BytesIO()
with tempfile.TemporaryDirectory(prefix="itworx-gitea-") as temporary:
root = Path(temporary) / "repository"
repo: Repo | None = None
try:
with _dulwich_clone_identity():
if authenticated:
repo = porcelain.clone(
remote,
root,
branch=branch,
depth=1,
errstream=stderr,
username="oauth2",
password=token,
)
else:
repo = porcelain.clone(
remote, root, branch=branch, depth=1, errstream=stderr
)
workspace_bytes = sum(
entry.stat(follow_symlinks=False).st_size
for entry in root.rglob("*")
if entry.is_file() and not entry.is_symlink()
)
if workspace_bytes > _MAX_WORKSPACE_BYTES:
raise GiteaError(
"GITEA_CHANGESET_TOO_LARGE",
"repository exceeds the changeset workspace budget",
)
staged: list[str] = []
removed: list[str] = []
for operation in normalized:
kind = operation["kind"]
relative = operation["path"]
target = self._relative(root, relative)
actual_sha = self._blob_sha(repo, relative)
expected_sha = operation.get("expectedSha")
if expected_sha is not None and actual_sha != expected_sha.casefold():
raise GiteaError(
"GITEA_CHANGESET_CONFLICT", f"stale expectedSha for {relative}"
)
if kind == "create":
if actual_sha is not None or target.exists():
raise GiteaError(
"GITEA_CHANGESET_CONFLICT", f"file already exists: {relative}"
)
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(operation["content"], encoding="utf-8")
staged.append(relative)
elif kind == "update":
if actual_sha is None:
raise GiteaError(
"GITEA_CHANGESET_CONFLICT", f"file does not exist: {relative}"
)
target.write_text(operation["content"], encoding="utf-8")
staged.append(relative)
elif kind == "patch":
if actual_sha is None:
raise GiteaError(
"GITEA_CHANGESET_CONFLICT", f"file does not exist: {relative}"
)
original = target.read_text(encoding="utf-8")
target.write_text(
apply_unified_patch(original, operation["patch"]), encoding="utf-8"
)
staged.append(relative)
elif kind == "delete":
if actual_sha is None:
raise GiteaError(
"GITEA_CHANGESET_CONFLICT", f"file does not exist: {relative}"
)
target.unlink()
removed.append(relative)
else:
destination = str(operation["destinationPath"])
destination_target = self._relative(root, destination)
if actual_sha is None or destination_target.exists():
raise GiteaError(
"GITEA_CHANGESET_CONFLICT", "move source/destination is invalid"
)
destination_target.parent.mkdir(parents=True, exist_ok=True)
os.replace(target, destination_target)
removed.append(relative)
staged.append(destination)
if removed:
porcelain.rm(repo, removed)
if staged:
porcelain.add(repo, staged)
status = porcelain.status(repo)
if not status.staged and not status.unstaged and not status.untracked:
raise GiteaError("GITEA_CHANGESET_EMPTY", "changeset produces no change")
commit_sha = _hex_object_id(
porcelain.commit(
repo,
message=message.encode("utf-8"),
author=b"ChatGPT MCP <mcp-agent@example.com>",
committer=b"ChatGPT MCP <mcp-agent@example.com>",
)
)
if authenticated:
porcelain.push(
repo,
remote,
refspecs=f"refs/heads/{branch}:refs/heads/{branch}",
force=False,
errstream=stderr,
username="oauth2",
password=token,
)
else:
porcelain.push(
repo,
remote,
refspecs=f"refs/heads/{branch}:refs/heads/{branch}",
force=False,
errstream=stderr,
)
except GiteaError:
raise
except Exception as exc:
raise GiteaError(
"GITEA_CHANGESET_FAILED", "bounded Git changeset could not be committed"
) from exc
finally:
if repo is not None:
repo.close()
return {
"commitSha": commit_sha,
"branch": branch,
"operationCount": len(normalized),
"atomic": True,
}