Navegador

navegador / navegador / cicd.py
Source Blame History 206 lines
aa55498… lmata 1 """
aa55498… lmata 2 CI/CD mode for navegador — non-interactive output, structured exit codes,
aa55498… lmata 3 and GitHub Actions integration.
aa55498… lmata 4
aa55498… lmata 5 Exit code contract:
aa55498… lmata 6 0 — success, no issues
aa55498… lmata 7 1 — hard error (ingest failed, DB unreachable, schema corrupt)
aa55498… lmata 8 2 — warnings only (migration needed, zero symbols ingested)
aa55498… lmata 9 """
aa55498… lmata 10
aa55498… lmata 11 import json
aa55498… lmata 12 import os
aa55498… lmata 13 import sys
aa55498… lmata 14 from dataclasses import dataclass, field
aa55498… lmata 15 from typing import Any
aa55498… lmata 16
aa55498… lmata 17 # ── Exit codes ────────────────────────────────────────────────────────────────
aa55498… lmata 18
aa55498… lmata 19 EXIT_SUCCESS = 0
aa55498… lmata 20 EXIT_ERROR = 1
aa55498… lmata 21 EXIT_WARN = 2
aa55498… lmata 22
aa55498… lmata 23
aa55498… lmata 24 # ── CI environment detection ─────────────────────────────────────────────────
aa55498… lmata 25
aa55498… lmata 26 _CI_VARS = ("GITHUB_ACTIONS", "CI", "GITLAB_CI", "CIRCLECI", "JENKINS_URL")
aa55498… lmata 27
aa55498… lmata 28
aa55498… lmata 29 def detect_ci() -> str | None:
aa55498… lmata 30 """
aa55498… lmata 31 Return the name of the detected CI environment, or None if not in CI.
aa55498… lmata 32
aa55498… lmata 33 Checks environment variables in priority order:
aa55498… lmata 34 GITHUB_ACTIONS → "github_actions"
aa55498… lmata 35 CI → "ci"
aa55498… lmata 36 GITLAB_CI → "gitlab_ci"
aa55498… lmata 37 CIRCLECI → "circleci"
aa55498… lmata 38 JENKINS_URL → "jenkins"
aa55498… lmata 39 """
aa55498… lmata 40 if os.environ.get("GITHUB_ACTIONS"):
aa55498… lmata 41 return "github_actions"
aa55498… lmata 42 if os.environ.get("CI"):
aa55498… lmata 43 return "ci"
aa55498… lmata 44 if os.environ.get("GITLAB_CI"):
aa55498… lmata 45 return "gitlab_ci"
aa55498… lmata 46 if os.environ.get("CIRCLECI"):
aa55498… lmata 47 return "circleci"
aa55498… lmata 48 if os.environ.get("JENKINS_URL"):
aa55498… lmata 49 return "jenkins"
aa55498… lmata 50 return None
aa55498… lmata 51
aa55498… lmata 52
aa55498… lmata 53 def is_ci() -> bool:
aa55498… lmata 54 """Return True if running inside any recognised CI environment."""
aa55498… lmata 55 return detect_ci() is not None
aa55498… lmata 56
aa55498… lmata 57
aa55498… lmata 58 def is_github_actions() -> bool:
aa55498… lmata 59 """Return True if running inside GitHub Actions specifically."""
aa55498… lmata 60 return detect_ci() == "github_actions"
aa55498… lmata 61
aa55498… lmata 62
aa55498… lmata 63 # ── Reporter ──────────────────────────────────────────────────────────────────
aa55498… lmata 64
aa55498… lmata 65
aa55498… lmata 66 @dataclass
aa55498… lmata 67 class CICDReporter:
aa55498… lmata 68 """
aa55498… lmata 69 Machine-readable reporter for CI/CD pipelines.
aa55498… lmata 70
aa55498… lmata 71 Collects errors and warnings during a command run, then emits either
aa55498… lmata 72 plain JSON (all CI systems) or GitHub Actions annotations + step summaries
aa55498… lmata 73 (when GITHUB_ACTIONS is set).
aa55498… lmata 74
aa55498… lmata 75 Usage::
aa55498… lmata 76
aa55498… lmata 77 reporter = CICDReporter()
aa55498… lmata 78 reporter.add_error("ingest failed: <reason>")
aa55498… lmata 79 reporter.add_warning("no Python files found")
aa55498… lmata 80 reporter.emit(data={"files": 0})
aa55498… lmata 81 sys.exit(reporter.exit_code())
aa55498… lmata 82 """
aa55498… lmata 83
aa55498… lmata 84 errors: list[str] = field(default_factory=list)
aa55498… lmata 85 warnings: list[str] = field(default_factory=list)
aa55498… lmata 86
aa55498… lmata 87 # ── Collecting ────────────────────────────────────────────────────────────
aa55498… lmata 88
aa55498… lmata 89 def add_error(self, message: str) -> None:
aa55498… lmata 90 """Record a hard error (will produce exit code 1)."""
aa55498… lmata 91 self.errors.append(message)
aa55498… lmata 92
aa55498… lmata 93 def add_warning(self, message: str) -> None:
aa55498… lmata 94 """Record a warning (will produce exit code 2 when no errors)."""
aa55498… lmata 95 self.warnings.append(message)
aa55498… lmata 96
aa55498… lmata 97 # ── Exit code ─────────────────────────────────────────────────────────────
aa55498… lmata 98
aa55498… lmata 99 def exit_code(self) -> int:
aa55498… lmata 100 """
aa55498… lmata 101 Return the appropriate process exit code.
aa55498… lmata 102
aa55498… lmata 103 0 — no errors, no warnings
aa55498… lmata 104 1 — at least one error
aa55498… lmata 105 2 — warnings only
aa55498… lmata 106 """
aa55498… lmata 107 if self.errors:
aa55498… lmata 108 return EXIT_ERROR
aa55498… lmata 109 if self.warnings:
aa55498… lmata 110 return EXIT_WARN
aa55498… lmata 111 return EXIT_SUCCESS
aa55498… lmata 112
aa55498… lmata 113 # ── Output ────────────────────────────────────────────────────────────────
aa55498… lmata 114
aa55498… lmata 115 def emit(self, data: dict[str, Any] | None = None, *, file=None) -> None:
aa55498… lmata 116 """
aa55498… lmata 117 Emit the report to stdout (or *file*).
aa55498… lmata 118
aa55498… lmata 119 In GitHub Actions this also:
aa55498… lmata 120 - Prints ``::error`` / ``::warning`` annotations for each issue.
aa55498… lmata 121 - Writes a Markdown step summary to $GITHUB_STEP_SUMMARY.
aa55498… lmata 122
aa55498… lmata 123 In all environments it prints a JSON envelope::
aa55498… lmata 124
aa55498… lmata 125 {
aa55498… lmata 126 "status": "success" | "error" | "warning",
aa55498… lmata 127 "errors": [...],
aa55498… lmata 128 "warnings": [...],
aa55498… lmata 129 "data": {...}
aa55498… lmata 130 }
aa55498… lmata 131 """
aa55498… lmata 132 if file is None:
aa55498… lmata 133 file = sys.stdout
aa55498… lmata 134
aa55498… lmata 135 payload = self._build_payload(data)
aa55498… lmata 136
aa55498… lmata 137 if is_github_actions():
aa55498… lmata 138 self._emit_github_annotations(file=file)
aa55498… lmata 139 self._write_github_step_summary(payload)
aa55498… lmata 140
aa55498… lmata 141 print(json.dumps(payload, indent=2), file=file)
aa55498… lmata 142
aa55498… lmata 143 # ── Internal helpers ──────────────────────────────────────────────────────
aa55498… lmata 144
aa55498… lmata 145 def _status_str(self) -> str:
aa55498… lmata 146 code = self.exit_code()
aa55498… lmata 147 if code == EXIT_ERROR:
aa55498… lmata 148 return "error"
aa55498… lmata 149 if code == EXIT_WARN:
aa55498… lmata 150 return "warning"
aa55498… lmata 151 return "success"
aa55498… lmata 152
aa55498… lmata 153 def _build_payload(self, data: dict[str, Any] | None) -> dict[str, Any]:
aa55498… lmata 154 payload: dict[str, Any] = {
aa55498… lmata 155 "status": self._status_str(),
aa55498… lmata 156 "errors": list(self.errors),
aa55498… lmata 157 "warnings": list(self.warnings),
aa55498… lmata 158 }
aa55498… lmata 159 if data is not None:
aa55498… lmata 160 payload["data"] = data
aa55498… lmata 161 return payload
aa55498… lmata 162
aa55498… lmata 163 def _emit_github_annotations(self, *, file=None) -> None:
aa55498… lmata 164 """Print GitHub Actions workflow commands for each issue."""
aa55498… lmata 165 if file is None:
aa55498… lmata 166 file = sys.stdout
aa55498… lmata 167 for err in self.errors:
aa55498… lmata 168 print(f"::error::{err}", file=file)
aa55498… lmata 169 for warn in self.warnings:
aa55498… lmata 170 print(f"::warning::{warn}", file=file)
aa55498… lmata 171
aa55498… lmata 172 def _write_github_step_summary(self, payload: dict[str, Any]) -> None:
aa55498… lmata 173 """Append a Markdown summary to $GITHUB_STEP_SUMMARY if set."""
aa55498… lmata 174 summary_path = os.environ.get("GITHUB_STEP_SUMMARY")
aa55498… lmata 175 if not summary_path:
aa55498… lmata 176 return
aa55498… lmata 177
aa55498… lmata 178 lines: list[str] = []
e5e1a3b… lmata 179 status_icon = {"success": "✅", "warning": "⚠️", "error": "❌"}.get(payload["status"], "ℹ️")
aa55498… lmata 180 lines.append(f"## Navegador — {status_icon} {payload['status'].capitalize()}\n")
aa55498… lmata 181
aa55498… lmata 182 if payload.get("data"):
aa55498… lmata 183 lines.append("### Stats\n")
aa55498… lmata 184 lines.append("| Key | Value |")
aa55498… lmata 185 lines.append("| --- | --- |")
aa55498… lmata 186 for k, v in payload["data"].items():
aa55498… lmata 187 lines.append(f"| {k} | {v} |")
aa55498… lmata 188 lines.append("")
aa55498… lmata 189
aa55498… lmata 190 if payload["errors"]:
aa55498… lmata 191 lines.append("### Errors\n")
aa55498… lmata 192 for e in payload["errors"]:
aa55498… lmata 193 lines.append(f"- {e}")
aa55498… lmata 194 lines.append("")
aa55498… lmata 195
aa55498… lmata 196 if payload["warnings"]:
aa55498… lmata 197 lines.append("### Warnings\n")
aa55498… lmata 198 for w in payload["warnings"]:
aa55498… lmata 199 lines.append(f"- {w}")
aa55498… lmata 200 lines.append("")
aa55498… lmata 201
aa55498… lmata 202 try:
aa55498… lmata 203 with open(summary_path, "a", encoding="utf-8") as fh:
aa55498… lmata 204 fh.write("\n".join(lines) + "\n")
aa55498… lmata 205 except OSError:
aa55498… lmata 206 pass # Non-fatal — summary write failure must never break a pipeline

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button