From 8b67ce1178319a4d4ccc09d90ebe157f9ffa6364 Mon Sep 17 00:00:00 2001 From: "Aryan Singh K." <70511529+aryansk@users.noreply.github.com> Date: Fri, 14 Aug 2026 23:27:12 +0530 Subject: [PATCH 1/3] ci: upload coverage to Codecov and add the badge The test job already runs coverage and uploads .coverage artifacts, but no one aggregates or publishes the numbers. Emit coverage.xml in the test job and upload it with codecov/codecov-action (fail_ci_if_error: false so CI stays green until the Codecov token or app is configured), gitignore the generated file, and add the Codecov badge to the README next to the build badge. Fixes #64. --- .github/workflows/ci.yml | 6 ++++++ .gitignore | 1 + README.md | 1 + 3 files changed, 8 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 309e07d..59eda22 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,6 +57,7 @@ jobs: uv run coverage erase uv run coverage run -m pytest uv run coverage report + uv run coverage xml - name: Upload coverage data if: always() uses: actions/upload-artifact@v7 @@ -66,6 +67,11 @@ jobs: include-hidden-files: true if-no-files-found: error retention-days: 7 + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v6 + with: + files: coverage.xml + fail_ci_if_error: false build: name: Build & audit package diff --git a/.gitignore b/.gitignore index f61d98c..2148e53 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ build/ .pytest_cache/ .tox/ .coverage +coverage.xml htmlcov/ .mypy_cache/ .ruff_cache/ diff --git a/README.md b/README.md index 907536a..6efe1f4 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # GCode ![Build](https://github.com/shauryagangrade/GCode/actions/workflows/ci.yml/badge.svg) +[![codecov](https://codecov.io/gh/shauryagangrade/GCode/branch/main/graph/badge.svg)](https://codecov.io/gh/shauryagangrade/GCode) [![License: Apache 2.0](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE) [![PyPI version](https://img.shields.io/pypi/v/gcode.svg)](https://pypi.org/project/gcode/) [![Stars](https://img.shields.io/github/stars/shauryagangrade/GCode?style=social)](https://github.com/shauryagangrade/GCode) From b6766d5f88803c5c49b61229da43633ee5983a23 Mon Sep 17 00:00:00 2001 From: "Aryan Singh K." <70511529+aryansk@users.noreply.github.com> Date: Fri, 14 Aug 2026 23:30:36 +0530 Subject: [PATCH 2/3] chore: add pre-commit config mirroring the CI lint job CI enforces ruff check/format and mypy, but there was no local hook, so contributors only learned about violations after pushing. Add a .pre-commit-config.yaml with local hooks that run the exact CI commands (uv run ruff check ., uv run ruff format --check ., uv run mypy gcode), add pre-commit to the dev extras, and document the one-line install in CONTRIBUTING. Fixes #58. --- .pre-commit-config.yaml | 23 +++++++++++++++++++++++ CONTRIBUTING.md | 15 +++++++++++++++ pyproject.toml | 1 + 3 files changed, 39 insertions(+) create mode 100644 .pre-commit-config.yaml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..066661e --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,23 @@ +# Mirrors the lint job in .github/workflows/ci.yml (ruff check, ruff format, +# mypy) so contributors get the same checks locally before pushing. +repos: + - repo: local + hooks: + - id: ruff-check + name: ruff check + entry: uv run ruff check . + language: system + types: [python] + pass_filenames: false + - id: ruff-format + name: ruff format + entry: uv run ruff format --check . + language: system + types: [python] + pass_filenames: false + - id: mypy + name: mypy type check + entry: uv run mypy gcode + language: system + types: [python] + pass_filenames: false diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 773c79d..c6a8875 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -46,6 +46,21 @@ Look for issues labeled [`good first issue`](https://github.com/shauryagangrade/ - Write clean, readable code with descriptive variable names. - Ensure all tests pass before opening a PR. +## Pre-commit Hooks + +Install the hooks once so lint, formatting, and type checks run before every +commit (they mirror the CI lint job): + +```bash +uv run pre-commit install +``` + +Run them on the whole tree any time with: + +```bash +uv run pre-commit run --all-files +``` + --- ## PR Guidelines diff --git a/pyproject.toml b/pyproject.toml index 05e3ebc..53edb71 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,7 @@ dev = [ "coverage>=7", "mypy>=1.13", "pip-audit>=2.7", + "pre-commit>=4.0", "pytest>=8", "ruff>=0.9", ] From 94b3a168f7528d5fdb373cc02e453ea1eb3e7bee Mon Sep 17 00:00:00 2001 From: "Aryan Singh K." <70511529+aryansk@users.noreply.github.com> Date: Fri, 14 Aug 2026 23:32:37 +0530 Subject: [PATCH 3/3] test: cover ui.py rendering helpers and slash menu Add unit coverage for _truncate (whitespace normalization, limit boundary, ellipsis), _summarize_tool (execute_bash/path/other-args branches), the /-menu command extraction with a mocked questionary (selection, cancel, KeyboardInterrupt), the streaming refresh handler against a fake Live, and tool_start rendering. Fixes #60. --- tests/test_ui.py | 152 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 151 insertions(+), 1 deletion(-) diff --git a/tests/test_ui.py b/tests/test_ui.py index 44ccb9c..46e3bfc 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -1,4 +1,6 @@ -from gcode.ui import RichUI +from unittest.mock import Mock, patch + +from gcode.ui import RichUI, _summarize_tool, _truncate from rich.console import Console @@ -13,3 +15,151 @@ def test_goodbye_renders_logo_and_session(): assert "Thanks for coding with GCode." in output assert "session" in output.lower() assert "demo-session" in output + + +# -- _truncate ------------------------------------------------------------- + + +def test_truncate_short_text_unchanged(): + assert _truncate("hello world", 50) == "hello world" + + +def test_truncate_normalizes_whitespace(): + assert _truncate("a b\n\tc", 50) == "a b c" + + +def test_truncate_long_text_appends_ellipsis(): + out = _truncate("x" * 100, 80) + assert out == "x" * 80 + "…" + + +def test_truncate_exact_limit_no_ellipsis(): + assert _truncate("x" * 80, 80) == "x" * 80 + + +def test_truncate_empty_string(): + assert _truncate("", 10) == "" + + +def test_truncate_non_string_input(): + assert _truncate(42, 10) == "42" + + +# -- _summarize_tool ------------------------------------------------------- + + +def test_summarize_tool_execute_bash_command(): + assert _summarize_tool("execute_bash", {"command": "ls -la"}) == "ls -la" + + +def test_summarize_tool_uses_path_when_present(): + assert _summarize_tool("read_file", {"path": "src/main.py"}) == "src/main.py" + + +def test_summarize_tool_non_string_path_becomes_empty(): + assert _summarize_tool("read_file", {"path": 123}) == "" + + +def test_summarize_tool_path_wins_over_other_args(): + assert _summarize_tool("grep", {"pattern": "foo", "path": "src"}) == "src" + + +def test_summarize_tool_empty_args(): + assert _summarize_tool("noop", {}) == "" + + +def test_summarize_tool_truncates_long_values(): + out = _summarize_tool("edit_file", {"path": "x" * 500}) + assert out == "x" * 120 + "…" + + +# -- _show_slash_menu ------------------------------------------------------ + + +def _mock_select(return_value): + sel = Mock() + sel.ask.return_value = return_value + return sel + + +def test_slash_menu_returns_selected_command(): + with patch( + "gcode.ui.questionary.select", + return_value=_mock_select("/help — Show available commands"), + ) as select_mock: + ui = RichUI() + assert ui._show_slash_menu() == "/help" + + choices = select_mock.call_args.kwargs["choices"] + assert choices[0].startswith("/help") + + +def test_slash_menu_cancel_returns_empty(): + with patch("gcode.ui.questionary.select", return_value=_mock_select(None)): + ui = RichUI() + assert ui._show_slash_menu() == "" + + +def test_slash_menu_keyboard_interrupt_returns_empty(): + sel = Mock() + sel.ask.side_effect = KeyboardInterrupt + with patch("gcode.ui.questionary.select", return_value=sel): + ui = RichUI() + assert ui._show_slash_menu() == "" + + +# -- streaming refresh handler --------------------------------------------- + + +class _FakeLive: + """Minimal stand-in for rich.live.Live that records updates.""" + + def __init__(self): + self.updates = [] + self.stopped = False + + def start(self): + pass + + def update(self, content): + self.updates.append(content) + + def stop(self): + self.stopped = True + + +def test_token_renders_markdown_after_threshold(monkeypatch): + from gcode import ui as ui_module + + fake = _FakeLive() + monkeypatch.setattr(ui_module, "Live", lambda *args, **kwargs: fake) + + ui = RichUI() + ui.assistant_start() + + # Below the 80-char re-render threshold: no update yet. + ui.token("x" * 40) + assert fake.updates == [] + + # Crossing the threshold triggers one Markdown update. + ui.token("y" * 50) # total 90 >= 80 + assert len(fake.updates) == 1 + + # Ending the stream renders the final text and stops the live region. + ui.assistant_end() + assert fake.stopped + assert len(fake.updates) == 2 + + +# -- tool display ---------------------------------------------------------- + + +def test_tool_start_renders_summary(): + ui = RichUI() + ui.console = Console(record=True, width=120) + + ui.tool_start("execute_bash", {"command": "ls"}) + + output = ui.console.export_text() + assert "execute_bash" in output + assert "ls" in output