diff --git a/README.md b/README.md index 6a0e3b4..75f0f66 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,23 @@ 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. +Inspect several candidates from a text file while preserving input order: + +```text +# candidates.txt +sigma67/ytmusicapi#986 +https://github.com/plotly/plotly.js/issues/7750 +``` + +```bash +contribcheck batch candidates.txt --actor your-username +contribcheck batch candidates.txt --json --fail-on caution +``` + +Batch JSON uses a stable top-level object with `schema_version`, ordered `results`, and a `summary` containing total, success, failure, and verdict counts. Each failed item is reported independently instead of discarding successful inspections. Batch concurrency defaults to four and can be changed with `--concurrency`. + +Batch exit codes are `0` when processing completes without reaching the configured threshold, `1` when input or processing fails for at least one item, and `2` when a successful report reaches the configured `--fail-on` threshold. + ### 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 ca46059..bc31550 100644 --- a/src/contribcheck/cli.py +++ b/src/contribcheck/cli.py @@ -17,7 +17,14 @@ from contribcheck.analyzer import IssueAnalyzer from contribcheck.exceptions import ContribCheckError from contribcheck.github import GitHubClient -from contribcheck.models import InspectionReport, OverallStatus, SignalStatus +from contribcheck.models import ( + BatchItem, + BatchReport, + BatchSummary, + InspectionReport, + OverallStatus, + SignalStatus, +) app = typer.Typer( name="contribcheck", @@ -107,6 +114,67 @@ def inspect( raise typer.Exit(code=2) +@app.command() +def batch( + input_file: Annotated[ + Path, + typer.Argument(help="Text file with one issue URL or owner/repository#number per line."), + ], + actor: Annotated[ + str | None, + typer.Option(help="Treat assignments and claims by this GitHub user as your own."), + ] = None, + token: Annotated[ + str | None, + typer.Option(envvar="GITHUB_TOKEN", hidden=True), + ] = None, + json_output: Annotated[ + bool, + typer.Option("--json", help="Print the stable batch report as JSON."), + ] = False, + concurrency: Annotated[ + int, + typer.Option(min=1, max=32, help="Maximum number of inspections running at once."), + ] = 4, + base_url: Annotated[ + str | None, + typer.Option(help="GitHub API endpoint; overrides GITHUB_API_URL."), + ] = None, + fail_on: Annotated[ + FailOn, + typer.Option(help="Return non-zero when any result reaches this readiness threshold."), + ] = FailOn.NEVER, +) -> None: + """Inspect issue references from a file while preserving input order.""" + + try: + references = _read_batch_references(input_file) + report = asyncio.run( + _batch( + references, + actor=actor, + token=token, + base_url=base_url, + concurrency=concurrency, + ) + ) + except (ContribCheckError, httpx.HTTPError, OSError, ValueError) as error: + console.print(f"[bold red]Batch 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)) + else: + _render_batch_report(report) + + if report.summary.failed: + raise typer.Exit(code=1) + if fail_on == FailOn.BLOCKED and report.summary.blocked: + raise typer.Exit(code=2) + if fail_on == FailOn.CAUTION and (report.summary.caution or report.summary.blocked): + raise typer.Exit(code=2) + + @app.command() def serve( host: Annotated[str, typer.Option(help="Interface to bind.")] = "127.0.0.1", @@ -156,6 +224,53 @@ async def _inspect( return await IssueAnalyzer(client).inspect(reference, actor=resolved_actor) +async def _batch( + references: list[str], + *, + actor: str | None, + token: str | None, + base_url: str | None, + concurrency: int, +) -> BatchReport: + resolved_actor = actor or os.getenv("GITHUB_ACTOR") + semaphore = asyncio.Semaphore(concurrency) + + async with GitHubClient(token=token, base_url=base_url) as client: + analyzer = IssueAnalyzer(client) + + async def inspect_one(reference: str) -> BatchItem: + async with semaphore: + try: + report = await analyzer.inspect(reference, actor=resolved_actor) + except (ContribCheckError, httpx.HTTPError, ValueError) as error: + return BatchItem(reference=reference, error=str(error)) + return BatchItem(reference=reference, report=report) + + results = await asyncio.gather(*(inspect_one(reference) for reference in references)) + + reports = [item.report for item in results if item.report is not None] + summary = BatchSummary( + total=len(results), + succeeded=len(reports), + failed=len(results) - len(reports), + ready=sum(report.status == OverallStatus.READY for report in reports), + caution=sum(report.status == OverallStatus.CAUTION for report in reports), + blocked=sum(report.status == OverallStatus.BLOCKED for report in reports), + ) + return BatchReport(results=results, summary=summary) + + +def _read_batch_references(input_file: Path) -> list[str]: + references = [ + line.strip() + for line in input_file.read_text(encoding="utf-8").splitlines() + if line.strip() and not line.lstrip().startswith("#") + ] + if not references: + raise ValueError("The batch input file does not contain any issue references.") + return references + + def _render_report(report: InspectionReport) -> None: colors = { OverallStatus.READY: "green", @@ -228,6 +343,35 @@ def _render_markdown(report: InspectionReport) -> str: return "\n".join(lines) +def _render_batch_report(report: BatchReport) -> None: + table = Table(show_header=True, header_style="bold", box=None, pad_edge=False) + table.add_column("Reference") + table.add_column("Verdict") + table.add_column("Title") + table.add_column("Notes") + for item in report.results: + if item.report: + report_status = item.report.status.value.upper() + notes = ( + "; ".join( + check.summary + for check in item.report.checks + if check.status + in {SignalStatus.WARNING, SignalStatus.FAILURE, SignalStatus.UNKNOWN} + ) + or "No warnings or blockers." + ) + table.add_row(item.reference, report_status, item.report.title, notes) + else: + table.add_row(item.reference, "ERROR", "", item.error or "Inspection failed.") + console.print(table) + console.print( + f"\n{report.summary.succeeded}/{report.summary.total} succeeded; " + f"{report.summary.ready} ready, {report.summary.caution} caution, " + f"{report.summary.blocked} blocked, {report.summary.failed} failed." + ) + + def _markdown_cell(value: str) -> str: """Escape text that is inserted into a Markdown table cell.""" diff --git a/src/contribcheck/models.py b/src/contribcheck/models.py index 408cb32..b884524 100644 --- a/src/contribcheck/models.py +++ b/src/contribcheck/models.py @@ -4,7 +4,7 @@ from datetime import UTC, datetime from enum import StrEnum -from typing import Any +from typing import Any, Literal from pydantic import BaseModel, ConfigDict, Field, HttpUrl @@ -90,6 +90,33 @@ def check(self, key: str) -> CheckResult: raise KeyError(key) +class BatchItem(StrictModel): + """One ordered result from a batch inspection.""" + + reference: str + report: InspectionReport | None = None + error: str | None = None + + +class BatchSummary(StrictModel): + """Stable aggregate counts for a batch inspection.""" + + total: int + succeeded: int + failed: int + ready: int + caution: int + blocked: int + + +class BatchReport(StrictModel): + """Machine-readable batch output with a versioned top-level shape.""" + + schema_version: Literal[1] = 1 + results: list[BatchItem] + summary: BatchSummary + + class InspectionRequest(StrictModel): """HTTP API request body.""" diff --git a/tests/test_cli.py b/tests/test_cli.py index c995c04..5e6bc1a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio from pathlib import Path import pytest @@ -7,8 +8,11 @@ from pydantic import HttpUrl from typer.testing import CliRunner -from contribcheck.cli import app +from contribcheck.cli import _batch, _read_batch_references, app from contribcheck.models import ( + BatchItem, + BatchReport, + BatchSummary, CheckResult, Evidence, InspectionReport, @@ -143,6 +147,107 @@ def test_inspect_output_requires_structured_format() -> None: assert "requires --json or --markdown" in normalized_output +def test_batch_json_preserves_input_order(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + input_file = tmp_path / "candidates.txt" + input_file.write_text("# comment\nowner/first#1\n\nowner/second#2\n", encoding="utf-8") + batch_report = BatchReport( + results=[ + BatchItem(reference="owner/first#1", report=_report()), + BatchItem(reference="owner/second#2", error="Not found"), + ], + summary=BatchSummary(total=2, succeeded=1, failed=1, ready=1, caution=0, blocked=0), + ) + + async def fake_batch(*args: object, **kwargs: object) -> BatchReport: + return batch_report + + monkeypatch.setattr("contribcheck.cli._batch", fake_batch) + result = runner.invoke(app, ["batch", str(input_file), "--json"]) + + assert result.exit_code == 1 + assert result.stdout.index("owner/first#1") < result.stdout.index("owner/second#2") + assert '"failed": 1' in result.stdout + + +def test_batch_bounds_concurrency_and_keeps_partial_failures( + monkeypatch: pytest.MonkeyPatch, +) -> None: + active = 0 + maximum = 0 + + class FakeClient: + def __init__(self, **kwargs: object) -> None: + del kwargs + + async def __aenter__(self) -> FakeClient: + return self + + async def __aexit__(self, *_: object) -> None: + return None + + class FakeAnalyzer: + def __init__(self, client: FakeClient) -> None: + del client + + async def inspect(self, reference: str, *, actor: str | None) -> InspectionReport: + nonlocal active, maximum + del actor + active += 1 + maximum = max(maximum, active) + await asyncio.sleep(0) + active -= 1 + if reference.endswith("bad#3"): + raise ValueError("invalid issue") + return _report() + + monkeypatch.setattr("contribcheck.cli.GitHubClient", FakeClient) + monkeypatch.setattr("contribcheck.cli.IssueAnalyzer", FakeAnalyzer) + report = asyncio.run( + _batch( + ["owner/first#1", "owner/bad#3", "owner/last#2"], + actor=None, + token=None, + base_url=None, + concurrency=1, + ) + ) + + assert maximum == 1 + assert [item.reference for item in report.results] == [ + "owner/first#1", + "owner/bad#3", + "owner/last#2", + ] + assert report.summary.failed == 1 + assert report.results[1].error == "invalid issue" + + +def test_batch_input_ignores_comments_and_blank_lines(tmp_path: Path) -> None: + input_file = tmp_path / "candidates.txt" + input_file.write_text("\n# heading\nowner/repo#1\n # note\nowner/repo#2\n", encoding="utf-8") + + assert _read_batch_references(input_file) == ["owner/repo#1", "owner/repo#2"] + + +def test_batch_fail_on_caution_returns_exit_code_two( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + input_file = tmp_path / "candidates.txt" + input_file.write_text("owner/repo#1\n", encoding="utf-8") + report = BatchReport( + results=[BatchItem(reference="owner/repo#1", report=_report(OverallStatus.CAUTION))], + summary=BatchSummary(total=1, succeeded=1, failed=0, ready=0, caution=1, blocked=0), + ) + + async def fake_batch(*args: object, **kwargs: object) -> BatchReport: + return report + + monkeypatch.setattr("contribcheck.cli._batch", fake_batch) + result = runner.invoke(app, ["batch", str(input_file), "--json", "--fail-on", "caution"]) + + assert result.exit_code == 2 + + def test_fail_on_blocked_returns_exit_code_two(monkeypatch: pytest.MonkeyPatch) -> None: async def fake_inspect( reference: str,