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
26 changes: 26 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,36 @@ permissions:
pull-requests: write

jobs:
# -- Lint, Format & Typecheck ----------------------------------------------
lint:
name: Lint, Format & Typecheck
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.13"

- name: Install tools
run: pip install ruff mypy types-requests

- name: Ruff lint
run: ruff check .

- name: Ruff format check
run: ruff format --check .

- name: Mypy typecheck
run: mypy lint_commits.py lint_local.py

# -- Unit tests (push + PR) ------------------------------------------------
test:
name: Unit Tests
runs-on: ubuntu-latest
needs: lint
steps:
- name: Checkout
uses: actions/checkout@v4
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,5 @@ __pycache__/
dist/
build/
.venv/
.ruff_cache/
.mypy_cache/
3 changes: 3 additions & 0 deletions Pipfile
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ llm-ollama = "*"

[dev-packages]
pytest = "*"
ruff = "*"
mypy = "*"
types-requests = "*"

[requires]
python_version = "3.13"
1,367 changes: 208 additions & 1,159 deletions Pipfile.lock

Large diffs are not rendered by default.

28 changes: 15 additions & 13 deletions lint_commits.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import re
import sys
from dataclasses import dataclass, field
from typing import Any

import llm
import requests
Expand Down Expand Up @@ -235,7 +236,7 @@ def _extract_flagged_word(match: dict, text: str) -> str:
offset = match.get("offset", 0)
length = match.get("length", 0)
if offset >= 0 and length > 0 and offset + length <= len(text):
return text[offset:offset + length]
return text[offset : offset + length]
return ""


Expand Down Expand Up @@ -322,9 +323,9 @@ def get_llm_model(model_id: str, api_key: str = "") -> llm.Model:
return model


def _parse_llm_json(text: str) -> dict:
def _parse_llm_json(text: str) -> dict[str, Any]:
"""Strip optional markdown fences and parse JSON from an LLM response.

Handles common issues with small models producing malformed JSON.
"""
text = text.strip()
Expand All @@ -333,17 +334,18 @@ def _parse_llm_json(text: str) -> dict:
if text.endswith("```"):
text = "\n".join(text.split("\n")[:-1])
text = text.strip()

# Try to parse as-is first
try:
return json.loads(text)
except json.JSONDecodeError:
# Small models sometimes produce invalid JSON with unescaped quotes
# Try some basic fixes
import re

# Fix common issue: "word" inside a string value (should be \"word\")
# This is a heuristic - may not catch all cases
lines = text.split('\n')
lines = text.split("\n")
fixed_lines = []
for line in lines:
# If line contains a key-value pair with quotes in the value
Expand All @@ -357,11 +359,11 @@ def _parse_llm_json(text: str) -> dict:
value = value.replace('"', '\\"')
line = prefix + value + suffix
fixed_lines.append(line)
text = '\n'.join(fixed_lines)
text = "\n".join(fixed_lines)
return json.loads(text)


def check_structure(message: str, model: llm.Model | None = None) -> dict:
def check_structure(message: str, model: llm.Model | None = None) -> dict[str, Any]:
"""Use an LLM to evaluate whether a commit message explains *why*.

Returns a dict with keys: explains_why, score, feedback, suggestion.
Expand All @@ -374,12 +376,12 @@ def check_structure(message: str, model: llm.Model | None = None) -> dict:
response = model.prompt(message, system=SYSTEM_PROMPT)
text = response.text()
result = _parse_llm_json(text)

# Enforce the rule: only suggest rewrites for scores < 7
# Small models sometimes don't follow instructions perfectly
if result.get("score", 0) >= 7:
result["suggestion"] = None

return result
except Exception as exc:
print(f"WARNING: LLM structure check failed: {exc}", file=sys.stderr)
Expand Down Expand Up @@ -419,8 +421,8 @@ def build_report(results: list[CommitIssue]) -> str:
for gi in r.grammar_issues:
suggestion = ""
if gi["replacements"]:
suggestion = f' -> try: *{", ".join(gi["replacements"])}*'
lines.append(f'- {gi["message"]}{suggestion} (`{gi["rule"]}`)')
suggestion = f" -> try: *{', '.join(gi['replacements'])}*"
lines.append(f"- {gi['message']}{suggestion} (`{gi['rule']}`)")
lines.append("")

if r.structure_issues:
Expand Down Expand Up @@ -480,12 +482,12 @@ def main() -> int:
ci.score = result.get("score")
ci.suggestion = result.get("suggestion")
feedback = result.get("feedback", "")

# Only treat feedback as an issue if the commit doesn't explain why
# or if it scores below 7 (our threshold for "good enough")
explains_why = result.get("explains_why", True)
score = result.get("score", 0)

if not explains_why or score < 7:
if feedback:
ci.structure_issues = [feedback]
Expand Down
44 changes: 26 additions & 18 deletions lint_local.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,11 @@

import argparse
import json
import os
import re
import subprocess
import sys
from dataclasses import dataclass, field
from typing import Any

import llm
import requests
Expand Down Expand Up @@ -198,7 +198,6 @@ def git_log(revision_range: str | None = None, last_n: int | None = None) -> lis
cmd.extend(["-n", str(last_n)])
else:
cmd.extend(["-n", "5"])

try:
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
except subprocess.CalledProcessError as exc:
Expand All @@ -216,10 +215,10 @@ def git_log(revision_range: str | None = None, last_n: int | None = None) -> lis
f"\n"
f"Suggestion: Check if the base commit exists with:\n"
f" git cat-file -t {revision_range.split('..')[0] if '..' in revision_range else 'BASE_SHA'}\n",
file=sys.stderr
file=sys.stderr,
)
raise

raw = result.stdout.strip()
if not raw:
return []
Expand Down Expand Up @@ -247,7 +246,7 @@ def _extract_flagged_word(match: dict, text: str) -> str:
offset = match.get("offset", 0)
length = match.get("length", 0)
if offset >= 0 and length > 0 and offset + length <= len(text):
return text[offset:offset + length]
return text[offset : offset + length]
return ""


Expand Down Expand Up @@ -336,9 +335,9 @@ def get_llm_model(model_id: str, api_key: str = "") -> llm.Model:
return model


def _parse_llm_json(text: str) -> dict:
def _parse_llm_json(text: str) -> dict[str, Any]:
"""Strip optional markdown fences and parse JSON from an LLM response.

