fix: auto-detect issue number in cycle retro from git branch name
cycle_retro.py now extracts the issue number from the current git branch (e.g. kimi/issue-492 → 492) when --issue is not explicitly provided. backfill_retro.py now skips the PR number suffix that Gitea appends to titles (e.g. (#491)) so it doesn't confuse PR numbers with issue numbers. Fixes #492 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -94,12 +94,17 @@ def extract_cycle_number(title: str) -> int | None:
|
|||||||
return int(m.group(1)) if m else None
|
return int(m.group(1)) if m else None
|
||||||
|
|
||||||
|
|
||||||
def extract_issue_number(title: str, body: str) -> int | None:
|
def extract_issue_number(title: str, body: str, pr_number: int | None = None) -> int | None:
|
||||||
# Try body first (usually has "closes #N")
|
"""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]:
|
for text in [body or "", title]:
|
||||||
m = ISSUE_RE.search(text)
|
for m in ISSUE_RE.finditer(text):
|
||||||
if m:
|
num = int(m.group(1))
|
||||||
return int(m.group(1))
|
if num != pr_number:
|
||||||
|
return num
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -140,7 +145,7 @@ def main():
|
|||||||
else:
|
else:
|
||||||
cycle_counter = max(cycle_counter, cycle)
|
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)
|
issue_type = classify_pr(title, body)
|
||||||
duration = estimate_duration(pr)
|
duration = estimate_duration(pr)
|
||||||
diff = get_pr_diff_stats(token, pr_num)
|
diff = get_pr_diff_stats(token, pr_num)
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
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
|
# How many recent entries to include in rolling summary
|
||||||
SUMMARY_WINDOW = 50
|
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 ────────────────────────────────────────────────────────
|
# ── Epoch turnover ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -230,6 +249,10 @@ def update_summary() -> None:
|
|||||||
def main() -> None:
|
def main() -> None:
|
||||||
args = parse_args()
|
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
|
# Reject idle cycles — no issue and no duration means nothing happened
|
||||||
if not args.issue and args.duration == 0:
|
if not args.issue and args.duration == 0:
|
||||||
print(f"[retro] Cycle {args.cycle} skipped — idle (no issue, no duration)")
|
print(f"[retro] Cycle {args.cycle} skipped — idle (no issue, no duration)")
|
||||||
|
|||||||
86
tests/loop/test_cycle_retro.py
Normal file
86
tests/loop/test_cycle_retro.py
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user