Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions src/odoo_forge_cli/commands/pipeline.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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)
74 changes: 73 additions & 1 deletion tests/cli/test_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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:
Expand Down Expand Up @@ -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"),
[
Expand Down Expand Up @@ -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(
Expand All @@ -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)

Expand All @@ -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(
Expand All @@ -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)

Expand All @@ -202,3 +256,21 @@ 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 provider_composition_calls == []
Loading