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
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:
Expand Down
67 changes: 65 additions & 2 deletions src/contribcheck/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import asyncio
import os
from enum import StrEnum
from pathlib import Path
from typing import Annotated

import httpx
Expand Down Expand Up @@ -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."),
Expand All @@ -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)
Expand Down Expand Up @@ -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()
94 changes: 93 additions & 1 deletion tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -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()

Expand Down Expand Up @@ -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,
Expand Down
Loading