diff --git a/PUBLIC_SOURCE_EXPORT.md b/PUBLIC_SOURCE_EXPORT.md index 5762819..0aba2e1 100644 --- a/PUBLIC_SOURCE_EXPORT.md +++ b/PUBLIC_SOURCE_EXPORT.md @@ -1,3 +1,3 @@ # Public source export -This source snapshot was generated from the private canonical repository at revision `0253e7acef01ad952266ff180ed434e22b986ddc`. It contains no private Git history or operational evidence. Changes are published from the canonical repository. +This source snapshot was generated from the private canonical repository at revision `7fde2770ba344c684b7e7a6b74f763dc19e7c560`. It contains no private Git history or operational evidence. Changes are published from the canonical repository. diff --git a/PUBLIC_SOURCE_MANIFEST.json b/PUBLIC_SOURCE_MANIFEST.json index 8e538dc..e6a3758 100644 --- a/PUBLIC_SOURCE_MANIFEST.json +++ b/PUBLIC_SOURCE_MANIFEST.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "sourceRevision": "0253e7acef01ad952266ff180ed434e22b986ddc", + "sourceRevision": "7fde2770ba344c684b7e7a6b74f763dc19e7c560", "files": [ { "path": ".dockerignore", @@ -1759,8 +1759,8 @@ }, { "path": "connectors/gitea/server.py", - "sha256": "4a07c6171893742d7a25e5cbdaa29eef438fc2d381ae93249a4bb6c1be493421", - "bytes": 228331 + "sha256": "bf1219aa38d650c5821167511137e9c6e27e21714436b430782f1c75418db441", + "bytes": 232805 }, { "path": "connectors/knowledge/__init__.py", @@ -2169,13 +2169,13 @@ }, { "path": "packages/connector_kit/gitea.py", - "sha256": "118e657d06142f71db40741b08d25504e30f13754bf0f67fc5f45f865cb41e2e", - "bytes": 50270 + "sha256": "fbc116935c7221ffcad4a41fddfa9c40a21a725364f8fa8bb52d9ef2ea9db333", + "bytes": 54778 }, { "path": "packages/connector_kit/gitea_resilient.py", - "sha256": "78e6f6a43023721c686630f39107bda92b5c4cc77a978ed5bf5a105247abe72f", - "bytes": 4442 + "sha256": "83a27fdb883f25ab17d0ebea9f41c9fe61679cd2bfeea4535abdd057a17d1ab9", + "bytes": 12348 }, { "path": "packages/connector_kit/knowledge.py", @@ -3239,8 +3239,8 @@ }, { "path": "tests/test_gitea_connector.py", - "sha256": "4fcd0e2a2aa822ca24958d3dcae2eb11f0c2c223da3fbdcc763631a57734b29f", - "bytes": 135127 + "sha256": "fe6d6d9e83f5f96239f22653e4f1815fe824cffdef2e7f8c26c52df61aa6fc1c", + "bytes": 138362 }, { "path": "tests/test_gitea_merge_eligibility.py", @@ -3254,14 +3254,19 @@ }, { "path": "tests/test_gitea_merge_validation_fallback.py", - "sha256": "44a7825b2c9b591cb4c64f8dec539ed7c36298bdf7b2418e2855e56e2496a84d", - "bytes": 7467 + "sha256": "9ce7be992b4878209ee232ec1cf30681059284128a713088de47f2fa22c08b5c", + "bytes": 6987 }, { "path": "tests/test_gitea_merge_version_compat.py", "sha256": "8ae5c98a3056d2e1afe9c50fbd7b39f019e31cce744c21d89feeb513668d024e", "bytes": 2717 }, + { + "path": "tests/test_gitea_native_pr_discovery.py", + "sha256": "46249e45eaa7bdd3810f803cd8ab8416983e442d44f40bbf5289ba2925fe448c", + "bytes": 18959 + }, { "path": "tests/test_gitea_pr_status_fallback.py", "sha256": "7cc54cfb5172b657613fe26da1f08518eb43c79f7c180e264f8f263a9b80b5ee", @@ -3277,6 +3282,11 @@ "sha256": "0b8357ea0a39319d80f5bcac8250de806866667329872d17429f2565cba6d8e0", "bytes": 1861 }, + { + "path": "tests/test_gitea_upstream_discovery.py", + "sha256": "184cd84aa7647989893c3f62b96bcd1df82a70c3fa8daced33532ded9aab38af", + "bytes": 9661 + }, { "path": "tests/test_gitea_validation_dedup.py", "sha256": "8e29587c67183aaf5abdd5851b0b2a283074ecb33e953ebd75c7dcfce9b171d4", @@ -3334,8 +3344,8 @@ }, { "path": "tests/test_managed_validation_action_pins.py", - "sha256": "8e4d3e81862570d281259b6eabadbe9568d2d48e56c240b1fa1da6a73efd3eb4", - "bytes": 721 + "sha256": "e80aeed714214a611399d703dc72a114ea9dde987ce36e7e821768ae5cc6d327", + "bytes": 792 }, { "path": "tests/test_mcp_hub_ingestion_publisher.py", @@ -3424,8 +3434,8 @@ }, { "path": "tests/test_release_images_workflow.py", - "sha256": "92e1e03d71aaab8ef2f31eb6643d42b9e6189b9ccca86f1f0b55425d841512c2", - "bytes": 2510 + "sha256": "055e9adb93816464381e4de32b0c11e86d54555606a17041ba1ee52f36d73487", + "bytes": 3063 }, { "path": "tests/test_stitch_connector.py", diff --git a/connectors/gitea/server.py b/connectors/gitea/server.py index ef21f0b..c3844bd 100644 --- a/connectors/gitea/server.py +++ b/connectors/gitea/server.py @@ -1900,9 +1900,12 @@ class GiteaConnector: "status": str(item.get("status", ""))[:32], "conclusion": str(item.get("conclusion", ""))[:32], "event": str(item.get("event", ""))[:64], - "headBranch": str(item.get("head_branch", ""))[:300], + "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", ""))[:2_048], + "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], @@ -1932,14 +1935,33 @@ class GiteaConnector: identities = cls._workflow_identity_values(workflow) if not identities: return [] - if not any(str(item.get("workflow_id", "")).strip() for item in runs): - return runs if allow_legacy_without_workflow_id else [] - return [ - item - for item in runs - if str(item.get("workflow_id", "")).strip().casefold() in identities - or str(item.get("workflow_id", "")).strip().casefold().rsplit("/", 1)[-1] in identities - ] + 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, @@ -1994,10 +2016,29 @@ class GiteaConnector: job_id = max(int(job.get("id", 0)), 0) logs = "" logs_truncated = False + logs_unavailable = False if include_logs and job_id: - logs, logs_truncated = self.client.action_job_logs(repository, job_id) - filtered_logs = safe_content(logs) - logs = filtered_logs or "[log excluded: secret-like content detected]" + 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, @@ -2008,6 +2049,7 @@ class GiteaConnector: "completedAt": str(job.get("completed_at", ""))[:64], "logs": logs, "logsTruncated": logs_truncated, + "logsUnavailable": logs_unavailable, } ) any_truncated = any_truncated or logs_truncated @@ -2182,12 +2224,41 @@ class GiteaConnector: 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.action_runs(repository, branch=safe_branch, page=1, limit=100), 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 - commit_sha = str((branch_payload.get("commit") or {}).get("id", ""))[:64] merge_eligibility: dict[str, Any] = { "eligible": False, "headSha": commit_sha, @@ -2443,9 +2514,13 @@ class GiteaConnector: 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), @@ -2470,7 +2545,14 @@ class GiteaConnector: "catalog": "READY" if published_names == handler_names else "DEGRADED", "dispatcher": "READY", "worker": "READY" if runtime["circuitState"] == "CLOSED" else "DEGRADED", - "upstream_gitea": "UNVERIFIED", + "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", @@ -2506,6 +2588,8 @@ class GiteaConnector: "capabilityFingerprint": capability["capabilityFingerprint"], "toolCount": capability["toolCount"], "credentialMode": "mounted-secret", + "connectorVersion": capability["connectorVersion"], + "upstreamGitea": capability["upstreamGitea"], }, source="gitea:connection", ) @@ -2533,6 +2617,13 @@ class GiteaConnector: "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", @@ -3930,32 +4021,24 @@ class GiteaConnector: ): 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: list[dict[str, Any]] = [] - for page in range(1, 6): - batch = self.client.repository_action_runs(repository, page=page, limit=100) - runs.extend(batch) - if len(batch) < 100: - break - return sorted( - ( - self._action_run_projection(item) - for item in runs - if str(item.get("event", "")) == "pull_request" - and str(item.get("head_sha", "")) == expected_head_sha - and ( - not isinstance(item.get("pull_requests"), list) - or not item["pull_requests"] - or any( - int(pr.get("number", 0)) == number - for pr in item["pull_requests"] - if isinstance(pr, dict) - ) - ) + runs = self._runs_for_workflow( + self.client.exact_head_action_runs( + repository, + branch=safe_branch, + head_sha=expected_head_sha, + pull_request_number=number, ), - key=lambda item: int(item.get("id", 0)), - reverse=True, + 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) @@ -4337,10 +4420,16 @@ class GiteaConnector: "GITEA_PR_VALIDATION_REQUIRED", "the required status has no verifiable canonical validation run", ) - run = self._action_run_projection(self.client.action_run(repository, int(run_match[1]))) + 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)) - workflow_id = str(run.get("workflowId", "")).strip().casefold() - identities = self._workflow_identity_values(workflow or {}) + 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 ( @@ -4349,11 +4438,7 @@ class GiteaConnector: or str(run.get("event", "")) != "pull_request" or str(run.get("status", "")) != "completed" or str(run.get("conclusion", "")) != "success" - or ( - workflow_id - and workflow_id not in identities - and workflow_id.rsplit("/", 1)[-1] not in identities - ) + or not canonical_run or not isinstance(full_job, dict) or full_job.get("status") != "completed" or full_job.get("conclusion") != "success" @@ -4614,13 +4699,18 @@ class GiteaConnector: 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": "reachable", + "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() @@ -4632,7 +4722,16 @@ class GiteaConnector: "dispatcher": "READY", "worker": ("READY" if runtime["circuitState"] == "CLOSED" else "DEGRADED"), "session": "RESUMABLE", - "upstream_gitea": "READY", + "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: diff --git a/packages/connector_kit/gitea.py b/packages/connector_kit/gitea.py index bd06946..1798675 100644 --- a/packages/connector_kit/gitea.py +++ b/packages/connector_kit/gitea.py @@ -3,11 +3,13 @@ from __future__ import annotations import base64 +import copy import ipaddress import json import re import time from collections.abc import Mapping +from datetime import UTC, datetime from pathlib import Path from threading import RLock from typing import Any, Protocol, cast @@ -331,6 +333,13 @@ class GiteaClient: """Small bounded Gitea API client with same-origin redirect enforcement.""" _path_part = re.compile(r"^[^/\\?#\x00-\x1f\x7f]+$") + _semver = re.compile( + r"v?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)" + r"(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?" + r"(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?" + ) + _version_cache_seconds = 30 + _supported_ranges = (">=1.14.0 <1.15.0", ">=1.25.0 <1.28.0", ">=28.0.0 <29.0.0") def __init__( self, @@ -347,7 +356,8 @@ class GiteaClient: follow_redirects=False, transport=transport, ) - self._merge_strategy_key: str | None = None + self._version_lock = RLock() + self._version_cache: tuple[float, dict[str, Any]] | None = None def close(self) -> None: self._client.close() @@ -813,27 +823,100 @@ class GiteaClient: ), ) - def _merge_strategy_field(self) -> str: - """Resolve the versioned Gitea merge field before the single write attempt.""" + def upstream_compatibility(self, *, force_refresh: bool = False) -> dict[str, Any]: + """Read the upstream version, independent of this connector's build version. - if self._merge_strategy_key is not None: - return self._merge_strategy_key - payload = self._request(self._path("version")) - version = payload.get("version") if isinstance(payload, dict) else None - legacy_match = re.fullmatch(r"1\.(14|25|26|27)(?:\.\d+)?(?:[-+].*)?", str(version or "")) - modern_match = re.fullmatch(r"(28)(?:\.\d+){1,2}(?:[-+].*)?", str(version or "")) - if legacy_match is None and modern_match is None: + Diagnostics cache for 30 seconds; each merge negotiates again immediately + before its one write. Failed probes replace cached success and never fall + back to the connector version. Gitea 28 drops the historical ``1.`` prefix. + """ + with self._version_lock: + now = time.monotonic() + if ( + not force_refresh + and self._version_cache is not None + and now - self._version_cache[0] < self._version_cache_seconds + ): + return copy.deepcopy(self._version_cache[1]) + diagnostics: dict[str, Any] = { + "source": "/api/v1/version", + "upstreamVersion": None, + "normalizedVersion": None, + "compatibility": "UNKNOWN", + "reason": "upstream_version_missing_or_malformed", + "mergeStrategyField": None, + "supportedRanges": list(self._supported_ranges), + "prereleasesSupported": False, + "checkedAt": datetime.now(UTC).isoformat(), + "cacheSeconds": self._version_cache_seconds, + "errorCode": None, + } + try: + payload = self._request(self._path("version")) + except GiteaError as exc: + diagnostics.update( + compatibility="UNAVAILABLE", + reason="upstream_version_probe_failed", + errorCode=exc.code, + ) + else: + version = payload.get("version") if isinstance(payload, dict) else None + # Do not reflect arbitrary upstream data (or secret-like content). + if isinstance(version, str) and len(version) <= 128: + version = version.strip() + match = self._semver.fullmatch(version) + if match is not None: + release = tuple(int(match.group(index)) for index in (1, 2, 3)) + prerelease = match.group(4) + invalid_prerelease = prerelease is not None and any( + part.isdigit() and len(part) > 1 and part.startswith("0") + for part in prerelease.split(".") + ) + if not invalid_prerelease: + # Arbitrary SemVer suffixes can contain opaque credentials. + # Display only the numeric release; negotiate the full parsed version. + display_version = ("v" if version.startswith("v") else "") + ".".join( + str(part) for part in release + ) + diagnostics.update( + upstreamVersion=display_version, + normalizedVersion=display_version.removeprefix("v"), + compatibility="UNSUPPORTED", + reason="upstream_release_outside_tested_ranges", + ) + supported = ( + (1, 14, 0) <= release < (1, 15, 0) + or (1, 25, 0) <= release < (1, 28, 0) + or (28, 0, 0) <= release < (29, 0, 0) + ) + if prerelease is not None: + diagnostics["reason"] = "upstream_prerelease_not_tested" + elif supported: + diagnostics.update( + compatibility="SUPPORTED", + reason="upstream_release_in_tested_ranges", + mergeStrategyField=("Do" if release < (1, 26, 0) else "do"), + ) + self._version_cache = (time.monotonic(), copy.deepcopy(diagnostics)) + return diagnostics + + def _merge_strategy_field(self) -> str: + """Negotiate against the current upstream immediately before the merge write.""" + diagnostics = self.upstream_compatibility(force_refresh=True) + if diagnostics["compatibility"] != "SUPPORTED": raise GiteaError( - "GITEA_VERSION_UNSUPPORTED", + diagnostics["errorCode"] or "GITEA_VERSION_UNSUPPORTED", ( "Gitea merge compatibility requires an identified " - "1.14, 1.25, 1.26, 1.27, or 28.x server" + "1.14, 1.25, 1.26, 1.27, or 28.x stable server; " + f"{diagnostics['reason']}" ), + category="compatibility", + expected_state="Stable upstream release in tested compatibility ranges", + actual_state=str(diagnostics["compatibility"]), + recovery_hint="Inspect gitea.capabilities.get upstreamGitea diagnostics", ) - self._merge_strategy_key = ( - "Do" if legacy_match is not None and legacy_match.group(1) in {"14", "25"} else "do" - ) - return self._merge_strategy_key + return str(diagnostics["mergeStrategyField"]) def update_pull_request_branch( self, diff --git a/packages/connector_kit/gitea_resilient.py b/packages/connector_kit/gitea_resilient.py index b8de530..726cb28 100644 --- a/packages/connector_kit/gitea_resilient.py +++ b/packages/connector_kit/gitea_resilient.py @@ -1,9 +1,9 @@ """Compatibility hardening for Gitea validation discovery and request latency. -Some Gitea versions expose pull-request check results as commit statuses while -returning an empty branch-filtered Actions run wrapper. The base client treats -collections strictly; this subclass preserves that strictness while translating -a fully-green exact-head status set into one synthetic completed validation run. +Native Gitea PR runs can omit their head branch. This client reads actual +exact-head runs without a branch filter and checks available PR identity. +Older status-only adapters retain an explicitly labeled legacy fallback; +exact-head native reads never synthesize a workflow run or merge proof. Large repositories can require longer than the historical 15-second client budget to commit files, apply changesets, or materialize a server-side squash @@ -15,6 +15,8 @@ base client and are not duplicated here. from __future__ import annotations +import re +import time from typing import Any import httpx @@ -40,6 +42,174 @@ class GiteaClient(BaseGiteaClient): ) super().__init__(resilient_settings, token_provider, transport=transport) + @staticmethod + def matches_exact_head_action_run( + run: dict[str, Any], + *, + branch: str, + head_sha: str, + pull_request_number: int | None = None, + ) -> bool: + if run.get("head_sha") != head_sha: + return False + head_branch = run.get("head_branch", "") + event = run.get("event") + if head_branch is None and event == "pull_request": + head_branch = "" + if event == "workflow_dispatch" and head_branch == f"refs/heads/{branch}": + head_branch = branch + if not isinstance(head_branch, str) or head_branch not in {"", branch}: + return False + if not head_branch and event != "pull_request": + return False + path = run.get("path") + path_number: int | None = None + if path is not None: + if not isinstance(path, str) or len(path) > 512 or path.count("@") > 1: + return False + if "@" in path: + ref = path.split("@", 1)[1] + match = re.fullmatch(r"refs/pull/([1-9][0-9]*)/head", ref) + if match: + path_number = int(match[1]) + if event != "pull_request" or path_number > 2_147_483_647: + return False + elif ref != f"refs/heads/{branch}" or event == "pull_request": + return False + raw_references = run.get("pull_requests") + references = [] if raw_references is None else raw_references + if not isinstance(references, list): + return False + numbers: list[int] = [] + for reference in references: + if not isinstance(reference, dict): + return False + number = reference.get("number") + if number is not None: + if type(number) is not int or not 1 <= number <= 2_147_483_647: + return False + numbers.append(number) + head = reference.get("head") + if head is not None: + if not isinstance(head, dict): + return False + if head.get("ref") not in (None, "", branch) or head.get("sha") not in ( + None, + "", + head_sha, + ): + return False + if event == "pull_request" and pull_request_number is not None: + if path_number not in {None, pull_request_number} or any( + number != pull_request_number for number in numbers + ): + return False + # Legacy adapters identify PR runs by a real branch and SHA. Native + # blank-branch runs need affirmative PR number/ref evidence. + if path_number != pull_request_number and pull_request_number not in numbers: + return head_branch == branch and not path + return True + + def exact_head_action_runs( + self, + repository: RegisteredRepository, + *, + branch: str, + head_sha: str, + pull_request_number: int | None = None, + ) -> list[dict[str, Any]]: + """Read bounded actual run evidence; never synthesize a native validation.""" + if not re.fullmatch(r"(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})", head_sha): + raise GiteaError("GITEA_TARGET_INVALID", "exact Actions head SHA is invalid") + if pull_request_number is not None and ( + type(pull_request_number) is not int or not 1 <= pull_request_number <= 2_147_483_647 + ): + raise GiteaError("GITEA_TARGET_INVALID", "exact Actions PR number is invalid") + output: list[dict[str, Any]] = [] + seen: set[tuple[int, int]] = set() + expected_count: int | None = None + deadline = time.monotonic() + 20 + for page in range(1, 11): + if time.monotonic() >= deadline: + raise GiteaError( + "GITEA_ACTION_RUNS_TRUNCATED", "exact Actions read exceeded its bound" + ) + payload = self._request( + self._path("repos", repository.owner, repository.name, "actions", "runs"), + {"head_sha": head_sha, "page": page, "limit": 100}, + timeout_seconds=min(10.0, max(0.1, deadline - time.monotonic())), + max_attempts=1, + ) + count = payload.get("total_count") if isinstance(payload, dict) else None + if isinstance(payload, dict) and "total_count" in payload: + if type(count) is not int or not 0 <= count <= 1000: + raise GiteaError("GITEA_ACTION_RUNS_INVALID", "invalid exact Actions count") + if expected_count is not None and count != expected_count: + raise GiteaError("GITEA_ACTION_RUNS_TRUNCATED", "exact Actions count changed") + expected_count = count + elif expected_count is not None: + raise GiteaError("GITEA_ACTION_RUNS_TRUNCATED", "exact Actions count disappeared") + if isinstance(payload, dict): + payload = ( + payload.get("workflow_runs") + if "workflow_runs" in payload + else payload.get("runs") + ) + runs = self._as_list(payload) + if len(runs) > 100: + raise GiteaError("GITEA_ACTION_RUNS_INVALID", "excess exact Actions page") + for run in runs: + identifier, attempt = run.get("id"), run.get("run_attempt", 0) + number = run.get("run_number", identifier) + if ( + type(identifier) is not int + or identifier < 1 + or type(attempt) is not int + or attempt < 0 + or ("run_attempt" in run and attempt == 0) + or type(number) is not int + or number < 1 + or (identifier, attempt) in seen + ): + raise GiteaError( + "GITEA_ACTION_RUNS_INVALID", "invalid or repeated exact Actions run" + ) + seen.add((identifier, attempt)) + output.append(run) + if expected_count is not None: + if len(output) > expected_count: + raise GiteaError("GITEA_ACTION_RUNS_INVALID", "excess exact Actions runs") + complete = len(output) == expected_count + else: + complete = len(runs) < 100 + if complete: + if time.monotonic() >= deadline: + raise GiteaError( + "GITEA_ACTION_RUNS_TRUNCATED", "exact Actions read exceeded its bound" + ) + matching = [ + run + for run in output + if self.matches_exact_head_action_run( + run, + branch=branch, + head_sha=head_sha, + pull_request_number=pull_request_number, + ) + ] + return sorted( + matching, + key=lambda run: ( + run.get("run_number", run["id"]), + run.get("run_attempt", 0), + run["id"], + ), + reverse=True, + ) + if not runs: + raise GiteaError("GITEA_ACTION_RUNS_TRUNCATED", "incomplete exact Actions page") + raise GiteaError("GITEA_ACTION_RUNS_TRUNCATED", "exact Actions pages exceeded their bound") + def action_runs( self, repository: RegisteredRepository, @@ -78,6 +248,11 @@ class GiteaClient(BaseGiteaClient): ): return [] + native_runs = self.exact_head_action_runs(repository, branch=branch, head_sha=head_sha) + if native_runs: + offset = (page - 1) * limit + return native_runs[offset : offset + limit] + statuses = self.commit_statuses(repository, head_sha, 1, 100) if not statuses: return [] diff --git a/tests/test_gitea_connector.py b/tests/test_gitea_connector.py index 3ef250f..9e442b9 100644 --- a/tests/test_gitea_connector.py +++ b/tests/test_gitea_connector.py @@ -699,6 +699,69 @@ class GiteaConnectorTests(unittest.TestCase): finally: connector_instance.client.close() + def test_validation_logs_do_not_request_unstarted_jobs(self) -> None: + connector_instance, fixture = connector() + fixture._action_run.update(status="in_progress", conclusion="") + jobs = [ + {"id": 1, "name": "backend", "status": "in_progress", "conclusion": ""}, + *[ + {"id": index, "name": status, "status": status, "conclusion": ""} + for index, status in enumerate(("requested", "queued", "waiting", "pending"), 2) + ], + ] + + def available_logs(_repository: RegisteredRepository, job_id: int) -> tuple[str, bool]: + if job_id != 1: + raise GiteaError("GITEA_NOT_FOUND", "Gitea resource was not found") + return "checks running", False + + try: + with ( + patch.object(connector_instance.client, "action_run_jobs", return_value=jobs), + patch.object( + connector_instance.client, "action_job_logs", side_effect=available_logs + ) as log_read, + ): + result = connector_instance.validation_get( + context(), "hub-repository", 42, include_logs=True + ) + projected = result["data"]["run"]["jobs"] + self.assertEqual(log_read.call_count, 1) + self.assertEqual(log_read.call_args.args[1], 1) + self.assertEqual(projected[0]["logs"], "checks running") + self.assertFalse(projected[0]["logsUnavailable"]) + self.assertTrue(all(job["logsUnavailable"] for job in projected[1:])) + self.assertTrue( + all(job["logs"] == "[job not started; logs unavailable]" for job in projected[1:]) + ) + self.assertEqual(result["data"]["run"]["validationState"], "running") + self.assertNotIn("fixture-token", json.dumps(result)) + finally: + connector_instance.client.close() + + def test_validation_logs_preserve_started_job_errors(self) -> None: + connector_instance, _fixture = connector() + job = {"id": 1, "name": "backend", "status": "in_progress", "conclusion": ""} + try: + for code in ("GITEA_ACCESS_DENIED", "GITEA_UNAVAILABLE"): + with ( + self.subTest(code=code), + patch.object(connector_instance.client, "action_run_jobs", return_value=[job]), + patch.object( + connector_instance.client, + "action_job_logs", + side_effect=GiteaError(code, "bounded upstream error"), + ) as log_read, + self.assertRaises(GiteaError) as raised, + ): + connector_instance.validation_get( + context(), "hub-repository", 42, include_logs=True + ) + self.assertEqual(raised.exception.code, code) + self.assertEqual(log_read.call_count, 1) + finally: + connector_instance.client.close() + def test_repository_context_stays_below_connector_response_budget(self) -> None: connector_instance, fixture = connector() fixture._file = "R" * 100_000 @@ -2028,7 +2091,7 @@ class GiteaConnectorTests(unittest.TestCase): try: current = context() capabilities = connector_instance.capabilities_get(current)["data"] - self.assertEqual(fixture.recorded_paths, []) + self.assertEqual(fixture.recorded_paths, ["/api/v1/version"]) self.assertEqual(capabilities["version"], "1.14.0") self.assertEqual(capabilities["changeBranchPrefixes"], []) self.assertEqual(capabilities["workBranchPolicy"], "any-valid-non-default-branch") @@ -2054,7 +2117,13 @@ class GiteaConnectorTests(unittest.TestCase): self.assertEqual(selftest["mutationsAttempted"], 0) self.assertEqual( {item["name"] for item in selftest["checks"]}, - {"handler-parity", "gitea-connection", "write-runtime", "managed-actions"}, + { + "handler-parity", + "gitea-connection", + "write-runtime", + "managed-actions", + "upstream-merge-compatibility", + }, ) repository_health = connector_instance.repository_health(current, "hub-repository")[ diff --git a/tests/test_gitea_merge_validation_fallback.py b/tests/test_gitea_merge_validation_fallback.py index 8e1d84f..a5910ca 100644 --- a/tests/test_gitea_merge_validation_fallback.py +++ b/tests/test_gitea_merge_validation_fallback.py @@ -2,8 +2,6 @@ from __future__ import annotations -from unittest.mock import patch - import pytest from packages.connector_kit import GiteaError, PageRequest @@ -66,30 +64,17 @@ def test_merge_uses_status_linked_run_when_actions_wrapper_is_empty() -> None: def test_merge_accepts_exact_head_managed_run_when_gitea_omits_workflow_id() -> None: connector_instance, fixture = connector() _add_validated_head_branch(fixture) - original_action_runs = connector_instance.client.action_runs - - def action_runs_without_workflow_id(*args, **kwargs): - return [ - {key: value for key, value in run.items() if key != "workflow_id"} - for run in original_action_runs(*args, **kwargs) - ] - + fixture._action_run.pop("workflow_id") try: - with patch.object( - connector_instance.client, - "action_runs", - side_effect=action_runs_without_workflow_id, - ): - review = connector_instance.change_review( - context(), "hub-repository", "codex/workflows" - )["data"] - assert review["readyToMerge"] is True - assert review["validationRuns"][0]["id"] > 0 - assert review["validationRuns"][0]["workflowId"] == "" - - merged = connector_instance.pull_request_merge( - context(), "hub-repository", 7, "e" * 40, "squash" - )["data"] + review = connector_instance.change_review(context(), "hub-repository", "codex/workflows")[ + "data" + ] + assert review["readyToMerge"] is True + assert review["validationRuns"][0]["id"] > 0 + assert review["validationRuns"][0]["workflowId"] == "" + merged = connector_instance.pull_request_merge( + context(), "hub-repository", 7, "e" * 40, "squash" + )["data"] assert merged["merged"] is True assert merged["validationRunId"] > 0 diff --git a/tests/test_gitea_native_pr_discovery.py b/tests/test_gitea_native_pr_discovery.py new file mode 100644 index 0000000..0ddc3e7 --- /dev/null +++ b/tests/test_gitea_native_pr_discovery.py @@ -0,0 +1,465 @@ +"""Exact-head native Gitea Actions discovery without synthetic PR proof.""" + +from __future__ import annotations + +import copy +from unittest.mock import patch + +import pytest + +from packages.connector_kit import GiteaError +from tests.test_gitea_connector import connector, context + +HEAD = "e" * 40 +BRANCH = "codex/workflows" + + +def native_run(**changes): + return { + "id": 42, + "run_number": 17, + "run_attempt": 1, + "name": None, + "workflow_id": None, + "display_title": "ci: validate actual commit message", + "path": "managed-validation.yml@refs/pull/7/head", + "event": "pull_request", + "head_branch": "", + "head_sha": HEAD, + "status": "completed", + "conclusion": "success", + **changes, + } + + +def setup_native(runs=None): + instance, fixture = connector() + fixture._branches.append({"name": BRANCH, "commit": {"id": HEAD}, "protected": False}) + fixture._action_run = native_run() + native = runs if runs is not None else [fixture._action_run] + original = instance.client._request + requests = [] + + def request(path, *args, **kwargs): + if path.endswith("/actions/runs"): + query = args[0] if args else kwargs.get("params", {}) + requests.append(dict(query)) + if query.get("branch"): + return {"workflow_runs": [], "total_count": 0} + assert query.get("head_sha") == HEAD + return {"workflow_runs": copy.deepcopy(native), "total_count": len(native)} + return original(path, *args, **kwargs) + + instance.client._request = request + return instance, fixture, requests + + +def test_change_status_discovers_original_native_pr_run_with_null_workflow_id(): + instance, _fixture, requests = setup_native() + try: + status = instance.change_status(context(), "hub-repository", BRANCH)["data"] + assert status["validationRuns"][0]["id"] == 42 + assert status["validationRuns"][0]["workflowId"] == "" + assert status["validationRuns"][0]["workflowPath"] == native_run()["path"] + assert status["validationState"] == "succeeded" + assert status["readyToMerge"] is True + assert "run-validation" not in status["nextActions"] + assert requests and all("branch" not in item for item in requests) + finally: + instance.client.close() + + +def test_validation_ensure_reuses_bound_native_canonical_pr_run(): + instance, fixture, requests = setup_native() + try: + result = instance.pull_request_validation_ensure(context(), "hub-repository", 7, HEAD)[ + "data" + ] + assert result["state"] == "success" + assert result["runId"] == 42 + assert result["reused"] is True + assert requests and all(item.get("head_sha") == HEAD for item in requests) + assert "POST" not in fixture.recorded_methods + assert "PATCH" not in fixture.recorded_methods + finally: + instance.client.close() + + +@pytest.mark.parametrize( + "changes", + [ + {"path": "managed-validation.yml", "pull_requests": []}, + {"path": "security.yml@refs/pull/7/head"}, + {"path": "managed-validation.yml@refs/pull/8/head"}, + {"pull_requests": [{"number": 8}]}, + {"workflow_id": "security.yml", "status": "queued", "conclusion": ""}, + {"path": "security.yml@refs/pull/7/head", "conclusion": "failure"}, + ], +) +def test_validation_ensure_does_not_reuse_or_rerun_unbound_native_run(changes): + instance, fixture, _requests = setup_native([native_run(**changes)]) + fixture._action_run.update(changes) + try: + with ( + patch.object(instance._writer(), "rerun_action_run") as rerun, + patch.object( + instance._writer(), + "close_pull_request", + side_effect=GiteaError("GITEA_ACCESS_DENIED", "PR event trigger unavailable"), + ) as close, + pytest.raises(GiteaError) as error, + ): + instance.pull_request_validation_ensure(context(), "hub-repository", 7, HEAD) + assert error.value.code == "GITEA_ACCESS_DENIED" + close.assert_called_once() + rerun.assert_not_called() + assert "POST" not in fixture.recorded_methods + assert "PATCH" not in fixture.recorded_methods + finally: + instance.client.close() + + +@pytest.mark.parametrize( + "changes", + [ + {"head_sha": "d" * 40}, + {"head_branch": "other"}, + {"path": "managed-validation.yml@refs/pull/8/head"}, + {"pull_requests": [{"number": 8}]}, + {"pull_requests": [{"number": 7, "head": {"ref": "other", "sha": HEAD}}]}, + {"pull_requests": [{"number": 7, "head": {"ref": BRANCH, "sha": "d" * 40}}]}, + {"pull_requests": "malformed"}, + {"pull_requests": {}}, + {"pull_requests": [{"number": 7, "head": {"ref": [], "sha": HEAD}}]}, + {"path": "managed-validation.yml", "pull_requests": []}, + ], +) +def test_native_exact_head_read_rejects_contradictory_or_unbound_pr_metadata(changes): + instance, fixture, _requests = setup_native([native_run(**changes)]) + fixture._action_run.update(changes) + try: + repository = instance._repo(context(), "hub-repository") + assert ( + instance.client.exact_head_action_runs( + repository, + branch=BRANCH, + head_sha=HEAD, + pull_request_number=7, + ) + == [] + ) + with pytest.raises(GiteaError) as error: + instance.pull_request_merge(context(), "hub-repository", 7, HEAD, "squash") + assert error.value.code == "GITEA_PR_VALIDATION_REQUIRED" + assert not any(body.get("Do") == "squash" for body in fixture.recorded_bodies) + finally: + instance.client.close() + + +@pytest.mark.parametrize( + "path", + [ + "security.yml@refs/pull/7/head", + "untrusted/managed-validation.yml@refs/pull/7/head", + "managed-validation.yml@refs/pull/7/head@extra", + ], +) +def test_native_workflow_title_cannot_substitute_for_canonical_path(path): + instance, fixture, _requests = setup_native([native_run(path=path)]) + fixture._action_run["path"] = path + try: + status = instance.change_status(context(), "hub-repository", BRANCH)["data"] + assert status["validationRuns"] == [] + assert status["readyToMerge"] is False + with pytest.raises(GiteaError) as error: + instance.pull_request_merge(context(), "hub-repository", 7, HEAD, "squash") + assert error.value.code == "GITEA_PR_VALIDATION_REQUIRED" + finally: + instance.client.close() + + +def test_newer_native_failed_attempt_controls_validation_projection(): + runs = [native_run(), native_run(id=44, run_attempt=2, conclusion="failure")] + instance, fixture, _requests = setup_native(runs) + fixture._statuses[0]["status"] = "failure" + try: + status = instance.change_status(context(), "hub-repository", BRANCH)["data"] + assert status["validationRuns"][0]["id"] == 44 + assert status["validationRuns"][0]["runAttempt"] == 2 + assert status["readyToMerge"] is False + finally: + instance.client.close() + + +@pytest.mark.parametrize("failure", ["duplicate", "empty", "changed", "overflow"]) +def test_native_inventory_rejects_incomplete_or_nonadvancing_pages(failure): + instance, _fixture, _requests = setup_native() + original = instance.client._request + + def request(path, *args, **kwargs): + if path.endswith("/actions/runs"): + query = args[0] + if query["page"] == 1: + return {"workflow_runs": [native_run()], "total_count": 2} + if failure == "duplicate": + return {"workflow_runs": [native_run()], "total_count": 2} + if failure == "empty": + return {"workflow_runs": [], "total_count": 2} + if failure == "changed": + return {"workflow_runs": [native_run(id=43)], "total_count": 3} + return {"workflow_runs": [native_run(id=43), native_run(id=44)], "total_count": 2} + return original(path, *args, **kwargs) + + instance.client._request = request + try: + repository = instance._repo(context(), "hub-repository") + with pytest.raises(GiteaError) as error: + instance.client.exact_head_action_runs( + repository, + branch=BRANCH, + head_sha=HEAD, + pull_request_number=7, + ) + assert error.value.code in {"GITEA_ACTION_RUNS_INVALID", "GITEA_ACTION_RUNS_TRUNCATED"} + finally: + instance.client.close() + + +def test_validation_get_preserves_skipped_job_metadata_without_missing_logs(): + instance, _fixture = connector() + jobs = [ + {"id": 43, "name": "preflight", "status": "completed", "conclusion": "failure"}, + {"id": 44, "name": "build", "status": "completed", "conclusion": "skipped"}, + ] + try: + with ( + patch.object(instance.client, "action_run_jobs", return_value=jobs), + patch.object( + instance.client, + "action_job_logs", + return_value=("preflight failed", False), + ) as logs, + ): + result = instance.validation_get(context(), "hub-repository", 42, True)["data"]["run"] + assert len(result["jobs"]) == 2 + assert result["jobs"][1]["conclusion"] == "skipped" + assert result["jobs"][1]["logsUnavailable"] is True + assert logs.call_count == 1 + finally: + instance.client.close() + + +def test_validation_get_marks_expected_missing_logs_without_suppressing_other_errors(): + instance, _fixture = connector() + try: + with patch.object( + instance.client, + "action_job_logs", + side_effect=GiteaError( + "GITEA_NOT_FOUND", + "logs unavailable", + ), + ): + result = instance.validation_get(context(), "hub-repository", 42, True)["data"]["run"] + assert result["jobs"][0]["logsUnavailable"] is True + assert result["jobs"][0]["status"] == "completed" + with ( + patch.object( + instance.client, + "action_job_logs", + side_effect=GiteaError( + "GITEA_ACCESS_DENIED", + "access denied", + ), + ), + pytest.raises(GiteaError) as error, + ): + instance.validation_get(context(), "hub-repository", 42, True) + assert error.value.code == "GITEA_ACCESS_DENIED" + finally: + instance.client.close() + + +def test_legacy_branch_read_prefers_actual_native_run_before_status_fallback(): + instance, _fixture, _requests = setup_native() + try: + repository = instance._repo(context(), "hub-repository") + runs = instance.client.action_runs(repository, branch=BRANCH, page=1, limit=25) + assert len(runs) == 1 and runs[0]["id"] == 42 + assert "validation_evidence" not in runs[0] + finally: + instance.client.close() + + +def test_native_read_walks_all_actual_pages_and_returns_newest_attempt_first(): + instance, _fixture, _requests = setup_native() + original = instance.client._request + seen = [] + + def request(path, *args, **kwargs): + if path.endswith("/actions/runs"): + page = args[0]["page"] + seen.append(page) + assert kwargs["max_attempts"] == 1 and 0 < kwargs["timeout_seconds"] <= 10 + return {"workflow_runs": [native_run(id=41 + page, run_attempt=page)], "total_count": 2} + return original(path, *args, **kwargs) + + instance.client._request = request + try: + repository = instance._repo(context(), "hub-repository") + runs = instance.client.exact_head_action_runs( + repository, branch=BRANCH, head_sha=HEAD, pull_request_number=7 + ) + assert seen == [1, 2] and [run["id"] for run in runs] == [43, 42] + finally: + instance.client.close() + + +@pytest.mark.parametrize("changes", [{"id": True}, {"run_attempt": 0}, {"run_number": None}]) +def test_native_inventory_rejects_malformed_run_ordering(changes): + instance, _fixture, _requests = setup_native([native_run(**changes)]) + try: + repository = instance._repo(context(), "hub-repository") + with pytest.raises(GiteaError) as error: + instance.client.exact_head_action_runs( + repository, branch=BRANCH, head_sha=HEAD, pull_request_number=7 + ) + assert error.value.code == "GITEA_ACTION_RUNS_INVALID" + finally: + instance.client.close() + + +@pytest.mark.parametrize("length", [39, 41, 63, 65]) +def test_native_read_rejects_non_git_sha_lengths(length): + instance, fixture, _requests = setup_native() + try: + repository = instance._repo(context(), "hub-repository") + with pytest.raises(GiteaError) as error: + instance.client.exact_head_action_runs(repository, branch=BRANCH, head_sha="e" * length) + assert error.value.code == "GITEA_TARGET_INVALID" + assert not fixture.recorded_bodies + finally: + instance.client.close() + + +def test_dispatch_exact_branch_ref_remains_bound_to_original_run(): + instance, fixture = connector() + try: + fixture._branches.append({"name": BRANCH, "commit": {"id": HEAD}, "protected": False}) + dispatched = instance.validation_run(context(), "hub-repository", BRANCH, "full")["data"] + assert fixture._action_run["head_branch"] == f"refs/heads/{BRANCH}" + result = instance.change_status(context(), "hub-repository", BRANCH)["data"] + assert result["validationRuns"][0]["id"] == dispatched["id"] == 42 + bad = dict(fixture._action_run, head_branch="refs/heads/other") + assert not instance.client.matches_exact_head_action_run(bad, branch=BRANCH, head_sha=HEAD) + finally: + instance.client.close() + + +def test_blank_native_pr_number_without_workflow_identity_cannot_authorize_merge(): + run = native_run(path=None, workflow_id=None, pull_requests=[{"number": 7}]) + instance, fixture, _requests = setup_native([run]) + fixture._action_run = dict(run) + try: + result = instance.change_status(context(), "hub-repository", BRANCH)["data"] + assert result["validationRuns"] == [] + assert result["readyToMerge"] is False + with pytest.raises(GiteaError) as error: + instance.pull_request_merge(context(), "hub-repository", 7, HEAD, "squash") + assert error.value.code == "GITEA_PR_VALIDATION_REQUIRED" + assert not any(body.get("Do") == "squash" for body in fixture.recorded_bodies) + finally: + instance.client.close() + + +def test_legacy_missing_workflow_identity_requires_exact_expected_branch_for_merge(): + instance, fixture = connector() + fixture._action_run.pop("workflow_id", None) + fixture._action_run["head_branch"] = "other" + try: + with pytest.raises(GiteaError) as error: + instance.pull_request_merge(context(), "hub-repository", 7, HEAD, "squash") + assert error.value.code == "GITEA_PR_VALIDATION_REQUIRED" + assert not any(body.get("Do") == "squash" for body in fixture.recorded_bodies) + finally: + instance.client.close() + + +def test_actual_run_22404_null_head_branch_retains_pr236_original_proof(): + instance, fixture = connector() + actual_head = "c19a8620de6a24241b63aae4dbe52c0e5505aa62" + run = native_run( + id=22404, + head_sha=actual_head, + head_branch=None, + run_number=1177, + path="managed-validation.yml@refs/pull/236/head", + ) + fixture._action_run = run + fixture._pulls[0]["number"] = 236 + fixture._pulls[0]["head"]["sha"] = actual_head + fixture._branches.append({"name": BRANCH, "commit": {"id": actual_head}, "protected": False}) + fixture._statuses[0]["target_url"] = "https://gitea.example/acme/hub/actions/runs/22404" + original_request = instance.client._request + # The general fake transport serves only PR7; preserve actual PR236 payload. + instance.client._request = lambda path, *args, **kwargs: original_request( + path.replace("/pulls/236", "/pulls/7"), *args, **kwargs + ) + try: + result = instance.change_status(context(), "hub-repository", BRANCH)["data"] + assert result["validationRuns"][0]["id"] == 22404 + assert result["validationRuns"][0]["headBranch"] == "" + assert result["validationRuns"][0]["workflowPath"] == run["path"] + assert result["readyToMerge"] is True + merged = instance.pull_request_merge( + context(), "hub-repository", 236, actual_head, "squash" + )["data"] + assert merged["validationRunId"] == 22404 + finally: + instance.client.close() + + +@pytest.mark.parametrize("branch_metadata", [None, False, 0, [], {}]) +def test_native_nonstring_branch_requires_null_pr_shape_and_workflow_identity(branch_metadata): + run = native_run(head_branch=branch_metadata, path=None, pull_requests=[{"number": 7}]) + instance, fixture, _requests = setup_native([run]) + fixture._action_run = dict(run) + try: + result = instance.change_status(context(), "hub-repository", BRANCH)["data"] + assert result["validationRuns"] == [] + assert result["readyToMerge"] is False + with pytest.raises(GiteaError): + instance.pull_request_merge(context(), "hub-repository", 7, HEAD, "squash") + assert not any(body.get("Do") == "squash" for body in fixture.recorded_bodies) + finally: + instance.client.close() + + +def test_null_native_branch_does_not_hide_failed_required_workflow_context(): + instance, fixture, _requests = setup_native([native_run(head_branch=None)]) + fixture._action_run["head_branch"] = None + fixture._branch_protections["acme/hub"][0]["status_check_contexts"].append( + "Security / scan (pull_request)" + ) + fixture._statuses.append({"context": "Security / scan (pull_request)", "status": "failure"}) + try: + result = instance.change_status(context(), "hub-repository", BRANCH)["data"] + assert result["validationRuns"][0]["id"] == 42 + assert result["readyToMerge"] is False + with pytest.raises(GiteaError) as error: + instance.pull_request_merge(context(), "hub-repository", 7, HEAD, "squash") + assert error.value.code == "GITEA_REQUIRED_CHECK_BLOCKED" + assert not any(body.get("Do") == "squash" for body in fixture.recorded_bodies) + finally: + instance.client.close() + + +def test_null_branch_is_not_absent_branch_for_push_or_dispatch(): + instance, fixture = connector() + try: + for event in ("push", "workflow_dispatch"): + assert not instance.client.matches_exact_head_action_run( + native_run(head_branch=None, event=event), branch=BRANCH, head_sha=HEAD + ) + finally: + instance.client.close() diff --git a/tests/test_gitea_upstream_discovery.py b/tests/test_gitea_upstream_discovery.py new file mode 100644 index 0000000..7dfeb59 --- /dev/null +++ b/tests/test_gitea_upstream_discovery.py @@ -0,0 +1,230 @@ +"""Version negotiation canary: read-only diagnostics and fail-closed merge gates.""" + +from __future__ import annotations + +import json +from unittest.mock import patch + +import pytest + +from connectors.gitea import FakeGiteaServer +from packages.connector_kit import GiteaError +from tests.test_gitea_connector import connector, context +from tests.test_gitea_merge_version_compat import _client + + +@pytest.mark.parametrize( + ("version", "normalized", "field"), + [ + ("28.0.0", "28.0.0", "do"), + (" v28.0.0+gitea.1 ", "28.0.0", "do"), + ("v1.25.5+vendor.7", "1.25.5", "Do"), + ("1.26.4", "1.26.4", "do"), + ("1.27.2", "1.27.2", "do"), + ], +) +def test_upstream_discovery_normalizes_verified_release(version, normalized, field) -> None: + client, fixture, _ = _client(version) + try: + diagnostics = client.upstream_compatibility() + assert diagnostics["upstreamVersion"] == version.strip().split("+", 1)[0] + assert diagnostics["normalizedVersion"] == normalized + assert diagnostics["compatibility"] == "SUPPORTED" + assert diagnostics["mergeStrategyField"] == field + assert diagnostics["source"] == "/api/v1/version" + assert fixture.recorded_methods == ["GET"] + finally: + client.close() + + +@pytest.mark.parametrize( + "metadata", + [ + "AKIA" + "A" * 16, + "glpat-" + "x" * 24, + "eyJ" + "a" * 15 + "." + "b" * 15 + "." + "c" * 15, + "fixture-token", + "a1" * 20, + ], +) +@pytest.mark.parametrize("separator", ["+", "-"]) +def test_version_diagnostics_exclude_secret_shaped_metadata(metadata, separator): + instance, fixture = connector(FakeGiteaServer(gitea_version="28.0.0" + separator + metadata)) + try: + data = instance.capabilities_get(context())["data"] + diagnostics = data["upstreamGitea"] + assert metadata not in json.dumps(data) + assert diagnostics["upstreamVersion"] == "28.0.0" + assert diagnostics["normalizedVersion"] == "28.0.0" + assert diagnostics["compatibility"] == ("SUPPORTED" if separator == "+" else "UNSUPPORTED") + assert "POST" not in fixture.recorded_methods + finally: + instance.client.close() + + +@pytest.mark.parametrize( + ("version", "verdict"), + [ + ("1.24.9", "UNSUPPORTED"), + ("1.28.0", "UNSUPPORTED"), + ("2.0.0", "UNSUPPORTED"), + ("29.0.0", "UNSUPPORTED"), + ("28.0.0-rc.1", "UNSUPPORTED"), + ("", "UNKNOWN"), + ("garbage", "UNKNOWN"), + ("28.0.0+", "UNKNOWN"), + ("28.0.0-", "UNKNOWN"), + ("28.00.0", "UNKNOWN"), + ("28.0.0\nforged", "UNKNOWN"), + ], +) +def test_unknown_or_untested_upstream_fails_before_write(version, verdict) -> None: + client, fixture, repository = _client(version) + try: + diagnostics = client.upstream_compatibility() + assert diagnostics["compatibility"] == verdict + assert diagnostics["mergeStrategyField"] is None + with pytest.raises(GiteaError) as raised: + client.merge_pull_request(repository, 7, method="squash", head_commit_id="e" * 40) + assert raised.value.code == "GITEA_VERSION_UNSUPPORTED" + assert "POST" not in fixture.recorded_methods + finally: + client.close() + + +@pytest.mark.parametrize("payload", [{}, {"version": None}, {"version": 28}, []]) +def test_missing_or_non_string_upstream_version_cannot_fall_back_to_connector(payload) -> None: + instance, fixture = connector() + try: + with patch.object(instance.client, "_request", return_value=payload): + data = instance.capabilities_get(context())["data"] + assert data["connectorVersion"] == "1.14.0" + assert data["upstreamGitea"]["compatibility"] == "UNKNOWN" + assert data["upstreamGitea"]["upstreamVersion"] is None + assert data["subsystems"]["upstream_gitea"] == "UNVERIFIED" + assert data["subsystems"]["merge"] == "UNAVAILABLE" + assert fixture.recorded_methods == [] + finally: + instance.client.close() + + +def test_connector_and_server_versions_are_distinct_in_read_only_canary() -> None: + instance, fixture = connector(FakeGiteaServer(gitea_version="28.0.0")) + try: + data = instance.capabilities_get(context())["data"] + assert data["version"] == data["connectorVersion"] == "1.14.0" + assert data["upstreamGitea"]["upstreamVersion"] == "28.0.0" + assert data["upstreamGitea"]["compatibility"] == "SUPPORTED" + assert data["subsystems"]["upstream_gitea"] == "READY" + assert data["subsystems"]["merge"] == "READY" + canary = instance.selftest_run(context())["data"] + assert canary["status"] == "PASS" + assert any(item["name"] == "upstream-merge-compatibility" for item in canary["checks"]) + assert "POST" not in fixture.recorded_methods + finally: + instance.client.close() + + +def test_diagnostics_cache_expires_and_rechecks_after_dependency_recovery() -> None: + client, fixture, _ = _client("28.0.0") + try: + with patch("packages.connector_kit.gitea.time.monotonic", return_value=100): + assert client.upstream_compatibility()["compatibility"] == "SUPPORTED" + fixture.gitea_version = "29.0.0" + with patch("packages.connector_kit.gitea.time.monotonic", return_value=120): + assert client.upstream_compatibility()["compatibility"] == "SUPPORTED" + with patch("packages.connector_kit.gitea.time.monotonic", return_value=131): + assert client.upstream_compatibility()["compatibility"] == "UNSUPPORTED" + fixture.gitea_version = "28.0.0" + with patch("packages.connector_kit.gitea.time.monotonic", return_value=162): + assert client.upstream_compatibility()["compatibility"] == "SUPPORTED" + assert fixture.recorded_paths.count("/api/v1/version") == 3 + finally: + client.close() + + +def test_merge_rechecks_server_even_with_cached_successful_diagnostics() -> None: + client, fixture, repository = _client("28.0.0") + try: + assert client.upstream_compatibility()["compatibility"] == "SUPPORTED" + fixture.gitea_version = "29.0.0" + with pytest.raises(GiteaError) as raised: + client.merge_pull_request(repository, 7, method="squash", head_commit_id="e" * 40) + assert raised.value.code == "GITEA_VERSION_UNSUPPORTED" + assert "POST" not in fixture.recorded_methods + assert fixture.recorded_paths.count("/api/v1/version") == 2 + finally: + client.close() + + +def test_version_outage_invalidates_cached_success_and_recovers() -> None: + client, fixture, repository = _client("28.0.0") + try: + assert client.upstream_compatibility()["compatibility"] == "SUPPORTED" + with patch.object( + client, + "_request", + side_effect=GiteaError("GITEA_UNAVAILABLE", "upstream request unavailable"), + ): + with pytest.raises(GiteaError) as raised: + client.merge_pull_request(repository, 7, method="squash", head_commit_id="e" * 40) + assert raised.value.code == "GITEA_UNAVAILABLE" + assert client.upstream_compatibility()["compatibility"] == "UNAVAILABLE" + assert "POST" not in fixture.recorded_methods + assert client.upstream_compatibility(force_refresh=True)["compatibility"] == "SUPPORTED" + finally: + client.close() + + +def test_readiness_reports_failed_version_probe_without_disabling_read_service() -> None: + instance, _ = connector() + original_request = instance.client._request + + def request(path, *args, **kwargs): + if path == "version": + raise GiteaError("GITEA_UNAVAILABLE", "upstream request unavailable") + return original_request(path, *args, **kwargs) + + try: + with patch.object(instance.client, "_request", side_effect=request): + data = instance.readiness() + assert data["status"] == "ready" + assert data["upstream"] == "unavailable" + assert data["subsystems"]["upstream_gitea"] == "UNAVAILABLE" + assert data["subsystems"]["merge"] == "UNAVAILABLE" + assert data["upstreamGitea"]["errorCode"] == "GITEA_UNAVAILABLE" + finally: + instance.client.close() + + +@pytest.mark.parametrize("mutation", ["wrong-head", "wrong-event", "failed"]) +def test_current_version_still_requires_exact_successful_pr_validation(mutation) -> None: + instance, fixture = connector(FakeGiteaServer(gitea_version="28.0.0")) + if mutation == "wrong-head": + fixture._action_run["head_sha"] = "d" * 40 + elif mutation == "wrong-event": + fixture._action_run["event"] = "workflow_dispatch" + else: + fixture._action_run["conclusion"] = "failure" + try: + with pytest.raises(GiteaError): + instance.pull_request_merge(context(), "hub-repository", 7, "e" * 40, "squash") + assert "/api/v1/repos/acme/hub/pulls/7/merge" not in fixture.recorded_paths + assert fixture._pulls[0].get("merged") is not True + finally: + instance.client.close() + + +def test_current_release_allows_only_sha_locked_non_forced_merge() -> None: + instance, fixture = connector(FakeGiteaServer(gitea_version="28.0.0")) + try: + result = instance.pull_request_merge(context(), "hub-repository", 7, "e" * 40, "squash")[ + "data" + ] + assert result["validationRunId"] == 42 + body = next(body for body in fixture.recorded_bodies if body.get("do") == "squash") + assert body["head_commit_id"] == "e" * 40 + assert body["force_merge"] is False + assert body["merge_when_checks_succeed"] is False + finally: + instance.client.close() diff --git a/tests/test_managed_validation_action_pins.py b/tests/test_managed_validation_action_pins.py index c9df711..c3bff55 100644 --- a/tests/test_managed_validation_action_pins.py +++ b/tests/test_managed_validation_action_pins.py @@ -9,8 +9,9 @@ def test_managed_validation_uses_resolvable_setup_dotnet_v4_pin() -> None: assert "actions/setup-dotnet@67a3573c9a9863f9c594539f4ab511d57bb3ce9" not in workflow -def test_managed_validation_uses_internal_gitea_for_checkout() -> None: +def test_managed_validation_uses_canonical_https_gitea_for_checkout() -> None: with open(".gitea/workflows/managed-validation.yml", encoding="utf-8") as workflow_file: workflow = workflow_file.read() - assert "github-server-url: http://gitea-runner-internal:3000" in workflow + assert workflow.count("github-server-url: https://gitea.example.com") == 2 + assert "http://gitea-runner-internal:3000" not in workflow diff --git a/tests/test_release_images_workflow.py b/tests/test_release_images_workflow.py index 5df31bb..decd770 100644 --- a/tests/test_release_images_workflow.py +++ b/tests/test_release_images_workflow.py @@ -16,6 +16,22 @@ def release_job() -> dict: return workflow["jobs"]["release-images"] +def test_all_checkouts_use_the_canonical_authenticated_https_origin() -> None: + workflow = yaml.safe_load( + (ROOT / ".gitea/workflows/managed-validation.yml").read_text(encoding="utf-8") + ) + checkouts = [ + step + for job in workflow["jobs"].values() + for step in job["steps"] + if step.get("uses") == CHECKOUT + ] + assert len(checkouts) == 2 + for checkout in checkouts: + assert checkout["with"]["github-server-url"] == "https://gitea.example.com" + assert "token" not in checkout["with"] + + def test_release_build_requires_successful_validation_and_exact_main_push() -> None: job = release_job() assert job["name"] == "release-images"