diff --git a/AGENTS.md b/AGENTS.md index 37edb1a..3a602ee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,8 +27,9 @@ work: ``` 7. Only after approval may AI create a branch and PR. -8. The MVP implementation creates a PR scaffold only. It must not change service - code automatically. +8. The GitHub Actions implementation creates a PR scaffold only. +9. A human may then comment `/ai implement` to allow the local mini PC runner to + continue that PR branch with Codex CLI. ## Hard Rules @@ -54,10 +55,23 @@ work: ## Approval Rules - The approval signal is a GitHub Issue comment containing `/ai approve`. +- The implementation signal is a GitHub Issue comment containing `/ai implement`. - The workflow should only act on Issues labeled for DevLoop proposals. - Optional repository variable `DEVLOOP_APPROVERS` can restrict who may approve. Use a comma-separated list of GitHub usernames. +## Continuing An Approved DevLoop PR + +When asked to continue an approved DevLoop PR: + +1. Confirm the current branch starts with `ai/issue-`. +2. Read `docs/ai-ideas/issue-N.md`. +3. Read `AI_POLICY.md`. +4. Implement only the approved scope. +5. Run relevant tests. +6. Commit to the same branch. +7. Push the branch so the existing PR updates. + ## Suggested Agent Behavior - Prefer documentation, tests, and narrow workflow improvements before product diff --git a/AI_POLICY.md b/AI_POLICY.md index f25e944..a012967 100644 --- a/AI_POLICY.md +++ b/AI_POLICY.md @@ -18,6 +18,8 @@ Allowed: - Create an `ai/` branch after approval. - Open a PR scaffold for the approved idea. - Add documentation-only planning artifacts for the approved idea. +- Detect `/ai implement` through a local mini PC runner. +- Run Codex CLI locally on an approved `ai/issue-*` PR branch. - Use manual proposal mode when a human wants to provide a Codex-drafted idea instead of spending API credits. @@ -33,6 +35,8 @@ Not allowed: - Secret creation, rotation, or disclosure. - OpenAI API calls from the approved implementation workflow. - More than one AI-generated idea per proposal workflow run. +- Local runner commits without a human `/ai implement` command. +- Local runner pushes to `main`. ## Required GitHub Configuration @@ -78,6 +82,17 @@ The implementation workflow only responds to Issue comments whose trimmed body i The Issue must also carry the `devloop` and `ai-proposed` labels. +## Implementation Signal + +The local mini PC runner only continues implementation after a human comments: + +```text +/ai implement +``` + +The runner should work on the existing `ai/issue-*` PR branch and must not merge +the PR. + ## Human Review Expectations Before approving an Issue, verify that: diff --git a/docs/ai-ideas/issue-2.md b/docs/ai-ideas/issue-2.md new file mode 100644 index 0000000..b2808bb --- /dev/null +++ b/docs/ai-ideas/issue-2.md @@ -0,0 +1,49 @@ +# DevLoop 아이디어 #2: [DevLoop] 검색 결과 카드에 하이라이트된 키워드 표시 개선 + + 이 파일은 GitHub Issue에서 사람이 승인한 뒤 생성된 PR scaffold입니다. + 의도적으로 실제 서비스 코드는 수정하지 않습니다. + + ## 승인된 이슈 + + - 이슈: #2 + - 제목: [DevLoop] 검색 결과 카드에 하이라이트된 키워드 표시 개선 + + ## 원본 제안 + + ## 문제 + +현재 검색 결과 카드에서 사용자가 왜 해당 글이 검색되었는지 이해하기 어려운 경우가 있어, 검색 결과의 신뢰도와 탐색 효율성이 떨어질 수 있습니다. + +## 지금 필요한 이유 + +TechCase의 핵심 가치는 실제 기업 적용 사례를 빠르게 이해하는 데 있으므로, 검색 결과 카드에 검색어와 매칭된 키워드를 명확히 하이라이트하여 사용자가 결과의 맥락을 빠르게 파악할 수 있도록 하는 것이 중요합니다. 이는 검색 품질 평가에도 긍정적인 영향을 줄 수 있습니다. + +## 제안 범위 + +프론트엔드 `apps/web` 내 검색 결과 카드 컴포넌트에 하이라이트된 키워드 표시 UI 개선 및 관련 문서(`docs/search-design.md`)에 하이라이트 정책과 구현 방식을 명확히 문서화하는 작업으로 제한합니다. 서비스 코드나 백엔드 검색 로직 변경은 포함하지 않습니다. + +## 하지 않을 일 + +검색 알고리즘 변경, 백엔드 Elasticsearch 쿼리 수정, 키워드 추출 로직 개선, 인증 및 권한 관련 변경, 인프라나 배포 설정 변경은 포함하지 않습니다. + +## 위험 요소 / 가드레일 + +UI 변경에 따른 사용자 혼란 가능성은 낮으며, 문서화 중심 작업이므로 서비스 안정성에 미치는 영향은 거의 없습니다. 다만 하이라이트 표현이 과도하거나 부족하지 않도록 적절한 밸런스 조정이 필요합니다. + +## 검증 방법 + +개선된 검색 결과 카드에서 검색어와 매칭된 키워드가 명확히 하이라이트되어 표시되는지 확인합니다. 여러 검색어 조합에 대해 하이라이트가 일관되게 동작하는지 UI 테스트를 수행하고, 기존 사용자 피드백과 비교해 탐색 편의성이 향상되었는지 검토합니다. + +## 승인 방법 + +이 아이디어를 진행하려면 이슈 댓글에 정확히 `/ai approve`를 남겨주세요. +승인 후 DevLoop가 `ai/` 브랜치와 PR scaffold를 생성합니다. + +DevLoop fingerprint: `4cb0bf0e9a61` + + ## 구현 메모 + + - 승인된 범위 안에서만 변경합니다. + - 인증, 결제, 데이터베이스 마이그레이션, 인프라는 건드리지 않습니다. + - 자동 머지는 활성화하지 않습니다. + - 사람 리뷰를 요청하기 전에 검증 메모를 남깁니다. diff --git a/docs/devloop-runner.md b/docs/devloop-runner.md new file mode 100644 index 0000000..9a76552 --- /dev/null +++ b/docs/devloop-runner.md @@ -0,0 +1,90 @@ +# DevLoop Local Runner + +This document describes the mini PC runner that continues an approved DevLoop PR +with Codex CLI. + +## Flow + +1. DevLoop creates an idea Issue. +2. A human comments `/ai approve`. +3. GitHub Actions creates an `ai/issue-*` scaffold PR. +4. A human comments `/ai implement` on the Issue. +5. The mini PC runner detects the command. +6. The runner checks out the matching PR branch locally. +7. The runner invokes `codex exec` with `--sandbox workspace-write`. +8. The runner can commit and push changes back to the same PR branch. + +## Required Local Setup + +Install and authenticate these on the mini PC: + +- `git` +- `python3` +- `codex` +- GitHub credentials that can read Issues, comment on Issues, fetch branches, + and push to the repository + +Required environment variables: + +- `GITHUB_REPOSITORY`: for example `SmileJune/techcase` +- `GITHUB_TOKEN` or `GH_TOKEN`: token for the local runner + +Optional environment variables: + +- `DEVLOOP_WORKSPACE`: path to the local clone. Defaults to the current + directory. +- `DEVLOOP_RUNNER_INTERVAL`: polling interval in seconds. Defaults to `300`. +- `DEVLOOP_APPROVERS`: comma-separated GitHub usernames allowed to use + `/ai implement`. +- `DEVLOOP_CODEX_MODEL`: optional Codex model override. +- `CODEX_BIN`: optional full path to the Codex CLI executable. Useful for + systemd, cron, or SSH non-interactive shells where `~/.local/bin` is not on + `PATH`. + +Use a dedicated clone for the runner. The runner refuses to start work when the +workspace is dirty. + +## Dry Run + +Check what the runner would do without running Codex: + +```bash +python3 scripts/devloop/runner.py \ + --repo SmileJune/techcase \ + --once +``` + +## Execute Without Commit + +Run Codex locally, but leave changes uncommitted for manual inspection: + +```bash +python3 scripts/devloop/runner.py \ + --repo SmileJune/techcase \ + --once \ + --execute-codex +``` + +## Execute And Push + +Run Codex, commit the resulting allowed changes, and push to the existing PR +branch: + +```bash +python3 scripts/devloop/runner.py \ + --repo SmileJune/techcase \ + --once \ + --execute-codex \ + --commit \ + --push +``` + +## Safety Notes + +- The runner only responds to `/ai implement`, not `/ai approve`. +- The runner uses `codex exec --sandbox workspace-write`. +- The runner refuses to commit changes under `.github/workflows/`, `infra/`, + and `apps/backend/alembic/`. +- The runner does not merge PRs. +- The runner does not push to `main`; it pushes only to the existing + `ai/issue-*` PR branch. diff --git a/scripts/ai/implement_idea.py b/scripts/ai/implement_idea.py index 73fe77d..1202c68 100644 --- a/scripts/ai/implement_idea.py +++ b/scripts/ai/implement_idea.py @@ -197,7 +197,7 @@ def create_pr(repo: str, token: str, base: str, branch: str, issue: dict[str, An f""" ## 승인된 아이디어 - Closes #{number} + Refs #{number} ## 변경 사항 diff --git a/scripts/ai/prompts/unified_employee.md b/scripts/ai/prompts/unified_employee.md index e64ca61..176eb73 100644 --- a/scripts/ai/prompts/unified_employee.md +++ b/scripts/ai/prompts/unified_employee.md @@ -33,3 +33,7 @@ Idea selection rules: - Prefer ideas that can be validated without production access. - Prefer ideas that are easy to review and easy to revert. - Avoid vague platform rewrites, broad redesigns, or multi-week projects. +- Do not repeat or closely paraphrase recent DevLoop proposals. If recent + proposals focus on result cards, keyword highlights, snippet display, or + contributor guidelines, choose a materially different area such as evaluation, + source metadata, onboarding, failure handling, or maintainer workflow. diff --git a/scripts/ai/propose_idea.py b/scripts/ai/propose_idea.py index 8f36817..8476dc2 100644 --- a/scripts/ai/propose_idea.py +++ b/scripts/ai/propose_idea.py @@ -93,7 +93,7 @@ def github_request( path: str, token: str, payload: dict[str, Any] | None = None, -) -> dict[str, Any]: +) -> dict[str, Any] | list[Any]: data = None if payload is None else json.dumps(payload).encode("utf-8") request = urllib.request.Request( f"{GITHUB_API}{path}", @@ -186,7 +186,46 @@ def manual_idea_from_env() -> dict[str, str]: return {field: require_env(env_name) for field, env_name in env_names.items()} -def api_idea() -> dict[str, str]: +def list_recent_devloop_issues(repo: str | None, token: str | None) -> list[dict[str, Any]]: + if not repo or not token: + return [] + + limit = int(env("DEVLOOP_RECENT_ISSUE_LIMIT", "10")) + query = urllib.parse.urlencode( + { + "state": "all", + "labels": "devloop,ai-proposed", + "per_page": str(limit), + "sort": "created", + "direction": "desc", + } + ) + result = github_request("GET", f"/repos/{repo}/issues?{query}", token) + if not isinstance(result, list): + raise RuntimeError("Unexpected GitHub issues response.") + return [issue for issue in result if "pull_request" not in issue] + + +def recent_issue_context(issues: list[dict[str, Any]]) -> str: + if not issues: + return "No previous DevLoop proposals were available." + + lines = [] + for issue in issues: + body = str(issue.get("body") or "") + excerpt = " ".join(body.split())[:500] + lines.append( + "\n".join( + [ + f"- #{issue.get('number')} ({issue.get('state')}): {issue.get('title')}", + f" excerpt: {excerpt}", + ] + ) + ) + return "\n".join(lines) + + +def api_idea(recent_context: str) -> dict[str, str]: prompt = PROMPT_PATH.read_text(encoding="utf-8") context = read_repo_context() model = env("OPENAI_MODEL", "gpt-4.1-mini") @@ -213,6 +252,10 @@ def api_idea() -> dict[str, str]: {context} + Recent DevLoop proposals that must not be repeated or closely paraphrased: + + {recent_context} + Return a JSON object with exactly these string fields: title, problem, rationale, scope, non_goals, risks, validation. All field values must be written in Korean. Keep the title concise. @@ -243,12 +286,12 @@ def validate_idea(idea: dict[str, Any]) -> dict[str, str]: return normalized -def load_idea() -> dict[str, str]: +def load_idea(recent_context: str) -> dict[str, str]: mode = env("DEVLOOP_PROPOSAL_MODE", "api").lower() if mode == "manual": return validate_idea(manual_idea_from_env()) if mode == "api": - return api_idea() + return api_idea(recent_context) raise RuntimeError("DEVLOOP_PROPOSAL_MODE must be 'manual' or 'api'.") @@ -285,7 +328,7 @@ def build_issue_body(idea: dict[str, str]) -> str: def search_existing_issue(repo: str, token: str, fingerprint: str) -> str | None: query = urllib.parse.urlencode( { - "q": f"repo:{repo} is:issue is:open {fingerprint}", + "q": f"repo:{repo} is:issue {fingerprint}", "per_page": "1", } ) @@ -333,7 +376,10 @@ def ensure_label(repo: str, token: str, name: str, color: str, description: str) def main() -> int: dry_run = env("DEVLOOP_DRY_RUN", "false").lower() == "true" - idea = load_idea() + repo = env("GITHUB_REPOSITORY") + token = env("GITHUB_TOKEN") or env("GH_TOKEN") + recent_context = recent_issue_context(list_recent_devloop_issues(repo, token)) + idea = load_idea(recent_context) title = f"[DevLoop] {idea['title']}" body = build_issue_body(idea) fingerprint = body.rsplit("`", 2)[1] @@ -343,11 +389,13 @@ def main() -> int: return 0 repo = require_env("GITHUB_REPOSITORY") - token = require_env("GITHUB_TOKEN") + token = env("GITHUB_TOKEN") or env("GH_TOKEN") + if not token: + raise RuntimeError("GITHUB_TOKEN or GH_TOKEN is required.") existing = search_existing_issue(repo, token, fingerprint) if existing: - print(f"Matching open DevLoop issue already exists: {existing}") + print(f"Matching DevLoop issue already exists: {existing}") return 0 ensure_label(repo, token, "devloop", "5319e7", "Managed by the DevLoop MVP workflow.") diff --git a/scripts/devloop/runner.py b/scripts/devloop/runner.py new file mode 100644 index 0000000..d537dfb --- /dev/null +++ b/scripts/devloop/runner.py @@ -0,0 +1,430 @@ +#!/usr/bin/env python3 +"""Local DevLoop runner for mini PC based Codex CLI implementation. + +This runner watches DevLoop Issues for a human `/ai implement` command, checks +out the matching `ai/issue-*` PR branch, and can run `codex exec` locally. + +It is intentionally conservative: +- default mode is dry-run +- it refuses to run on a dirty workspace +- commit and push are separate explicit flags +- forbidden path changes block commit/push +""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +import tempfile +import textwrap +import time +import urllib.error +import urllib.parse +import urllib.request +from dataclasses import dataclass +from pathlib import Path +from shutil import which +from typing import Any + + +GITHUB_API = "https://api.github.com" +IMPLEMENT_COMMAND = "/ai implement" +START_MARKER = "" +DONE_MARKER = "" +FORBIDDEN_PATH_PREFIXES = ( + ".github/workflows/", + "infra/", + "apps/backend/alembic/", +) +FORBIDDEN_PATH_NAMES = ( + "alembic.ini", + "terraform.tfvars", + "terraform.tfvars.json", +) + + +@dataclass(frozen=True) +class Candidate: + issue: dict[str, Any] + command_comment: dict[str, Any] + pull_request: dict[str, Any] + + +def env(name: str, default: str | None = None) -> str | None: + value = os.environ.get(name) + if value is None or value == "": + return default + return value + + +def run_command( + args: list[str], + cwd: Path, + check: bool = True, + capture_output: bool = True, +) -> subprocess.CompletedProcess[str]: + return subprocess.run( + args, + cwd=cwd, + check=check, + capture_output=capture_output, + text=True, + ) + + +def codex_binary() -> str: + configured = env("CODEX_BIN") + if configured: + return configured + + found = which("codex") + if found: + return found + + local_bin = Path.home() / ".local" / "bin" / "codex" + if local_bin.exists(): + return str(local_bin) + + raise RuntimeError( + "codex executable was not found. Install Codex CLI or set CODEX_BIN to its full path." + ) + + +class GitHubClient: + def __init__(self, repo: str, token: str) -> None: + self.repo = repo + self.token = token + + def request( + self, + method: str, + path: str, + payload: dict[str, Any] | None = None, + ) -> dict[str, Any] | list[Any]: + data = None if payload is None else json.dumps(payload).encode("utf-8") + request = urllib.request.Request( + f"{GITHUB_API}{path}", + data=data, + method=method, + headers={ + "Accept": "application/vnd.github+json", + "Authorization": f"Bearer {self.token}", + "X-GitHub-Api-Version": "2022-11-28", + }, + ) + try: + with urllib.request.urlopen(request, timeout=60) as response: + raw = response.read().decode("utf-8") + return {} if not raw else json.loads(raw) + except urllib.error.HTTPError as exc: + body = exc.read().decode("utf-8", errors="replace") + raise RuntimeError(f"GitHub API {method} {path} failed: HTTP {exc.code}: {body}") from exc + + def list_open_devloop_issues(self) -> list[dict[str, Any]]: + query = urllib.parse.urlencode( + { + "state": "open", + "labels": "devloop,ai-proposed", + "per_page": "100", + } + ) + result = self.request("GET", f"/repos/{self.repo}/issues?{query}") + if not isinstance(result, list): + raise RuntimeError("Unexpected GitHub issues response.") + return [issue for issue in result if "pull_request" not in issue] + + def list_comments(self, issue_number: int) -> list[dict[str, Any]]: + result = self.request( + "GET", + f"/repos/{self.repo}/issues/{issue_number}/comments?per_page=100", + ) + if not isinstance(result, list): + raise RuntimeError("Unexpected GitHub comments response.") + return result + + def list_open_pull_requests(self) -> list[dict[str, Any]]: + result = self.request("GET", f"/repos/{self.repo}/pulls?state=open&per_page=100") + if not isinstance(result, list): + raise RuntimeError("Unexpected GitHub pulls response.") + return result + + def comment(self, issue_number: int, body: str) -> None: + self.request( + "POST", + f"/repos/{self.repo}/issues/{issue_number}/comments", + {"body": body}, + ) + + +def is_allowed_approver(comment: dict[str, Any]) -> bool: + allowed_users = { + user.strip().lower() + for user in env("DEVLOOP_APPROVERS", "").split(",") + if user.strip() + } + commenter = comment["user"]["login"].lower() + if allowed_users: + return commenter in allowed_users + return comment.get("author_association") in {"OWNER", "MEMBER", "COLLABORATOR"} + + +def last_implement_command(comments: list[dict[str, Any]]) -> dict[str, Any] | None: + matching = [ + comment + for comment in comments + if (comment.get("body") or "").strip() == IMPLEMENT_COMMAND and is_allowed_approver(comment) + ] + return matching[-1] if matching else None + + +def has_runner_marker_after(comments: list[dict[str, Any]], marker: str, created_at: str) -> bool: + return any( + marker in (comment.get("body") or "") and comment.get("created_at", "") > created_at + for comment in comments + ) + + +def find_issue_pr(issue_number: int, pulls: list[dict[str, Any]]) -> dict[str, Any] | None: + prefix = f"ai/issue-{issue_number}" + for pull in pulls: + head = pull.get("head") or {} + branch = str(head.get("ref") or "") + if branch == prefix or branch.startswith(f"{prefix}-"): + return pull + return None + + +def find_candidates(client: GitHubClient, force: bool = False) -> list[Candidate]: + pulls = client.list_open_pull_requests() + candidates: list[Candidate] = [] + for issue in client.list_open_devloop_issues(): + issue_number = int(issue["number"]) + comments = client.list_comments(issue_number) + command = last_implement_command(comments) + if not command: + continue + + if not force and ( + has_runner_marker_after(comments, START_MARKER, command["created_at"]) + or has_runner_marker_after(comments, DONE_MARKER, command["created_at"]) + ): + continue + + pull = find_issue_pr(issue_number, pulls) + if not pull: + continue + candidates.append(Candidate(issue=issue, command_comment=command, pull_request=pull)) + return candidates + + +def assert_clean_workspace(workspace: Path) -> None: + result = run_command(["git", "status", "--porcelain"], cwd=workspace) + if result.stdout.strip(): + raise RuntimeError( + "Workspace is dirty. Use a dedicated mini PC clone or commit/stash local changes first." + ) + + +def checkout_branch(workspace: Path, branch: str) -> None: + run_command( + ["git", "fetch", "origin", f"+refs/heads/{branch}:refs/remotes/origin/{branch}"], + cwd=workspace, + capture_output=False, + ) + run_command(["git", "checkout", "-B", branch, f"origin/{branch}"], cwd=workspace, capture_output=False) + run_command(["git", "pull", "--ff-only", "origin", branch], cwd=workspace, capture_output=False) + + +def changed_files(workspace: Path) -> list[str]: + result = run_command(["git", "diff", "--name-only"], cwd=workspace) + staged = run_command(["git", "diff", "--cached", "--name-only"], cwd=workspace) + files = set(result.stdout.splitlines()) | set(staged.stdout.splitlines()) + return sorted(file for file in files if file) + + +def forbidden_changed_files(files: list[str]) -> list[str]: + forbidden = [] + for file in files: + if file in FORBIDDEN_PATH_NAMES or any(file.startswith(prefix) for prefix in FORBIDDEN_PATH_PREFIXES): + forbidden.append(file) + return forbidden + + +def plan_path_for_issue(workspace: Path, issue_number: int) -> Path: + return workspace / "docs" / "ai-ideas" / f"issue-{issue_number}.md" + + +def build_codex_prompt(issue_number: int, plan_path: Path) -> str: + return textwrap.dedent( + f""" + `AI_POLICY.md`, `AGENTS.md`, and `{plan_path.relative_to(plan_path.parents[2])}`를 읽고 + 승인된 DevLoop 아이디어 #{issue_number}를 실제 구현해줘. + + 필수 제약: + - 현재 브랜치에서만 작업한다. + - 승인된 범위 밖으로 확장하지 않는다. + - 인증, 인가, 결제, 데이터베이스 마이그레이션, 인프라 설정은 변경하지 않는다. + - 자동 머지나 main 직접 push는 하지 않는다. + - 구현 후 관련 테스트나 정적 검사를 실행한다. + - 커밋이나 push는 하지 않는다. 변경과 검증 결과만 남긴다. + """ + ).strip() + + +def run_codex(workspace: Path, issue_number: int, model: str | None) -> Path: + plan_path = plan_path_for_issue(workspace, issue_number) + if not plan_path.exists(): + raise RuntimeError(f"Expected scaffold file does not exist: {plan_path}") + + output_dir = Path(tempfile.mkdtemp(prefix="devloop-codex-")) + output_path = output_dir / f"issue-{issue_number}-last-message.md" + command = [ + codex_binary(), + "exec", + "--sandbox", + "workspace-write", + "--cd", + str(workspace), + "--output-last-message", + str(output_path), + ] + if model: + command.extend(["--model", model]) + command.append(build_codex_prompt(issue_number, plan_path)) + run_command(command, cwd=workspace, capture_output=False) + return output_path + + +def commit_and_push(workspace: Path, issue_number: int, branch: str, push: bool) -> str: + files = changed_files(workspace) + if not files: + return "No changes were produced by Codex." + + forbidden = forbidden_changed_files(files) + if forbidden: + raise RuntimeError( + "Codex changed forbidden paths; refusing to commit: " + ", ".join(forbidden) + ) + + run_command(["git", "add", "-A"], cwd=workspace, capture_output=False) + run_command(["git", "commit", "-m", f"Implement DevLoop idea #{issue_number}"], cwd=workspace) + if push: + run_command(["git", "push", "origin", f"HEAD:{branch}"], cwd=workspace, capture_output=False) + return f"Committed and pushed changes to `{branch}`." + return "Committed changes locally. Push was skipped." + + +def process_candidate( + client: GitHubClient, + candidate: Candidate, + workspace: Path, + execute_codex: bool, + commit: bool, + push: bool, + model: str | None, +) -> None: + issue_number = int(candidate.issue["number"]) + branch = str((candidate.pull_request.get("head") or {}).get("ref")) + pr_url = str(candidate.pull_request.get("html_url")) + + if not execute_codex: + print(f"[dry-run] Would implement issue #{issue_number} on {branch}: {pr_url}") + return + + assert_clean_workspace(workspace) + client.comment( + issue_number, + textwrap.dedent( + f""" + {START_MARKER} + DevLoop runner가 미니 PC에서 구현을 시작합니다. + + - PR: {pr_url} + - 브랜치: `{branch}` + """ + ).strip(), + ) + checkout_branch(workspace, branch) + output_path = run_codex(workspace, issue_number, model) + files = changed_files(workspace) + + if commit: + result = commit_and_push(workspace, issue_number, branch, push) + else: + result = "Codex 실행은 완료했지만 commit 옵션이 꺼져 있어 커밋하지 않았습니다." + + file_lines = "\n".join(f"- `{file}`" for file in files) if files else "- 변경 파일 없음" + client.comment( + issue_number, + textwrap.dedent( + f""" + {DONE_MARKER} + DevLoop runner 실행이 완료되었습니다. + + - PR: {pr_url} + - 브랜치: `{branch}` + - 결과: {result} + - Codex 마지막 메시지: `{output_path}` + + 변경 파일: + {file_lines} + """ + ).strip(), + ) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Run the local DevLoop Codex CLI runner.") + parser.add_argument("--repo", default=env("GITHUB_REPOSITORY")) + parser.add_argument("--workspace", default=env("DEVLOOP_WORKSPACE", os.getcwd())) + parser.add_argument("--interval", type=int, default=int(env("DEVLOOP_RUNNER_INTERVAL", "300"))) + parser.add_argument("--once", action="store_true") + parser.add_argument("--force", action="store_true") + parser.add_argument("--execute-codex", action="store_true") + parser.add_argument("--commit", action="store_true") + parser.add_argument("--push", action="store_true") + parser.add_argument("--model", default=env("DEVLOOP_CODEX_MODEL")) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + if not args.repo: + raise RuntimeError("GITHUB_REPOSITORY or --repo is required.") + token = env("GITHUB_TOKEN") or env("GH_TOKEN") + if not token: + raise RuntimeError("GITHUB_TOKEN, GH_TOKEN, or equivalent local runner token is required.") + + workspace = Path(args.workspace).resolve() + client = GitHubClient(args.repo, token) + + while True: + candidates = find_candidates(client, force=args.force) + if not candidates: + print("No approved DevLoop implementation commands found.") + for candidate in candidates: + process_candidate( + client=client, + candidate=candidate, + workspace=workspace, + execute_codex=args.execute_codex, + commit=args.commit, + push=args.push, + model=args.model, + ) + + if args.once: + break + time.sleep(args.interval) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except Exception as exc: + print(f"error: {exc}", file=sys.stderr) + raise SystemExit(1)