diff --git a/README.zh-CN.md b/README.zh-CN.md index 11fb715..21a203b 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -255,6 +255,56 @@ configs/kimi-local-integration.dashscope.env.example 它可以从 Web UI 或 `PUT /api/settings/runtime` 写入,随后会自动注入 Worker 的 `GITHUB_TOKEN` / `GH_TOKEN`。 +同一个运行时设置里还包含: + +- `auto_review_merge_reload` + +开启后,worker 完成并返回 `pr_url` 时,系统会优先执行自动路径: + +1. 用 GitHub API 给 PR approve +2. 用 GitHub API 做 squash merge +3. 删除 worker 分支 +4. 将 incident 标记为 `approved` +5. 由 deploy watcher 拉取目标分支并触发热重载 + +如果自动路径失败,系统会回退到飞书 review 通知流程。 + +服务器侧建议补充配置: + +- `CODE_TERMINATOR_DEPLOY_BRANCH`:deploy watcher 要拉取的目标分支 +- `CODE_TERMINATOR_AGENT_ENABLE_INGEST=1`:保持日志监听开启 + +GitHub token 需要具备: + +- PR review 权限 +- PR merge 权限 +- 删除分支权限 +- 若仓库启用了分支保护,仍需满足对应保护规则 + +同一组运行时设置里还包含: + +- `auto_review_merge_reload` + +这个开关开启后,worker 完成并返回 `pr_url` 时,系统会优先走自动路径: + +1. 用 GitHub API 给 PR 执行 approve +2. 用 GitHub API 执行 squash merge +3. 删除 worker 创建的分支 +4. 将 incident 标记为 `approved` +5. 由 deploy watcher 拉取最新代码并触发热重载 + +如果自动路径失败,系统会回退到原来的飞书 review 通知流程。 + +服务器侧建议额外配置: + +- `CODE_TERMINATOR_DEPLOY_BRANCH`:热重载时要拉取的目标分支,默认优先使用当前检出分支,未能识别时回退到 `main` +- `CODE_TERMINATOR_AGENT_ENABLE_INGEST=1`:保持日志监听启用 + +注意: + +- GitHub token 需要具备 PR review / merge / delete branch 权限 +- 如果仓库启用了强制人工 review 或分支保护,自动 merge 仍可能被 GitHub 拒绝,这是仓库规则,不是本地代码问题 + ### Leader Tuning | 变量 | 默认值 | 说明 | diff --git a/docs/auto-review-server-checklist.md b/docs/auto-review-server-checklist.md new file mode 100644 index 0000000..2ccf64b --- /dev/null +++ b/docs/auto-review-server-checklist.md @@ -0,0 +1,36 @@ +# Auto Review / Merge / Reload Server Checklist + +Use this checklist before enabling automatic incident repair on a server clone. + +## Required Runtime Settings + +- Save a GitHub token from the Web UI or `PUT /api/settings/runtime`. +- Enable `auto_review_merge_reload` in the same runtime settings payload. +- Set `CODE_TERMINATOR_AGENT_ENABLE_INGEST=1` so the log listener stays active. +- Set `CODE_TERMINATOR_DEPLOY_BRANCH` to the branch the server should pull after merge. + +Example: + +```bash +export CODE_TERMINATOR_AGENT_ENABLE_INGEST=1 +export CODE_TERMINATOR_DEPLOY_BRANCH=main +``` + +## GitHub Token Permissions + +The token must be able to: + +- review pull requests +- merge pull requests +- delete worker branches after merge +- satisfy any branch protection rules configured on the target branch + +## Expected Flow + +1. The log listener opens or resumes an incident. +2. The worker fixes the issue and returns `workflow_updates.pr_url`. +3. The runtime auto-approves and squash-merges the PR through the GitHub API. +4. The incident is marked `approved`. +5. The deploy watcher pulls `CODE_TERMINATOR_DEPLOY_BRANCH`. +6. The ecommerce reload stack refreshes and health checks the gateway. +7. The incident moves from `deployed` to `resolved` after the verification window. diff --git a/src/api/models.py b/src/api/models.py index f9f3c54..9837b4b 100644 --- a/src/api/models.py +++ b/src/api/models.py @@ -38,11 +38,13 @@ class ChatSendRequest(BaseModel): class RuntimeSettingsResponse(BaseModel): github_token: str = "" + auto_review_merge_reload: bool = False updated_at: str = Field(default_factory=now_iso) class RuntimeSettingsUpdateRequest(BaseModel): github_token: str = "" + auto_review_merge_reload: bool = False class PlanItemPayload(BaseModel): diff --git a/src/api/services/runtime_service.py b/src/api/services/runtime_service.py index 8171996..b4c23c5 100644 --- a/src/api/services/runtime_service.py +++ b/src/api/services/runtime_service.py @@ -4,6 +4,7 @@ import json import os import shutil +from urllib.parse import quote import uuid from dataclasses import dataclass, field from pathlib import Path @@ -81,7 +82,10 @@ def get_runtime_settings(self) -> RuntimeSettingsResponse: def update_runtime_settings( self, request: RuntimeSettingsUpdateRequest ) -> RuntimeSettingsResponse: - settings = save_runtime_settings(github_token=request.github_token) + settings = save_runtime_settings( + github_token=request.github_token, + auto_review_merge_reload=request.auto_review_merge_reload, + ) return RuntimeSettingsResponse.model_validate(settings.model_dump()) def list_agent_status(self) -> AgentStatusResponse: @@ -641,20 +645,35 @@ async def _dispatch_hook_event( workflow_updates = details.get("workflow_updates", {}) if thread_id.startswith("incident::"): fingerprint = thread_id.replace("incident::", "") + from src.app.auto_review_merge import ( + auto_review_merge_reload, + ) from src.app.incident_registry import get as get_incident from src.app.review_bridge import send_review_notification entry = get_incident(fingerprint) if entry and entry.get("status") not in ("resolved", "suppressed"): - send_review_notification( - fingerprint=fingerprint, - service=entry.get("service", ""), - exception_type=entry.get("exception_type", ""), - traceback_summary=entry.get("sample_traceback", ""), - branch_name=str(workflow_updates.get("branch_name", "")), - commit_sha=str(workflow_updates.get("commit_sha", "")), - pr_url=str(workflow_updates.get("pr_url", "")), - ) + settings = load_runtime_settings() + common = { + "fingerprint": fingerprint, + "service": entry.get("service", ""), + "exception_type": entry.get("exception_type", ""), + "traceback_summary": entry.get("sample_traceback", ""), + "branch_name": str(workflow_updates.get("branch_name", "")), + "commit_sha": str(workflow_updates.get("commit_sha", "")), + "pr_url": str(workflow_updates.get("pr_url", "")), + } + if settings.auto_review_merge_reload: + result = auto_review_merge_reload(**common) + if not result.get("ok"): + logger.warning( + "auto_review_merge.failed fingerprint=%s error=%s", + fingerprint, + result.get("error", ""), + ) + send_review_notification(**common) + else: + send_review_notification(**common) except Exception as exc: logger.warning("review_bridge.auto_notify.error error=%s", exc) return True @@ -769,10 +788,10 @@ def _reset_startup_runtime_state(self) -> None: } def _conversation_path(self, conversation_id: str) -> Path: - return self._state_root / "conversations" / f"{conversation_id}.json" + return self._state_root / "conversations" / f"{_safe_runtime_filename(conversation_id)}.json" def _plan_path(self, conversation_id: str) -> Path: - return self._state_root / "plans" / f"{conversation_id}.json" + return self._state_root / "plans" / f"{_safe_runtime_filename(conversation_id)}.json" def _conversation_id_for_thread(self, thread_id: str) -> str: for conversation_id, stored_thread_id in self._threads.items(): @@ -845,3 +864,8 @@ def _load_plan_snapshot_from_disk( return PlanSnapshotResponse.model_validate(payload) except Exception: return None + + +def _safe_runtime_filename(value: str) -> str: + encoded = quote(value.strip() or "default", safe="") + return encoded or "default" diff --git a/src/app/auto_review_merge.py b/src/app/auto_review_merge.py new file mode 100644 index 0000000..8274605 --- /dev/null +++ b/src/app/auto_review_merge.py @@ -0,0 +1,227 @@ +from __future__ import annotations + +import os +import re +import subprocess +from typing import Any + +import httpx + +from src.app.incident_registry import set_status +from src.observability import get_logger, sanitize_text +from src.runtime_settings import load_runtime_settings + +logger = get_logger(__name__) + +_PR_URL_RE = re.compile( + r"^https://github\.com/(?P[^/\s]+)/(?P[^/\s]+)/pull/(?P\d+)" +) + + +def auto_review_merge_reload( + *, + fingerprint: str, + service: str, + exception_type: str, + traceback_summary: str, + branch_name: str = "", + commit_sha: str = "", + pr_url: str = "", +) -> dict[str, Any]: + del service, exception_type, traceback_summary, branch_name, commit_sha + normalized_pr = pr_url.strip() + if not normalized_pr: + return { + "ok": False, + "error": "missing_pr_url", + "message": "Worker did not return a PR URL; falling back to manual review.", + } + + token = load_runtime_settings().github_token.strip() + if not token: + return { + "ok": False, + "error": "missing_github_token", + "message": "GitHub token is required for automatic PR review and merge.", + } + + review = _approve_pr(normalized_pr, token=token) + merge = _merge_pr(normalized_pr, token=token) + if not merge["ok"]: + return {"ok": False, "error": "merge_failed", **merge} + + set_status(fingerprint, "approved") + logger.info( + "auto_review_merge.done fingerprint=%s pr_url=%s", + fingerprint, + sanitize_text(normalized_pr), + ) + return { + "ok": True, + "action": "auto_review_merge_reload", + "fingerprint": fingerprint, + "pr_url": normalized_pr, + "review": review, + "merge": merge, + } + + +def _approve_pr(pr_url: str, *, token: str) -> dict[str, Any]: + parsed = _parse_github_pr_url(pr_url) + if parsed: + owner, repo, number = parsed + response = _github_request( + "POST", + f"https://api.github.com/repos/{owner}/{repo}/pulls/{number}/reviews", + token=token, + json={ + "event": "APPROVE", + "body": "Auto-approved by code-terminator reviewer.", + }, + ) + if response["ok"]: + return response + logger.warning( + "auto_review_merge.review_api_failed pr_url=%s status=%s error=%s", + sanitize_text(pr_url), + response.get("status_code", ""), + sanitize_text(str(response.get("stderr", ""))), + ) + + return _run_gh( + [ + "gh", + "pr", + "review", + pr_url, + "--approve", + "--body", + "Auto-approved by code-terminator reviewer.", + ], + token=token, + ) + + +def _merge_pr(pr_url: str, *, token: str) -> dict[str, Any]: + parsed = _parse_github_pr_url(pr_url) + if parsed: + owner, repo, number = parsed + merge = _github_request( + "PUT", + f"https://api.github.com/repos/{owner}/{repo}/pulls/{number}/merge", + token=token, + json={"merge_method": "squash"}, + ) + if merge["ok"]: + _delete_pr_branch(owner=owner, repo=repo, number=number, token=token) + return merge + + return _run_gh( + ["gh", "pr", "merge", pr_url, "--squash", "--delete-branch"], + token=token, + ) + + +def _delete_pr_branch(*, owner: str, repo: str, number: str, token: str) -> None: + pr = _github_request( + "GET", + f"https://api.github.com/repos/{owner}/{repo}/pulls/{number}", + token=token, + ) + if not pr["ok"] or not isinstance(pr.get("json"), dict): + return + payload = pr["json"] + head = payload.get("head", {}) if isinstance(payload, dict) else {} + if not isinstance(head, dict): + return + head_repo = head.get("repo", {}) + if not isinstance(head_repo, dict): + return + head_owner = str(head_repo.get("owner", {}).get("login", "")).strip() + head_repo_name = str(head_repo.get("name", "")).strip() + head_ref = str(head.get("ref", "")).strip() + if not head_owner or not head_repo_name or not head_ref: + return + encoded_ref = head_ref.replace("/", "%2F") + _github_request( + "DELETE", + f"https://api.github.com/repos/{head_owner}/{head_repo_name}/git/refs/heads/{encoded_ref}", + token=token, + ) + + +def _parse_github_pr_url(pr_url: str) -> tuple[str, str, str] | None: + match = _PR_URL_RE.match(pr_url.strip()) + if match is None: + return None + return match.group("owner"), match.group("repo"), match.group("number") + + +def _github_request( + method: str, + url: str, + *, + token: str, + json: dict[str, Any] | None = None, +) -> dict[str, Any]: + headers = { + "Accept": "application/vnd.github+json", + "Authorization": f"Bearer {token}", + "X-GitHub-Api-Version": "2022-11-28", + } + try: + response = httpx.request( + method, + url, + headers=headers, + json=json, + timeout=30.0, + ) + except Exception as exc: + logger.warning("auto_review_merge.github_api_error url=%s error=%s", url, exc) + return {"ok": False, "stdout": "", "stderr": str(exc), "status_code": 0} + + response_payload: Any = None + if response.content: + try: + response_payload = response.json() + except Exception: + response_payload = response.text + ok = 200 <= response.status_code < 300 + return { + "ok": ok, + "stdout": response.text, + "stderr": "" if ok else response.text, + "status_code": response.status_code, + "json": response_payload, + } + + +def _run_gh(command: list[str], *, token: str) -> dict[str, Any]: + env = {**os.environ, "GITHUB_TOKEN": token, "GH_TOKEN": token} + try: + result = subprocess.run( + command, + capture_output=True, + text=True, + timeout=60, + env=env, + ) + except Exception as exc: + logger.warning("auto_review_merge.command_error command=%s error=%s", command, exc) + return {"ok": False, "stdout": "", "stderr": str(exc), "returncode": -1} + + ok = result.returncode == 0 + logger.info( + "auto_review_merge.command_done command=%s returncode=%s stdout=%s stderr=%s", + command[:3], + result.returncode, + sanitize_text(result.stdout.strip())[:500], + sanitize_text(result.stderr.strip())[:500], + ) + return { + "ok": ok, + "stdout": result.stdout.strip(), + "stderr": result.stderr.strip(), + "returncode": result.returncode, + } diff --git a/src/app/gitops.py b/src/app/gitops.py index 47b57a0..31b9f3e 100644 --- a/src/app/gitops.py +++ b/src/app/gitops.py @@ -1,5 +1,6 @@ from __future__ import annotations +import os import subprocess from datetime import UTC, datetime from pathlib import Path @@ -32,7 +33,8 @@ def git_fetch() -> bool: return False -def git_pull(branch: str = "feature/incident-ingest") -> dict[str, Any]: +def git_pull(branch: str | None = None) -> dict[str, Any]: + branch = branch or _deploy_branch() before_sha = _current_sha() try: result = subprocess.run( @@ -83,3 +85,25 @@ def _current_sha() -> str: return result.stdout.strip() except Exception: return "" + + +def _deploy_branch() -> str: + configured = os.getenv("CODE_TERMINATOR_DEPLOY_BRANCH", "").strip() + if configured: + return configured + current = _current_branch() + return current or "main" + + +def _current_branch() -> str: + try: + result = subprocess.run( + ["git", "branch", "--show-current"], + cwd=_REPO_ROOT, + capture_output=True, + text=True, + timeout=10, + ) + return result.stdout.strip() + except Exception: + return "" diff --git a/src/runtime_settings.py b/src/runtime_settings.py index b7cb4ea..5919832 100644 --- a/src/runtime_settings.py +++ b/src/runtime_settings.py @@ -14,6 +14,7 @@ def now_iso() -> str: class RuntimeSettings(BaseModel): github_token: str = "" + auto_review_merge_reload: bool = False updated_at: str = Field(default_factory=now_iso) @@ -46,9 +47,12 @@ def load_runtime_settings() -> RuntimeSettings: return RuntimeSettings() -def save_runtime_settings(*, github_token: str) -> RuntimeSettings: +def save_runtime_settings( + *, github_token: str, auto_review_merge_reload: bool = False +) -> RuntimeSettings: settings = RuntimeSettings( github_token=github_token.strip(), + auto_review_merge_reload=auto_review_merge_reload, updated_at=now_iso(), ) runtime_settings_path().write_text( diff --git a/tests/test_api_integration.py b/tests/test_api_integration.py index 78d7c32..1adae6d 100644 --- a/tests/test_api_integration.py +++ b/tests/test_api_integration.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import json from collections.abc import AsyncIterator from contextlib import contextmanager from typing import Any @@ -10,7 +11,7 @@ from src.api.app import create_app from src.api.deps import runtime_service from src.app.runtime_event_bus import RuntimeEventBus -from src.runtime_settings import runtime_settings_path +from src.runtime_settings import runtime_settings_path, save_runtime_settings @contextmanager @@ -43,17 +44,102 @@ def test_runtime_settings_roundtrip(monkeypatch: Any, tmp_path: Any) -> None: get_resp = c.get("/api/settings/runtime") assert get_resp.status_code == 200 assert get_resp.json()["github_token"] == "" + assert get_resp.json()["auto_review_merge_reload"] is False update_resp = c.put( "/api/settings/runtime", - json={"github_token": "runtime-token-123"}, + json={ + "github_token": "runtime-token-123", + "auto_review_merge_reload": True, + }, ) assert update_resp.status_code == 200 assert update_resp.json()["github_token"] == "runtime-token-123" + assert update_resp.json()["auto_review_merge_reload"] is True get_again = c.get("/api/settings/runtime") assert get_again.status_code == 200 assert get_again.json()["github_token"] == "runtime-token-123" + assert get_again.json()["auto_review_merge_reload"] is True + + +def test_worker_completion_uses_auto_review_when_enabled( + monkeypatch: Any, tmp_path: Any +) -> None: + monkeypatch.setenv("CODE_TERMINATOR_API_STATE_ROOT", str(tmp_path / "runtime-state")) + save_runtime_settings(github_token="runtime-token", auto_review_merge_reload=True) + + calls: dict[str, int] = {"auto": 0, "feishu": 0} + + def fake_get_incident(fingerprint: str) -> dict[str, str]: + assert fingerprint == "fp-auto" + return { + "status": "waiting_review", + "service": "inventory-service", + "exception_type": "ValueError", + "sample_traceback": "trace", + } + + def fake_auto_review_merge_reload(**kwargs: Any) -> dict[str, Any]: + calls["auto"] += 1 + assert kwargs["fingerprint"] == "fp-auto" + assert kwargs["pr_url"] == "https://github.com/acme/repo/pull/7" + return {"ok": True} + + def fake_send_review_notification(**kwargs: Any) -> bool: + del kwargs + calls["feishu"] += 1 + return True + + async def fake_run( + task: str, + *, + thread_id: str | None = None, + resume: bool = False, + checkpoint_id: str | None = None, + current_event: dict[str, Any] | None = None, + ) -> dict[str, Any]: + del task, thread_id, resume, checkpoint_id, current_event + return {"final_output": "", "task_units": []} + + monkeypatch.setattr("src.api.services.runtime_service.run", fake_run) + monkeypatch.setattr("src.app.incident_registry.get", fake_get_incident) + monkeypatch.setattr( + "src.app.auto_review_merge.auto_review_merge_reload", + fake_auto_review_merge_reload, + ) + monkeypatch.setattr( + "src.app.review_bridge.send_review_notification", + fake_send_review_notification, + ) + + event = { + "event_type": "subagent_result", + "payload": { + "task_id": "task-1", + "status": "completed", + "role": "worker", + "details": json.dumps( + { + "workflow_updates": { + "branch_name": "worker-fix", + "commit_sha": "abc123", + "pr_url": "https://github.com/acme/repo/pull/7", + } + } + ), + }, + } + + asyncio.run( + runtime_service._dispatch_hook_event( + conversation_id="incident::fp-auto", + thread_id="incident::fp-auto", + event=event, + ) + ) + + assert calls == {"auto": 1, "feishu": 0} def test_chat_and_history(monkeypatch: Any) -> None: diff --git a/tests/test_auto_deploy_flow.py b/tests/test_auto_deploy_flow.py new file mode 100644 index 0000000..345f929 --- /dev/null +++ b/tests/test_auto_deploy_flow.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import asyncio +from typing import Any + +from src.app import deploy_watcher, gitops, incident_registry + + +def test_git_pull_uses_configured_deploy_branch(monkeypatch: Any) -> None: + monkeypatch.setenv("CODE_TERMINATOR_DEPLOY_BRANCH", "main") + + calls: list[list[str]] = [] + + class FakeCompleted: + returncode = 0 + stdout = "ok" + stderr = "" + + def fake_run(command: list[str], **kwargs: Any) -> FakeCompleted: + del kwargs + calls.append(command) + if command[:2] == ["git", "rev-parse"]: + completed = FakeCompleted() + completed.stdout = "abc123" + return completed + return FakeCompleted() + + monkeypatch.setattr("src.app.gitops.subprocess.run", fake_run) + + result = gitops.git_pull() + + assert result["ok"] is True + assert ["git", "pull", "--ff-only", "origin", "main"] in calls + + +def test_deploy_watcher_handles_approved_incident( + monkeypatch: Any, tmp_path: Any +) -> None: + monkeypatch.setattr( + incident_registry, + "_REGISTRY_FILE", + tmp_path / "incidents" / "registry.json", + ) + monkeypatch.setattr(deploy_watcher, "_VERIFY_WINDOW_SECONDS", 0) + monkeypatch.setattr("src.app.deploy_watcher.git_fetch", lambda: True) + monkeypatch.setattr( + "src.app.deploy_watcher.git_pull", + lambda: { + "ok": True, + "before_sha": "abc123", + "after_sha": "def456", + "changed": True, + "stdout": "", + "stderr": "", + }, + ) + monkeypatch.setattr("src.app.deploy_watcher._health_check", _ok_health_check) + monkeypatch.setattr("src.app.deploy_watcher.asyncio.sleep", _fast_sleep) + + incident_registry.upsert("fp-deploy", status="approved") + + asyncio.run(deploy_watcher._handle_approved({"fingerprint": "fp-deploy"})) + + entry = incident_registry.get("fp-deploy") + assert entry is not None + assert entry["status"] == "resolved" + assert entry["deployed_commit"] == "def456" + + +async def _ok_health_check() -> bool: + return True + + +async def _fast_sleep(seconds: float) -> None: + del seconds diff --git a/tests/test_auto_review_merge.py b/tests/test_auto_review_merge.py new file mode 100644 index 0000000..9f71aea --- /dev/null +++ b/tests/test_auto_review_merge.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +from typing import Any + +from src.app import incident_registry +from src.app.auto_review_merge import auto_review_merge_reload +from src.app.incident_registry import get, upsert +from src.runtime_settings import save_runtime_settings + + +def test_auto_review_merge_requires_pr_url(monkeypatch: Any, tmp_path: Any) -> None: + monkeypatch.setenv("CODE_TERMINATOR_API_STATE_ROOT", str(tmp_path)) + result = auto_review_merge_reload( + fingerprint="fp-1", + service="svc", + exception_type="ValueError", + traceback_summary="trace", + pr_url="", + ) + + assert result["ok"] is False + assert result["error"] == "missing_pr_url" + + +def test_auto_review_merge_approves_and_merges( + monkeypatch: Any, tmp_path: Any +) -> None: + monkeypatch.setenv("CODE_TERMINATOR_API_STATE_ROOT", str(tmp_path)) + monkeypatch.setattr( + incident_registry, + "_REGISTRY_FILE", + tmp_path / "incidents" / "registry.json", + ) + save_runtime_settings( + github_token="token-123", + auto_review_merge_reload=True, + ) + upsert("fp-2", status="waiting_review", service="svc", exception_type="ValueError") + + calls: list[tuple[str, str, dict[str, Any] | None]] = [] + + class FakeResponse: + def __init__(self, status_code: int, payload: dict[str, Any]) -> None: + self.status_code = status_code + self._payload = payload + self.text = "ok" + self.content = b"ok" + + def json(self) -> dict[str, Any]: + return self._payload + + def fake_request( + method: str, + url: str, + *, + headers: dict[str, str], + json: dict[str, Any] | None = None, + timeout: float, + ) -> FakeResponse: + del timeout + assert headers["Authorization"] == "Bearer token-123" + calls.append((method, url, json)) + if method == "GET": + return FakeResponse( + 200, + { + "head": { + "ref": "worker-fix", + "repo": {"name": "repo", "owner": {"login": "acme"}}, + } + }, + ) + return FakeResponse(200, {"ok": True}) + + monkeypatch.setattr("src.app.auto_review_merge.httpx.request", fake_request) + + result = auto_review_merge_reload( + fingerprint="fp-2", + service="svc", + exception_type="ValueError", + traceback_summary="trace", + pr_url="https://github.com/acme/repo/pull/1", + ) + + assert result["ok"] is True + assert calls[0] == ( + "POST", + "https://api.github.com/repos/acme/repo/pulls/1/reviews", + {"event": "APPROVE", "body": "Auto-approved by code-terminator reviewer."}, + ) + assert calls[1] == ( + "PUT", + "https://api.github.com/repos/acme/repo/pulls/1/merge", + {"merge_method": "squash"}, + ) + assert calls[2][0] == "GET" + assert calls[3][0] == "DELETE" + assert get("fp-2")["status"] == "approved" diff --git a/web/src/App.tsx b/web/src/App.tsx index 74c2d60..d2759af 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -94,6 +94,8 @@ export function App() { const [error, setError] = useState(""); const [runtimeSettings, setRuntimeSettings] = useState(null); const [githubTokenDraft, setGithubTokenDraft] = useState(""); + const [autoReviewMergeReloadDraft, setAutoReviewMergeReloadDraft] = + useState(false); const [savingToken, setSavingToken] = useState(false); const [showGithubToken, setShowGithubToken] = useState(false); const [tokenStatus, setTokenStatus] = useState(""); @@ -139,6 +141,7 @@ export function App() { .then((settings) => { setRuntimeSettings(settings); setGithubTokenDraft(settings.github_token); + setAutoReviewMergeReloadDraft(settings.auto_review_merge_reload); }) .catch((err: Error) => { logBackgroundError("fetchRuntimeSettings", err); @@ -200,8 +203,9 @@ export function App() { [input, loading], ); const tokenDirty = runtimeSettings - ? githubTokenDraft !== runtimeSettings.github_token - : githubTokenDraft.length > 0; + ? githubTokenDraft !== runtimeSettings.github_token || + autoReviewMergeReloadDraft !== runtimeSettings.auto_review_merge_reload + : githubTokenDraft.length > 0 || autoReviewMergeReloadDraft; const planItems = planSnapshot?.plan_items ?? []; const activityLog = planSnapshot?.activity_log ?? []; @@ -235,9 +239,13 @@ export function App() { setSavingToken(true); setTokenStatus(""); try { - const saved = await saveRuntimeSettings(githubTokenDraft); + const saved = await saveRuntimeSettings( + githubTokenDraft, + autoReviewMergeReloadDraft, + ); setRuntimeSettings(saved); setGithubTokenDraft(saved.github_token); + setAutoReviewMergeReloadDraft(saved.auto_review_merge_reload); setTokenStatus(saved.github_token ? "已保存到本地运行时配置" : "已清空本地运行时配置"); } catch (err) { const message = err instanceof Error ? err.message : "保存 token 失败"; @@ -516,6 +524,14 @@ export function App() { > {savingToken ? "保存中…" : "保存"} +
{tokenStatus || diff --git a/web/src/api.ts b/web/src/api.ts index 591b8e8..5f9b914 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -45,11 +45,15 @@ export function fetchPlanSnapshot(conversationId: string): Promise(`/api/conversations/${conversationId}/plan`); } -export function saveRuntimeSettings(githubToken: string): Promise { +export function saveRuntimeSettings( + githubToken: string, + autoReviewMergeReload: boolean, +): Promise { return request("/api/settings/runtime", { method: "PUT", body: JSON.stringify({ github_token: githubToken, + auto_review_merge_reload: autoReviewMergeReload, }), }); } diff --git a/web/src/styles.css b/web/src/styles.css index f3b0c87..1397164 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -277,6 +277,23 @@ textarea, input { font-family: inherit; } display: flex; gap: 8px; margin-top: 10px; + align-items: center; + flex-wrap: wrap; +} + +.auto-review-toggle { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--text-sub); + font-size: 12px; + white-space: nowrap; +} + +.auto-review-toggle input { + width: 16px; + height: 16px; + accent-color: var(--accent); } .token-input { diff --git a/web/src/types.ts b/web/src/types.ts index fb3c2bc..5df2b4e 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -37,6 +37,7 @@ export type ChatHistoryResponse = { export type RuntimeSettings = { github_token: string; + auto_review_merge_reload: boolean; updated_at: string; };