From 21bd30035b427bb864db2955850f73019b8c5dc7 Mon Sep 17 00:00:00 2001 From: Angel Parra <607418+aparragithub@users.noreply.github.com> Date: Sat, 8 Aug 2026 06:48:13 -0500 Subject: [PATCH 1/2] feat(pipeline): expose run logs through the CLI --- src/odoo_forge_cli/commands/pipeline.py | 17 +++++- tests/cli/test_pipeline.py | 75 ++++++++++++++++++++++++- 2 files changed, 89 insertions(+), 3 deletions(-) diff --git a/src/odoo_forge_cli/commands/pipeline.py b/src/odoo_forge_cli/commands/pipeline.py index 4f98ada..962d641 100644 --- a/src/odoo_forge_cli/commands/pipeline.py +++ b/src/odoo_forge_cli/commands/pipeline.py @@ -1,4 +1,4 @@ -"""Pipeline commands: trigger a CI run and check its status.""" +"""Pipeline commands: trigger a CI run, check its status, and print its logs.""" import typer @@ -44,7 +44,20 @@ def status(run_id: str = typer.Option(..., "--run-id", help="Run id to check")) raise typer.Exit(code=1) from exc +def logs(run_id: str = typer.Option(..., "--run-id", help="Run id to inspect")) -> None: + """Print a pipeline run's logs.""" + try: + ref = PipelineRunRef(run_id=run_id) + provider = _composition._make_pipeline_provider() + log_text = provider.logs(ref) + typer.echo(log_text) + except (OSError, RuntimeError, ValueError, KeyError) as exc: + typer.echo(f"error: {exc}", err=True) + raise typer.Exit(code=1) from exc + + def register(app: typer.Typer) -> None: - """Bind the two pipeline commands onto `app`.""" + """Bind the pipeline commands onto `app`.""" app.command(name="pipeline-trigger")(trigger) app.command(name="pipeline-status")(status) + app.command(name="pipeline-logs")(logs) diff --git a/tests/cli/test_pipeline.py b/tests/cli/test_pipeline.py index d54f984..ea830b9 100644 --- a/tests/cli/test_pipeline.py +++ b/tests/cli/test_pipeline.py @@ -21,15 +21,20 @@ def __init__( *, run_id: str = "12345", state: PipelineRunState = "succeeded", + logs_output: str = "", trigger_error: Exception | None = None, status_error: Exception | None = None, + logs_error: Exception | None = None, ) -> None: self.trigger_calls: list[PipelineRunSpec] = [] self.status_calls: list[PipelineRunRef] = [] + self.logs_calls: list[PipelineRunRef] = [] self._run_id = run_id self._state = state + self._logs_output = logs_output self._trigger_error = trigger_error self._status_error = status_error + self._logs_error = logs_error def trigger(self, spec: PipelineRunSpec) -> PipelineRunRef: self.trigger_calls.append(spec) @@ -44,7 +49,10 @@ def status(self, ref: PipelineRunRef) -> PipelineRunStatus: return PipelineRunStatus(state=self._state) def logs(self, ref: PipelineRunRef) -> str: - raise AssertionError("logs() must not be used") + self.logs_calls.append(ref) + if self._logs_error is not None: + raise self._logs_error + return self._logs_output def test_pipeline_trigger_prints_run_id(monkeypatch: pytest.MonkeyPatch) -> None: @@ -113,6 +121,34 @@ def test_pipeline_status_prints_run_state(monkeypatch: pytest.MonkeyPatch) -> No assert fake_provider.status_calls[0].run_id == "42" +def test_pipeline_logs_forwards_run_id_and_preserves_multiline_output( + monkeypatch: pytest.MonkeyPatch, +) -> None: + log_text = "build started\nbuild passed" + fake_provider = _FakePipelineProvider(logs_output=log_text) + monkeypatch.setattr(_composition, "_make_pipeline_provider", lambda: fake_provider) + + result = runner.invoke(app, ["pipeline-logs", "--run-id", "run-123"]) + + assert result.exit_code == 0 + assert result.output == f"{log_text}\n" + assert len(fake_provider.logs_calls) == 1 + assert fake_provider.logs_calls[0].run_id == "run-123" + + +def test_pipeline_logs_preserves_empty_provider_output( + monkeypatch: pytest.MonkeyPatch, +) -> None: + fake_provider = _FakePipelineProvider(logs_output="") + monkeypatch.setattr(_composition, "_make_pipeline_provider", lambda: fake_provider) + + result = runner.invoke(app, ["pipeline-logs", "--run-id", "run-123"]) + + assert result.exit_code == 0 + assert result.output == "\n" + assert len(fake_provider.logs_calls) == 1 + + @pytest.mark.parametrize( ("command", "args", "error"), [ @@ -146,6 +182,21 @@ def test_pipeline_status_prints_run_state(monkeypatch: pytest.MonkeyPatch) -> No ["--run-id", "42"], KeyError("status"), ), + ( + "pipeline-logs", + ["--run-id", "42"], + RuntimeError("logs unavailable"), + ), + ( + "pipeline-logs", + ["--run-id", "42"], + OSError("connection reset"), + ), + ( + "pipeline-logs", + ["--run-id", "42"], + KeyError("logs"), + ), ], ) def test_pipeline_commands_render_single_error_line_no_traceback( @@ -157,6 +208,7 @@ def test_pipeline_commands_render_single_error_line_no_traceback( fake_provider = _FakePipelineProvider( trigger_error=error if command == "pipeline-trigger" else None, status_error=error if command == "pipeline-status" else None, + logs_error=error if command == "pipeline-logs" else None, ) monkeypatch.setattr(_composition, "_make_pipeline_provider", lambda: fake_provider) @@ -172,6 +224,7 @@ def test_pipeline_commands_render_single_error_line_no_traceback( [ ("pipeline-trigger", ["--workflow", "ci.yml"]), ("pipeline-status", ["--run-id", "42"]), + ("pipeline-logs", ["--run-id", "42"]), ], ) def test_pipeline_commands_never_echo_token( @@ -188,6 +241,7 @@ def test_pipeline_commands_never_echo_token( error_provider = _FakePipelineProvider( trigger_error=RuntimeError("boom") if command == "pipeline-trigger" else None, status_error=RuntimeError("boom") if command == "pipeline-status" else None, + logs_error=RuntimeError("boom") if command == "pipeline-logs" else None, ) monkeypatch.setattr(_composition, "_make_pipeline_provider", lambda: error_provider) @@ -202,3 +256,22 @@ def test_pipeline_commands_reachable_from_root_app() -> None: assert result.exit_code == 0 assert "pipeline-trigger" in result.output assert "pipeline-status" in result.output + assert "pipeline-logs" in result.output + + +def test_pipeline_logs_requires_run_id_before_provider_composition( + monkeypatch: pytest.MonkeyPatch, +) -> None: + provider_composition_calls: list[None] = [] + + def compose_provider() -> _FakePipelineProvider: + provider_composition_calls.append(None) + raise AssertionError("provider composition must not run") + + monkeypatch.setattr(_composition, "_make_pipeline_provider", compose_provider) + + result = runner.invoke(app, ["pipeline-logs"]) + + assert result.exit_code == 2 + assert "--run-id" in result.output + assert provider_composition_calls == [] From 7b089bc52aab0efd59bf379bb864e04e356e06b0 Mon Sep 17 00:00:00 2001 From: Angel Parra <607418+aparragithub@users.noreply.github.com> Date: Sat, 8 Aug 2026 06:56:37 -0500 Subject: [PATCH 2/2] test(pipeline): avoid ANSI-sensitive missing-option assertion --- tests/cli/test_pipeline.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/cli/test_pipeline.py b/tests/cli/test_pipeline.py index ea830b9..12fd41f 100644 --- a/tests/cli/test_pipeline.py +++ b/tests/cli/test_pipeline.py @@ -273,5 +273,4 @@ def compose_provider() -> _FakePipelineProvider: result = runner.invoke(app, ["pipeline-logs"]) assert result.exit_code == 2 - assert "--run-id" in result.output assert provider_composition_calls == []