Handles common issues with small models producing malformed JSON.
"""
text = text.strip()
Expand All @@ -347,17 +346,18 @@ def _parse_llm_json(text: str) -> dict:
if text.endswith("```"):
text = "\n".join(text.split("\n")[:-1])
text = text.strip()

# Try to parse as-is first
try:
return json.loads(text)
except json.JSONDecodeError:
# Small models sometimes produce invalid JSON with unescaped quotes
# Try some basic fixes
import re

# Fix common issue: "word" inside a string value (should be \"word\")
# This is a heuristic - may not catch all cases
lines = text.split('\n')
lines = text.split("\n")
fixed_lines = []
for line in lines:
# If line contains a key-value pair with quotes in the value
Expand All @@ -371,11 +371,13 @@ def _parse_llm_json(text: str) -> dict:
value = value.replace('"', '\\"')
line = prefix + value + suffix
fixed_lines.append(line)
text = '\n'.join(fixed_lines)
text = "\n".join(fixed_lines)
return json.loads(text)


def check_structure(message: str, model: llm.Model | None = None, model_id: str = "", api_key: str = "") -> dict:
def check_structure(
message: str, model: llm.Model | None = None, model_id: str = "", api_key: str = ""
) -> dict[str, Any]:
"""Use an LLM to evaluate whether a commit message explains *why*.

Pass either a pre-loaded *model* or a *model_id* (+ optional *api_key*)
Expand All @@ -391,12 +393,12 @@ def check_structure(message: str, model: llm.Model | None = None, model_id: str
response = model.prompt(message, system=SYSTEM_PROMPT)
text = response.text()
result = _parse_llm_json(text)

# Enforce the rule: only suggest rewrites for scores < 7
# Small models sometimes don't follow instructions perfectly
if result.get("score", 0) >= 7:
result["suggestion"] = None

return result
except Exception as exc:
print(f"WARNING: LLM structure check failed: {exc}", file=sys.stderr)
Expand Down Expand Up @@ -443,7 +445,7 @@ def print_report(results: list[CommitIssue]) -> None:
for gi in r.grammar_issues:
suggestion = ""
if gi["replacements"]:
suggestion = f' -> try: {", ".join(gi["replacements"])}'
suggestion = f" -> try: {', '.join(gi['replacements'])}"
print(f" - {gi['message']}{suggestion} ({gi['rule']})")

if r.structure_issues:
Expand Down Expand Up @@ -496,7 +498,11 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
action="store_true",
help="Enable the LanguageTool grammar checker (disabled by default)",
)
p.add_argument("--grammar-only", action="store_true", help="Only run the grammar check (implies --enable-grammar)")
p.add_argument(
"--grammar-only",
action="store_true",
help="Only run the grammar check (implies --enable-grammar)",
)
p.add_argument("--structure-only", action="store_true", help="Only run the LLM structure check")

# LLM (via the llm library -- model discovery handled by llm + plugins)
Expand Down Expand Up @@ -610,17 +616,19 @@ def main(argv: list[str] | None = None) -> int:
ci.score = result.get("score")
ci.suggestion = result.get("suggestion")
feedback = result.get("feedback", "")

# Only treat feedback as an issue if the commit doesn't explain why
# or if it scores below 7 (our threshold for "good enough")
explains_why = result.get("explains_why", True)
score = result.get("score", 0)

if not explains_why or score < 7:
if feedback:
ci.structure_issues = [feedback]
elif not explains_why:
ci.structure_issues = ["Commit message does not explain why the change was made"]
ci.structure_issues = [
"Commit message does not explain why the change was made"
]

results.append(ci)

Expand Down
49 changes: 49 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
[tool.ruff]
target-version = "py313"
line-length = 100

[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # pyflakes
"I", # isort (import sorting)
"B", # flake8-bugbear
"C4", # flake8-comprehensions
"UP", # pyupgrade
"SIM", # flake8-simplify
"TCH", # flake8-type-checking
"RUF", # Ruff-specific rules
]
ignore = [
"E501", # line too long (let formatter handle it)
]

[tool.ruff.lint.isort]
known-first-party = ["lint_commits", "lint_local"]

[tool.ruff.format]
quote-style = "double"
indent-style = "space"

[tool.mypy]
python_version = "3.13"
warn_return_any = false
warn_unused_ignores = true
disallow_untyped_defs = true
disallow_incomplete_defs = true
check_untyped_defs = true
strict_optional = true
ignore_missing_imports = false

[[tool.mypy.overrides]]
module = "llm.*"
ignore_missing_imports = true

[[tool.mypy.overrides]]
module = "tests.*"
ignore_errors = true

[[tool.mypy.overrides]]
module = "conftest"
ignore_errors = true
Loading