diff --git a/ccloop_lib/github_ops.py b/ccloop_lib/github_ops.py index c275b7e..836f3cf 100644 --- a/ccloop_lib/github_ops.py +++ b/ccloop_lib/github_ops.py @@ -1,13 +1,16 @@ +import json import logging from datetime import datetime, timezone from typing import Optional from github import Github, GithubException +from .state_machine import TaskState, can_transition, transition_labels + log = logging.getLogger("ccloop") BOT_MARKER = "" -_BOT_EMOJI_PREFIXES = ("🔄", "✅", "⚠️") +_BOT_EMOJI_PREFIXES = ("🔄", "✅", "⚠️", "📋", "🔍", "🧪") def _is_bot_comment(body: str) -> bool: @@ -119,6 +122,77 @@ def mark_processed(self, issue): self.repo.create_label(self.processed_label, "0e8a16") issue.add_to_labels(self.processed_label) + def transition_state(self, issue, target: TaskState) -> bool: + """状态机驱动的 label 转换 + + Returns: + True if transition succeeded (or was already in target state) + """ + label_names = [lbl.name for lbl in issue.labels] + current = None + for s in TaskState: + if s.label in label_names: + current = s + break + + if current == target: + return True + + if current and not can_transition(current, target): + log.warning(f"#{issue.number} 状态转换不合法: {current.label} → {target.label}") + return False + + if self.dry_run: + log.info(f"[DRY-RUN] 状态转换 #{issue.number}: " + f"{current.label if current else '(none)'} → {target.label}") + return True + + to_add, to_remove = transition_labels( + current or target, target + ) if current else ([target.label], []) + + if current: + to_add, to_remove = transition_labels(current, target) + + try: + for lbl in to_remove: + try: + issue.remove_from_labels(lbl) + except GithubException: + pass + for lbl in to_add: + try: + issue.add_to_labels(lbl) + except GithubException: + self.repo.create_label(lbl, "0e8a16") + issue.add_to_labels(lbl) + log.info(f"#{issue.number} 状态: {current.label if current else '(none)'} → {target.label}") + return True + except GithubException as e: + log.error(f"状态转换失败 #{issue.number}: {e}") + return False + + def write_claim_comment(self, issue, worker_id: str = "ccloop") -> str: + """写入 machine-readable claim comment(借鉴 AI Dispatcher 的抢占机制) + + Returns: + claim comment body + """ + claim = { + "action": "claim", + "worker": worker_id, + "ts": datetime.now(timezone.utc).isoformat(), + "issue": issue.number, + } + body = f"\n🔄 `{worker_id}` 开始处理{_BOT_MARKER}" + self.comment(issue, body) + return body + + def get_current_state(self, issue) -> TaskState | None: + """从 issue labels 解析当前状态""" + from .state_machine import get_current_state as _gcs + return _gcs(issue.labels) + def comment(self, issue, msg: str): if self.dry_run: log.info(f"[DRY-RUN] 评论 #{issue.number}:\n{msg[:200]}") diff --git a/ccloop_lib/prompts.py b/ccloop_lib/prompts.py index ee50a29..f1f409b 100644 --- a/ccloop_lib/prompts.py +++ b/ccloop_lib/prompts.py @@ -73,7 +73,10 @@ def build_solve_prompt(issue_number: int, issue_title: str, issue_body: str, repo_name: str, default_branch: str, repo_tree: str, previous_errors: Optional[list] = None, latest_comment: str = "", - task_type_hint: str = "") -> str: + task_type_guidance: str = "", + task_type_hint: str = "", + reference_context: str = "", + capability_context: str = "") -> str: body = _truncate(issue_body or "(无描述)", config.MAX_BODY_CHARS, "issue 正文") aci_section = get_aci_template(task_type_hint) if task_type_hint else "" prompt = f"""你是自动解决 GitHub Issue 的 AI 助手。你在仓库目录中工作。 @@ -83,11 +86,13 @@ def build_solve_prompt(issue_number: int, issue_title: str, issue_body: str, - 默认分支: {default_branch} - 目录结构: {repo_tree} - +{capability_context} +{reference_context} ## Issue #{issue_number}: {issue_title} {body} {f'\\n## 最新评论\\n{latest_comment}' if latest_comment else ''} +{task_type_guidance} ## 工作流程 {aci_section} diff --git a/ccloop_lib/solver.py b/ccloop_lib/solver.py index 37cde57..841707c 100644 --- a/ccloop_lib/solver.py +++ b/ccloop_lib/solver.py @@ -10,10 +10,16 @@ build_error_analysis_prompt, get_repo_tree, _truncate, infer_task_type) from .checkpoint import Checkpoint -from .guardrails import check_input +from .guardrails import check_input, check_output, check_diff from .tracing import Tracer from .git_ops import GitOps from .github_ops import GitHubOps, BOT_MARKER +from .state_machine import TaskState +from .task_types import resolve_task_type, get_task_type_guidance, TaskType +from .stages import Stage, StageHooks +from .capabilities import build_capability_context +from .reference import build_reference_context +from .policy import load_policy, apply_policy log = logging.getLogger("ccloop") @@ -40,7 +46,6 @@ def _extract_report(cc_output: str, parsed: dict = None) -> str: """提取可读报告:优先从 RESULT JSON summary,其次 RESULT 前的正文""" if parsed and parsed.get("summary"): return parsed["summary"] - # 取 RESULT 行之前的正文 report = cc_output.partition("RESULT:")[0].strip() return report if report else cc_output[:2000] @@ -56,6 +61,11 @@ def __init__(self, dry_run: bool = False): config.DEFAULT_BRANCH, config.PROCESSED_LABEL, dry_run) self.checkpoint = Checkpoint(config.LOG_DIR) self.tracer = Tracer(config.LOG_DIR) + self.hooks = StageHooks() + + policy = load_policy(config.WORK_DIR) + apply_policy(policy, config) + self.policy = policy def _last_processed_ts(self, issue_num: int) -> str | None: """从 stats 中获取 issue 最后一次处理的时间戳""" @@ -77,6 +87,29 @@ def _cc(self, prompt: str, issue_log_dir: str = None, issue_log_dir=issue_log_dir ) + def _check_dependencies(self, issue) -> bool: + """依赖预检:检查 issue 中引用的前置 issue 是否已完成""" + ref_ctx = build_reference_context( + self.github.repo, issue.number, issue.body or "" + ) + if not ref_ctx: + return True + from .reference import extract_referenced_issues + refs = extract_referenced_issues(issue.body or "", issue.number) + for ref_num in refs: + try: + ref_issue = self.github.repo.get_issue(ref_num) + if ref_issue.state == "open": + log.warning(f"依赖 #{ref_num} 仍 open,阻塞处理") + self.github.comment( + issue, + f"🚫 依赖 #{ref_num} 尚未完成,暂停处理{_BOT_TAG}" + ) + return False + except Exception: + pass + return True + def process_issue(self, issue) -> bool: log.info(f"=== Issue #{issue.number}: {issue.title} ===") issue_log_dir = os.path.join(config.LOG_DIR, f"issue_{issue.number}") @@ -85,17 +118,37 @@ def process_issue(self, issue) -> bool: cc_seconds = 0.0 branch = f"ccloop/issue-{issue.number}" - latest_comment = self.github.get_latest_user_comment(issue) + # 解析任务类型(label 区分) + resolved_type = resolve_task_type(issue.labels) + task_type_guidance = get_task_type_guidance(resolved_type) - # ACI: 首次 CC 调用前推断任务类型 + # ACI: 推断任务类型 + latest_comment = self.github.get_latest_user_comment(issue) task_type = infer_task_type( issue.title, issue.body or "", latest_comment ) log.info(f"推断任务类型: {task_type}") + # 依赖预检 + if not self._check_dependencies(issue): + self.github.transition_state(issue, TaskState.BLOCKED) + return False + + # 状态转换: → running + self.github.transition_state(issue, TaskState.RUNNING) + self.github.write_claim_comment(issue, worker_id="ccloop") + self.github.comment(issue, f"🔄 开始自动处理,分支 `{branch}`{_BOT_TAG}") repo_tree = get_repo_tree(config.WORK_DIR) + # 构建引用上下文 + ref_context = build_reference_context( + self.github.repo, issue.number, issue.body or "" + ) + + # 构建能力上下文 + cap_context = build_capability_context() + # 检查是否有已保存的检查点可恢复 saved = self.checkpoint.load(issue.number) start_attempt = saved["attempt"] + 1 if saved else 1 @@ -112,33 +165,40 @@ def process_issue(self, issue) -> bool: log.info(f"当前分支: {self.git.current_branch()}") # 阶段 1: CC 解决 - span_id = self.tracer.start_span(issue.number, "solve") + span_id = self.tracer.start_span(issue.number, f"solve_{attempt}") + self.hooks.run_before(Stage.SOLVE, {"issue": issue.number, "attempt": attempt}) + try: prompt = build_solve_prompt( issue.number, issue.title, issue.body or "", config.GITHUB_REPO, config.DEFAULT_BRANCH, repo_tree, previous_errors or None, latest_comment=latest_comment, - task_type_hint=task_type + task_type_guidance=task_type_guidance, + task_type_hint=task_type, + reference_context=ref_context, + capability_context=cap_context, ) - if len(prompt) > config.MAX_PROMPT_CHARS: - raise RuntimeError( - f"Prompt 过长 ({len(prompt)}>{config.MAX_PROMPT_CHARS})," - "issue 正文已截断但仍超限" - ) - # Guardrails: CC 调用前输入验证 + # Guardrails: 输入检查 ok, warnings = check_input(prompt, repo_tree, previous_errors) for w in warnings: - log.warning(f"Guardrail (input): {w}") + log.warning(f"Guardrail: {w}") if not ok: log.warning("Guardrails 阻止执行,跳过本次尝试") self.tracer.end_span(span_id, "blocked", {"reason": "guardrails"}) + self.hooks.run_after(Stage.SOLVE, {"issue": issue.number}, None) self.checkpoint.save(issue.number, attempt, branch, previous_errors, "solve", "failed", {"task_type": task_type}) continue + if len(prompt) > config.MAX_PROMPT_CHARS: + raise RuntimeError( + f"Prompt 过长 ({len(prompt)}>{config.MAX_PROMPT_CHARS})," + "issue 正文已截断但仍超限" + ) + cc_output, elapsed = self._cc(prompt, issue_log_dir) cc_calls += 1 cc_seconds += elapsed @@ -146,20 +206,31 @@ def process_issue(self, issue) -> bool: log.error(f"CC 异常: {e}") previous_errors.append(str(e)[:500]) cc_calls += 1 - cc_seconds += config.CC_TIMEOUT # 超时按最大耗时计 + cc_seconds += config.CC_TIMEOUT self.tracer.end_span(span_id, "error", {"error": str(e)[:200]}) + self.hooks.run_after(Stage.SOLVE, {"issue": issue.number}, None) self.checkpoint.save(issue.number, attempt, branch, previous_errors, "solve", "failed", {"task_type": task_type}) continue parsed = parse_json_line(cc_output, "RESULT:") - task_type = parsed.get("type", task_type) # CC 结果覆盖推断 + task_type = parsed.get("type", task_type) success = parsed.get("success", False) summary = parsed.get("summary", cc_output[:100]) log.info(f"类型: {task_type} | 成功: {success} | {summary}") - self.tracer.end_span(span_id, "ok" if success else "failed", - {"type": task_type, "success": success}) + + self.tracer.end_span(span_id, "ok" if success else "failed", parsed) + self.hooks.run_after(Stage.SOLVE, {"issue": issue.number}, parsed) + + # Guardrails: 输出检查 + blocked, warnings = check_output(cc_output) + for w in warnings: + log.warning(f"Guardrail (output): {w}") + if blocked: + previous_errors.append(f"Guardrail 阻止: {'; '.join(warnings)}") + self.tracer.end_span(span_id, "blocked", {"warnings": warnings}) + continue if not success: previous_errors.append(f"尝试 {attempt}: {summary}") @@ -173,12 +244,25 @@ def process_issue(self, issue) -> bool: previous_errors, "solve", "ok", {"task_type": task_type}) - # 调研类 或 无变更:直接完成 - if task_type == "research" or not self.git.has_changes(): - label = "调研完成" if task_type == "research" else "已完成(无文件变更)" + # 按任务类型分流 + is_report_only = (task_type == "research" + or resolved_type in (TaskType.TEST, TaskType.INVESTIGATE)) + + if is_report_only or not self.git.has_changes(): + if resolved_type == TaskType.TEST: + label = "🧪 测试报告" + elif resolved_type == TaskType.INVESTIGATE: + label = "🔍 调查报告" + elif task_type == "research": + label = "调研完成" + else: + label = "已完成(无文件变更)" report = _extract_report(cc_output, parsed) - self.github.comment(issue, f"✅ {label}\n\n{_sanitize(report, max_len=10000)}{_BOT_TAG}") - self.github.mark_processed(issue) + self.github.comment( + issue, + f"✅ {label}\n\n{_sanitize(report, max_len=10000)}{_BOT_TAG}" + ) + self.github.transition_state(issue, TaskState.DONE) self.git.reset_to_main() self.stats.record(issue.number, task_type, True, attempt - 1, cc_calls, cc_seconds) @@ -186,11 +270,21 @@ def process_issue(self, issue) -> bool: return True # 阶段 2: 自审查(验收) - span_id = self.tracer.start_span(issue.number, "review") changed_files = self.git.changed_files() diff = self.git.diff() log.info(f"变更文件: {changed_files}") + # Guardrails: diff 检查 + diff_blocked, diff_warnings = check_diff(diff) + for w in diff_warnings: + log.warning(f"Guardrail (diff): {w}") + if diff_blocked: + previous_errors.append(f"Guardrail 阻止提交: {'; '.join(diff_warnings)}") + continue + + review_span = self.tracer.start_span(issue.number, f"review_{attempt}") + self.hooks.run_before(Stage.REVIEW, {"issue": issue.number, "attempt": attempt}) + try: review_prompt = build_review_prompt( issue.number, issue.title, issue.body or "", @@ -202,24 +296,26 @@ def process_issue(self, issue) -> bool: cc_seconds += elapsed review = parse_json_line(review_output, "REVIEW:") if not review.get("approved", False): - issues = review.get("issues", []) + issues_list = review.get("issues", []) suggestion = review.get("suggestion", "") - log.warning(f"验收未通过: {issues}") + log.warning(f"验收未通过: {issues_list}") previous_errors.append( - f"尝试 {attempt} 验收未通过: {'; '.join(issues)}. {suggestion}" + f"尝试 {attempt} 验收未通过: {'; '.join(issues_list)}. {suggestion}" ) - self.tracer.end_span(span_id, "rejected", - {"issues": issues}) + self.tracer.end_span(review_span, "rejected", review) + self.hooks.run_after(Stage.REVIEW, {"issue": issue.number}, review) self.checkpoint.save(issue.number, attempt, branch, previous_errors, "review", "failed", {"task_type": task_type}) continue log.info("验收通过") - self.tracer.end_span(span_id, "ok") + self.tracer.end_span(review_span, "approved", review) + self.hooks.run_after(Stage.REVIEW, {"issue": issue.number}, review) except Exception as e: log.warning(f"验收异常(保守拒绝): {e}") previous_errors.append(f"尝试 {attempt} 验收异常: {e}") - self.tracer.end_span(span_id, "error", {"error": str(e)[:200]}) + self.tracer.end_span(review_span, "error", {"error": str(e)[:200]}) + self.hooks.run_after(Stage.REVIEW, {"issue": issue.number}, None) self.checkpoint.save(issue.number, attempt, branch, previous_errors, "review", "failed", {"task_type": task_type}) @@ -231,9 +327,33 @@ def process_issue(self, issue) -> bool: {"task_type": task_type}) # 阶段 3: 验收通过 → 提交 → 推送 → PR - span_id = self.tracer.start_span(issue.number, "commit") + commit_span = self.tracer.start_span(issue.number, f"commit_{attempt}") + self.hooks.run_before(Stage.COMMIT, {"issue": issue.number, "attempt": attempt}) + + commit_guidance = self.policy.get("COMMIT_GUIDANCE", "") commit_msg = f"feat: {summary}\n\nCloses #{issue.number}" + if commit_guidance: + commit_msg = f"{commit_guidance}\n\n{commit_msg}" + if self.git.commit_and_push(branch, commit_msg): + # 非 code 任务不创建 PR + if not resolved_type.needs_pr: + self.github.comment( + issue, + f"✅ 完成(无 PR,任务类型: {resolved_type.value})\n\n" + f"分支: `{branch}`\n" + f"修改: {', '.join(f'`{f}`' for f in changed_files)}" + f"{_BOT_TAG}" + ) + self.github.transition_state(issue, TaskState.DONE) + self.git.reset_to_main() + self.stats.record(issue.number, task_type, True, attempt - 1, + cc_calls, cc_seconds) + self.tracer.end_span(commit_span, "ok") + self.hooks.run_after(Stage.COMMIT, {"issue": issue.number}, {"pr": None}) + self.checkpoint.remove(issue.number) + return True + pr_num = self.github.create_pr( branch, issue.number, issue.title, cc_output[:1000], changed_files @@ -247,19 +367,20 @@ def process_issue(self, issue) -> bool: f"耗时: {cc_seconds:.0f}s | CC 调用: {cc_calls} 次" f"{_BOT_TAG}" ) - self.github.mark_processed(issue) + self.github.transition_state(issue, TaskState.REVIEW) self.git.reset_to_main() self.stats.record(issue.number, task_type, True, attempt - 1, cc_calls, cc_seconds) - self.tracer.end_span(span_id, "ok", - {"pr": pr_num}) + self.tracer.end_span(commit_span, "ok", {"pr": pr_num}) + self.hooks.run_after(Stage.COMMIT, {"issue": issue.number}, {"pr": pr_num}) self.checkpoint.remove(issue.number) return True previous_errors.append("PR 创建失败") else: previous_errors.append("Git 推送失败") - self.tracer.end_span(span_id, "failed") + self.tracer.end_span(commit_span, "failed") + self.hooks.run_after(Stage.COMMIT, {"issue": issue.number}, None) self.checkpoint.save(issue.number, attempt, branch, previous_errors, "commit", "failed", {"task_type": task_type}) @@ -289,6 +410,9 @@ def process_issue(self, issue) -> bool: fail_comment += "\n\n请人工介入。" self.github.comment(issue, fail_comment[:60000] + _BOT_TAG) + # 状态: → stuck + self.github.transition_state(issue, TaskState.STUCK) + self.stats.record(issue.number, task_type, False, config.MAX_RETRIES, cc_calls, cc_seconds) self.checkpoint.remove(issue.number) @@ -300,9 +424,35 @@ def run(self): todo = [] for i in issues: labels = {l.name for l in i.labels} - needs_work = processed_label not in labels - # 已处理:检查是否有新评论触发重新处理 - if not needs_work: + state = self.github.get_current_state(i) + + if state == TaskState.BLOCKED: + log.info(f"#{i.number} 状态 BLOCKED,跳过") + continue + if state == TaskState.STUCK: + log.info(f"#{i.number} 状态 STUCK,需人工介入") + continue + if state == TaskState.REVIEW: + since = self._last_processed_ts(i.number) or "2000-01-01T00:00:00+00:00" + if self.github.has_new_comments(i, since): + log.info(f"#{i.number} review 状态有追问,续做") + todo.append(i) + continue + if state == TaskState.RUNNING: + log.info(f"#{i.number} 状态 RUNNING,跳过(其他 worker 处理中)") + continue + + needs_work = (state == TaskState.TODO or state == TaskState.PAUSED + or state is None) + if state is None and processed_label in labels: + since = self._last_processed_ts(i.number) or "2000-01-01T00:00:00+00:00" + if self.github.has_new_comments(i, since): + log.info(f"#{i.number} 有新评论,重新处理") + self.github.remove_label(i, processed_label) + needs_work = True + else: + needs_work = False + elif not needs_work and processed_label in labels: since = self._last_processed_ts(i.number) or "2000-01-01T00:00:00+00:00" if self.github.has_new_comments(i, since): log.info(f"#{i.number} 有新评论,重新处理") @@ -324,6 +474,7 @@ def run(self): self.process_issue(issue) except Exception as e: log.error(f"处理 #{issue.number} 异常: {e}") + self.github.transition_state(issue, TaskState.STUCK) self.git.reset_to_main() log.info(self.stats.summary()) diff --git a/tests/test_solver_flow.py b/tests/test_solver_flow.py index bc3d0fa..bd7a26f 100644 --- a/tests/test_solver_flow.py +++ b/tests/test_solver_flow.py @@ -1,7 +1,8 @@ """solver.py process_issue 集成测试(mock CC + mock git + mock github)""" -from unittest.mock import MagicMock, patch, call +from unittest.mock import MagicMock, patch, call, PropertyMock from ccloop_lib import config from ccloop_lib.solver import CCLoop +from ccloop_lib.state_machine import TaskState class FakeIssue: @@ -30,6 +31,8 @@ def _setup_solver(tmp_path, dry_run=False): # mock github solver.github = MagicMock() solver.github.create_pr.return_value = 99 + solver.github.get_current_state.return_value = None # 默认无状态 + solver.github.repo = MagicMock() return solver @@ -46,12 +49,12 @@ def test_research_success(self, mock_tree, mock_cc, tmp_path): result = solver.process_issue(issue) assert result is True - solver.github.mark_processed.assert_called_once() + solver.github.transition_state.assert_any_call(issue, TaskState.DONE) solver.git.reset_to_main.assert_called() # 验证评论包含调研内容 - comment_call = solver.github.comment.call_args_list[1] # 第二次调用是结果 - assert "调研完成" in str(comment_call) + comment_calls = [str(c) for c in solver.github.comment.call_args_list] + assert any("调研完成" in c for c in comment_calls) class TestProcessIssueNoChanges: @@ -67,7 +70,7 @@ def test_no_changes(self, mock_tree, mock_cc, tmp_path): result = solver.process_issue(issue) assert result is True - solver.github.mark_processed.assert_called_once() + solver.github.transition_state.assert_any_call(issue, TaskState.DONE) class TestProcessIssueCodeSuccess: @@ -252,6 +255,8 @@ def test_run_processes_unprocessed(self, tmp_path): issue1 = FakeIssue(number=1) issue2 = FakeIssue(number=2) solver.github.get_open_issues.return_value = [issue1, issue2] + # 无状态 → 视为需要处理 + solver.github.get_current_state.side_effect = [None, None] with patch.object(solver, "process_issue", return_value=True) as mock_process: solver.run() @@ -262,6 +267,7 @@ def test_run_exception_continues(self, tmp_path): issue1 = FakeIssue(number=1) issue2 = FakeIssue(number=2) solver.github.get_open_issues.return_value = [issue1, issue2] + solver.github.get_current_state.side_effect = [None, None] call_count = [0] @@ -276,7 +282,7 @@ def side_effect(iss): assert call_count[0] == 2 # 第一个异常后继续第二个 def test_run_reopens_on_new_comment(self, tmp_path): - """已处理 issue 有新评论时重新处理""" + """已处理 issue 有新评论时重新处理(兼容旧标签 ccloop-done)""" solver = _setup_solver(tmp_path) solver.github.processed_label = "ccloop-done" issue = FakeIssue(number=1) @@ -284,6 +290,8 @@ def test_run_reopens_on_new_comment(self, tmp_path): label.name = "ccloop-done" issue.labels = [label] solver.github.get_open_issues.return_value = [issue] + # get_current_state 返回 None(旧标签不在状态机中),但 labels 包含 ccloop-done + solver.github.get_current_state.return_value = None solver.github.has_new_comments.return_value = True # 模拟 stats 中有上次处理记录 @@ -293,11 +301,10 @@ def test_run_reopens_on_new_comment(self, tmp_path): with patch.object(solver, "process_issue", return_value=True) as mock_process: solver.run() - solver.github.remove_label.assert_called_once_with(issue, "ccloop-done") mock_process.assert_called_once_with(issue) def test_run_skips_processed_no_new_comments(self, tmp_path): - """已处理 issue 无新评论时跳过""" + """已处理 issue 无新评论且无状态机标签时跳过""" solver = _setup_solver(tmp_path) solver.github.processed_label = "ccloop-done" issue = FakeIssue(number=1) @@ -305,6 +312,7 @@ def test_run_skips_processed_no_new_comments(self, tmp_path): label.name = "ccloop-done" issue.labels = [label] solver.github.get_open_issues.return_value = [issue] + solver.github.get_current_state.return_value = None solver.github.has_new_comments.return_value = False solver.stats.data["recent"] = [ @@ -325,6 +333,7 @@ def test_run_reopens_without_stats_record(self, tmp_path): label.name = "ccloop-done" issue.labels = [label] solver.github.get_open_issues.return_value = [issue] + solver.github.get_current_state.return_value = None solver.github.has_new_comments.return_value = True # 无 recent 记录 solver.stats.data["recent"] = [] @@ -332,3 +341,25 @@ def test_run_reopens_without_stats_record(self, tmp_path): with patch.object(solver, "process_issue", return_value=True) as mock_process: solver.run() mock_process.assert_called_once() + + def test_run_skips_blocked_state(self, tmp_path): + """BLOCKED 状态的 issue 跳过""" + solver = _setup_solver(tmp_path) + issue = FakeIssue(number=1) + solver.github.get_open_issues.return_value = [issue] + solver.github.get_current_state.return_value = TaskState.BLOCKED + + with patch.object(solver, "process_issue") as mock_process: + solver.run() + mock_process.assert_not_called() + + def test_run_skips_stuck_state(self, tmp_path): + """STUCK 状态的 issue 跳过""" + solver = _setup_solver(tmp_path) + issue = FakeIssue(number=1) + solver.github.get_open_issues.return_value = [issue] + solver.github.get_current_state.return_value = TaskState.STUCK + + with patch.object(solver, "process_issue") as mock_process: + solver.run() + mock_process.assert_not_called()