Navegador

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

Keyboard Shortcuts

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