diff --git a/scripts/backfill_retro.py b/scripts/backfill_retro.py index 67f37f2..8bb26f4 100644 --- a/scripts/backfill_retro.py +++ b/scripts/backfill_retro.py @@ -94,12 +94,17 @@ def extract_cycle_number(title: str) -> int | None: return int(m.group(1)) if m else None -def extract_issue_number(title: str, body: str) -> int | None: - # Try body first (usually has "closes #N") +def extract_issue_number(title: str, body: str, pr_number: int | None = None) -> int | None: + """Extract the issue number from PR body/title, ignoring the PR number itself. + + Gitea appends "(#N)" to PR titles where N is the PR number — skip that + so we don't confuse it with the linked issue. + """ for text in [body or "", title]: - m = ISSUE_RE.search(text) - if m: - return int(m.group(1)) + for m in ISSUE_RE.finditer(text): + num = int(m.group(1)) + if num != pr_number: + return num return None @@ -140,7 +145,7 @@ def main(): else: cycle_counter = max(cycle_counter, cycle) - issue = extract_issue_number(title, body) + issue = extract_issue_number(title, body, pr_number=pr_num) issue_type = classify_pr(title, body) duration = estimate_duration(pr) diff = get_pr_diff_stats(token, pr_num) diff --git a/scripts/cycle_retro.py b/scripts/cycle_retro.py index 0803ce1..77ba9fd 100644 --- a/scripts/cycle_retro.py +++ b/scripts/cycle_retro.py @@ -44,6 +44,8 @@ from __future__ import annotations import argparse import json +import re +import subprocess import sys from datetime import datetime, timezone from pathlib import Path @@ -56,6 +58,23 @@ EPOCH_COUNTER_FILE = REPO_ROOT / ".loop" / "retro" / ".epoch_counter" # How many recent entries to include in rolling summary SUMMARY_WINDOW = 50 +# Branch patterns that encode an issue number, e.g. kimi/issue-492 +BRANCH_ISSUE_RE = re.compile(r"issue[/-](\d+)", re.IGNORECASE) + + +def detect_issue_from_branch() -> int | None: + """Try to extract an issue number from the current git branch name.""" + try: + branch = subprocess.check_output( + ["git", "rev-parse", "--abbrev-ref", "HEAD"], + stderr=subprocess.DEVNULL, + text=True, + ).strip() + except (subprocess.CalledProcessError, FileNotFoundError): + return None + m = BRANCH_ISSUE_RE.search(branch) + return int(m.group(1)) if m else None + # ── Epoch turnover ──────────────────────────────────────────────────────── @@ -230,6 +249,10 @@ def update_summary() -> None: def main() -> None: args = parse_args() + # Auto-detect issue from branch when not explicitly provided + if args.issue is None: + args.issue = detect_issue_from_branch() + # Reject idle cycles — no issue and no duration means nothing happened if not args.issue and args.duration == 0: print(f"[retro] Cycle {args.cycle} skipped — idle (no issue, no duration)") diff --git a/tests/loop/test_cycle_retro.py b/tests/loop/test_cycle_retro.py new file mode 100644 index 0000000..53fc19e --- /dev/null +++ b/tests/loop/test_cycle_retro.py @@ -0,0 +1,86 @@ +"""Tests for scripts/cycle_retro.py issue auto-detection.""" + +from __future__ import annotations + +# Import the module under test — it's a script so we import the helpers directly +import importlib +import subprocess +from pathlib import Path +from unittest.mock import patch + +import pytest + +SCRIPTS_DIR = Path(__file__).resolve().parent.parent.parent / "scripts" + + +@pytest.fixture(autouse=True) +def _add_scripts_to_path(monkeypatch): + monkeypatch.syspath_prepend(str(SCRIPTS_DIR)) + + +@pytest.fixture() +def mod(): + """Import cycle_retro as a module.""" + return importlib.import_module("cycle_retro") + + +class TestDetectIssueFromBranch: + def test_kimi_issue_branch(self, mod): + with patch.object(subprocess, "check_output", return_value="kimi/issue-492\n"): + assert mod.detect_issue_from_branch() == 492 + + def test_plain_issue_branch(self, mod): + with patch.object(subprocess, "check_output", return_value="issue-123\n"): + assert mod.detect_issue_from_branch() == 123 + + def test_issue_slash_number(self, mod): + with patch.object(subprocess, "check_output", return_value="fix/issue/55\n"): + assert mod.detect_issue_from_branch() == 55 + + def test_no_issue_in_branch(self, mod): + with patch.object(subprocess, "check_output", return_value="main\n"): + assert mod.detect_issue_from_branch() is None + + def test_feature_branch(self, mod): + with patch.object(subprocess, "check_output", return_value="feature/add-widget\n"): + assert mod.detect_issue_from_branch() is None + + def test_git_not_available(self, mod): + with patch.object(subprocess, "check_output", side_effect=FileNotFoundError): + assert mod.detect_issue_from_branch() is None + + def test_git_fails(self, mod): + with patch.object( + subprocess, + "check_output", + side_effect=subprocess.CalledProcessError(1, "git"), + ): + assert mod.detect_issue_from_branch() is None + + +class TestBackfillExtractIssueNumber: + """Tests for backfill_retro.extract_issue_number PR-number filtering.""" + + @pytest.fixture() + def backfill(self): + return importlib.import_module("backfill_retro") + + def test_body_has_issue(self, backfill): + assert backfill.extract_issue_number("fix: foo (#491)", "Fixes #490", pr_number=491) == 490 + + def test_title_skips_pr_number(self, backfill): + assert backfill.extract_issue_number("fix: foo (#491)", "", pr_number=491) is None + + def test_title_with_issue_and_pr(self, backfill): + # [loop-cycle-538] refactor: ... (#459) (#481) + assert ( + backfill.extract_issue_number( + "[loop-cycle-538] refactor: remove dead airllm (#459) (#481)", + "", + pr_number=481, + ) + == 459 + ) + + def test_no_pr_number_provided(self, backfill): + assert backfill.extract_issue_number("fix: foo (#491)", "") == 491