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
10 changes: 8 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
22 changes: 22 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -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/
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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`.
Expand Down
86 changes: 81 additions & 5 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
4 changes: 2 additions & 2 deletions src/different_agent/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ def create_inspiration_agent(
*,
include_commits: bool = True,
include_issues: bool = True,
):
) -> Any:
tools = [
git_github_repo,
github_recent_prs,
Expand All @@ -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],
Expand Down
27 changes: 9 additions & 18 deletions src/different_agent/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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 "
Expand All @@ -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:
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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(
Expand Down
5 changes: 2 additions & 3 deletions src/different_agent/github_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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:
Expand Down
11 changes: 8 additions & 3 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import os
import shutil
import subprocess
from pathlib import Path

Expand All @@ -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,
Expand All @@ -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)
Expand All @@ -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)
Expand Down
8 changes: 4 additions & 4 deletions tests/test_cli_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion tests/test_cli_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@


class DummyUsage:
usage_metadata: dict = {}
def __init__(self) -> None:
self.usage_metadata: dict = {}

def __enter__(self):
return self
Expand Down
Loading
Loading