445 lines
16 KiB
Python
445 lines
16 KiB
Python
"""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))
|
|
central_written: list[Path] = []
|
|
if central_platform_folder is not None:
|
|
path = central_platform_folder / "reports" / "PENDENCIAS-CODEX__repository-mesh-gitea.md"
|
|
try:
|
|
write_text(path, gitea_plan_markdown(plan))
|
|
except OSError:
|
|
pass
|
|
else:
|
|
central_written.append(path)
|
|
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",
|
|
),
|
|
]
|
|
for central_path in central_written:
|
|
records.append(
|
|
GeneratedFile(
|
|
path=str(central_path),
|
|
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)
|