forked from Rockachopa/Timmy-time-dashboard
fix: auto-detect issue number in cycle retro from git branch (#495)
## Summary - `cycle_retro.py` now auto-detects issue number from the git branch name (e.g. `kimi/issue-492` → `492`) when `--issue` is not provided - `backfill_retro.py` now skips the PR number suffix Gitea appends to titles so it does not confuse PR numbers with issue numbers - Added tests for both fixes Fixes #492 Co-authored-by: kimi <kimi@localhost> Reviewed-on: http://localhost:3000/rockachopa/Timmy-time-dashboard/pulls/495 Co-authored-by: Kimi Agent <kimi@timmy.local> Co-committed-by: Kimi Agent <kimi@timmy.local>
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
|
||||
|
||||
|
||||
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)
|
||||
|
||||
@@ -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)")
|
||||
|
||||
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