diff --git a/README.md b/README.md index 1886648..6a0e3b4 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,15 @@ contribcheck inspect owner/repository#123 --fail-on caution Exit codes are `0` for a completed inspection, `1` for an operational error, and `2` when the configured readiness threshold is reached. +Render a portable GitHub-flavored Markdown report: + +```bash +contribcheck inspect owner/repository#123 --markdown +contribcheck inspect owner/repository#123 --markdown --output report.md +``` + +Markdown reports include the issue title and URL, verdict, check summaries, linked evidence, and next actions. `--markdown` and `--json` cannot be used together. Existing terminal output remains the default. + ### GitHub Enterprise Server Set the API endpoint with `GITHUB_API_URL`, or override it for one command with `--base-url`: diff --git a/src/contribcheck/cli.py b/src/contribcheck/cli.py index f72ae75..ca46059 100644 --- a/src/contribcheck/cli.py +++ b/src/contribcheck/cli.py @@ -5,6 +5,7 @@ import asyncio import os from enum import StrEnum +from pathlib import Path from typing import Annotated import httpx @@ -52,6 +53,14 @@ def inspect( bool, typer.Option("--json", help="Print the complete report as JSON."), ] = False, + markdown_output: Annotated[ + bool, + typer.Option("--markdown", help="Print the report as GitHub-flavored Markdown."), + ] = False, + output: Annotated[ + Path | None, + typer.Option("--output", help="Write structured output to this file."), + ] = None, base_url: Annotated[ str | None, typer.Option(help="GitHub API endpoint; overrides GITHUB_API_URL."), @@ -63,16 +72,34 @@ def inspect( ) -> None: """Inspect one public GitHub issue.""" + if json_output and markdown_output: + raise typer.BadParameter("--json and --markdown are mutually exclusive.") + if output and not (json_output or markdown_output): + raise typer.BadParameter("--output requires --json or --markdown.") + try: report = asyncio.run(_inspect(reference, actor=actor, token=token, base_url=base_url)) - except (ContribCheckError, httpx.HTTPError, ValueError) as error: + except (ContribCheckError, httpx.HTTPError, OSError, ValueError) as error: console.print(f"[bold red]Inspection failed:[/bold red] {error}", highlight=False) raise typer.Exit(code=1) from error if json_output: - typer.echo(report.model_dump_json(indent=2)) + rendered = report.model_dump_json(indent=2) + elif markdown_output: + rendered = _render_markdown(report) else: _render_report(report) + rendered = None + + if rendered is not None: + try: + if output: + output.write_text(f"{rendered}\n", encoding="utf-8") + else: + typer.echo(rendered) + except OSError as error: + console.print(f"[bold red]Output failed:[/bold red] {error}", highlight=False) + raise typer.Exit(code=1) from error if fail_on == FailOn.BLOCKED and report.status == OverallStatus.BLOCKED: raise typer.Exit(code=2) @@ -171,5 +198,41 @@ def _render_report(report: InspectionReport) -> None: console.print(f"- {action}") +def _render_markdown(report: InspectionReport) -> str: + """Render a report without terminal styling or ANSI escape sequences.""" + + lines = [ + f"# {_markdown_cell(report.title)}", + "", + f"Issue: [{report.target.url}]({report.target.url})", + "", + f"## Verdict: {report.status.value.upper()}", + "", + "| Status | Check | Finding |", + "| --- | --- | --- |", + ] + for check in report.checks: + lines.append( + f"| {check.status.value.upper()} | {_markdown_cell(check.title)} | " + f"{_markdown_cell(check.summary)} |" + ) + for evidence in check.evidence: + text = _markdown_cell(evidence.text) + if evidence.url: + url = str(evidence.url) + text = f"[{text}]({url})" + lines.append(f"| | Evidence | {text} |") + + lines.extend(["", "## Next actions", ""]) + lines.extend(f"- {_markdown_cell(action)}" for action in report.next_actions) + return "\n".join(lines) + + +def _markdown_cell(value: str) -> str: + """Escape text that is inserted into a Markdown table cell.""" + + return value.replace("\\", "\\\\").replace("|", "\\|").replace("\n", " ") + + if __name__ == "__main__": app() diff --git a/tests/test_cli.py b/tests/test_cli.py index d4edd84..c995c04 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,10 +1,21 @@ from __future__ import annotations +from pathlib import Path + import pytest +from click import unstyle +from pydantic import HttpUrl from typer.testing import CliRunner from contribcheck.cli import app -from contribcheck.models import InspectionReport, IssueTarget, OverallStatus +from contribcheck.models import ( + CheckResult, + Evidence, + InspectionReport, + IssueTarget, + OverallStatus, + SignalStatus, +) runner = CliRunner() @@ -51,6 +62,87 @@ async def fake_inspect( assert '"status": "ready"' in result.stdout +def test_inspect_markdown_output_has_links_and_no_ansi(monkeypatch: pytest.MonkeyPatch) -> None: + async def fake_inspect( + reference: str, + *, + actor: str | None, + token: str | None, + base_url: str | None = None, + ) -> InspectionReport: + return InspectionReport( + target=IssueTarget(owner="owner", repository="repo", number=7), + title="Ready | issue", + status=OverallStatus.READY, + checks=[ + CheckResult( + key="scope", + title="Issue scope", + status=SignalStatus.PASS, + summary="The scope is clear.", + evidence=[ + Evidence( + text="Issue discussion", + url=HttpUrl("https://github.com/owner/repo/issues/7#issuecomment-1"), + ), + Evidence(text="Plain evidence"), + ], + ) + ], + next_actions=["Read the guide."], + ) + + monkeypatch.setattr("contribcheck.cli._inspect", fake_inspect) + result = runner.invoke(app, ["inspect", "owner/repo#7", "--markdown"]) + + assert result.exit_code == 0 + assert "## Verdict: READY" in result.stdout + assert ( + "[Issue discussion](https://github.com/owner/repo/issues/7#issuecomment-1)" in result.stdout + ) + assert "Plain evidence" in result.stdout + assert "\\x1b" not in result.stdout + + +def test_inspect_rejects_json_and_markdown_together() -> None: + result = runner.invoke(app, ["inspect", "owner/repo#7", "--json", "--markdown"]) + + assert result.exit_code == 2 + assert "mutually exclusive" in result.output + + +def test_inspect_markdown_output_can_be_written_to_file( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + async def fake_inspect( + reference: str, + *, + actor: str | None, + token: str | None, + base_url: str | None = None, + ) -> InspectionReport: + return _report() + + monkeypatch.setattr("contribcheck.cli._inspect", fake_inspect) + output_file = tmp_path / "report.md" + result = runner.invoke( + app, + ["inspect", "owner/repo#7", "--markdown", "--output", str(output_file)], + ) + + assert result.exit_code == 0 + assert output_file.read_text(encoding="utf-8").startswith("# Ready issue") + assert result.stdout == "" + + +def test_inspect_output_requires_structured_format() -> None: + result = runner.invoke(app, ["inspect", "owner/repo#7", "--output", "report.txt"]) + + assert result.exit_code == 2 + normalized_output = " ".join(unstyle(result.output).split()) + assert "requires --json or --markdown" in normalized_output + + def test_fail_on_blocked_returns_exit_code_two(monkeypatch: pytest.MonkeyPatch) -> None: async def fake_inspect( reference: str,