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: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:
Expand Down
146 changes: 145 additions & 1 deletion src/contribcheck/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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."""

Expand Down
29 changes: 28 additions & 1 deletion src/contribcheck/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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."""

Expand Down
107 changes: 106 additions & 1 deletion tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
from __future__ import annotations

import asyncio
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.cli import _batch, _read_batch_references, app
from contribcheck.models import (
BatchItem,
BatchReport,
BatchSummary,
CheckResult,
Evidence,
InspectionReport,
Expand Down Expand Up @@ -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,
Expand Down
Loading