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
31 changes: 31 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
version: 2
updates:
- package-ecosystem: uv
directory: /
schedule:
interval: cron
cronjob: 35 4 * * *
timezone: Europe/Warsaw
open-pull-requests-limit: 1
versioning-strategy: increase-if-necessary
allow:
- dependency-name: costs
dependency-type: all
- dependency-name: goal
dependency-type: all
- dependency-name: pfix
dependency-type: all
- dependency-name: clickmd
dependency-type: all
- dependency-name: code2llm
dependency-type: all
groups:
internal-packages:
patterns:
- costs
- goal
- pfix
- clickmd
- code2llm
commit-message:
prefix: "chore(deps)"
35 changes: 35 additions & 0 deletions .github/internal-dependencies.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{
"schema": "goal.internal-dependencies/v1",
"packages": [
{
"name": "costs",
"repository": "semcod/costs",
"registry": "pypi",
"versioning": "semver"
},
{
"name": "goal",
"repository": "semcod/goal",
"registry": "pypi",
"versioning": "semver"
},
{
"name": "pfix",
"repository": "semcod/pfix",
"registry": "pypi",
"versioning": "semver"
},
{
"name": "clickmd",
"repository": "semcod/clickmd",
"registry": "pypi",
"versioning": "semver"
},
{
"name": "code2llm",
"repository": "semcod/code2llm",
"registry": "pypi",
"versioning": "semver"
}
]
}
18 changes: 11 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,15 @@ on:
pull_request:
branches: [main, develop]

permissions:
contents: read

jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13"]

Expand All @@ -23,22 +28,21 @@ jobs:

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install pytest-cov
pip install -e ".[dev]"
python -m pip install uv
uv sync --locked --extra dev --python "${{ matrix.python-version }}"

- name: Run tests with coverage
run: |
PYTHONPATH=src python -m pytest tests/ -v --cov=nxdo --cov-report=term-missing --cov-fail-under=95
uv run --no-sync python -m pytest tests/ -v --cov=nxdo --cov-report=term-missing --cov-fail-under=95

- name: Run type checking
run: |
mypy src/nxdo
uv run --no-sync mypy src/nxdo

- name: Run linting
run: |
ruff check src/nxdo tests/
uv run --no-sync ruff check src/nxdo tests/

- name: Verify committed examples
run: |
./examples/check-examples.sh
uv run --no-sync bash examples/check-examples.sh
36 changes: 36 additions & 0 deletions .github/workflows/internal-dependency-freshness.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
name: Internal dependency freshness

on:
schedule:
- cron: "47 5 * * *"
workflow_dispatch:
pull_request:
paths:
- pyproject.toml
- uv.lock
- .github/internal-dependencies.json
- .github/workflows/internal-dependency-freshness.yml

permissions:
contents: read

jobs:
freshness:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: "3.12"
- name: Install released checker
run: python -m pip install "goal==2.2.0"
- name: Verify published stable targets
run: goal dependencies --catalog .github/internal-dependencies.json --check > dependency-freshness.json
- name: Retain freshness evidence
if: always()
uses: actions/upload-artifact@v4
with:
name: dependency-freshness
path: dependency-freshness.json
if-no-files-found: ignore
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -370,3 +370,7 @@ PYTHONPATH=src python -m unittest discover -s tests -v
## License

Licensed under Apache-2.0.

## Dependency maintenance

See [internal dependency updates](docs/dependencies.md) for daily updates, locked tests and Python support.
31 changes: 31 additions & 0 deletions docs/dependencies.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Internal dependency updates

