diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5d9b7aa..c8c8943 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,10 +20,16 @@ jobs: run: uv sync --all-groups --frozen - name: Ruff lint - run: uv run ruff check . + run: uv run ruff check --output-format=github . - name: Ty type check run: uv run ty check src/ - name: Pytest - run: uv run pytest + run: uv run pytest --cov-report=xml + + - name: Upload coverage + uses: codecov/codecov-action@v5 + with: + files: ./coverage.xml + fail_ci_if_error: false diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..2d8cc9c --- /dev/null +++ b/Makefile @@ -0,0 +1,22 @@ +.PHONY: dev lint format test build clean + +dev: + uv sync --all-groups + +lint: + uv run ruff format --check . && uv run ruff check . && uv run ty check src/ + +format: + uv run ruff format . + +test: + uv run pytest + +test-fast: + uv run pytest -x -q --no-cov + +build: + uv build + +clean: + rm -rf dist/ build/ *.egg-info/ .pytest_cache/ .coverage htmlcov/ diff --git a/README.md b/README.md index b8948aa..ffd5089 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,12 @@ uv sync --group lint prek run --all-files ``` +Or use the Makefile: + +```bash +make lint +``` + ## Testing - `pytest` runs with `pytest-cov` . - Warnings are treated as errors. @@ -35,6 +41,12 @@ Run: uv run pytest ``` +Or use the Makefile: + +```bash +make test +``` + ## Configuration The app reads `different.toml`. This is where you set the “recent” window (days + max commits), how many patch lines are fetched per commit, whether GitHub enrichment is enabled, whether HTML reports are generated, and the default model settings. You can also set `extract.since_date` (YYYY-MM-DD or ISO-8601) to scan from a fixed date; it overrides `since_days`. diff --git a/pyproject.toml b/pyproject.toml index 82d3b5e..246be94 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,28 +24,104 @@ lint = [ ] test = [ "pytest>=9.0.2", - "pytest-cov>=5.0.0", + "pytest-cov>=6.0", ] dev = [ { include-group = "lint" }, { include-group = "test" }, ] +[tool.uv] +default-groups = ["dev"] + [tool.ruff] line-length = 100 target-version = "py311" +src = ["src"] [tool.ruff.lint] -select = ["E", "F", "W", "I", "B", "UP"] +select = ["ALL"] +ignore = [ + "D", # pydocstyle (docstrings) + "C901", # complexity warnings (refactor later) + "COM812", # missing trailing comma (conflicts with formatter) + "ISC001", # single-line implicit string concat (conflicts with formatter) + "TC", # type-checking imports (too strict for this project) + "TD002", # missing TODO author + "TD003", # missing TODO link + "FIX002", # line contains TODO + "TRY003", # long exception messages (too strict for CLI apps) + "EM101", # exception string literal (too strict) + "EM102", # exception f-string (too strict) + "ANN401", # Any type disallowed (sometimes necessary) + "PLR0915", # too many statements (refactor later) + "PLR0911", # too many return statements (acceptable for parsing) + "PLR0912", # too many branches (acceptable for parsing/CLI flows) + "PLR0913", # too many arguments (acceptable for tool APIs) + "PLR2004", # magic value comparison (too strict) + "PLC0415", # import not at top level (intentional for optional deps) + "PTH", # pathlib enforcement (would require full refactor) + "RUF001", # ambiguous unicode (en dash is intentional) + "PLW2901", # loop variable overwritten (common pattern) + "S310", # URL open audit (handled at call site) + "FBT", # boolean trap (acceptable for tool APIs) +] [tool.ruff.lint.per-file-ignores] -"src/different_agent/agents.py" = ["E501"] -"src/different_agent/report.py" = ["E501"] +"tests/**/*.py" = [ + "S101", # assert allowed in tests + "S603", # subprocess call (test helper) + "PLR2004", # magic values allowed in tests + "ANN", # type annotations optional in tests + "INP001", # implicit namespace package + "SLF001", # private member access (tests cover internal helpers) +] +"src/different_agent/agents.py" = [ + "E501", # long lines in prompt strings +] +"src/different_agent/report.py" = [ + "E501", # long lines in HTML template + "PERF401", # for loop more readable than comprehension for HTML generation +] +"src/different_agent/git_tools.py" = [ + "S603", # subprocess.run with trusted git commands +] +"src/different_agent/github_tools.py" = [ + "BLE001", # broad exceptions for robust API wrappers +] +"src/different_agent/config.py" = [ + "TRY004", # ValueError is appropriate for config parsing + "FBT001", # boolean default args in config helpers +] + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" +docstring-code-format = true [tool.pytest.ini_options] testpaths = ["tests"] -addopts = "--cov=different_agent --cov-report=term-missing --cov-fail-under=80" +pythonpath = ["src"] +addopts = [ + "-ra", + "--strict-markers", + "--strict-config", + "--cov=different_agent", + "--cov-report=term-missing", + "--cov-fail-under=80", +] filterwarnings = ["error"] +[tool.coverage.run] +branch = true +source = ["src/different_agent"] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "if TYPE_CHECKING:", + "if __name__ == .__main__.:", +] + [tool.ty.environment] python-version = "3.11" diff --git a/src/different_agent/agents.py b/src/different_agent/agents.py index b9eb17d..95ad42c 100644 --- a/src/different_agent/agents.py +++ b/src/different_agent/agents.py @@ -199,7 +199,7 @@ def create_inspiration_agent( *, include_commits: bool = True, include_issues: bool = True, -): +) -> Any: tools = [ git_github_repo, github_recent_prs, @@ -223,7 +223,7 @@ def create_inspiration_agent( ) -def create_target_agent(model: BaseChatModel, cache: BaseCache | None = None): +def create_target_agent(model: BaseChatModel, cache: BaseCache | None = None) -> Any: return create_deep_agent( model=model, tools=[git_grep, git_show_file], diff --git a/src/different_agent/cli.py b/src/different_agent/cli.py index ddcbd8b..3aab2e7 100644 --- a/src/different_agent/cli.py +++ b/src/different_agent/cli.py @@ -7,7 +7,7 @@ import os from datetime import UTC, datetime from pathlib import Path -from typing import Any +from typing import Any, ClassVar, cast from dotenv import load_dotenv from langchain_core.callbacks import get_usage_metadata_callback @@ -24,14 +24,14 @@ class _ColorFormatter(logging.Formatter): - _COLORS = { + _COLORS: ClassVar[dict[int, str]] = { logging.DEBUG: "\x1b[36m", logging.INFO: "\x1b[32m", logging.WARNING: "\x1b[33m", logging.ERROR: "\x1b[31m", logging.CRITICAL: "\x1b[35m", } - _RESET = "\x1b[0m" + _RESET: ClassVar[str] = "\x1b[0m" def format(self, record: logging.LogRecord) -> str: original_level = record.levelname @@ -62,8 +62,7 @@ def _write_output_html(path: Path, html_content: str) -> None: def _output_suffix(now: datetime | None = None) -> str: - timestamp = (now or datetime.now()).strftime("%m-%d_%H-%M") - return timestamp + return (now or datetime.now(UTC)).strftime("%m-%d_%H-%M") def _output_project_name(inspiration_path: str, target_path: str | None) -> str: @@ -131,7 +130,7 @@ def _apply_cli_overrides(cfg: AppConfig, args: argparse.Namespace) -> AppConfig: raw_since_date = None if since_days_override is None and raw_since_date is not None: try: - parsed = datetime.fromisoformat(raw_since_date.replace("Z", "+00:00")) + parsed = datetime.fromisoformat(raw_since_date) except ValueError as exc: raise SystemExit( "Invalid since_date. Use YYYY-MM-DD or an ISO-8601 datetime like " @@ -150,14 +149,8 @@ def _apply_cli_overrides(cfg: AppConfig, args: argparse.Namespace) -> AppConfig: since_days_override if since_days_override is not None else cfg.extract.since_days ) effective_since_date = None - if from_pr_override is None: - from_pr = cfg.extract.from_pr - else: - from_pr = from_pr_override - if to_pr_override is None: - to_pr = cfg.extract.to_pr - else: - to_pr = to_pr_override + from_pr = cfg.extract.from_pr if from_pr_override is None else from_pr_override + to_pr = cfg.extract.to_pr if to_pr_override is None else to_pr_override if (from_pr is None) ^ (to_pr is None): raise SystemExit("--from-pr and --to-pr must be provided together.") if from_pr is not None and to_pr is not None: @@ -397,8 +390,7 @@ def main() -> int: ) _ensure_git_repo(inspiration_path) if not args.extract_only: - assert target_path is not None - _ensure_git_repo(target_path) + _ensure_git_repo(cast(str, target_path)) output_suffix = _output_suffix() output_project_name = _output_project_name(inspiration_path, target_path) @@ -463,11 +455,10 @@ def main() -> int: } target_agent = create_target_agent(resolved.model, cache=cache) - assert target_path is not None target_prompt = ( "Check this target repository for applicability of the findings in " "/inputs/findings.json.\n\n" - f"target_repo_path: {target_path}\n" + f"target_repo_path: {cast(str, target_path)}\n" ) logger.info("Invoking target agent.") target_result = target_agent.invoke( diff --git a/src/different_agent/github_tools.py b/src/different_agent/github_tools.py index acf6793..dcce71e 100644 --- a/src/different_agent/github_tools.py +++ b/src/different_agent/github_tools.py @@ -59,8 +59,7 @@ def _github_request_json(url: str) -> Any: headers["Authorization"] = f"Bearer {token}" req = urllib.request.Request(url, headers=headers) - # S310: URL is built from GitHub API base; no user-controlled host/scheme. - with urllib.request.urlopen(req, timeout=30) as resp: # noqa: S310 + with urllib.request.urlopen(req, timeout=30) as resp: payload = resp.read().decode("utf-8") return json.loads(payload) @@ -253,7 +252,7 @@ def github_recent_prs( date_str = merged_at or updated_at if isinstance(date_str, str): try: - dt = datetime.fromisoformat(date_str.replace("Z", "+00:00")) + dt = datetime.fromisoformat(date_str) except ValueError: dt = None if dt is not None and dt < threshold: diff --git a/tests/conftest.py b/tests/conftest.py index 6353219..023f5ec 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +import shutil import subprocess from pathlib import Path @@ -17,8 +18,11 @@ def _git_env() -> dict[str, str]: def _run_git(repo_path: Path, args: list[str]) -> None: + git = shutil.which("git") + if git is None: + raise RuntimeError("git not found in PATH") subprocess.run( - ["git", *args], + [git, *args], cwd=repo_path, check=True, text=True, @@ -36,12 +40,12 @@ def init_git_repo(repo_path: Path) -> Path: return repo_path -@pytest.fixture() +@pytest.fixture def git_repo(tmp_path: Path) -> Path: return init_git_repo(tmp_path / "repo") -@pytest.fixture() +@pytest.fixture def make_git_repo(tmp_path: Path): def _make(name: str) -> Path: return init_git_repo(tmp_path / name) @@ -58,6 +62,7 @@ def _require_skip_reason(marker: pytest.Mark, item: pytest.Item) -> None: def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> None: + del config for item in items: for marker in item.iter_markers(name="skip"): _require_skip_reason(marker, item) diff --git a/tests/test_cli_helpers.py b/tests/test_cli_helpers.py index ebcf54f..7c90437 100644 --- a/tests/test_cli_helpers.py +++ b/tests/test_cli_helpers.py @@ -11,11 +11,11 @@ def test_output_helpers() -> None: - fixed = datetime(2024, 1, 2, 3, 4) + fixed = datetime(2024, 1, 2, 3, 4, tzinfo=UTC) assert cli._output_suffix(fixed) == "01-02_03-04" - assert cli._output_project_name("/tmp/inspiration", None) == "inspiration" - assert cli._output_project_name("/tmp/inspiration", "/tmp/target") == "target" + assert cli._output_project_name("/home/test/inspiration", None) == "inspiration" + assert cli._output_project_name("/home/test/inspiration", "/home/test/target") == "target" assert cli._output_project_name("/", None) == "project" base = Path("outputs/findings.json") @@ -61,7 +61,7 @@ def test_apply_cli_overrides_with_since_date(monkeypatch: pytest.MonkeyPatch) -> class FixedDateTime(datetime): @classmethod - def now(cls, tz=None): + def now(cls, _tz=None): return fixed_now monkeypatch.setattr(cli, "datetime", FixedDateTime) diff --git a/tests/test_cli_main.py b/tests/test_cli_main.py index 7706206..5dec7c9 100644 --- a/tests/test_cli_main.py +++ b/tests/test_cli_main.py @@ -10,7 +10,8 @@ class DummyUsage: - usage_metadata: dict = {} + def __init__(self) -> None: + self.usage_metadata: dict = {} def __enter__(self): return self diff --git a/tests/test_github_tools.py b/tests/test_github_tools.py index 20488e5..c63b169 100644 --- a/tests/test_github_tools.py +++ b/tests/test_github_tools.py @@ -1,6 +1,7 @@ from __future__ import annotations import urllib.error +import urllib.request import pytest @@ -31,8 +32,8 @@ def test_git_github_repo_success(monkeypatch: pytest.MonkeyPatch) -> None: class DummyResult: stdout = "git@github.com:acme/widgets.git" - monkeypatch.setattr(gh, "_run_git", lambda repo_path, args: DummyResult) - assert gh.git_github_repo.invoke({"repo_path": "/tmp/repo"}) == { + monkeypatch.setattr(gh, "_run_git", lambda _repo_path, _args: DummyResult) + assert gh.git_github_repo.invoke({"repo_path": "/home/test/repo"}) == { "owner": "acme", "repo": "widgets", } @@ -43,7 +44,7 @@ def boom(_repo_path: str, _args: list[str]) -> None: raise RuntimeError("no remote") monkeypatch.setattr(gh, "_run_git", boom) - result = gh.git_github_repo.invoke({"repo_path": "/tmp/repo"}) + result = gh.git_github_repo.invoke({"repo_path": "/home/test/repo"}) assert "error" in result @@ -124,3 +125,159 @@ def fake_request(url: str): {"owner": "acme", "repo": "widgets", "from_pr": 1, "to_pr": 1} ) assert results == [] + + +def test_github_request_json_sets_auth_header_and_parses_json( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("GH_TOKEN", "fake-token") + + seen: dict[str, object] = {} + + class FakeResponse: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def read(self): + return b'{"ok": true}' + + def fake_urlopen(req: urllib.request.Request, timeout: int): # noqa: ARG001 + seen["auth"] = req.headers.get("Authorization") + return FakeResponse() + + monkeypatch.setattr(gh.urllib.request, "urlopen", fake_urlopen) + assert gh._github_request_json("https://api.github.com/test") == {"ok": True} + assert seen["auth"] == "Bearer fake-token" + + +def test_record_analyzed_pr_ignores_invalid_inputs() -> None: + gh.reset_analyzed_pr_count() + gh._record_analyzed_pr(None, "repo", 1) + gh._record_analyzed_pr("owner", "", 1) + gh._record_analyzed_pr("owner", "repo", None) + assert gh.get_analyzed_pr_count() == 0 + + +def test_parse_github_repo_from_remote_empty() -> None: + assert gh._parse_github_repo_from_remote(" ") is None + + +def test_git_github_repo_parse_error(monkeypatch: pytest.MonkeyPatch) -> None: + class DummyResult: + stdout = "https://gitlab.com/acme/widgets" + + monkeypatch.setattr(gh, "_run_git", lambda _repo_path, _args: DummyResult) + result = gh.git_github_repo.invoke({"repo_path": "/home/test/repo"}) + assert "error" in result + + +def test_github_recent_prs_non_range_filters_by_since_days(monkeypatch: pytest.MonkeyPatch) -> None: + def fake_request(_url: str) -> list[dict]: + return [ + { + "number": 1, + "title": "Old PR", + "state": "closed", + "merged_at": None, + "updated_at": "1970-01-01T00:00:00Z", + "html_url": "https://example.com/1", + }, + { + "number": 2, + "title": "New PR", + "state": "closed", + "merged_at": None, + "updated_at": "2999-01-01T00:00:00Z", + "html_url": "https://example.com/2", + }, + { + "number": 3, + "title": "No date", + "state": "closed", + "merged_at": None, + "updated_at": None, + "html_url": "https://example.com/3", + }, + ] + + gh.reset_analyzed_pr_count() + monkeypatch.setattr(gh, "_github_request_json", fake_request) + results = gh.github_recent_prs.invoke( + {"owner": "acme", "repo": "widgets", "since_days": 1, "max_count": 10} + ) + assert [item["number"] for item in results] == [2, 3] + assert gh.get_analyzed_pr_count() == 2 + + +def test_github_recent_issues_error_non_list(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(gh, "_github_request_json", lambda _url: {"nope": True}) + results = gh.github_recent_issues.invoke({"owner": "acme", "repo": "widgets"}) + assert results[0]["error"] == "Unexpected response from GitHub issues API" + + +def test_github_recent_issues_error_exception(monkeypatch: pytest.MonkeyPatch) -> None: + def boom(_url: str): + raise RuntimeError("boom") + + monkeypatch.setattr(gh, "_github_request_json", boom) + results = gh.github_recent_issues.invoke({"owner": "acme", "repo": "widgets"}) + assert "error" in results[0] + + +def test_github_fetch_issue_success_and_error(monkeypatch: pytest.MonkeyPatch) -> None: + def ok(_url: str) -> dict: + return { + "number": 123, + "title": "Issue", + "state": "closed", + "labels": [{"name": "bug"}], + "closed_at": None, + "updated_at": None, + "html_url": "https://example.com", + "body": "x" * 13000, + } + + monkeypatch.setattr(gh, "_github_request_json", ok) + issue = gh.github_fetch_issue.invoke({"owner": "acme", "repo": "widgets", "number": 123}) + assert issue["number"] == 123 + assert issue["labels"] == ["bug"] + assert len(issue["body"]) == 12000 + + monkeypatch.setattr(gh, "_github_request_json", lambda _url: ["nope"]) + error = gh.github_fetch_issue.invoke({"owner": "acme", "repo": "widgets", "number": 1}) + assert "error" in error + + +def test_github_fetch_pr_success_and_error(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + gh, + "_github_request_json", + lambda _url: { + "number": 12, + "title": "PR", + "state": "closed", + "merged_at": None, + "updated_at": None, + "html_url": "https://example.com", + "body": "x" * 13000, + }, + ) + pr = gh.github_fetch_pr.invoke({"owner": "acme", "repo": "widgets", "number": 12}) + assert pr["number"] == 12 + assert len(pr["body"]) == 12000 + + monkeypatch.setattr(gh, "_github_request_json", lambda _url: ["nope"]) + error = gh.github_fetch_pr.invoke({"owner": "acme", "repo": "widgets", "number": 12}) + assert "error" in error + + +def test_github_fetch_pr_files_error(monkeypatch: pytest.MonkeyPatch) -> None: + def boom(_url: str): + raise RuntimeError("boom") + + monkeypatch.setattr(gh, "_github_request_json", boom) + results = gh.github_fetch_pr_files.invoke({"owner": "acme", "repo": "widgets", "number": 12}) + assert "error" in results[0] diff --git a/uv.lock b/uv.lock index 69f71b8..7b2919c 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.11" [[package]] @@ -287,7 +287,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ { name = "pytest", specifier = ">=9.0.2" }, - { name = "pytest-cov", specifier = ">=5.0.0" }, + { name = "pytest-cov", specifier = ">=6.0" }, { name = "ruff", specifier = ">=0.14.14" }, { name = "ty", specifier = ">=0.0.13" }, ] @@ -297,7 +297,7 @@ lint = [ ] test = [ { name = "pytest", specifier = ">=9.0.2" }, - { name = "pytest-cov", specifier = ">=5.0.0" }, + { name = "pytest-cov", specifier = ">=6.0" }, ] [[package]]