Navegador

feat: CI/CD mode — machine-readable output, exit codes, GitHub Actions integration CICDReporter with CI detection, structured JSON output, GH Actions annotations and step summaries. CLI: navegador ci ingest/stats/check. Closes #31

lmata 2026-03-23 04:59 trunk
Commit aa55498dd7df8987c23461de41ed2d81139151fc949dd61a7a949e15bc5b81ed
--- a/navegador/cicd.py
+++ b/navegador/cicd.py
@@ -0,0 +1,163 @@
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 impathlib import P
15
+
16
+# ── Exit codes ────────────────────────────────────────────────────────────────
17
+
18
+EXIT_SUCCESS = 0
19
+EXIT_ERROR = 1
20
+EXIT_WARN = 2
21
+
22
+
23
+# ── CI environment detection ─────────────────────────────────────────────────
24
+
25
+_CI_VARS = ("GITHUB_ACTIONS", "CI", "GITLAB_CI", "CIRCLECI", "JENKINS_URL")
26
+
27
+
28
+def detect_ci() -> str | None:
29
+ """
30
+ Return the name of the detected CI environment, or None if not in CI.
31
+
32
+ Checks environment variables in priority order:
33
+ GITHUB_ACTIONS → "github_actions"
34
+ CI → "ci"
35
+ GITLAB_CI → "gitlab_ci"
36
+ CIRCLECI → "circleci"
37
+ JENKINS_URL → "jenkins"
38
+ """
39
+ if os.environ.get("GITHUB_ACTIONS"):
40
+ return "github_actions"
41
+ if os.environ.get("CI"):
42
+ return "ci"
43
+ if os.environ.get("GITLAB_CI"):
44
+ return "gitlab_ci"
45
+ if os.environ.get("CIRCLECI"):
46
+ return "circleci"
47
+ if os.environ.get("JENKINS_URL"):
48
+ return "jenkins"
49
+ return None
50
+
51
+
52
+def is_ci() -> bool:
53
+ """Return True if running inside any recognised CI environment."""
54
+ return detect_ci() is not None
55
+
56
+
57
+def is_github_actions() -> bool:
58
+ """Return True if running inside GitHub Actions specifically."""
59
+ return detect_ci() == "github_actions"
60
+
61
+
62
+# ── Reporter ──────────────────────────────────────────────────────────────────
63
+
64
+
65
+@dataclass
66
+class CICDReporter:
67
+ """
68
+ Machine-readable reporter for CI/CD pipelines.
69
+
70
+ Collects errors and warnings during a command run, then emits either
71
+ plain JSON (all CI systems) or GitHub Actions annotations + step summaries
72
+ (when GITHUB_ACTIONS is set).
73
+
74
+ Usage::
75
+
76
+ reporter = CICDReporter()
77
+ reporter.add_error("ingest failed: <reason>")
78
+ reporter.add_warning("no Python files found")
79
+ reporter.emit(data={"files": 0})
80
+ sys.exit(reporter.exit_code())
81
+ """
82
+
83
+ errors: list[str] = field(default_factory=list)
84
+ warnings: list[str] = field(default_factory=list)
85
+
86
+ # ── Collecting ────────────────────────────────────────────────────────────
87
+
88
+ def add_error(self, message: str) -> None:
89
+ """Record a hard error (will produce exit code 1)."""
90
+ self.errors.append(message)
91
+
92
+ def add_warning(self, message: str) -> None:
93
+ """Record a warning (will produce exit code 2 when no errors)."""
94
+ self.warnings.append(message)
95
+
96
+ # ── Exit code ─────────────────────────────────────────────────────────────
97
+
98
+ def exit_code(self) -> int:
99
+ """
100
+ Return the appropriate process exit code.
101
+
102
+ 0 — no errors, no warnings
103
+ 1 — at least one error
104
+ 2 — warnings only
105
+ """
106
+ if self.errors:
107
+ return EXIT_ERROR
108
+ if self.warnings:
109
+ return EXIT_WARN
110
+ return EXIT_SUCCESS
111
+
112
+ # ── Output ────────────────────────────────────────────────────────────────
113
+
114
+ def emit(self, data: dict[str, Any] | None = None, *, file=None) -> None:
115
+ """
116
+ Emit the report to stdout (or *file*).
117
+
118
+ In GitHub Actions this also:
119
+ - Prints ``::error`` / ``::warning`` annotations for each issue.
120
+ - Writes a Markdown step summary to $GITHUB_STEP_SUMMARY.
121
+
122
+ In all environments it prints a JSON envelope::
123
+
124
+ {
125
+ "status": "success" | "error" | "warning",
126
+ "errors": [...],
127
+ "warnings": [...],
128
+ "data": {...}
129
+ }
130
+ """
131
+ if file is None:
132
+ file = sys.stdout
133
+
134
+ payload = self._build_payload(data)
135
+
136
+ if is_github_actions():
137
+ self._emit_github_annotations(file=file)
138
+ self._write_github_step_summary(payload)
139
+
140
+ print(json.dumps(payload, indent=2), file=file)
141
+
142
+ # ── Internal helpers ──────────────────────────────────────────────────────
143
+
144
+ def _status_str(self) -> str:
145
+ code = self.exit_code()
146
+ if code == EXIT_ERROR:
147
+ return "error"
148
+ if code == EXIT_WARN:
149
+ return "warning"
150
+ return "success"
151
+
152
+ def _build_payload(self, data: dict[str, Any] | None) -> dict[str, Any]:
153
+ payload: dict[str, Any] = {
154
+ "status": self._status_str(),
155
+ "errors": list(self.errors),
156
+ "warnings": list(self.warnings),
157
+ }
158
+ if data is not None:
159
+ payload["data"] = data
160
+ return payload
161
+
162
+ def _emit_github_annotations(self, *, file=None) -> None:
163
+ """Print
--- a/navegador/cicd.py
+++ b/navegador/cicd.py
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/navegador/cicd.py
+++ b/navegador/cicd.py
@@ -0,0 +1,163 @@
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 impathlib import P
15
16 # ── Exit codes ────────────────────────────────────────────────────────────────
17
18 EXIT_SUCCESS = 0
19 EXIT_ERROR = 1
20 EXIT_WARN = 2
21
22
23 # ── CI environment detection ─────────────────────────────────────────────────
24
25 _CI_VARS = ("GITHUB_ACTIONS", "CI", "GITLAB_CI", "CIRCLECI", "JENKINS_URL")
26
27
28 def detect_ci() -> str | None:
29 """
30 Return the name of the detected CI environment, or None if not in CI.
31
32 Checks environment variables in priority order:
33 GITHUB_ACTIONS → "github_actions"
34 CI → "ci"
35 GITLAB_CI → "gitlab_ci"
36 CIRCLECI → "circleci"
37 JENKINS_URL → "jenkins"
38 """
39 if os.environ.get("GITHUB_ACTIONS"):
40 return "github_actions"
41 if os.environ.get("CI"):
42 return "ci"
43 if os.environ.get("GITLAB_CI"):
44 return "gitlab_ci"
45 if os.environ.get("CIRCLECI"):
46 return "circleci"
47 if os.environ.get("JENKINS_URL"):
48 return "jenkins"
49 return None
50
51
52 def is_ci() -> bool:
53 """Return True if running inside any recognised CI environment."""
54 return detect_ci() is not None
55
56
57 def is_github_actions() -> bool:
58 """Return True if running inside GitHub Actions specifically."""
59 return detect_ci() == "github_actions"
60
61
62 # ── Reporter ──────────────────────────────────────────────────────────────────
63
64
65 @dataclass
66 class CICDReporter:
67 """
68 Machine-readable reporter for CI/CD pipelines.
69
70 Collects errors and warnings during a command run, then emits either
71 plain JSON (all CI systems) or GitHub Actions annotations + step summaries
72 (when GITHUB_ACTIONS is set).
73
74 Usage::
75
76 reporter = CICDReporter()
77 reporter.add_error("ingest failed: <reason>")
78 reporter.add_warning("no Python files found")
79 reporter.emit(data={"files": 0})
80 sys.exit(reporter.exit_code())
81 """
82
83 errors: list[str] = field(default_factory=list)
84 warnings: list[str] = field(default_factory=list)
85
86 # ── Collecting ────────────────────────────────────────────────────────────
87
88 def add_error(self, message: str) -> None:
89 """Record a hard error (will produce exit code 1)."""
90 self.errors.append(message)
91
92 def add_warning(self, message: str) -> None:
93 """Record a warning (will produce exit code 2 when no errors)."""
94 self.warnings.append(message)
95
96 # ── Exit code ─────────────────────────────────────────────────────────────
97
98 def exit_code(self) -> int:
99 """
100 Return the appropriate process exit code.
101
102 0 — no errors, no warnings
103 1 — at least one error
104 2 — warnings only
105 """
106 if self.errors:
107 return EXIT_ERROR
108 if self.warnings:
109 return EXIT_WARN
110 return EXIT_SUCCESS
111
112 # ── Output ────────────────────────────────────────────────────────────────
113
114 def emit(self, data: dict[str, Any] | None = None, *, file=None) -> None:
115 """
116 Emit the report to stdout (or *file*).
117
118 In GitHub Actions this also:
119 - Prints ``::error`` / ``::warning`` annotations for each issue.
120 - Writes a Markdown step summary to $GITHUB_STEP_SUMMARY.
121
122 In all environments it prints a JSON envelope::
123
124 {
125 "status": "success" | "error" | "warning",
126 "errors": [...],
127 "warnings": [...],
128 "data": {...}
129 }
130 """
131 if file is None:
132 file = sys.stdout
133
134 payload = self._build_payload(data)
135
136 if is_github_actions():
137 self._emit_github_annotations(file=file)
138 self._write_github_step_summary(payload)
139
140 print(json.dumps(payload, indent=2), file=file)
141
142 # ── Internal helpers ──────────────────────────────────────────────────────
143
144 def _status_str(self) -> str:
145 code = self.exit_code()
146 if code == EXIT_ERROR:
147 return "error"
148 if code == EXIT_WARN:
149 return "warning"
150 return "success"
151
152 def _build_payload(self, data: dict[str, Any] | None) -> dict[str, Any]:
153 payload: dict[str, Any] = {
154 "status": self._status_str(),
155 "errors": list(self.errors),
156 "warnings": list(self.warnings),
157 }
158 if data is not None:
159 payload["data"] = data
160 return payload
161
162 def _emit_github_annotations(self, *, file=None) -> None:
163 """Print
--- navegador/cli/commands.py
+++ navegador/cli/commands.py
@@ -723,10 +723,168 @@
723723
f"({len(applied)} migration{'s' if len(applied) != 1 else ''})"
724724
)
725725
else:
726726
console.print(f"[green]Schema is up to date[/green] (v{current})")
727727
728
+
729
+# ── Editor integrations ───────────────────────────────────────────────────────
730
+
731
+
732
+@main.group()
733
+def editor():
734
+ """Generate MCP config snippets for AI coding editors."""
735
+
736
+
737
+@editor.command("setup")
738
+@click.argument("editor_name", metavar="EDITOR")
739
+@DB_OPTION
740
+@click.option(
741
+ "--write",
742
+ "do_write",
743
+ is_flag=True,
744
+ help="Write the config file to the expected path in the current directory.",
745
+)
746
+def editor_setup(editor_name: str, db: str, do_write: bool):
747
+ """Generate the MCP config snippet for an editor.
748
+
749
+ \b
750
+ EDITOR is one of: claude-code, cursor, codex, windsurf, all
751
+
752
+ \b
753
+ Examples:
754
+ navegador editor setup claude-code
755
+ navegador editor setup cursor --db .navegador/graph.db
756
+ navegador editor setup all --write
757
+ """
758
+ from navegador.editor import SUPPORTED_EDITORS, EditorIntegration
759
+
760
+ if editor_name not in SUPPORTED_EDITORS and editor_name != "all":
761
+ raise click.BadParameter(
762
+ f"Unknown editor {editor_name!r}. "
763
+ f"Choose from: {', '.join(SUPPORTED_EDITORS + ['all'])}",
764
+ param_hint="EDITOR",
765
+ )
766
+
767
+ integration = EditorIntegration(db=db)
768
+ targets = SUPPORTED_EDITORS if editor_name == "all" else [editor_name]
769
+
770
+ for target in targets:
771
+ config_json = integration.config_json(target)
772
+ config_path = integration.config_path(target)
773
+
774
+ if len(targets) > 1:
775
+ console.print(f"\n[bold cyan]{target}[/bold cyan] ({config_path})")
776
+
777
+ click.echo(config_json)
778
+
779
+ if do_write:
780
+ written = integration.write_config(target)
781
+ console.print(f"[green]Written:[/green] {written}")
782
+
783
+
784
+# ── CI/CD ─────────────────────────────────────────────────────────────────────
785
+
786
+
787
+@main.group()
788
+def ci():
789
+ """CI/CD mode — machine-readable output and structured exit codes.
790
+
791
+ All subcommands emit JSON to stdout and exit with:
792
+ 0 success
793
+ 1 error
794
+ 2 warnings only
795
+ """
796
+
797
+
798
+@ci.command("ingest")
799
+@click.argument("repo_path", type=click.Path(exists=True))
800
+@DB_OPTION
801
+@click.option("--clear", is_flag=True, help="Clear existing graph before ingesting.")
802
+@click.option("--incremental", is_flag=True, help="Only re-parse changed files.")
803
+def ci_ingest(repo_path: str, db: str, clear: bool, incremental: bool):
804
+ """Ingest a repository and exit non-zero on errors or empty results."""
805
+ import sys
806
+
807
+ from navegador.cicd import CICDReporter
808
+ from navegador.ingestion import RepoIngester
809
+
810
+ reporter = CICDReporter()
811
+ data: dict = {}
812
+
813
+ try:
814
+ store = _get_store(db)
815
+ ingester = RepoIngester(store)
816
+ stats = ingester.ingest(repo_path, clear=clear, incremental=incremental)
817
+ data = stats
818
+ if stats.get("files", 0) == 0:
819
+ reporter.add_warning("No source files were ingested.")
820
+ except Exception as exc: # noqa: BLE001
821
+ reporter.add_error(str(exc))
822
+
823
+ reporter.emit(data=data or None)
824
+ sys.exit(reporter.exit_code())
825
+
826
+
827
+@ci.command("stats")
828
+@DB_OPTION
829
+def ci_stats(db: str):
830
+ """Emit graph statistics as JSON (for CI consumption)."""
831
+ import sys
832
+
833
+ from navegador.cicd import CICDReporter
834
+ from navegador.graph import queries as q
835
+
836
+ reporter = CICDReporter()
837
+ data: dict = {}
838
+
839
+ try:
840
+ store = _get_store(db)
841
+ node_rows = store.query(q.NODE_TYPE_COUNTS).result_set or []
842
+ edge_rows = store.query(q.EDGE_TYPE_COUNTS).result_set or []
843
+ data = {
844
+ "total_nodes": sum(r[1] for r in node_rows),
845
+ "total_edges": sum(r[1] for r in edge_rows),
846
+ "nodes": {r[0]: r[1] for r in node_rows},
847
+ "edges": {r[0]: r[1] for r in edge_rows},
848
+ }
849
+ except Exception as exc: # noqa: BLE001
850
+ reporter.add_error(str(exc))
851
+
852
+ reporter.emit(data=data or None)
853
+ sys.exit(reporter.exit_code())
854
+
855
+
856
+@ci.command("check")
857
+@DB_OPTION
858
+def ci_check(db: str):
859
+ """Check schema version — exits 2 if migration is needed, 1 on hard error."""
860
+ import sys
861
+
862
+ from navegador.cicd import CICDReporter
863
+ from navegador.graph.migrations import (
864
+ CURRENT_SCHEMA_VERSION,
865
+ get_schema_version,
866
+ needs_migration,
867
+ )
868
+
869
+ reporter = CICDReporter()
870
+ data: dict = {}
871
+
872
+ try:
873
+ store = _get_store(db)
874
+ current = get_schema_version(store)
875
+ data = {"schema_version": current, "current_schema_version": CURRENT_SCHEMA_VERSION}
876
+ if needs_migration(store):
877
+ reporter.add_warning(
878
+ f"Schema migration needed: v{current} → v{CURRENT_SCHEMA_VERSION}"
879
+ )
880
+ except Exception as exc: # noqa: BLE001
881
+ reporter.add_error(str(exc))
882
+
883
+ reporter.emit(data=data or None)
884
+ sys.exit(reporter.exit_code())
885
+
728886
729887
# ── MCP ───────────────────────────────────────────────────────────────────────
730888
731889
732890
@main.command()
733891
734892
ADDED tests/test_cicd.py
--- navegador/cli/commands.py
+++ navegador/cli/commands.py
@@ -723,10 +723,168 @@
723 f"({len(applied)} migration{'s' if len(applied) != 1 else ''})"
724 )
725 else:
726 console.print(f"[green]Schema is up to date[/green] (v{current})")
727
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
728
729 # ── MCP ───────────────────────────────────────────────────────────────────────
730
731
732 @main.command()
733
734 DDED tests/test_cicd.py
--- navegador/cli/commands.py
+++ navegador/cli/commands.py
@@ -723,10 +723,168 @@
723 f"({len(applied)} migration{'s' if len(applied) != 1 else ''})"
724 )
725 else:
726 console.print(f"[green]Schema is up to date[/green] (v{current})")
727
728
729 # ── Editor integrations ───────────────────────────────────────────────────────
730
731
732 @main.group()
733 def editor():
734 """Generate MCP config snippets for AI coding editors."""
735
736
737 @editor.command("setup")
738 @click.argument("editor_name", metavar="EDITOR")
739 @DB_OPTION
740 @click.option(
741 "--write",
742 "do_write",
743 is_flag=True,
744 help="Write the config file to the expected path in the current directory.",
745 )
746 def editor_setup(editor_name: str, db: str, do_write: bool):
747 """Generate the MCP config snippet for an editor.
748
749 \b
750 EDITOR is one of: claude-code, cursor, codex, windsurf, all
751
752 \b
753 Examples:
754 navegador editor setup claude-code
755 navegador editor setup cursor --db .navegador/graph.db
756 navegador editor setup all --write
757 """
758 from navegador.editor import SUPPORTED_EDITORS, EditorIntegration
759
760 if editor_name not in SUPPORTED_EDITORS and editor_name != "all":
761 raise click.BadParameter(
762 f"Unknown editor {editor_name!r}. "
763 f"Choose from: {', '.join(SUPPORTED_EDITORS + ['all'])}",
764 param_hint="EDITOR",
765 )
766
767 integration = EditorIntegration(db=db)
768 targets = SUPPORTED_EDITORS if editor_name == "all" else [editor_name]
769
770 for target in targets:
771 config_json = integration.config_json(target)
772 config_path = integration.config_path(target)
773
774 if len(targets) > 1:
775 console.print(f"\n[bold cyan]{target}[/bold cyan] ({config_path})")
776
777 click.echo(config_json)
778
779 if do_write:
780 written = integration.write_config(target)
781 console.print(f"[green]Written:[/green] {written}")
782
783
784 # ── CI/CD ─────────────────────────────────────────────────────────────────────
785
786
787 @main.group()
788 def ci():
789 """CI/CD mode — machine-readable output and structured exit codes.
790
791 All subcommands emit JSON to stdout and exit with:
792 0 success
793 1 error
794 2 warnings only
795 """
796
797
798 @ci.command("ingest")
799 @click.argument("repo_path", type=click.Path(exists=True))
800 @DB_OPTION
801 @click.option("--clear", is_flag=True, help="Clear existing graph before ingesting.")
802 @click.option("--incremental", is_flag=True, help="Only re-parse changed files.")
803 def ci_ingest(repo_path: str, db: str, clear: bool, incremental: bool):
804 """Ingest a repository and exit non-zero on errors or empty results."""
805 import sys
806
807 from navegador.cicd import CICDReporter
808 from navegador.ingestion import RepoIngester
809
810 reporter = CICDReporter()
811 data: dict = {}
812
813 try:
814 store = _get_store(db)
815 ingester = RepoIngester(store)
816 stats = ingester.ingest(repo_path, clear=clear, incremental=incremental)
817 data = stats
818 if stats.get("files", 0) == 0:
819 reporter.add_warning("No source files were ingested.")
820 except Exception as exc: # noqa: BLE001
821 reporter.add_error(str(exc))
822
823 reporter.emit(data=data or None)
824 sys.exit(reporter.exit_code())
825
826
827 @ci.command("stats")
828 @DB_OPTION
829 def ci_stats(db: str):
830 """Emit graph statistics as JSON (for CI consumption)."""
831 import sys
832
833 from navegador.cicd import CICDReporter
834 from navegador.graph import queries as q
835
836 reporter = CICDReporter()
837 data: dict = {}
838
839 try:
840 store = _get_store(db)
841 node_rows = store.query(q.NODE_TYPE_COUNTS).result_set or []
842 edge_rows = store.query(q.EDGE_TYPE_COUNTS).result_set or []
843 data = {
844 "total_nodes": sum(r[1] for r in node_rows),
845 "total_edges": sum(r[1] for r in edge_rows),
846 "nodes": {r[0]: r[1] for r in node_rows},
847 "edges": {r[0]: r[1] for r in edge_rows},
848 }
849 except Exception as exc: # noqa: BLE001
850 reporter.add_error(str(exc))
851
852 reporter.emit(data=data or None)
853 sys.exit(reporter.exit_code())
854
855
856 @ci.command("check")
857 @DB_OPTION
858 def ci_check(db: str):
859 """Check schema version — exits 2 if migration is needed, 1 on hard error."""
860 import sys
861
862 from navegador.cicd import CICDReporter
863 from navegador.graph.migrations import (
864 CURRENT_SCHEMA_VERSION,
865 get_schema_version,
866 needs_migration,
867 )
868
869 reporter = CICDReporter()
870 data: dict = {}
871
872 try:
873 store = _get_store(db)
874 current = get_schema_version(store)
875 data = {"schema_version": current, "current_schema_version": CURRENT_SCHEMA_VERSION}
876 if needs_migration(store):
877 reporter.add_warning(
878 f"Schema migration needed: v{current} → v{CURRENT_SCHEMA_VERSION}"
879 )
880 except Exception as exc: # noqa: BLE001
881 reporter.add_error(str(exc))
882
883 reporter.emit(data=data or None)
884 sys.exit(reporter.exit_code())
885
886
887 # ── MCP ───────────────────────────────────────────────────────────────────────
888
889
890 @main.command()
891
892 DDED tests/test_cicd.py
--- a/tests/test_cicd.py
+++ b/tests/test_cicd.py
@@ -0,0 +1,524 @@
1
+"""Tests for navegador.cicd — CI/CD mode, CICDReporter, and `navegador ci` commands."""
2
+
3
+import json
4
+import os
5
+from io import StringIO
6
+from pathlib import Path
7
+from unittest.mock import MagicMock, patch
8
+
9
+import pytest
10
+from click.testing import CliRunner
11
+
12
+from navegador.cicd import (
13
+ EXIT_ERROR,
14
+ EXIT_SUCCESS,
15
+ EXIT_WARN,
16
+ CICDReporter,
17
+ detect_ci,
18
+ is_ci,
19
+ is_github_actions,
20
+)
21
+from navegador.cli.commands import main
22
+
23
+
24
+# ── Helpers ───────────────────────────────────────────────────────────────────
25
+
26
+
27
+def _clear_ci_env(monkeypatch):
28
+ """Remove all known CI indicator env vars so each test starts clean."""
29
+ for var in ("GITHUB_ACTIONS", "CI", "GITLAB_CI", "CIRCLECI", "JENKINS_URL"):
30
+ monkeypatch.delenv(var, raising=False)
31
+
32
+
33
+def _mock_store():
34
+ store = MagicMock()
35
+ store.query.return_value = MagicMock(result_set=[])
36
+ return store
37
+
38
+
39
+# ── CI detection ──────────────────────────────────────────────────────────────
40
+
41
+
42
+class TestDetectCI:
43
+ def test_returns_none_outside_ci(self, monkeypatch):
44
+ _clear_ci_env(monkeypatch)
45
+ assert detect_ci() is None
46
+
47
+ def test_detects_github_actions(self, monkeypatch):
48
+ _clear_ci_env(monkeypatch)
49
+ monkeypatch.setenv("GITHUB_ACTIONS", "true")
50
+ assert detect_ci() == "github_actions"
51
+
52
+ def test_detects_generic_ci(self, monkeypatch):
53
+ _clear_ci_env(monkeypatch)
54
+ monkeypatch.setenv("CI", "true")
55
+ assert detect_ci() == "ci"
56
+
57
+ def test_detects_gitlab_ci(self, monkeypatch):
58
+ _clear_ci_env(monkeypatch)
59
+ monkeypatch.setenv("GITLAB_CI", "true")
60
+ assert detect_ci() == "gitlab_ci"
61
+
62
+ def test_detects_circleci(self, monkeypatch):
63
+ _clear_ci_env(monkeypatch)
64
+ monkeypatch.setenv("CIRCLECI", "true")
65
+ assert detect_ci() == "circleci"
66
+
67
+ def test_detects_jenkins(self, monkeypatch):
68
+ _clear_ci_env(monkeypatch)
69
+ monkeypatch.setenv("JENKINS_URL", "http://jenkins.local/")
70
+ assert detect_ci() == "jenkins"
71
+
72
+ def test_github_actions_takes_priority_over_ci(self, monkeypatch):
73
+ _clear_ci_env(monkeypatch)
74
+ monkeypatch.setenv("GITHUB_ACTIONS", "true")
75
+ monkeypatch.setenv("CI", "true")
76
+ assert detect_ci() == "github_actions"
77
+
78
+
79
+class TestIsCI:
80
+ def test_false_outside_ci(self, monkeypatch):
81
+ _clear_ci_env(monkeypatch)
82
+ assert is_ci() is False
83
+
84
+ def test_true_in_ci(self, monkeypatch):
85
+ _clear_ci_env(monkeypatch)
86
+ monkeypatch.setenv("CI", "true")
87
+ assert is_ci() is True
88
+
89
+
90
+class TestIsGitHubActions:
91
+ def test_false_outside_gha(self, monkeypatch):
92
+ _clear_ci_env(monkeypatch)
93
+ assert is_github_actions() is False
94
+
95
+ def test_false_for_generic_ci(self, monkeypatch):
96
+ _clear_ci_env(monkeypatch)
97
+ monkeypatch.setenv("CI", "true")
98
+ assert is_github_actions() is False
99
+
100
+ def test_true_for_github_actions(self, monkeypatch):
101
+ _clear_ci_env(monkeypatch)
102
+ monkeypatch.setenv("GITHUB_ACTIONS", "true")
103
+ assert is_github_actions() is True
104
+
105
+
106
+# ── CICDReporter — exit codes ─────────────────────────────────────────────────
107
+
108
+
109
+class TestExitCodes:
110
+ def test_success_when_clean(self):
111
+ r = CICDReporter()
112
+ assert r.exit_code() == EXIT_SUCCESS
113
+
114
+ def test_error_when_error_added(self):
115
+ r = CICDReporter()
116
+ r.add_error("something broke")
117
+ assert r.exit_code() == EXIT_ERROR
118
+
119
+ def test_warn_when_only_warnings(self):
120
+ r = CICDReporter()
121
+ r.add_warning("heads up")
122
+ assert r.exit_code() == EXIT_WARN
123
+
124
+ def test_error_takes_priority_over_warning(self):
125
+ r = CICDReporter()
126
+ r.add_warning("minor issue")
127
+ r.add_error("fatal issue")
128
+ assert r.exit_code() == EXIT_ERROR
129
+
130
+ def test_exit_code_constants(self):
131
+ assert EXIT_SUCCESS == 0
132
+ assert EXIT_ERROR == 1
133
+ assert EXIT_WARN == 2
134
+
135
+
136
+# ── CICDReporter — JSON output ────────────────────────────────────────────────
137
+
138
+
139
+class TestJSONOutput:
140
+ def _edata=None, monkeypatch=None) -> dict:
141
+ data"] == {"files": 5, "fif monkeypatch:
142
+ _clear_ci_enveporter.emit(data=data, file=buf)
143
+ return json.loads(buf.getvalue())
144
+
145
+ dAB_CI", "trr = CICDReporter()
146
+ )
147
+ assert outTests for navegador.cicdegador.cicd — CI/CDerrorleci(self, monkeypatch):
148
+ boom)
149
+ assert out["status"] == "error"
150
+
151
+ def test_status_warning(selfAB_CI", "trmands."""
152
+
153
+import json
154
+import o"""Tests for naveonkeypatch)
155
+ assert out["status"] == "warning"
156
+
157
+ def test_AB_CI", "trr = CICDReporter()
158
+ r.add_error("err1")
159
+ r.add_error("err2")
160
+ )
161
+ assert out["errors"] == ["err1", "err2"]
162
+
163
+ def test_warnings_list_in_payload(selfAB_CI", "trr.add_warning("w1")
164
+ )
165
+ assert onkeypatch)
166
+ assert out["warnings"] == ["w1"]
167
+
168
+ def test_data_AB_CI", "trr = CICDReporter()
169
+ data=ry.write_text("# Previous con)
170
+ assert out["data"] == {"files": 5, "functions": 20}
171
+
172
+ def test_data_absent_when_not_provided(selfAB_CI", "trr = CICDReporter()
173
+ )
174
+ assert "data" not in out
175
+
176
+ def tesAB_CI", "trr.add_error("oops")
177
+ r.add_warning("watch out")
178
+ buf = StringIO()
179
+ r.emit(data={"key": "value"}, file=buf)
180
+ parsed = json.loads(buf.getvalue())
181
+ assert isinstance(parsed, dict)
182
+
183
+
184
+# ── CICDReporter — GitHub Actions annotations ─────────────────────────────────
185
+
186
+
187
+class TestGitHubActionsAnnotations:
188
+ def test_annotations_emitted_in_gha(self, monkeypatch):
189
+ _clear_ci_env(monkeypatch)
190
+ monkeypatch.setenv("GITHUB_ACTIONS", "true")
191
+
192
+ r = CICDReporter()
193
+ r.add_error("bad thing")
194
+ r.add_warning("odd thing")
195
+
196
+ buf = StringIO()
197
+ r.emit(file=buf)
198
+ output = buf.getvalue()
199
+
200
+ assert "::error::bad thing" in output
201
+ assert "::warning::odd thing" in output
202
+
203
+ def test_no_annotations_outside_gha(self, monkeypatch):
204
+ _clear_ci_env(monkeypatch)
205
+ monkeypatch.setenv("CI", "true")
206
+
207
+ r = CICDReporter()
208
+ r.add_error("something")
209
+
210
+ buf = StringIO()
211
+ r.emit(file=buf)
212
+ output = buf.getvalue()
213
+
214
+ assert "::error::" not in output
215
+
216
+ def test_multiple_errors_all_annotated(self, monkeypatch):
217
+ _clear_ci_env(monkeypatch)
218
+ monkeypatch.setenv("GITHUB_ACTIONS", "true")
219
+
220
+ r = CICDReporter()
221
+ r.add_error("e1")
222
+ r.add_error("e2")
223
+
224
+ buf = StringIO()
225
+ r.emit(file=buf)
226
+ output = buf.getvalue()
227
+
228
+ assert "::error::e1" in output
229
+ assert "::error::e2" in output
230
+
231
+
232
+# ── CICDReporter — GitHub Actions step summary ────────────────────────────────
233
+
234
+
235
+class TestGitHubStepSummary:
236
+ def test_writes_summary_file(self, monkeypatch, tmp_path):
237
+ _clear_ci_env(monkeypatch)
238
+ monkeypatch.setenv("GITHUB_ACTIONS", "true")
239
+ summary = tmp_path / "summary.md"
240
+ monkeypatch.setenv("GITHUB_STEP_SUMMARY", str(summary))
241
+
242
+ r = CICDReporter()
243
+ r.emit(data={"files": 3}, file=StringIO())
244
+
245
+ content = summary.read_text()
246
+ assert "Navegador" in content
247
+ assert "files" in content
248
+
249
+ def test_summary_includes_errors(self, monkeypatch, tmp_path):
250
+ _clear_ci_env(monkeypatch)
251
+ monkeypatch.setenv("GITHUB_ACTIONS", "true")
252
+ summary = tmp_path / "summary.md"
253
+ monkeypatch.setenv("GITHUB_STEP_SUMMARY", str(summary))
254
+
255
+ r = CICDReporter()
256
+ r.add_error("ingest failed")
257
+ r.emit(file=StringIO())
258
+
259
+ content = summary.read_text()
260
+ assert "ingest failed" in content
261
+
262
+ def test_summary_includes_warnings(self, monkeypatch, tmp_path):
263
+ _clear_ci_env(monkeypatch)
264
+ monkeypatch.setenv("GITHUB_ACTIONS", "true")
265
+ summary = tmp_path / "summary.md"
266
+ monkeypatch.setenv("GITHUB_STEP_SUMMARY", str(summary))
267
+
268
+ r = CICDReporter()
269
+ r.add_warning("no files found")
270
+ r.emit(file=StringIO())
271
+
272
+ content = summary.read_text()
273
+ assert "no files found" in content
274
+
275
+ def test_no_summary_without_env_var(self, monkeypatch, tmp_path):
276
+ _clear_ci_env(monkeypatch)
277
+ monkeypatch.setenv("GITHUB_ACTIONS", "true")
278
+ monkeypatch.delenv("GITHUB_STEP_SUMMARY", raising=False)
279
+
280
+ r = CICDReporter()
281
+ # Should not raise even when GITHUB_STEP_SUMMARY is absent
282
+ r.emit(file=StringIO())
283
+
284
+ def test_summary_appends_not_overwrites(self, monkeypatch, tmp_path):
285
+ _clear_ci_env(monkeypatch)
286
+ monkeypatch.setenv("GITHUB_ACTIONS", "true")
287
+ summary = tmp_path / "summary.md"
288
+ summary.write_text("# Previous content\n")
289
+ monkeypatch.setenv("GITHUB_STEP_SUMMARY", str(summary))
290
+
291
+ r = CICDReporter()
292
+ r.emit(file=StringIO())
293
+
294
+ content = summary.read_text()
295
+ assert "# Previous content" in content
296
+ assert "Navegador" in content
297
+
298
+ def test_summary_handles_oserror_gracefully(self, monkeypatch, tmp_path):
299
+ _clear_ci_env(monkeypatch)
300
+ monkeypatch.setenv("GITHUB_ACTIONS", "true")
301
+ # Point to a directory instead of a file — open() will raise OSError
302
+ monkeypatch.setenv("GITHUB_STEP_SUMMARY", str(tmp_path))
303
+
304
+ r = CICDReporter()
305
+ # Should not raise
306
+ r.emit(file=StringIO())
307
+
308
+ def test_annotations_default_to_stdout(self, monkeypatch, capsys):
309
+ _clear_ci_env(monkeypatch)
310
+ monkeypatch.setenv("GITHUB_ACTIONS", "true")
311
+ monkeypatch.delenv("GITHUB_STEP_SUMMARY", raising=False)
312
+
313
+ r = CICDReporter()
314
+ r.add_error("test error")
315
+ r._emit_github_annotations()
316
+ captured = capsys.readouterr()
317
+ assert "::error::test error" in captured.out
318
+
319
+
320
+# ── CLI: navegador ci ingest ──────────────────────────────────────────────────
321
+
322
+
323
+class TestCIIngestCommand:
324
+ def tes):ort pytest
325
+from click.testing import CliRunner
326
+
327
+from navegador.cicd import (
328
+ EXIT_ERROR,
329
+ EXIT_SUCCESS,
330
+ EXIT_WARN,
331
+ CICDReporter,
332
+ detect_ci,
333
+ is_ci,
334
+ is_github_actions,
335
+)
336
+from navegador.cli.commands import main
337
+
338
+
339
+# ── Helpers ──────────────────────────�"""Tests for navegador.cicd — CI/CD mode, CICDReporter, and `navegador ci` commands."""
340
+
341
+import json
342
+import os
343
+from io import StringIO
344
+from pathlib import Path
345
+from unittest.mock import MagicMock, patch
346
+
347
+import pytest
348
+from click.testing import CliRunner
349
+
350
+from navegador.cicd import (
351
+ EXIT_ERROR,
352
+ EXIT_SUCCESS,
353
+ EXIT_WARN,
354
+ CICD):ort pytest
355
+from click.testing import CliRunner
356
+
357
+from navegador.cicd import (
358
+ EXIT_ERROR,
359
+ EXIT_SUCCESS,
360
+ EXIT_WARN,
361
+ CICDReporter,
362
+ detect_ci,
363
+ is_ci,
364
+ is_github_actions,
365
+)
366
+from navegador.cli.commands import main
367
+
368
+
369
+# ── Helpers ──────────────────────────�"""Tests for navegador.cicd — CI/CD mode, CICDReporter, and `navegador ci` commands."""
370
+
371
+import json
372
+import os
373
+from io import StringIO
374
+from pathlib import Path
375
+from unittest.mock import MagicMock, patch
376
+
377
+import pytest
378
+from click.testing import CliRunner
379
+
380
+from navegador.cicd import (
381
+ EXIT_ERROR,
382
+ EXIT_SUCCESS,
383
+ EXI):ort pytest
384
+from click.testing import CliRunner
385
+
386
+from navegador.cicd import (
387
+ EXIT_ERROR,
388
+ EXIT_SUCCESS,
389
+ EXIT_WARN,
390
+ CICDReporter,
391
+ detect_ci,
392
+ is_ci,
393
+ is_github_actions,
394
+)
395
+from navegador.cli.commands import main
396
+
397
+
398
+# ── Helpers ─────────�"""Tests for navegador.cicd — CI/CD mode, CICDReporter, and `navegador ci` commands."""
399
+
400
+import json
401
+import os
402
+from io import StringIO
403
+from pathlib import Path
404
+from unittest.mock import MagicMock, patch
405
+
406
+import pytest
407
+from click.testing import CliRunner
408
+
409
+from navegador.cicd import (
410
+ EXIT_ERROR,
411
+ EXIT_SUCCESS,
412
+ EXIT_WARN,
413
+ C):ort pytest
414
+from click.testing import CliRunner
415
+
416
+from navegador.cicd import (
417
+ EXIT_ERROR,
418
+ EXIT_SUCCESS,
419
+ EXIT_WARN,
420
+ CICDReporter,
421
+ detect_ci,
422
+ is_ci,
423
+ is_github_actions,
424
+)
425
+from navegador.cli.commands import main
426
+
427
+
428
+# ── Helpers ───────────────────────────────────────────────────────────────────
429
+
430
+
431
+def _clear_ci_env(monkeypatch):
432
+ """Remove all known CI indicator env vars so each test starts clean."""
433
+ for var in ("GITHUB_ACTIONS", "CI", "GITLAB_CI", "CIRCLECI", "JENKINS_URL"):
434
+ monkeypatch.delenv(var, raising=False)
435
+
436
+
437
+def _mock_store():
438
+ store = MagicMock()
439
+ store.query.return_value = MagicMock(result_set=[])
440
+ return store
441
+
442
+
443
+# ── CI detection ──────────────────────────────────────────────────────────────
444
+
445
+
446
+class TestDetectCI:
447
+ def test_returns_none_outside_ci(self, monkeypatch):
448
+ _clear_ci_env(monkeypatch)
449
+ assert detect_ci() is None""gester") as MockRI:
450
+ MockRI.return_value.ingest.side_effect = RuntimeError("DB unavailable")
451
+ result = runner.invoke(main, ["ci", "ingest", "src"])
452
+ assert result.exit_code == 1
453
+ payload = json.loads(result.output)
454
+ assert payload["status"] == "error"
455
+ assert "DB unavailable" in payload["errors"][0]
456
+
457
+ def test_output_is_valid_json(self, monkeypatch):
458
+ _clear_ci_env(monkeypatch):gester") as MockRI:
459
+ MockRI.return_value.ingest.side_effect = RuntimeEr.cli.commands._get_store", side_effect=RuntimeError("no db")):
460
+ result = r)
461
+ ass1 payload = json.loads(result.output)
462
+ assert payload["status"]error"mary.read_text()
463
+ assercheckch):
464
+ _clear_ci_env(monkeypatch)
465
+ monkeypatch.setenv("CI", "true")
466
+ assert detect_ci() == "ci"
467
+
468
+ def test_detects_gitlab_ci(───────Checkatch.setenv("GITHUB_ACTIONS", when_schema_current(self):stCIStatsCommand:
469
+ def _store_with_counts(self):
470
+ store = MagicMock()
471
+
472
+ def _query(cypher, *args, **kwargs):
473
+ result = MagicMock()
474
+ if "NODE" in cypher.upper() or "node" in cypher.lower():
475
+ result.result_set = [["Function", 10], ["Class", 3]]
476
+ else:
477
+ result.result_set = [["CALLS", 25]]
478
+ return result
479
+
480
+ store.query.side_effect = _query
481
+ return store
482
+
483
+ def test_outputs_json_stats(self, monkeypatch):
484
+ _clear_ci_env(monkeypatch)
485
+ runner = CliRunner()
486
+ with pa):
487
+ s# Return versi store = MagicMock()
488
+ store.query.return_value = MagicMock(result_set=[[CURRENT_SCHEMA_VERSION]])
489
+
490
+ runner = CliRunner()
491
+ with patch("navegador.cli.commands._get_store", return_value=store):
492
+ result = runner.invoke(main, ["ci", "check"])
493
+ payload = json.loads(result.output)
494
+ assert "schema_version" in payload["data"]
495
+ assert "current_schema_version" in payload["data"]
496
+e = MagicMock(result_set=[[0]])
497
+
498
+ runner = CliRunner()
499
+ with patch("navegador.cli.commands._get_store", return_value=store):
500
+ result = runner.invoke(main, ["ci", "check"])
501
+ assert result.exit_code == 2
502
+ payload = json.loads(result.output)
503
+ assert payload["status"] == "warning"
504
+ assert payload["warnings"]
505
+
506
+ def test_):gester") as MockRI:
507
+ MockRI.return_value.ingest.side_effect = RuntimeEr.cli.commands._get_store", side_effect=RuntimeError("no db")):
508
+ result = runner.invoke(main, ["ci", "check"])
509
+ assert result.exit_code == 1
510
+ payload = json.loads(result.output)
511
+ assert payload["status"] == "error"
512
+
513
+ def test_payload):stCIStatsCommand:
514
+ def _store_with_counts(self):
515
+ store = MagicMock()
516
+
517
+ def _query(cypher, *args, **kwargs):
518
+ result = MagicMock()
519
+ if "NODE" in cypher.upper() or "node" in cypher.lower():
520
+ result.result_set = [["Function", 10], ["Class", 3]]
521
+ else:
522
+ respayload = json.loads(result.output)
523
+ assert "schema_version" in payload["data"]
524
+ assert "current_schema_v
--- a/tests/test_cicd.py
+++ b/tests/test_cicd.py
@@ -0,0 +1,524 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/tests/test_cicd.py
+++ b/tests/test_cicd.py
@@ -0,0 +1,524 @@
1 """Tests for navegador.cicd — CI/CD mode, CICDReporter, and `navegador ci` commands."""
2
3 import json
4 import os
5 from io import StringIO
6 from pathlib import Path
7 from unittest.mock import MagicMock, patch
8
9 import pytest
10 from click.testing import CliRunner
11
12 from navegador.cicd import (
13 EXIT_ERROR,
14 EXIT_SUCCESS,
15 EXIT_WARN,
16 CICDReporter,
17 detect_ci,
18 is_ci,
19 is_github_actions,
20 )
21 from navegador.cli.commands import main
22
23
24 # ── Helpers ───────────────────────────────────────────────────────────────────
25
26
27 def _clear_ci_env(monkeypatch):
28 """Remove all known CI indicator env vars so each test starts clean."""
29 for var in ("GITHUB_ACTIONS", "CI", "GITLAB_CI", "CIRCLECI", "JENKINS_URL"):
30 monkeypatch.delenv(var, raising=False)
31
32
33 def _mock_store():
34 store = MagicMock()
35 store.query.return_value = MagicMock(result_set=[])
36 return store
37
38
39 # ── CI detection ──────────────────────────────────────────────────────────────
40
41
42 class TestDetectCI:
43 def test_returns_none_outside_ci(self, monkeypatch):
44 _clear_ci_env(monkeypatch)
45 assert detect_ci() is None
46
47 def test_detects_github_actions(self, monkeypatch):
48 _clear_ci_env(monkeypatch)
49 monkeypatch.setenv("GITHUB_ACTIONS", "true")
50 assert detect_ci() == "github_actions"
51
52 def test_detects_generic_ci(self, monkeypatch):
53 _clear_ci_env(monkeypatch)
54 monkeypatch.setenv("CI", "true")
55 assert detect_ci() == "ci"
56
57 def test_detects_gitlab_ci(self, monkeypatch):
58 _clear_ci_env(monkeypatch)
59 monkeypatch.setenv("GITLAB_CI", "true")
60 assert detect_ci() == "gitlab_ci"
61
62 def test_detects_circleci(self, monkeypatch):
63 _clear_ci_env(monkeypatch)
64 monkeypatch.setenv("CIRCLECI", "true")
65 assert detect_ci() == "circleci"
66
67 def test_detects_jenkins(self, monkeypatch):
68 _clear_ci_env(monkeypatch)
69 monkeypatch.setenv("JENKINS_URL", "http://jenkins.local/")
70 assert detect_ci() == "jenkins"
71
72 def test_github_actions_takes_priority_over_ci(self, monkeypatch):
73 _clear_ci_env(monkeypatch)
74 monkeypatch.setenv("GITHUB_ACTIONS", "true")
75 monkeypatch.setenv("CI", "true")
76 assert detect_ci() == "github_actions"
77
78
79 class TestIsCI:
80 def test_false_outside_ci(self, monkeypatch):
81 _clear_ci_env(monkeypatch)
82 assert is_ci() is False
83
84 def test_true_in_ci(self, monkeypatch):
85 _clear_ci_env(monkeypatch)
86 monkeypatch.setenv("CI", "true")
87 assert is_ci() is True
88
89
90 class TestIsGitHubActions:
91 def test_false_outside_gha(self, monkeypatch):
92 _clear_ci_env(monkeypatch)
93 assert is_github_actions() is False
94
95 def test_false_for_generic_ci(self, monkeypatch):
96 _clear_ci_env(monkeypatch)
97 monkeypatch.setenv("CI", "true")
98 assert is_github_actions() is False
99
100 def test_true_for_github_actions(self, monkeypatch):
101 _clear_ci_env(monkeypatch)
102 monkeypatch.setenv("GITHUB_ACTIONS", "true")
103 assert is_github_actions() is True
104
105
106 # ── CICDReporter — exit codes ─────────────────────────────────────────────────
107
108
109 class TestExitCodes:
110 def test_success_when_clean(self):
111 r = CICDReporter()
112 assert r.exit_code() == EXIT_SUCCESS
113
114 def test_error_when_error_added(self):
115 r = CICDReporter()
116 r.add_error("something broke")
117 assert r.exit_code() == EXIT_ERROR
118
119 def test_warn_when_only_warnings(self):
120 r = CICDReporter()
121 r.add_warning("heads up")
122 assert r.exit_code() == EXIT_WARN
123
124 def test_error_takes_priority_over_warning(self):
125 r = CICDReporter()
126 r.add_warning("minor issue")
127 r.add_error("fatal issue")
128 assert r.exit_code() == EXIT_ERROR
129
130 def test_exit_code_constants(self):
131 assert EXIT_SUCCESS == 0
132 assert EXIT_ERROR == 1
133 assert EXIT_WARN == 2
134
135
136 # ── CICDReporter — JSON output ────────────────────────────────────────────────
137
138
139 class TestJSONOutput:
140 def _edata=None, monkeypatch=None) -> dict:
141 data"] == {"files": 5, "fif monkeypatch:
142 _clear_ci_enveporter.emit(data=data, file=buf)
143 return json.loads(buf.getvalue())
144
145 dAB_CI", "trr = CICDReporter()
146 )
147 assert outTests for navegador.cicdegador.cicd — CI/CDerrorleci(self, monkeypatch):
148 boom)
149 assert out["status"] == "error"
150
151 def test_status_warning(selfAB_CI", "trmands."""
152
153 import json
154 import o"""Tests for naveonkeypatch)
155 assert out["status"] == "warning"
156
157 def test_AB_CI", "trr = CICDReporter()
158 r.add_error("err1")
159 r.add_error("err2")
160 )
161 assert out["errors"] == ["err1", "err2"]
162
163 def test_warnings_list_in_payload(selfAB_CI", "trr.add_warning("w1")
164 )
165 assert onkeypatch)
166 assert out["warnings"] == ["w1"]
167
168 def test_data_AB_CI", "trr = CICDReporter()
169 data=ry.write_text("# Previous con)
170 assert out["data"] == {"files": 5, "functions": 20}
171
172 def test_data_absent_when_not_provided(selfAB_CI", "trr = CICDReporter()
173 )
174 assert "data" not in out
175
176 def tesAB_CI", "trr.add_error("oops")
177 r.add_warning("watch out")
178 buf = StringIO()
179 r.emit(data={"key": "value"}, file=buf)
180 parsed = json.loads(buf.getvalue())
181 assert isinstance(parsed, dict)
182
183
184 # ── CICDReporter — GitHub Actions annotations ─────────────────────────────────
185
186
187 class TestGitHubActionsAnnotations:
188 def test_annotations_emitted_in_gha(self, monkeypatch):
189 _clear_ci_env(monkeypatch)
190 monkeypatch.setenv("GITHUB_ACTIONS", "true")
191
192 r = CICDReporter()
193 r.add_error("bad thing")
194 r.add_warning("odd thing")
195
196 buf = StringIO()
197 r.emit(file=buf)
198 output = buf.getvalue()
199
200 assert "::error::bad thing" in output
201 assert "::warning::odd thing" in output
202
203 def test_no_annotations_outside_gha(self, monkeypatch):
204 _clear_ci_env(monkeypatch)
205 monkeypatch.setenv("CI", "true")
206
207 r = CICDReporter()
208 r.add_error("something")
209
210 buf = StringIO()
211 r.emit(file=buf)
212 output = buf.getvalue()
213
214 assert "::error::" not in output
215
216 def test_multiple_errors_all_annotated(self, monkeypatch):
217 _clear_ci_env(monkeypatch)
218 monkeypatch.setenv("GITHUB_ACTIONS", "true")
219
220 r = CICDReporter()
221 r.add_error("e1")
222 r.add_error("e2")
223
224 buf = StringIO()
225 r.emit(file=buf)
226 output = buf.getvalue()
227
228 assert "::error::e1" in output
229 assert "::error::e2" in output
230
231
232 # ── CICDReporter — GitHub Actions step summary ────────────────────────────────
233
234
235 class TestGitHubStepSummary:
236 def test_writes_summary_file(self, monkeypatch, tmp_path):
237 _clear_ci_env(monkeypatch)
238 monkeypatch.setenv("GITHUB_ACTIONS", "true")
239 summary = tmp_path / "summary.md"
240 monkeypatch.setenv("GITHUB_STEP_SUMMARY", str(summary))
241
242 r = CICDReporter()
243 r.emit(data={"files": 3}, file=StringIO())
244
245 content = summary.read_text()
246 assert "Navegador" in content
247 assert "files" in content
248
249 def test_summary_includes_errors(self, monkeypatch, tmp_path):
250 _clear_ci_env(monkeypatch)
251 monkeypatch.setenv("GITHUB_ACTIONS", "true")
252 summary = tmp_path / "summary.md"
253 monkeypatch.setenv("GITHUB_STEP_SUMMARY", str(summary))
254
255 r = CICDReporter()
256 r.add_error("ingest failed")
257 r.emit(file=StringIO())
258
259 content = summary.read_text()
260 assert "ingest failed" in content
261
262 def test_summary_includes_warnings(self, monkeypatch, tmp_path):
263 _clear_ci_env(monkeypatch)
264 monkeypatch.setenv("GITHUB_ACTIONS", "true")
265 summary = tmp_path / "summary.md"
266 monkeypatch.setenv("GITHUB_STEP_SUMMARY", str(summary))
267
268 r = CICDReporter()
269 r.add_warning("no files found")
270 r.emit(file=StringIO())
271
272 content = summary.read_text()
273 assert "no files found" in content
274
275 def test_no_summary_without_env_var(self, monkeypatch, tmp_path):
276 _clear_ci_env(monkeypatch)
277 monkeypatch.setenv("GITHUB_ACTIONS", "true")
278 monkeypatch.delenv("GITHUB_STEP_SUMMARY", raising=False)
279
280 r = CICDReporter()
281 # Should not raise even when GITHUB_STEP_SUMMARY is absent
282 r.emit(file=StringIO())
283
284 def test_summary_appends_not_overwrites(self, monkeypatch, tmp_path):
285 _clear_ci_env(monkeypatch)
286 monkeypatch.setenv("GITHUB_ACTIONS", "true")
287 summary = tmp_path / "summary.md"
288 summary.write_text("# Previous content\n")
289 monkeypatch.setenv("GITHUB_STEP_SUMMARY", str(summary))
290
291 r = CICDReporter()
292 r.emit(file=StringIO())
293
294 content = summary.read_text()
295 assert "# Previous content" in content
296 assert "Navegador" in content
297
298 def test_summary_handles_oserror_gracefully(self, monkeypatch, tmp_path):
299 _clear_ci_env(monkeypatch)
300 monkeypatch.setenv("GITHUB_ACTIONS", "true")
301 # Point to a directory instead of a file — open() will raise OSError
302 monkeypatch.setenv("GITHUB_STEP_SUMMARY", str(tmp_path))
303
304 r = CICDReporter()
305 # Should not raise
306 r.emit(file=StringIO())
307
308 def test_annotations_default_to_stdout(self, monkeypatch, capsys):
309 _clear_ci_env(monkeypatch)
310 monkeypatch.setenv("GITHUB_ACTIONS", "true")
311 monkeypatch.delenv("GITHUB_STEP_SUMMARY", raising=False)
312
313 r = CICDReporter()
314 r.add_error("test error")
315 r._emit_github_annotations()
316 captured = capsys.readouterr()
317 assert "::error::test error" in captured.out
318
319
320 # ── CLI: navegador ci ingest ──────────────────────────────────────────────────
321
322
323 class TestCIIngestCommand:
324 def tes):ort pytest
325 from click.testing import CliRunner
326
327 from navegador.cicd import (
328 EXIT_ERROR,
329 EXIT_SUCCESS,
330 EXIT_WARN,
331 CICDReporter,
332 detect_ci,
333 is_ci,
334 is_github_actions,
335 )
336 from navegador.cli.commands import main
337
338
339 # ── Helpers ──────────────────────────�"""Tests for navegador.cicd — CI/CD mode, CICDReporter, and `navegador ci` commands."""
340
341 import json
342 import os
343 from io import StringIO
344 from pathlib import Path
345 from unittest.mock import MagicMock, patch
346
347 import pytest
348 from click.testing import CliRunner
349
350 from navegador.cicd import (
351 EXIT_ERROR,
352 EXIT_SUCCESS,
353 EXIT_WARN,
354 CICD):ort pytest
355 from click.testing import CliRunner
356
357 from navegador.cicd import (
358 EXIT_ERROR,
359 EXIT_SUCCESS,
360 EXIT_WARN,
361 CICDReporter,
362 detect_ci,
363 is_ci,
364 is_github_actions,
365 )
366 from navegador.cli.commands import main
367
368
369 # ── Helpers ──────────────────────────�"""Tests for navegador.cicd — CI/CD mode, CICDReporter, and `navegador ci` commands."""
370
371 import json
372 import os
373 from io import StringIO
374 from pathlib import Path
375 from unittest.mock import MagicMock, patch
376
377 import pytest
378 from click.testing import CliRunner
379
380 from navegador.cicd import (
381 EXIT_ERROR,
382 EXIT_SUCCESS,
383 EXI):ort pytest
384 from click.testing import CliRunner
385
386 from navegador.cicd import (
387 EXIT_ERROR,
388 EXIT_SUCCESS,
389 EXIT_WARN,
390 CICDReporter,
391 detect_ci,
392 is_ci,
393 is_github_actions,
394 )
395 from navegador.cli.commands import main
396
397
398 # ── Helpers ─────────�"""Tests for navegador.cicd — CI/CD mode, CICDReporter, and `navegador ci` commands."""
399
400 import json
401 import os
402 from io import StringIO
403 from pathlib import Path
404 from unittest.mock import MagicMock, patch
405
406 import pytest
407 from click.testing import CliRunner
408
409 from navegador.cicd import (
410 EXIT_ERROR,
411 EXIT_SUCCESS,
412 EXIT_WARN,
413 C):ort pytest
414 from click.testing import CliRunner
415
416 from navegador.cicd import (
417 EXIT_ERROR,
418 EXIT_SUCCESS,
419 EXIT_WARN,
420 CICDReporter,
421 detect_ci,
422 is_ci,
423 is_github_actions,
424 )
425 from navegador.cli.commands import main
426
427
428 # ── Helpers ───────────────────────────────────────────────────────────────────
429
430
431 def _clear_ci_env(monkeypatch):
432 """Remove all known CI indicator env vars so each test starts clean."""
433 for var in ("GITHUB_ACTIONS", "CI", "GITLAB_CI", "CIRCLECI", "JENKINS_URL"):
434 monkeypatch.delenv(var, raising=False)
435
436
437 def _mock_store():
438 store = MagicMock()
439 store.query.return_value = MagicMock(result_set=[])
440 return store
441
442
443 # ── CI detection ──────────────────────────────────────────────────────────────
444
445
446 class TestDetectCI:
447 def test_returns_none_outside_ci(self, monkeypatch):
448 _clear_ci_env(monkeypatch)
449 assert detect_ci() is None""gester") as MockRI:
450 MockRI.return_value.ingest.side_effect = RuntimeError("DB unavailable")
451 result = runner.invoke(main, ["ci", "ingest", "src"])
452 assert result.exit_code == 1
453 payload = json.loads(result.output)
454 assert payload["status"] == "error"
455 assert "DB unavailable" in payload["errors"][0]
456
457 def test_output_is_valid_json(self, monkeypatch):
458 _clear_ci_env(monkeypatch):gester") as MockRI:
459 MockRI.return_value.ingest.side_effect = RuntimeEr.cli.commands._get_store", side_effect=RuntimeError("no db")):
460 result = r)
461 ass1 payload = json.loads(result.output)
462 assert payload["status"]error"mary.read_text()
463 assercheckch):
464 _clear_ci_env(monkeypatch)
465 monkeypatch.setenv("CI", "true")
466 assert detect_ci() == "ci"
467
468 def test_detects_gitlab_ci(───────Checkatch.setenv("GITHUB_ACTIONS", when_schema_current(self):stCIStatsCommand:
469 def _store_with_counts(self):
470 store = MagicMock()
471
472 def _query(cypher, *args, **kwargs):
473 result = MagicMock()
474 if "NODE" in cypher.upper() or "node" in cypher.lower():
475 result.result_set = [["Function", 10], ["Class", 3]]
476 else:
477 result.result_set = [["CALLS", 25]]
478 return result
479
480 store.query.side_effect = _query
481 return store
482
483 def test_outputs_json_stats(self, monkeypatch):
484 _clear_ci_env(monkeypatch)
485 runner = CliRunner()
486 with pa):
487 s# Return versi store = MagicMock()
488 store.query.return_value = MagicMock(result_set=[[CURRENT_SCHEMA_VERSION]])
489
490 runner = CliRunner()
491 with patch("navegador.cli.commands._get_store", return_value=store):
492 result = runner.invoke(main, ["ci", "check"])
493 payload = json.loads(result.output)
494 assert "schema_version" in payload["data"]
495 assert "current_schema_version" in payload["data"]
496 e = MagicMock(result_set=[[0]])
497
498 runner = CliRunner()
499 with patch("navegador.cli.commands._get_store", return_value=store):
500 result = runner.invoke(main, ["ci", "check"])
501 assert result.exit_code == 2
502 payload = json.loads(result.output)
503 assert payload["status"] == "warning"
504 assert payload["warnings"]
505
506 def test_):gester") as MockRI:
507 MockRI.return_value.ingest.side_effect = RuntimeEr.cli.commands._get_store", side_effect=RuntimeError("no db")):
508 result = runner.invoke(main, ["ci", "check"])
509 assert result.exit_code == 1
510 payload = json.loads(result.output)
511 assert payload["status"] == "error"
512
513 def test_payload):stCIStatsCommand:
514 def _store_with_counts(self):
515 store = MagicMock()
516
517 def _query(cypher, *args, **kwargs):
518 result = MagicMock()
519 if "NODE" in cypher.upper() or "node" in cypher.lower():
520 result.result_set = [["Function", 10], ["Class", 3]]
521 else:
522 respayload = json.loads(result.output)
523 assert "schema_version" in payload["data"]
524 assert "current_schema_v

Keyboard Shortcuts

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