|
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 |