This repository owns its dependency configuration and this guide, following the repository ownership principle in [wellmanifest/docs](https://github.com/wellmanifest/docs/blob/f64de5806577769672ebc1730d2e144b4c7671ec/README.md). The checker belongs to [semcod/goal](https://github.com/semcod/goal/blob/84f18540d14c24cc8ff5b7f202d2874344779ecc/docs/internal-dependencies.md).

## Python and installation

Application support remains Python >=3.10. Source inspection found no Goal imports or executable invocation in application Python code. Goal's old declarations moved to an `automation` dependency group requiring Python >=3.12, so updating release tooling does not remove Python 3.10 application support.

```sh
uv sync --locked --extra dev --python 3.10
uv run --no-sync python -m pytest -q
UV_PROJECT_ENVIRONMENT=.venv-automation uv sync --locked --group automation --python 3.12
```

The automation command uses a separate environment. Synchronize the application environment again after merging tested dependency updates.

## Daily checks

[Dependabot](../.github/dependabot.yml) checks the explicit costs, Goal, pfix, clickmd and code2llm catalog daily, including weekends, and groups updates into one PR. It includes transitive packages and may widen constraints when necessary. This is a bounded catalog, not discovery of every internal package. Local/Git sources and other packages need separate review.

[Freshness CI](../.github/workflows/internal-dependency-freshness.yml) uses released Goal 2.2.0 to compare uv.lock with the highest published stable three-part versions of the catalog packages actually used here. It has read-only repository permissions, runs daily/manually/on dependency PRs, and retains JSON evidence. Registry and resolution errors remain visible. An audit does not prove a deployed environment has installed the new versions.

[Locked CI](../.github/workflows/ci.yml) installs the dev extra from uv.lock and runs the full suite, 95% coverage gate, mypy, Ruff and examples on Python 3.10, 3.11, 3.12 and 3.13. A permissive version declaration alone does not refresh the lockfile; update creation, testing, merge and installation are separate steps.

## Delivery

The registry targets observed on 2026-09-05 are costs 0.2.0, Goal 2.2.0, pfix 0.1.79, clickmd 1.1.15 and code2llm 0.5.176. PR and Actions checks hold the test results; the [ecosystem rollout history](https://github.com/semcod/costs/tree/main/docs/dependencies) belongs to costs. Reports and instructions are versioned in repository docs, rather than temporary machine directories.

The existing CI retains its 95% coverage gate. Regression tests exercise real temporary Git histories, optional Koru failures, and auto-mode dry-run/synchronization boundaries. Bug-fix counting now combines keyword filters in one Git query and counts each commit once, preventing densities above 100% caused by repeated keywords. `pytest-cov` is declared in the dev extra so the coverage gate can also run from the lockfile.

The existing CI now uses uv.lock for all its gates. Its previous pip installation selected Ruff 0.16.6 while the lockfile contained 0.15.17, producing different lint rules from local checks. The lockfile is the shared source of dependency versions; updates to third-party tools need their own tested lockfile changes.
8 changes: 7 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,9 @@ dev = [
"pytest-mock",
"ruff",
"mypy",
"goal>=2.1.0",
"costs>=0.1.20",
"pfix>=0.1.60",
"pytest-cov>=5.0",
]

[project.urls]
Expand Down Expand Up @@ -96,3 +96,9 @@ max_commits = 500

# Cost thresholds for badge colors (USD)
badge_color_thresholds = { low = 1.0, medium = 5.0, high = 10.0, critical = 50.0 }

[tool.uv.dependency-groups.automation]
requires-python = ">=3.12"

[dependency-groups]
automation = ["goal>=2.2.0"]
2 changes: 1 addition & 1 deletion src/nxdo/metrics/coupling.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ def _get_commits_with_files(repo_path: Path, max_commits: int = 100) -> list[lis
return []

commits = []
current_files = []
current_files: list[str] = []

for line in result.stdout.strip().split("\n"):
if not line:
Expand Down
27 changes: 11 additions & 16 deletions src/nxdo/metrics/hotspots.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,22 +73,17 @@ def _get_bug_fix_commits(repo_path: Path, file_path: str, since: str = "90.days.
bug_patterns = ["fix", "bug", "repair", "hotfix", "patch", "resolve", "issue"]

try:
# Use git log with grep for each pattern
total = 0
for pattern in bug_patterns:
result = subprocess.run(
["git", "log", f"--since={since}", f"--grep={pattern}", "-i",
"--oneline", "--", file_path],
cwd=repo_path,
capture_output=True,
text=True,
)
if result.returncode == 0:
# Count unique commits (git log --grep can return duplicates across patterns)
commits = set(line.strip() for line in result.stdout.strip().split("\n") if line.strip())
total += len(commits)

return total
result = subprocess.run(
["git", "log", f"--since={since}", "--format=%H", "-i",
*(f"--grep={pattern}" for pattern in bug_patterns), "--", file_path],
cwd=repo_path,
capture_output=True,
text=True,
)
if result.returncode != 0:
return 0
# Git combines these patterns with OR and emits each commit once.
return len({line.strip() for line in result.stdout.splitlines() if line.strip()})
except Exception:
return 0

Expand Down
60 changes: 60 additions & 0 deletions tests/test_auto_cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""Verify auto mode dispatch and its dry-run boundary without a live provider."""

from types import SimpleNamespace
from unittest.mock import Mock

import pytest
from typer.testing import CliRunner

from nxdo import cli, metrics
from nxdo.models import Task, TaskPlan


@pytest.fixture
def workflow(monkeypatch):
plan = TaskPlan(project_name="demo", summary="Repair the parser", tasks=[
Task(number=1, title="Parser tests", description="Cover malformed input"),
])
generate = Mock(return_value=plan)
sync = Mock()
provider = Mock()
monkeypatch.setattr(cli, "generate_next_tasks", generate)
monkeypatch.setattr(cli, "_sync_planfile_if_requested", sync)
monkeypatch.setattr(cli, "OpenAICompatProvider", provider)
monkeypatch.setattr(cli, "get_settings", Mock())
monkeypatch.setattr(metrics, "identify_bug_hotspots", Mock(return_value=[]))
monkeypatch.setattr(metrics, "collect_file_metrics", Mock(return_value=[]))
return plan, generate, sync, provider


@pytest.mark.parametrize("has_issues", [False, True])
def test_auto_dry_run_does_not_call_provider_or_write_tickets(tmp_path, workflow, has_issues):
if has_issues:
metrics.identify_bug_hotspots.return_value = [SimpleNamespace()]
metrics.collect_file_metrics.return_value = [SimpleNamespace(cyclomatic_complexity=12)]
result = CliRunner().invoke(cli.app, ["auto", str(tmp_path), "--dry-run"])
assert result.exit_code == 0, result.output
assert "would execute" in result.output
assert ("high-complexity" if has_issues else "healthy") in result.output
for mock in workflow[1:]:
mock.assert_not_called()
assert list(tmp_path.iterdir()) == []


def test_auto_syncs_the_generated_plan_and_forwards_context(tmp_path, workflow):
plan, generate, sync, provider = workflow
result = CliRunner().invoke(cli.app, ["auto", str(tmp_path), "--extra-context", "Parser failures"])
assert result.exit_code == 0, result.output
assert "1 tickets queued" in result.output
assert generate.call_args.kwargs["extra_context"] == "Parser failures"
assert generate.call_args.kwargs["koru_aware"] is True
assert generate.call_args.kwargs["provider"] is provider.return_value
sync.assert_called_once_with(plan, tmp_path.resolve(), sync_planfile=True)


def test_auto_provider_error_does_not_sync_partial_output(tmp_path, workflow):
workflow[1].side_effect = ValueError("provider offline")
result = CliRunner().invoke(cli.app, ["auto", str(tmp_path)])
assert result.exit_code == 1
assert "provider offline" in result.output
workflow[2].assert_not_called()
Loading
Loading