Compare commits

..

1 Commits

Author SHA1 Message Date
TERRA
fe4a04145a feat: add diff analyzer for PR change categorization (closes #118) 2026-04-14 23:25:15 -04:00
4 changed files with 463 additions and 240 deletions

239
scripts/diff_analyzer.py Normal file
View File

@@ -0,0 +1,239 @@
"""
Diff Analyzer — Pipeline 6.1
Reads PR diffs and categorizes changes: new code, deleted code, modified code, moved code.
Produces a change summary with line counts per category.
Usage:
from diff_analyzer import DiffAnalyzer
analyzer = DiffAnalyzer()
summary = analyzer.analyze(diff_text)
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from enum import Enum
from typing import List, Optional
class ChangeCategory(Enum):
"""Categories of code changes in a diff hunk."""
ADDED = "added"
DELETED = "deleted"
MODIFIED = "modified"
MOVED = "moved"
CONTEXT = "context"
@dataclass
class Hunk:
"""A single diff hunk with metadata."""
header: str
old_start: int
old_count: int
new_start: int
new_count: int
lines: List[str] = field(default_factory=list)
category: ChangeCategory = ChangeCategory.CONTEXT
old_lines: int = 0
new_lines: int = 0
@dataclass
class FileChange:
"""Changes within a single file."""
path: str
old_path: Optional[str] = None # For renames
is_new: bool = False
is_deleted: bool = False
is_renamed: bool = False
hunks: List[Hunk] = field(default_factory=list)
added_lines: int = 0
deleted_lines: int = 0
context_lines: int = 0
@dataclass
class ChangeSummary:
"""Summary of all changes in a diff."""
files_changed: int = 0
files_added: int = 0
files_deleted: int = 0
files_renamed: int = 0
files_modified: int = 0
total_added: int = 0
total_deleted: int = 0
total_context: int = 0
hunks_added: int = 0
hunks_deleted: int = 0
hunks_modified: int = 0
hunks_moved: int = 0
file_changes: List[FileChange] = field(default_factory=list)
def to_dict(self) -> dict:
"""Serialize to dict for JSON output."""
return {
"files_changed": self.files_changed,
"files_added": self.files_added,
"files_deleted": self.files_deleted,
"files_renamed": self.files_renamed,
"files_modified": self.files_modified,
"total_added": self.total_added,
"total_deleted": self.total_deleted,
"total_context": self.total_context,
"hunks_added": self.hunks_added,
"hunks_deleted": self.hunks_deleted,
"hunks_modified": self.hunks_modified,
"hunks_moved": self.hunks_moved,
"files": [
{
"path": fc.path,
"old_path": fc.old_path,
"is_new": fc.is_new,
"is_deleted": fc.is_deleted,
"is_renamed": fc.is_renamed,
"added": fc.added_lines,
"deleted": fc.deleted_lines,
"context": fc.context_lines,
}
for fc in self.file_changes
],
}
# Regex for unified diff headers
_HUNK_RE = re.compile(
r"^@@\s+-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s+@@(.*)$"
)
_FILE_HEADER_RE = re.compile(r"^diff --git a/(.*) b/(.*)$")
_RENAME_RE = re.compile(r"^rename from (.+)$|^rename to (.+)$")
class DiffAnalyzer:
"""Parses unified diffs and categorizes changes."""
def analyze(self, diff_text: str) -> ChangeSummary:
"""Analyze a unified diff string and return a ChangeSummary."""
summary = ChangeSummary()
lines = diff_text.splitlines(keepends=False)
current_file: Optional[FileChange] = None
current_hunk: Optional[Hunk] = None
old_path: Optional[str] = None
new_path: Optional[str] = None
for line in lines:
# File header
m = _FILE_HEADER_RE.match(line)
if m:
# Save previous file
if current_file:
self._classify_file(current_file)
summary.file_changes.append(current_file)
old_path = m.group(1)
new_path = m.group(2)
current_file = FileChange(path=new_path, old_path=old_path)
current_hunk = None
continue
if current_file is None:
continue
# Detect new/deleted file markers
if line.startswith("new file mode"):
current_file.is_new = True
continue
if line.startswith("deleted file mode"):
current_file.is_deleted = True
continue
# Detect renames
rm = _RENAME_RE.match(line)
if rm:
current_file.is_renamed = True
continue
# Hunk header
hm = _HUNK_RE.match(line)
if hm:
if current_hunk:
self._classify_hunk(current_hunk)
current_file.hunks.append(current_hunk)
current_hunk = Hunk(
header=line,
old_start=int(hm.group(1)),
old_count=int(hm.group(2) or 1),
new_start=int(hm.group(3)),
new_count=int(hm.group(4) or 1),
)
continue
if current_hunk is None:
continue
# Hunk content
current_hunk.lines.append(line)
if line.startswith("+"):
current_hunk.new_lines += 1
current_file.added_lines += 1
elif line.startswith("-"):
current_hunk.old_lines += 1
current_file.deleted_lines += 1
elif line.startswith(" "):
current_file.context_lines += 1
# Finalize last hunk and file
if current_hunk:
self._classify_hunk(current_hunk)
if current_file:
current_file.hunks.append(current_hunk)
if current_file:
self._classify_file(current_file)
summary.file_changes.append(current_file)
# Aggregate
summary.files_changed = len(summary.file_changes)
for fc in summary.file_changes:
summary.total_added += fc.added_lines
summary.total_deleted += fc.deleted_lines
summary.total_context += fc.context_lines
if fc.is_new:
summary.files_added += 1
elif fc.is_deleted:
summary.files_deleted += 1
elif fc.is_renamed:
summary.files_renamed += 1
else:
summary.files_modified += 1
for h in fc.hunks:
if h.category == ChangeCategory.ADDED:
summary.hunks_added += 1
elif h.category == ChangeCategory.DELETED:
summary.hunks_deleted += 1
elif h.category == ChangeCategory.MODIFIED:
summary.hunks_modified += 1
elif h.category == ChangeCategory.MOVED:
summary.hunks_moved += 1
return summary
def _classify_hunk(self, hunk: Hunk) -> None:
"""Classify a hunk based on its add/delete ratio."""
if hunk.new_lines > 0 and hunk.old_lines == 0:
hunk.category = ChangeCategory.ADDED
elif hunk.old_lines > 0 and hunk.new_lines == 0:
hunk.category = ChangeCategory.DELETED
elif hunk.new_lines > 0 and hunk.old_lines > 0:
hunk.category = ChangeCategory.MODIFIED
else:
hunk.category = ChangeCategory.CONTEXT
def _classify_file(self, fc: FileChange) -> None:
"""Final file classification (renames already detected via headers)."""
pass

View File

@@ -1,131 +0,0 @@
#!/usr/bin/env python3
"""
Gitea Issue Body Parser — Extract structured data from markdown issue bodies.
Usage:
cat issue_body.txt | python3 scripts/gitea_issue_parser.py --stdin --pretty
python3 scripts/gitea_issue_parser.py --url https://forge.../api/v1/repos/.../issues/123 --pretty
python3 scripts/gitea_issue_parser.py body.txt --title "Fix thing (#42)" --labels pipeline extraction
"""
import argparse
import json
import re
import sys
from typing import Dict, List, Any, Optional
def parse_issue_body(body: str, title: str = "", labels: List[str] = None) -> Dict[str, Any]:
"""Parse a Gitea issue markdown body into structured JSON.
Extracted fields:
- title: Issue title
- context: Background/description section
- criteria[]: Acceptance criteria (checkboxes or numbered lists)
- labels[]: Issue labels
- epic_ref: Parent/epic issue reference (from "Closes #N" or title)
- sections{}: All ## sections as key-value pairs
"""
result = {
"title": title,
"context": "",
"criteria": [],
"labels": labels or [],
"epic_ref": None,
"sections": {},
}
if not body:
return result
# Extract epic reference from title or body
epic_patterns = [
r"(?:closes|fixes|addresses|refs?)\s+#(\d+)",
r"#(\d+)",
]
for pattern in epic_patterns:
match = re.search(pattern, (title + " " + body).lower())
if match:
result["epic_ref"] = int(match.group(1))
break
# Parse ## sections
section_pattern = r"^##\s+(.+?)$\n((?:^(?!##\s).*$\n?)*)"
for match in re.finditer(section_pattern, body, re.MULTILINE):
section_name = match.group(1).strip().lower().replace(" ", "_")
section_content = match.group(2).strip()
result["sections"][section_name] = section_content
# Extract acceptance criteria (checkboxes)
checkbox_pattern = r"^\s*-\s*\[([ xX])\]\s*(.+)$"
for match in re.finditer(checkbox_pattern, body, re.MULTILINE):
checked = match.group(1).lower() == "x"
text = match.group(2).strip()
result["criteria"].append({"text": text, "checked": checked})
# If no checkboxes, try numbered lists in "Acceptance Criteria" or "Criteria" section
if not result["criteria"]:
for section_name in ["acceptance_criteria", "criteria", "acceptance criteria"]:
if section_name in result["sections"]:
numbered = r"^\s*\d+\.\s*(.+)$"
for match in re.finditer(numbered, result["sections"][section_name], re.MULTILINE):
result["criteria"].append({"text": match.group(1).strip(), "checked": False})
break
# Extract context (first section or first paragraph before any ## heading)
first_heading = body.find("## ")
if first_heading > 0:
context_text = body[:first_heading].strip()
else:
context_text = body.split("\n\n")[0].strip()
# Clean up: remove "## Context" or "## Problem" header if present
context_text = re.sub(r"^#+\s*\w+\s*\n?", "", context_text).strip()
result["context"] = context_text[:500] # Cap at 500 chars
return result
def fetch_issue_from_url(url: str) -> Dict[str, Any]:
"""Fetch an issue from a Gitea API URL and parse it."""
import urllib.request
req = urllib.request.Request(url, headers={"Accept": "application/json"})
with urllib.request.urlopen(req) as resp:
data = json.loads(resp.read())
return parse_issue_body(
body=data.get("body", ""),
title=data.get("title", ""),
labels=[l["name"] for l in data.get("labels", [])]
)
def main():
parser = argparse.ArgumentParser(description="Parse Gitea issue markdown into structured JSON")
parser.add_argument("file", nargs="?", help="Issue body file (or use --stdin)")
parser.add_argument("--stdin", action="store_true", help="Read from stdin")
parser.add_argument("--url", help="Gitea API URL to fetch issue from")
parser.add_argument("--title", default="", help="Issue title")
parser.add_argument("--labels", nargs="*", default=[], help="Issue labels")
parser.add_argument("--pretty", action="store_true", help="Pretty-print JSON output")
args = parser.parse_args()
if args.url:
result = fetch_issue_from_url(args.url)
elif args.stdin:
body = sys.stdin.read()
result = parse_issue_body(body, args.title, args.labels)
elif args.file:
with open(args.file) as f:
body = f.read()
result = parse_issue_body(body, args.title, args.labels)
else:
parser.print_help()
sys.exit(1)
indent = 2 if args.pretty else None
print(json.dumps(result, indent=indent))
if __name__ == "__main__":
main()

View File

@@ -1,109 +0,0 @@
#!/usr/bin/env python3
"""Tests for scripts/gitea_issue_parser.py"""
import sys
import os
sys.path.insert(0, os.path.dirname(__file__) or ".")
# Import from sibling
import importlib.util
spec = importlib.util.spec_from_file_location("parser", os.path.join(os.path.dirname(__file__) or ".", "gitea_issue_parser.py"))
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
parse_issue_body = mod.parse_issue_body
def test_basic_parsing():
body = """## Context
This is the background info.
## Acceptance Criteria
- [ ] First criterion
- [x] Second criterion (done)
## What to build
Some description.
"""
result = parse_issue_body(body, title="Test (#42)", labels=["bug"])
assert result["title"] == "Test (#42)"
assert result["labels"] == ["bug"]
assert result["epic_ref"] == 42
assert len(result["criteria"]) == 2
assert result["criteria"][0]["text"] == "First criterion"
assert result["criteria"][0]["checked"] == False
assert result["criteria"][1]["checked"] == True
assert "context" in result["sections"]
print("PASS: test_basic_parsing")
def test_numbered_criteria():
body = """## Acceptance Criteria
1. First item
2. Second item
3. Third item
"""
result = parse_issue_body(body)
assert len(result["criteria"]) == 3
assert result["criteria"][0]["text"] == "First item"
print("PASS: test_numbered_criteria")
def test_epic_ref_from_body():
body = "Closes #123\n\nSome description."
result = parse_issue_body(body)
assert result["epic_ref"] == 123
print("PASS: test_epic_ref_from_body")
def test_empty_body():
result = parse_issue_body("")
assert result["criteria"] == []
assert result["context"] == ""
assert result["sections"] == {}
print("PASS: test_empty_body")
def test_no_sections():
body = "Just a plain issue body with no headings."
result = parse_issue_body(body)
assert result["context"] == "Just a plain issue body with no headings."
print("PASS: test_no_sections")
def test_multiple_sections():
body = """## Problem
Something is broken.
## Fix
Do this instead.
## Notes
Additional info.
"""
result = parse_issue_body(body)
assert "problem" in result["sections"]
assert "fix" in result["sections"]
assert "notes" in result["sections"]
assert "Something is broken" in result["sections"]["problem"]
print("PASS: test_multiple_sections")
def run_all():
test_basic_parsing()
test_numbered_criteria()
test_epic_ref_from_body()
test_empty_body()
test_no_sections()
test_multiple_sections()
print("\nAll 6 tests passed!")
if __name__ == "__main__":
run_all()

224
tests/test_diff_analyzer.py Normal file
View File

@@ -0,0 +1,224 @@
"""Tests for diff_analyzer module."""
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'scripts'))
from diff_analyzer import DiffAnalyzer, ChangeCategory
def test_parse_simple_addition():
diff = """diff --git a/foo.py b/foo.py
new file mode 100644
--- /dev/null
+++ b/foo.py
@@ -0,0 +1,3 @@
+def hello():
+ return "world"
+# end
"""
analyzer = DiffAnalyzer()
summary = analyzer.analyze(diff)
assert summary.files_changed == 1
assert summary.files_added == 1
assert summary.files_modified == 0
assert summary.total_added == 3
assert summary.total_deleted == 0
assert summary.hunks_added == 1
assert len(summary.file_changes) == 1
assert summary.file_changes[0].is_new is True
assert summary.file_changes[0].path == "foo.py"
def test_parse_simple_deletion():
diff = """diff --git a/old.py b/old.py
deleted file mode 100644
--- a/old.py
+++ /dev/null
@@ -1,2 +0,0 @@
-x = 1
-y = 2
"""
analyzer = DiffAnalyzer()
summary = analyzer.analyze(diff)
assert summary.files_changed == 1
assert summary.files_deleted == 1
assert summary.total_deleted == 2
assert summary.total_added == 0
assert summary.hunks_deleted == 1
assert summary.file_changes[0].is_deleted is True
def test_parse_modification():
diff = """diff --git a/bar.py b/bar.py
--- a/bar.py
+++ b/bar.py
@@ -10,3 +10,4 @@ def foo():
existing()
- old_call()
+ new_call()
+ extra_step()
return
"""
analyzer = DiffAnalyzer()
summary = analyzer.analyze(diff)
assert summary.files_changed == 1
assert summary.files_modified == 1
assert summary.total_added == 2 # +new_call(), +extra_step()
assert summary.total_deleted == 1 # -old_call()
assert summary.total_context == 2 # 2 context lines
assert summary.hunks_modified == 1
def test_parse_multiple_files():
diff = """diff --git a/a.py b/a.py
--- a/a.py
+++ b/a.py
@@ -1,1 +1,2 @@
existing
+added
diff --git a/b.py b/b.py
new file mode 100644
--- /dev/null
+++ b/b.py
@@ -0,0 +1,1 @@
+new file
diff --git a/c.py b/c.py
deleted file mode 100644
--- a/c.py
+++ /dev/null
@@ -1,1 +0,0 @@
-gone
"""
analyzer = DiffAnalyzer()
summary = analyzer.analyze(diff)
assert summary.files_changed == 3
assert summary.files_added == 1
assert summary.files_deleted == 1
assert summary.files_modified == 1
assert summary.total_added == 2
assert summary.total_deleted == 1
def test_parse_rename():
diff = """diff --git a/old_name.py b/new_name.py
rename from old_name.py
rename to new_name.py
--- a/old_name.py
+++ b/new_name.py
@@ -1,1 +1,1 @@
-old_func()
+new_func()
"""
analyzer = DiffAnalyzer()
summary = analyzer.analyze(diff)
assert summary.files_changed == 1
assert summary.files_renamed == 1
assert summary.file_changes[0].is_renamed is True
assert summary.file_changes[0].old_path == "old_name.py"
assert summary.file_changes[0].path == "new_name.py"
def test_parse_mixed_hunks():
"""A file with one add hunk and one delete hunk."""
diff = """diff --git a/mixed.py b/mixed.py
--- a/mixed.py
+++ b/mixed.py
@@ -5,0 +6,2 @@
+new_line_1
+new_line_2
@@ -20,2 +22,0 @@
-removed_1
-removed_2
"""
analyzer = DiffAnalyzer()
summary = analyzer.analyze(diff)
assert summary.files_changed == 1
assert summary.hunks_added == 1
assert summary.hunks_deleted == 1
assert summary.total_added == 2
assert summary.total_deleted == 2
def test_empty_diff():
analyzer = DiffAnalyzer()
summary = analyzer.analyze("")
assert summary.files_changed == 0
assert summary.total_added == 0
assert summary.total_deleted == 0
def test_to_dict():
diff = """diff --git a/test.py b/test.py
new file mode 100644
--- /dev/null
+++ b/test.py
@@ -0,0 +1,2 @@
+line1
+line2
"""
analyzer = DiffAnalyzer()
summary = analyzer.analyze(diff)
d = summary.to_dict()
assert d["files_changed"] == 1
assert d["files_added"] == 1
assert d["total_added"] == 2
assert d["total_deleted"] == 0
assert len(d["files"]) == 1
assert d["files"][0]["path"] == "test.py"
assert d["files"][0]["is_new"] is True
def test_context_only_hunk():
"""A hunk with only context lines (rare but possible)."""
diff = """diff --git a/noop.py b/noop.py
--- a/noop.py
+++ b/noop.py
@@ -5,3 +5,3 @@
context1
context2
context3
"""
analyzer = DiffAnalyzer()
summary = analyzer.analyze(diff)
assert summary.total_context == 3
assert summary.total_added == 0
assert summary.total_deleted == 0
def test_binary_files_skipped():
"""Binary file diffs have no content lines — just headers."""
diff = """diff --git a/image.png b/image.png
--- a/image.png
+++ b/image.png
Binary files a/image.png and b/image.png differ
"""
analyzer = DiffAnalyzer()
summary = analyzer.analyze(diff)
assert summary.files_changed == 1
assert summary.total_added == 0
assert summary.total_deleted == 0
if __name__ == "__main__":
test_parse_simple_addition()
test_parse_simple_deletion()
test_parse_modification()
test_parse_multiple_files()
test_parse_rename()
test_parse_mixed_hunks()
test_empty_diff()
test_to_dict()
test_context_only_hunk()
test_binary_files_skipped()
print("All 10 tests passed.")