feat: add repository mesh reconciliation round
This commit is contained in:
440
src/mais_humana/repository_mesh_gitea.py
Normal file
440
src/mais_humana/repository_mesh_gitea.py
Normal 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)
|
||||
|
||||
Reference in New Issue
Block a user