diff --git a/agents/DEV_AGENT.md b/agents/DEV_AGENT.md index 3a1aab0..002fa3d 100644 --- a/agents/DEV_AGENT.md +++ b/agents/DEV_AGENT.md @@ -96,40 +96,54 @@ python scripts/agent_poller.py --action get --issue N ### 4. 提交 PR -Push 后立即创建 PR(每个 Issue 对应一个 PR): +Push 后立即用 `agent_poller.py` 创建 PR: ```bash -gh pr create \ - --title "fix: <描述> - Closes #N" \ - --body "$(cat <<'EOF' -## Summary +python scripts/agent_poller.py --action create-pr \ + --issue N --branch dev/issue-N- \ + --body "## Summary - <改动摘要> ## Test -- [ ] pytest 全量通过 -- [ ] UT / 集成测试已更新 +- [x] pytest 全量通过 (XX passed, Y skipped) +- [x] UT / 集成测试已更新 -Closes #N - -🤖 Generated with [Claude Code](https://claude.com/claude-code) -EOF -)" +Closes #N" ``` ### 5. 等待 CI -PR 创建后 CI 自动触发。可通过 Gitea Actions 页面或 `gh pr checks` 查看状态。 +PR 创建后 CI 自动触发。用 agent_poller 监控状态: -### 6. 处理结果 +```bash +python scripts/agent_poller.py --action pr-status --pr +``` -- **CI 通过**: - 1. Merge PR 到 main:`gh pr merge --merge` - 2. 在 Issue 中评论 PR 信息和合并结果 - 3. `Closes #N` 自动关闭 Issue -- **CI 失败**: - 1. CI 自动创建 `ci-failure` Issue - 2. 分析失败原因,在 PR 上 push 修复 commit - 3. CI 重新触发,直到通过 +### 6. Merge & 关闭 + +CI 通过后,执行 merge 并关闭 Issue: + +```bash +# Merge PR(会自动检查 CI 状态) +python scripts/agent_poller.py --action merge-pr --pr + +# 如果 Issue 未被自动关闭,手动关闭 +python scripts/agent_poller.py --action close-issue --issue N \ + --body "PR # merged. 变更已合入 main." +``` + +**一键查看完整生命周期:** +```bash +python scripts/agent_poller.py --action lifecycle --issue N +``` + +### 7. CI 失败处理 + +CI 失败时 Gitea 自动创建 `ci-failure` Issue: +1. `agent_poller.py --action get --issue ` 分析失败原因 +2. 在修复分支上修改代码,`git commit --amend` 或新 commit +3. `git push origin dev/issue-N-` 触发 CI 重跑 +4. 重复步骤 5-6 直到 CI 通过 ## 闭环 @@ -138,11 +152,11 @@ QE-Agent 开 Issue (qe-feedback) ↓ Dev-Agent 分析 → 开发/重构 → 更新测试 ↓ - git push → 创建 PR → CI (pytest) + git push → create-pr → CI (pytest) ↓ ┌─ 失败 → 自动开 Issue → push 修复 → 回到 CI │ - └─ 成功 → Merge PR → 评论 Issue → 关闭 → QE-Agent 验证 → 新反馈 + └─ 成功 → merge-pr → close-issue → QE-Agent 验证 → 新反馈 ``` ## 提交规范 diff --git a/scripts/agent_poller.py b/scripts/agent_poller.py index 35ff7e4..c9443cf 100644 --- a/scripts/agent_poller.py +++ b/scripts/agent_poller.py @@ -1,10 +1,14 @@ -"""Helper for dev agent to interact with Gitea issues. +"""Helper for dev agent to interact with Gitea issues and PRs. Usage: python scripts/agent_poller.py --action list python scripts/agent_poller.py --action get --issue 1 python scripts/agent_poller.py --action comment --issue 1 --body "Working on this" python scripts/agent_poller.py --action create-pr --issue 1 --branch fix/issue-1 + python scripts/agent_poller.py --action pr-status --pr 4 + python scripts/agent_poller.py --action merge-pr --pr 4 + python scripts/agent_poller.py --action close-issue --issue 2 --body "Done" + python scripts/agent_poller.py --action lifecycle --issue 2 """ import argparse @@ -19,7 +23,6 @@ GITEA_REPO = os.environ.get("GITEA_REPO", "pzhang_zywl/document_analyzer") GITEA_TOKEN = os.environ.get("GITEA_API_TOKEN", "") BASE = f"{GITEA_URL}/api/v1/repos/{GITEA_REPO}" -TARGET_LABELS = set() # List all issues, Dev-Agent handles all non-test issues def _req(method, path, data=None): @@ -37,6 +40,8 @@ def _req(method, path, data=None): sys.exit(1) +# ── Issue operations ───────────────────────────────────────────────────────── + def list_issues(): issues = _req("GET", "/issues?state=open") if not issues: @@ -65,26 +70,127 @@ def comment_issue(num, body): return i -def create_pr(issue_num, branch): - # Get issue title for PR description +def close_issue(num, body=None): + """Close an issue, optionally with a final comment.""" + if body: + comment_issue(num, body) + i = _req("PATCH", f"/issues/{num}", {"state": "closed"}) + print(f"Issue #{num} closed") + return i + + +# ── PR operations ──────────────────────────────────────────────────────────── + +def create_pr(issue_num, branch, body=None): + """Create a PR for the given issue and branch.""" issue = _req("GET", f"/issues/{issue_num}") - title = f"Fix #{issue_num}: {issue['title'].replace('CI Failure: ', '')}" - body = f"Closes #{issue_num}\n\n{issue.get('body', '')}\n\n🤖 Generated by dev agent" + title = f"fix: {issue['title']} - Closes #{issue_num}" + if body is None: + body = f"Closes #{issue_num}\n\n{issue.get('body', '')}\n\n🤖 Generated by dev agent" pr = _req("POST", "/pulls", { "title": title, "head": branch, "base": "main", "body": body, }) - print(f"PR created: {pr.get('html_url', pr.get('url', ''))}") + pr_url = pr.get('html_url', pr.get('url', '')) + print(f"PR created: {pr_url}") return pr +def pr_status(pr_num): + """Check PR state and CI status.""" + pr = _req("GET", f"/pulls/{pr_num}") + print(f"PR #{pr['number']}: {pr['title']}") + print(f"State: {pr['state']}") + print(f"Merged: {pr.get('merged', False)}") + print(f"Mergeable: {pr.get('mergeable', 'unknown')}") + print(f"URL: {pr['html_url']}") + + # CI status + sha = pr.get("head", {}).get("sha", "") + if sha: + try: + status = _req("GET", f"/commits/{sha}/status") + print(f"CI Status: {status.get('state', 'pending')}") + for s in status.get('statuses', []): + desc = s.get('description', '') + print(f" - {s.get('context')}: {s.get('state')} ({desc})") + except SystemExit: + print("CI Status: no statuses found") + return pr + + +def merge_pr(pr_num): + """Merge a PR. Fails if CI hasn't passed or PR is not mergeable.""" + pr = _req("GET", f"/pulls/{pr_num}") + + if pr.get("state") == "closed": + if pr.get("merged"): + print(f"PR #{pr_num} already merged") + return pr + else: + print(f"PR #{pr_num} is closed (not merged)", file=sys.stderr) + sys.exit(1) + + # Check CI + sha = pr.get("head", {}).get("sha", "") + ci_passed = True + if sha: + try: + status = _req("GET", f"/commits/{sha}/status") + ci_state = status.get("state", "pending") + if ci_state in ("failure", "error"): + ci_passed = False + print(f"CI status: {ci_state} — cannot merge", file=sys.stderr) + for s in status.get('statuses', []): + print(f" {s.get('context')}: {s.get('state')}", file=sys.stderr) + except SystemExit: + pass # No statuses, proceed + + if not ci_passed: + print("Merge blocked: CI has not passed", file=sys.stderr) + sys.exit(1) + + result = _req("POST", f"/pulls/{pr_num}/merge", {"Do": "merge"}) + if result.get("merged"): + print(f"PR #{pr_num} merged successfully") + else: + print(f"Merge result: {result.get('message', 'unknown')}") + return result + + +# ── Lifecycle management ───────────────────────────────────────────────────── + +def lifecycle(issue_num): + """Print the full lifecycle status for an issue: branch, PR, CI, merge.""" + print(f"=== Issue #{issue_num} Lifecycle ===\n") + + issue = _req("GET", f"/issues/{issue_num}") + print(f"Issue: {issue['title']}") + print(f"State: {issue['state']}\n") + + # Find associated PRs + prs = _req("GET", "/pulls?state=all") + related = [p for p in prs if f"Closes #{issue_num}" in p.get("body", "") + or f"#{issue_num}" in p.get("title", "")] + if related: + for pr in related: + print(f"PR #{pr['number']}: {pr['state']} (merged={pr.get('merged', False)})") + pr_status(pr["number"]) + else: + print("No associated PR found.") + + +# ── CLI ────────────────────────────────────────────────────────────────────── + def main(): parser = argparse.ArgumentParser(description="Dev agent Gitea helper") parser.add_argument("--action", required=True, - choices=["list", "get", "comment", "create-pr"]) + choices=["list", "get", "comment", "close-issue", + "create-pr", "pr-status", "merge-pr", "lifecycle"]) parser.add_argument("--issue", type=int) + parser.add_argument("--pr", type=int) parser.add_argument("--branch") parser.add_argument("--body") args = parser.parse_args() @@ -106,11 +212,31 @@ def main(): print("--issue and --body are required for 'comment' action", file=sys.stderr) sys.exit(1) comment_issue(args.issue, args.body) + elif args.action == "close-issue": + if not args.issue: + print("--issue is required for 'close-issue' action", file=sys.stderr) + sys.exit(1) + close_issue(args.issue, args.body) elif args.action == "create-pr": if not args.issue or not args.branch: print("--issue and --branch are required for 'create-pr' action", file=sys.stderr) sys.exit(1) - create_pr(args.issue, args.branch) + create_pr(args.issue, args.branch, args.body) + elif args.action == "pr-status": + if not args.pr: + print("--pr is required for 'pr-status' action", file=sys.stderr) + sys.exit(1) + pr_status(args.pr) + elif args.action == "merge-pr": + if not args.pr: + print("--pr is required for 'merge-pr' action", file=sys.stderr) + sys.exit(1) + merge_pr(args.pr) + elif args.action == "lifecycle": + if not args.issue: + print("--issue is required for 'lifecycle' action", file=sys.stderr) + sys.exit(1) + lifecycle(args.issue) if __name__ == "__main__": diff --git a/skills/ir_generation_skill/config.py b/skills/ir_generation_skill/config.py index 6f17ef2..984d3c1 100644 --- a/skills/ir_generation_skill/config.py +++ b/skills/ir_generation_skill/config.py @@ -10,15 +10,22 @@ import yaml # ---- Paths ---- BASE_DIR = os.path.dirname(os.path.abspath(__file__)) WORKSPACE_DIR = os.path.dirname(BASE_DIR) +PROJECT_ROOT = os.path.dirname(WORKSPACE_DIR) +PROJECT_OUTPUT = os.path.join(PROJECT_ROOT, "output") + +# Subdirectories under PROJECT_OUTPUT +IR_OUTPUT = os.path.join(PROJECT_OUTPUT, "ir") +FINAL_OUTPUT = os.path.join(PROJECT_OUTPUT, "final") + +# Legacy paths (maintained for doc_parser integration) DOC_PARSER_OUTPUT = os.path.join(WORKSPACE_DIR, "doc_parser_skill", "output") PROMPTS_DIR = os.path.join(BASE_DIR, "prompts") TESTS_DIR = os.path.join(BASE_DIR, "tests") -OUTPUT_DIR = os.path.join(BASE_DIR, "output") +OUTPUT_DIR = IR_OUTPUT # backward compatibility alias # Input file (the parsed PRD JSON) _DEFAULT_INPUT = os.path.join( - DOC_PARSER_OUTPUT, - "车机娱乐系统禁止功能文档_脱敏 v0.9_v2_updated.json", + PROJECT_OUTPUT, "车机娱乐系统禁止功能文档_脱敏 v0.9_v2_updated.json", ) INPUT_JSON = os.environ.get("IR_INPUT_JSON", _DEFAULT_INPUT) @@ -35,18 +42,18 @@ SECRETS_YAML = os.path.join( OPENCLAW_HOME, "workspace-document-analyzer", "config", "secrets.yaml", ) -# Intermediate outputs -SEMANTIC_INDEX_R1_JSON = os.path.join(OUTPUT_DIR, "semantic_index_r1.json") -SEMANTIC_INDEX_R2_JSON = os.path.join(OUTPUT_DIR, "semantic_index_r2.json") -SEMANTIC_INDEX_R3_JSON = os.path.join(OUTPUT_DIR, "semantic_index_r3.json") -SEMANTIC_INDEX_JSON = os.path.join(OUTPUT_DIR, "semantic_index.json") # merged final -IR_FRAGMENTS_JSON = os.path.join(OUTPUT_DIR, "ir_fragments.json") -PATH_ENUM_JSON = os.path.join(OUTPUT_DIR, "path_enumeration.json") -IR_AUTOCOMPLETE_FRAGMENTS_JSON = os.path.join(OUTPUT_DIR, "ir_autocomplete_fragments.json") +# Intermediate outputs (all under PROJECT_OUTPUT/ir/) +SEMANTIC_INDEX_R1_JSON = os.path.join(IR_OUTPUT, "semantic_index_r1.json") +SEMANTIC_INDEX_R2_JSON = os.path.join(IR_OUTPUT, "semantic_index_r2.json") +SEMANTIC_INDEX_R3_JSON = os.path.join(IR_OUTPUT, "semantic_index_r3.json") +SEMANTIC_INDEX_JSON = os.path.join(IR_OUTPUT, "semantic_index.json") +IR_FRAGMENTS_JSON = os.path.join(IR_OUTPUT, "ir_fragments.json") +PATH_ENUM_JSON = os.path.join(IR_OUTPUT, "path_enumeration.json") +IR_AUTOCOMPLETE_FRAGMENTS_JSON = os.path.join(IR_OUTPUT, "ir_autocomplete_fragments.json") -# Final deliverables (placed in doc_parser output per spec) -IR_FINAL_JSON = os.path.join(DOC_PARSER_OUTPUT, "ir_final.json") -IR_AUDIT_REPORT_MD = os.path.join(DOC_PARSER_OUTPUT, "ir_audit_report.md") +# Final deliverables (under PROJECT_OUTPUT/final/) +IR_FINAL_JSON = os.path.join(FINAL_OUTPUT, "ir_final.json") +IR_AUDIT_REPORT_MD = os.path.join(FINAL_OUTPUT, "ir_audit_report.md") # ---- LLM API ---- # Choose provider: "deepseek" | "dashscope" diff --git a/tests/acceptance/conftest.py b/tests/acceptance/conftest.py index 9d320eb..8c397bb 100644 --- a/tests/acceptance/conftest.py +++ b/tests/acceptance/conftest.py @@ -115,10 +115,7 @@ def ir_path(request) -> str: path = ( request.config.getoption("--ir-path") or os.environ.get("TEST_IR_PATH") - or str( - Path.home() - / ".openclaw/workspace/skills/doc_parser_skill/output/ir_final.json" - ) + or str(_PROJECT_ROOT / "output" / "final" / "ir_final.json") ) if not os.path.exists(path): pytest.skip(f"IR file not found: {path}") @@ -139,8 +136,7 @@ def parsed_path(request) -> str | None: request.config.getoption("--parsed-path") or os.environ.get("TEST_PARSED_PATH") or str( - _PROJECT_ROOT - / "skills/ir_generation_skill/车机娱乐系统禁止功能文档_精简_updated.json" + _PROJECT_ROOT / "output" / "车机娱乐系统禁止功能文档_精简_updated.json" ) ) if os.path.exists(path): diff --git a/tests/test_config.py b/tests/test_config.py index b53e224..c9a3eb0 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,5 +1,6 @@ """Unit tests for config.py pure functions.""" +import os import sys from pathlib import Path @@ -21,6 +22,10 @@ def test_set_input_file(): def test_config_constants_exist(): """Verify all expected path constants are defined.""" assert config.BASE_DIR + assert config.PROJECT_ROOT + assert config.PROJECT_OUTPUT + assert config.IR_OUTPUT + assert config.FINAL_OUTPUT assert config.OUTPUT_DIR assert config.PROMPTS_DIR assert config.TESTS_DIR @@ -33,6 +38,23 @@ def test_config_constants_exist(): assert config.IR_AUDIT_REPORT_MD +def test_output_dir_is_under_project_root(): + """PROJECT_OUTPUT, IR_OUTPUT, FINAL_OUTPUT should all be under PROJECT_ROOT.""" + assert config.PROJECT_OUTPUT.startswith(config.PROJECT_ROOT) + assert config.IR_OUTPUT.startswith(config.PROJECT_OUTPUT) + assert config.FINAL_OUTPUT.startswith(config.PROJECT_OUTPUT) + + +def test_output_dir_structure(): + """IR files should go to output/ir/, final deliverable to output/final/.""" + assert config.IR_OUTPUT.endswith(os.path.join("output", "ir")) + assert config.FINAL_OUTPUT.endswith(os.path.join("output", "final")) + assert config.SEMANTIC_INDEX_JSON.startswith(config.IR_OUTPUT) + assert config.IR_FRAGMENTS_JSON.startswith(config.IR_OUTPUT) + assert config.IR_FINAL_JSON.startswith(config.FINAL_OUTPUT) + assert config.IR_AUDIT_REPORT_MD.startswith(config.FINAL_OUTPUT) + + def test_ensemble_temperatures_count(): """Should have exactly 3 ensemble temperatures.""" assert len(config.ENSEMBLE_TEMPERATURES) == 3 diff --git a/tests/test_sample.py b/tests/test_sample.py index 088fd42..c72ab08 100644 --- a/tests/test_sample.py +++ b/tests/test_sample.py @@ -83,7 +83,7 @@ def test_sample_ir_json_is_valid(): """The sample IR JSON file should be valid JSON.""" sample_path = os.path.join( os.path.dirname(os.path.dirname(__file__)), - "skills", "ir_generation_skill", + "output", "车机娱乐系统禁止功能文档_精简_updated.json" ) if os.path.exists(sample_path):