feat: add repository mesh reconciliation round

This commit is contained in:
2026-04-30 10:50:07 -03:00
parent 3d2748adf5
commit b79fdce99d
113 changed files with 81555 additions and 22807 deletions

View File

@@ -0,0 +1,440 @@
"""Gitea planning helpers for repository mesh consolidation.
The permanent order asks the mesh to be consolidated in Gitea. This module
keeps the Gitea part explicit and safe: it can build API requests, classify
responses, and plan repository creation/rename work, but it does not hide
missing credentials or assume a remote exists just because a local directory
does.
"""
from __future__ import annotations
import base64
import json
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from typing import Any, Mapping, Sequence
from .models import GeneratedFile, as_plain_data, merge_unique, utc_now
from .repository_mesh import MeshReport, RepositoryTarget, rows_to_csv, stable_digest
class GiteaRepoStatus(str, Enum):
"""Remote repository status."""
EXISTS = "exists"
MISSING = "missing"
UNAUTHORIZED = "unauthorized"
FORBIDDEN = "forbidden"
NETWORK_ERROR = "network_error"
UNKNOWN = "unknown"
class GiteaPlannedAction(str, Enum):
"""Gitea actions that may be required."""
NONE = "none"
VERIFY_REPOSITORY = "verify_repository"
CREATE_REPOSITORY = "create_repository"
RENAME_REPOSITORY = "rename_repository"
CONFIGURE_MIRROR = "configure_mirror"
REQUIRE_TOKEN = "require_token"
REQUIRE_OWNER_DECISION = "require_owner_decision"
@dataclass(frozen=True, slots=True)
class GiteaEndpoint:
base_url: str
owner: str
repo: str
@property
def api_repo_path(self) -> str:
owner = urllib.parse.quote(self.owner, safe="")
repo = urllib.parse.quote(self.repo, safe="")
return f"/api/v1/repos/{owner}/{repo}"
@property
def web_url(self) -> str:
return f"{self.base_url.rstrip('/')}/{self.owner}/{self.repo}"
@property
def clone_url(self) -> str:
return f"{self.web_url}.git"
@dataclass(frozen=True, slots=True)
class GiteaAuth:
token: str | None = None
username: str | None = None
password: str | None = None
@property
def available(self) -> bool:
return bool(self.token or (self.username and self.password))
def headers(self) -> dict[str, str]:
headers = {"Accept": "application/json"}
if self.token:
headers["Authorization"] = f"token {self.token}"
elif self.username and self.password:
raw = f"{self.username}:{self.password}".encode("utf-8")
headers["Authorization"] = "Basic " + base64.b64encode(raw).decode("ascii")
return headers
def redacted_label(self) -> str:
if self.token:
return "token:<redacted>"
if self.username and self.password:
return f"basic:{self.username}:<redacted>"
return "none"
@dataclass(frozen=True, slots=True)
class GiteaResponse:
status: GiteaRepoStatus
http_status: int | None
url: str
payload: Mapping[str, Any]
error: str = ""
@property
def ok(self) -> bool:
return self.status == GiteaRepoStatus.EXISTS
def to_dict(self) -> dict[str, Any]:
return as_plain_data(self)
@dataclass(frozen=True, slots=True)
class GiteaRepositoryPlan:
plan_id: str
declared_name: str
expected_local_name: str
owner: str
repo: str
clone_url: str
status: GiteaRepoStatus
actions: tuple[GiteaPlannedAction, ...]
reason: str
commands: tuple[str, ...]
api_requests: tuple[str, ...]
evidence: tuple[str, ...]
@property
def blocked(self) -> bool:
return any(action in {GiteaPlannedAction.REQUIRE_TOKEN, GiteaPlannedAction.REQUIRE_OWNER_DECISION} for action in self.actions)
def to_dict(self) -> dict[str, Any]:
return as_plain_data(self)
@dataclass(frozen=True, slots=True)
class GiteaMeshPlan:
mesh_plan_id: str
generated_at: str
base_url: str
authenticated_as: str
repositories: tuple[GiteaRepositoryPlan, ...]
@property
def blocked_count(self) -> int:
return sum(1 for repo in self.repositories if repo.blocked)
@property
def missing_count(self) -> int:
return sum(1 for repo in self.repositories if repo.status == GiteaRepoStatus.MISSING)
@property
def exists_count(self) -> int:
return sum(1 for repo in self.repositories if repo.status == GiteaRepoStatus.EXISTS)
def to_dict(self) -> dict[str, Any]:
return as_plain_data(self)
def endpoint_for_target(target: RepositoryTarget, base_url: str = "https://git.ami.app.br") -> GiteaEndpoint:
owner, _, repo = target.gitea_repo.partition("/")
return GiteaEndpoint(base_url=base_url.rstrip("/"), owner=owner or "admin", repo=repo or target.expected_local_name)
def classify_http_error(status: int | None, error: str = "") -> GiteaRepoStatus:
if status == 200:
return GiteaRepoStatus.EXISTS
if status == 404:
return GiteaRepoStatus.MISSING
if status == 401:
return GiteaRepoStatus.UNAUTHORIZED
if status == 403:
return GiteaRepoStatus.FORBIDDEN
if status is None and error:
return GiteaRepoStatus.NETWORK_ERROR
return GiteaRepoStatus.UNKNOWN
def request_gitea_repo(endpoint: GiteaEndpoint, auth: GiteaAuth, timeout: int = 15) -> GiteaResponse:
url = endpoint.base_url.rstrip("/") + endpoint.api_repo_path
request = urllib.request.Request(url, headers=auth.headers(), method="GET")
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
text = response.read().decode("utf-8", errors="replace")
payload = json.loads(text) if text.strip() else {}
return GiteaResponse(GiteaRepoStatus.EXISTS, response.status, url, payload)
except urllib.error.HTTPError as exc:
text = exc.read().decode("utf-8", errors="replace")
try:
payload = json.loads(text) if text.strip() else {}
except json.JSONDecodeError:
payload = {"raw": text}
return GiteaResponse(classify_http_error(exc.code), exc.code, url, payload, str(exc))
except (urllib.error.URLError, TimeoutError, OSError) as exc:
return GiteaResponse(GiteaRepoStatus.NETWORK_ERROR, None, url, {}, str(exc))
def infer_status_from_report(target: RepositoryTarget, report: MeshReport) -> GiteaRepoStatus:
for summary in report.summaries:
if summary.target.declared_name != target.declared_name:
continue
if summary.credential_error_count:
return GiteaRepoStatus.UNAUTHORIZED
if any(summary.hash_set):
return GiteaRepoStatus.EXISTS
if summary.missing_count == len(summary.observations):
return GiteaRepoStatus.UNKNOWN
return GiteaRepoStatus.UNKNOWN
def plan_for_target(
target: RepositoryTarget,
*,
endpoint: GiteaEndpoint,
status: GiteaRepoStatus,
auth: GiteaAuth,
) -> GiteaRepositoryPlan:
actions: list[GiteaPlannedAction] = []
commands: list[str] = []
api_requests: list[str] = []
evidence: list[str] = [endpoint.clone_url]
reason = "repositorio Gitea verificado"
if not auth.available:
actions.append(GiteaPlannedAction.REQUIRE_TOKEN)
reason = "credencial Gitea ausente; nao e seguro declarar existencia/criacao remota"
if status == GiteaRepoStatus.EXISTS:
actions.append(GiteaPlannedAction.VERIFY_REPOSITORY)
api_requests.append(f"GET {endpoint.api_repo_path}")
elif status == GiteaRepoStatus.MISSING:
actions.append(GiteaPlannedAction.CREATE_REPOSITORY)
api_requests.append(f"POST /api/v1/orgs/{endpoint.owner}/repos")
commands.append(f"curl -X POST {endpoint.base_url}/api/v1/orgs/{endpoint.owner}/repos -d '{{\"name\":\"{endpoint.repo}\"}}'")
reason = "repositorio Gitea nao encontrado; criar somente com token e owner confirmados"
elif status in {GiteaRepoStatus.UNAUTHORIZED, GiteaRepoStatus.FORBIDDEN}:
actions.append(GiteaPlannedAction.REQUIRE_TOKEN)
reason = "Gitea respondeu sem permissao suficiente"
elif status == GiteaRepoStatus.NETWORK_ERROR:
actions.append(GiteaPlannedAction.VERIFY_REPOSITORY)
reason = "erro de rede impede verificar repositório central"
else:
actions.append(GiteaPlannedAction.VERIFY_REPOSITORY)
reason = "estado remoto nao confirmado"
if target.requires_nominal_reconciliation:
actions.append(GiteaPlannedAction.REQUIRE_OWNER_DECISION)
evidence.extend(target.notes)
if target.canonical_name and target.canonical_name != endpoint.repo:
actions.append(GiteaPlannedAction.RENAME_REPOSITORY)
api_requests.append(f"PATCH {endpoint.api_repo_path} name={target.canonical_name}")
reason += "; renome remoto depende de decisao institucional"
actions = list(merge_unique(action.value for action in actions))
action_enums = tuple(GiteaPlannedAction(value) for value in actions)
seed = {
"target": target.declared_name,
"status": status.value,
"actions": [action.value for action in action_enums],
"endpoint": endpoint.clone_url,
}
return GiteaRepositoryPlan(
plan_id=f"gitea-repo-plan-{stable_digest(seed, 12)}",
declared_name=target.declared_name,
expected_local_name=target.expected_local_name,
owner=endpoint.owner,
repo=endpoint.repo,
clone_url=endpoint.clone_url,
status=status,
actions=action_enums,
reason=reason,
commands=tuple(commands),
api_requests=tuple(api_requests),
evidence=tuple(evidence),
)
def build_gitea_mesh_plan(
report: MeshReport,
*,
base_url: str = "https://git.ami.app.br",
auth: GiteaAuth | None = None,
live_check: bool = False,
) -> GiteaMeshPlan:
auth = auth or GiteaAuth()
plans: list[GiteaRepositoryPlan] = []
for target in report.targets:
endpoint = endpoint_for_target(target, base_url)
if live_check and auth.available:
response = request_gitea_repo(endpoint, auth)
status = response.status
evidence = (response.url, response.error)
else:
status = infer_status_from_report(target, report)
evidence = ()
plan = plan_for_target(target, endpoint=endpoint, status=status, auth=auth)
if evidence:
plan = GiteaRepositoryPlan(
plan_id=plan.plan_id,
declared_name=plan.declared_name,
expected_local_name=plan.expected_local_name,
owner=plan.owner,
repo=plan.repo,
clone_url=plan.clone_url,
status=plan.status,
actions=plan.actions,
reason=plan.reason,
commands=plan.commands,
api_requests=plan.api_requests,
evidence=merge_unique((*plan.evidence, *evidence)),
)
plans.append(plan)
seed = {"report": report.report_id, "base": base_url, "plans": [plan.plan_id for plan in plans]}
return GiteaMeshPlan(
mesh_plan_id=f"gitea-mesh-plan-{stable_digest(seed, 12)}",
generated_at=utc_now(),
base_url=base_url.rstrip("/"),
authenticated_as=auth.redacted_label(),
repositories=tuple(plans),
)
def gitea_plan_rows(plan: GiteaMeshPlan) -> list[list[str]]:
rows = [["plan_id", "declared_name", "owner", "repo", "status", "blocked", "actions", "reason", "api_requests"]]
for repo in plan.repositories:
rows.append(
[
repo.plan_id,
repo.declared_name,
repo.owner,
repo.repo,
repo.status.value,
"yes" if repo.blocked else "no",
" | ".join(action.value for action in repo.actions),
repo.reason,
" | ".join(repo.api_requests),
]
)
return rows
def gitea_plan_csv(plan: GiteaMeshPlan) -> str:
return rows_to_csv(gitea_plan_rows(plan))
def gitea_plan_markdown(plan: GiteaMeshPlan) -> str:
lines = [
"# Gitea Repository Mesh Plan",
"",
f"- mesh_plan_id: `{plan.mesh_plan_id}`",
f"- generated_at: `{plan.generated_at}`",
f"- base_url: `{plan.base_url}`",
f"- authenticated_as: `{plan.authenticated_as}`",
f"- exists: `{plan.exists_count}`",
f"- missing: `{plan.missing_count}`",
f"- blocked: `{plan.blocked_count}`",
"",
"## Repositorios",
"",
]
for repo in plan.repositories:
lines.append(f"### {repo.declared_name}")
lines.append("")
lines.append(f"- clone_url: `{repo.clone_url}`")
lines.append(f"- status: `{repo.status.value}`")
lines.append(f"- blocked: `{repo.blocked}`")
lines.append(f"- actions: {', '.join(action.value for action in repo.actions)}")
lines.append(f"- reason: {repo.reason}")
if repo.api_requests:
lines.append("- api_requests:")
for request in repo.api_requests:
lines.append(f" - `{request}`")
lines.append("")
return "\n".join(lines).strip() + "\n"
def write_json(path: Path, payload: object) -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(as_plain_data(payload), ensure_ascii=False, indent=2, sort_keys=True), encoding="utf-8")
return path
def write_text(path: Path, text: str) -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
return path
def write_gitea_plan_artifacts(
plan: GiteaMeshPlan,
project_root: Path,
*,
central_platform_folder: Path | None = None,
) -> tuple[GeneratedFile, ...]:
write_json(project_root / "dados" / "repository-mesh-gitea-plan.json", plan)
write_text(project_root / "matrizes" / "repository-mesh-gitea-plan.csv", gitea_plan_csv(plan))
write_text(project_root / "ecossistema" / "REPOSITORY-MESH-GITEA.md", gitea_plan_markdown(plan))
if central_platform_folder is not None:
reports = central_platform_folder / "reports"
reports.mkdir(parents=True, exist_ok=True)
write_text(reports / "PENDENCIAS-CODEX__repository-mesh-gitea.md", gitea_plan_markdown(plan))
records = [
GeneratedFile(
path="dados/repository-mesh-gitea-plan.json",
description="Plano Gitea da malha de repositorios.",
function="repository mesh gitea plan",
file_type="json",
changed_by="mais_humana.repository_mesh_gitea",
change_summary="Criado plano de verificacao/criacao/renome remoto.",
relation_to_order="000_sincronizacao-dos-espelhos",
),
GeneratedFile(
path="matrizes/repository-mesh-gitea-plan.csv",
description="Matriz do plano Gitea.",
function="repository mesh gitea matrix",
file_type="csv",
changed_by="mais_humana.repository_mesh_gitea",
change_summary="Gerada matriz de acoes Gitea.",
relation_to_order="000_sincronizacao-dos-espelhos",
),
GeneratedFile(
path="ecossistema/REPOSITORY-MESH-GITEA.md",
description="Relatorio humano do plano Gitea.",
function="repository mesh gitea report",
file_type="markdown",
changed_by="mais_humana.repository_mesh_gitea",
change_summary="Documentado plano Gitea sem credencial bruta.",
relation_to_order="000_sincronizacao-dos-espelhos",
),
]
if central_platform_folder is not None:
records.append(
GeneratedFile(
path=str(central_platform_folder / "reports" / "PENDENCIAS-CODEX__repository-mesh-gitea.md"),
description="Pendencias Gitea registradas na central.",
function="repository mesh central gitea",
file_type="markdown",
changed_by="mais_humana.repository_mesh_gitea",
change_summary="Registradas pendencias de token/decisao para Gitea.",
relation_to_order="000_sincronizacao-dos-espelhos",
)
)
return tuple(records)