From a7edb95d390dcfea485b4c2ed82e95ab7ccc696a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=EC=9D=BC=EC=A4=80?= Date: Sun, 24 May 2026 22:10:43 +0900 Subject: [PATCH 1/4] Automate local DevLoop runner service --- docs/devloop-runner.md | 60 +++++++++++++++++- scripts/devloop/home-1.service | 14 +++++ scripts/devloop/home-1.timer | 10 +++ scripts/devloop/runner.py | 109 ++++++++++++++++++++++++++++++--- 4 files changed, 182 insertions(+), 11 deletions(-) create mode 100644 scripts/devloop/home-1.service create mode 100644 scripts/devloop/home-1.timer diff --git a/docs/devloop-runner.md b/docs/devloop-runner.md index 9a76552..3373e7c 100644 --- a/docs/devloop-runner.md +++ b/docs/devloop-runner.md @@ -27,7 +27,11 @@ Install and authenticate these on the mini PC: Required environment variables: - `GITHUB_REPOSITORY`: for example `SmileJune/techcase` -- `GITHUB_TOKEN` or `GH_TOKEN`: token for the local runner + +Optional but recommended: + +- `GITHUB_TOKEN` or `GH_TOKEN`: token for the local runner. Without a token, the + runner can read public Issues and PRs but cannot leave status comments. Optional environment variables: @@ -40,6 +44,8 @@ Optional environment variables: - `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`. +- `DEVLOOP_STATE_FILE`: optional path for the local processed-command state. + Defaults to `~/.local/state/devloop/techcase-runner.json`. Use a dedicated clone for the runner. The runner refuses to start work when the workspace is dirty. @@ -79,9 +85,61 @@ python3 scripts/devloop/runner.py \ --push ``` +## Mark Existing Commands + +Before enabling a timer on a repository that already has `/ai implement` +comments, mark the current commands as processed so the runner only handles new +commands: + +```bash +python3 scripts/devloop/runner.py \ + --repo SmileJune/techcase \ + --once \ + --mark-existing +``` + +## Run Continuously With systemd + +The repository includes user-service templates for the `home-1` mini PC: + +- `scripts/devloop/home-1.service` +- `scripts/devloop/home-1.timer` + +Install them into the user systemd directory: + +```bash +mkdir -p ~/.config/systemd/user ~/.config/devloop +cp scripts/devloop/home-1.service ~/.config/systemd/user/techcase-devloop.service +cp scripts/devloop/home-1.timer ~/.config/systemd/user/techcase-devloop.timer +cp scripts/devloop/runner.py ~/.local/bin/techcase-devloop-runner.py +chmod +x ~/.local/bin/techcase-devloop-runner.py +cat > ~/.config/devloop/techcase.env <<'EOF' +GITHUB_REPOSITORY=SmileJune/techcase +DEVLOOP_WORKSPACE=/home/godhkekf24/devloop/techcase-runner +CODEX_BIN=/home/godhkekf24/.local/bin/codex +DEVLOOP_APPROVERS=SmileJune +# Optional. Add a fine-grained token if you want runner status comments: +# GH_TOKEN=github_pat_... +# Optional state file override: +# DEVLOOP_STATE_FILE=/home/godhkekf24/.local/state/devloop/techcase-runner.json +EOF +systemctl --user daemon-reload +python3 scripts/devloop/runner.py --repo SmileJune/techcase --once --mark-existing +systemctl --user enable --now techcase-devloop.timer +``` + +Check timer and logs: + +```bash +systemctl --user status techcase-devloop.timer +journalctl --user -u techcase-devloop.service -n 100 --no-pager +``` + ## Safety Notes - The runner only responds to `/ai implement`, not `/ai approve`. +- The runner records processed `/ai implement` comment ids in a local state file + so a systemd timer does not repeat the same implementation command. - The runner uses `codex exec --sandbox workspace-write`. - The runner refuses to commit changes under `.github/workflows/`, `infra/`, and `apps/backend/alembic/`. diff --git a/scripts/devloop/home-1.service b/scripts/devloop/home-1.service new file mode 100644 index 0000000..e7c38f6 --- /dev/null +++ b/scripts/devloop/home-1.service @@ -0,0 +1,14 @@ +[Unit] +Description=TechCase DevLoop local Codex runner +Wants=network-online.target +After=network-online.target + +[Service] +Type=oneshot +WorkingDirectory=%h/devloop/techcase-runner +EnvironmentFile=-%h/.config/devloop/techcase.env +Environment=PATH=%h/.local/bin:/usr/local/bin:/usr/bin:/bin +ExecStart=/usr/bin/python3 %h/.local/bin/techcase-devloop-runner.py --once --execute-codex --commit --push + +[Install] +WantedBy=default.target diff --git a/scripts/devloop/home-1.timer b/scripts/devloop/home-1.timer new file mode 100644 index 0000000..3f16657 --- /dev/null +++ b/scripts/devloop/home-1.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Run TechCase DevLoop local runner every five minutes + +[Timer] +OnCalendar=*:0/5 +AccuracySec=30s +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/scripts/devloop/runner.py b/scripts/devloop/runner.py index d537dfb..84a3fd5 100644 --- a/scripts/devloop/runner.py +++ b/scripts/devloop/runner.py @@ -53,6 +53,11 @@ class Candidate: pull_request: dict[str, Any] +@dataclass +class RunnerState: + processed_comment_ids: set[int] + + def env(name: str, default: str | None = None) -> str | None: value = os.environ.get(name) if value is None or value == "": @@ -60,6 +65,25 @@ def env(name: str, default: str | None = None) -> str | None: return value +def load_env_file() -> None: + configured = os.environ.get("DEVLOOP_ENV_FILE") + candidates = [ + Path(configured).expanduser() if configured else None, + Path.home() / ".config" / "devloop" / "techcase.env", + ] + for path in candidates: + if not path or not path.exists(): + continue + for line in path.read_text(encoding="utf-8").splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("#") or "=" not in stripped: + continue + key, value = stripped.split("=", 1) + key = key.strip() + if key and key not in os.environ: + os.environ[key] = value.strip().strip('"').strip("'") + + def run_command( args: list[str], cwd: Path, @@ -93,8 +117,40 @@ def codex_binary() -> str: ) +def state_path() -> Path: + configured = env("DEVLOOP_STATE_FILE") + if configured: + return Path(configured).expanduser() + return Path.home() / ".local" / "state" / "devloop" / "techcase-runner.json" + + +def load_state() -> RunnerState: + path = state_path() + if not path.exists(): + return RunnerState(processed_comment_ids=set()) + + data = json.loads(path.read_text(encoding="utf-8")) + return RunnerState( + processed_comment_ids={int(value) for value in data.get("processed_comment_ids", [])} + ) + + +def save_state(state: RunnerState) -> None: + path = state_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps( + {"processed_comment_ids": sorted(state.processed_comment_ids)}, + ensure_ascii=False, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + + class GitHubClient: - def __init__(self, repo: str, token: str) -> None: + def __init__(self, repo: str, token: str | None) -> None: self.repo = repo self.token = token @@ -105,15 +161,17 @@ def request( payload: dict[str, Any] | None = None, ) -> dict[str, Any] | list[Any]: data = None if payload is None else json.dumps(payload).encode("utf-8") + headers = { + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + } + if self.token: + headers["Authorization"] = f"Bearer {self.token}" 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", - }, + headers=headers, ) try: with urllib.request.urlopen(request, timeout=60) as response: @@ -152,6 +210,9 @@ def list_open_pull_requests(self) -> list[dict[str, Any]]: return result def comment(self, issue_number: int, body: str) -> None: + if not self.token: + print(f"Skipping GitHub comment on issue #{issue_number}: no token configured.") + return self.request( "POST", f"/repos/{self.repo}/issues/{issue_number}/comments", @@ -168,7 +229,10 @@ def is_allowed_approver(comment: dict[str, Any]) -> bool: commenter = comment["user"]["login"].lower() if allowed_users: return commenter in allowed_users - return comment.get("author_association") in {"OWNER", "MEMBER", "COLLABORATOR"} + allowed_associations = {"OWNER", "MEMBER", "COLLABORATOR"} + if env("DEVLOOP_ALLOW_UNAUTHENTICATED_APPROVERS", "false").lower() == "true": + allowed_associations.add("NONE") + return comment.get("author_association") in allowed_associations def last_implement_command(comments: list[dict[str, Any]]) -> dict[str, Any] | None: @@ -197,7 +261,11 @@ def find_issue_pr(issue_number: int, pulls: list[dict[str, Any]]) -> dict[str, A return None -def find_candidates(client: GitHubClient, force: bool = False) -> list[Candidate]: +def find_candidates( + client: GitHubClient, + state: RunnerState, + force: bool = False, +) -> list[Candidate]: pulls = client.list_open_pull_requests() candidates: list[Candidate] = [] for issue in client.list_open_devloop_issues(): @@ -206,6 +274,9 @@ def find_candidates(client: GitHubClient, force: bool = False) -> list[Candidate command = last_implement_command(comments) if not command: continue + command_id = int(command["id"]) + if not force and command_id in state.processed_comment_ids: + continue if not force and ( has_runner_marker_after(comments, START_MARKER, command["created_at"]) @@ -376,6 +447,11 @@ def process_candidate( ) +def mark_processed(state: RunnerState, candidate: Candidate) -> None: + state.processed_comment_ids.add(int(candidate.command_comment["id"])) + save_state(state) + + def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Run the local DevLoop Codex CLI runner.") parser.add_argument("--repo", default=env("GITHUB_REPOSITORY")) @@ -384,6 +460,7 @@ def parse_args() -> argparse.Namespace: 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("--mark-existing", 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")) @@ -391,21 +468,31 @@ def parse_args() -> argparse.Namespace: def main() -> int: + load_env_file() 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.") + print("No GitHub token configured; using unauthenticated read-only GitHub API.") workspace = Path(args.workspace).resolve() client = GitHubClient(args.repo, token) + state = load_state() while True: - candidates = find_candidates(client, force=args.force) + candidates = find_candidates(client, state=state, force=args.force) if not candidates: print("No approved DevLoop implementation commands found.") for candidate in candidates: + if args.mark_existing: + mark_processed(state, candidate) + print( + "Marked existing DevLoop implementation command as processed: " + f"issue #{candidate.issue['number']}" + ) + continue + process_candidate( client=client, candidate=candidate, @@ -415,6 +502,8 @@ def main() -> int: push=args.push, model=args.model, ) + if args.execute_codex: + mark_processed(state, candidate) if args.once: break From f5e5a65746989fc525c961eb183ffd2472c635fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=EC=9D=BC=EC=A4=80?= Date: Sun, 24 May 2026 22:14:10 +0900 Subject: [PATCH 2/4] Implement approved DevLoop ideas automatically --- docs/devloop-runner.md | 19 ++++++++++++++----- scripts/devloop/runner.py | 12 ++++++++++-- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/docs/devloop-runner.md b/docs/devloop-runner.md index 3373e7c..0f0a442 100644 --- a/docs/devloop-runner.md +++ b/docs/devloop-runner.md @@ -8,7 +8,8 @@ with Codex CLI. 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. +4. The mini PC runner sees `/ai approve` or `/ai implement` after the scaffold + PR exists. 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`. @@ -39,8 +40,11 @@ Optional environment variables: directory. - `DEVLOOP_RUNNER_INTERVAL`: polling interval in seconds. Defaults to `300`. - `DEVLOOP_APPROVERS`: comma-separated GitHub usernames allowed to use - `/ai implement`. + implementation commands. - `DEVLOOP_CODEX_MODEL`: optional Codex model override. +- `DEVLOOP_IMPLEMENT_COMMANDS`: comma-separated comment commands that trigger + local implementation after a scaffold PR exists. Defaults to + `/ai implement,/ai approve`. - `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`. @@ -118,6 +122,8 @@ GITHUB_REPOSITORY=SmileJune/techcase DEVLOOP_WORKSPACE=/home/godhkekf24/devloop/techcase-runner CODEX_BIN=/home/godhkekf24/.local/bin/codex DEVLOOP_APPROVERS=SmileJune +# Optional. Defaults to /ai implement,/ai approve: +# DEVLOOP_IMPLEMENT_COMMANDS=/ai implement,/ai approve # Optional. Add a fine-grained token if you want runner status comments: # GH_TOKEN=github_pat_... # Optional state file override: @@ -137,9 +143,12 @@ journalctl --user -u techcase-devloop.service -n 100 --no-pager ## Safety Notes -- The runner only responds to `/ai implement`, not `/ai approve`. -- The runner records processed `/ai implement` comment ids in a local state file - so a systemd timer does not repeat the same implementation command. +- The runner only starts implementation after a matching scaffold PR exists. + By default it accepts `/ai approve` and `/ai implement`; set + `DEVLOOP_IMPLEMENT_COMMANDS=/ai implement` if implementation should require a + second explicit command. +- The runner records processed implementation command ids in a local state file + so a systemd timer does not repeat the same command. - The runner uses `codex exec --sandbox workspace-write`. - The runner refuses to commit changes under `.github/workflows/`, `infra/`, and `apps/backend/alembic/`. diff --git a/scripts/devloop/runner.py b/scripts/devloop/runner.py index 84a3fd5..b0304d7 100644 --- a/scripts/devloop/runner.py +++ b/scripts/devloop/runner.py @@ -31,7 +31,7 @@ GITHUB_API = "https://api.github.com" -IMPLEMENT_COMMAND = "/ai implement" +DEFAULT_IMPLEMENT_COMMANDS = ("/ai implement", "/ai approve") START_MARKER = "" DONE_MARKER = "" FORBIDDEN_PATH_PREFIXES = ( @@ -235,11 +235,19 @@ def is_allowed_approver(comment: dict[str, Any]) -> bool: return comment.get("author_association") in allowed_associations +def implement_commands() -> set[str]: + configured = env("DEVLOOP_IMPLEMENT_COMMANDS") + if not configured: + return set(DEFAULT_IMPLEMENT_COMMANDS) + return {command.strip() for command in configured.split(",") if command.strip()} + + def last_implement_command(comments: list[dict[str, Any]]) -> dict[str, Any] | None: + commands = implement_commands() matching = [ comment for comment in comments - if (comment.get("body") or "").strip() == IMPLEMENT_COMMAND and is_allowed_approver(comment) + if (comment.get("body") or "").strip() in commands and is_allowed_approver(comment) ] return matching[-1] if matching else None From eab4ea4e8601e09aa80f097ca8ceac2e001d351c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=EC=9D=BC=EC=A4=80?= Date: Sun, 24 May 2026 22:15:48 +0900 Subject: [PATCH 3/4] Run DevLoop timer every minute --- scripts/devloop/home-1.timer | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/devloop/home-1.timer b/scripts/devloop/home-1.timer index 3f16657..8ad494d 100644 --- a/scripts/devloop/home-1.timer +++ b/scripts/devloop/home-1.timer @@ -1,8 +1,8 @@ [Unit] -Description=Run TechCase DevLoop local runner every five minutes +Description=Run TechCase DevLoop local runner every minute [Timer] -OnCalendar=*:0/5 +OnCalendar=*:0/1 AccuracySec=30s Persistent=true From d659e194e04f5b2c931feeb8060f176808a73536 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=EC=9D=BC=EC=A4=80?= Date: Sun, 24 May 2026 22:25:47 +0900 Subject: [PATCH 4/4] Add DevLoop revise command --- docs/devloop-runner.md | 40 +++++++++- scripts/devloop/runner.py | 162 +++++++++++++++++++++++++++++++------- 2 files changed, 170 insertions(+), 32 deletions(-) diff --git a/docs/devloop-runner.md b/docs/devloop-runner.md index 0f0a442..dcce6f4 100644 --- a/docs/devloop-runner.md +++ b/docs/devloop-runner.md @@ -14,6 +14,8 @@ with Codex CLI. 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. +9. A human can leave `/ai revise` feedback on the Issue or PR, and the runner + adds a follow-up commit on the same PR branch. ## Required Local Setup @@ -45,6 +47,8 @@ Optional environment variables: - `DEVLOOP_IMPLEMENT_COMMANDS`: comma-separated comment commands that trigger local implementation after a scaffold PR exists. Defaults to `/ai implement,/ai approve`. +- `DEVLOOP_REVISE_COMMAND`: optional feedback command override. Defaults to + `/ai revise`. - `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`. @@ -89,11 +93,24 @@ python3 scripts/devloop/runner.py \ --push ``` +## Revise An Existing PR + +Leave feedback on the original DevLoop Issue or on the matching scaffold PR: + +```text +/ai revise +검색 평가 데이터는 좋은데 MySQL 쿼리가 너무 넓어서 relevance가 낮습니다. +MySQL 운영 성능 대신 더 구체적인 쿼리 2개로 나눠 주세요. +``` + +The runner will keep the existing PR branch, run Codex with the feedback, and +push a follow-up `Revise DevLoop idea #...` commit to the same PR. + ## Mark Existing Commands -Before enabling a timer on a repository that already has `/ai implement` -comments, mark the current commands as processed so the runner only handles new -commands: +Before enabling a timer on a repository that already has `/ai implement`, +`/ai approve`, or `/ai revise` comments, mark the current commands as processed +so the runner only handles new commands: ```bash python3 scripts/devloop/runner.py \ @@ -147,6 +164,10 @@ journalctl --user -u techcase-devloop.service -n 100 --no-pager By default it accepts `/ai approve` and `/ai implement`; set `DEVLOOP_IMPLEMENT_COMMANDS=/ai implement` if implementation should require a second explicit command. +- The runner accepts `/ai revise` on the Issue or matching PR after the first + PR exists, even if the original DevLoop Issue is already closed. Revision + prompts explicitly preserve existing PR work and apply only the requested + feedback. - The runner records processed implementation command ids in a local state file so a systemd timer does not repeat the same command. - The runner uses `codex exec --sandbox workspace-write`. @@ -155,3 +176,16 @@ journalctl --user -u techcase-devloop.service -n 100 --no-pager - The runner does not merge PRs. - The runner does not push to `main`; it pushes only to the existing `ai/issue-*` PR branch. + +## Comment Shortcuts + +GitHub does not currently support repository-defined slash-command autocomplete +inside Issue or PR comments. GitHub's built-in slash commands include +`/saved-replies`, which can insert saved replies from your user account. +Practical alternatives: + +- Use browser or OS text replacement snippets such as `;revise` -> + `/ai revise`. +- Use GitHub saved replies for common DevLoop commands, then type + `/saved-replies` in the comment box and choose the saved reply. +- Keep the command examples in this document and paste them into comments. diff --git a/scripts/devloop/runner.py b/scripts/devloop/runner.py index b0304d7..ffb53c6 100644 --- a/scripts/devloop/runner.py +++ b/scripts/devloop/runner.py @@ -1,8 +1,9 @@ #!/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. +This runner watches DevLoop Issues or matching PRs for human `/ai ...` commands, +checks out the matching `ai/issue-*` PR branch, and can run `codex exec` +locally. It is intentionally conservative: - default mode is dry-run @@ -32,6 +33,7 @@ GITHUB_API = "https://api.github.com" DEFAULT_IMPLEMENT_COMMANDS = ("/ai implement", "/ai approve") +DEFAULT_REVISE_COMMAND = "/ai revise" START_MARKER = "" DONE_MARKER = "" FORBIDDEN_PATH_PREFIXES = ( @@ -51,6 +53,8 @@ class Candidate: issue: dict[str, Any] command_comment: dict[str, Any] pull_request: dict[str, Any] + command_kind: str + command_thread_number: int @dataclass @@ -181,10 +185,10 @@ def request( 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]]: + def list_devloop_issues(self) -> list[dict[str, Any]]: query = urllib.parse.urlencode( { - "state": "open", + "state": "all", "labels": "devloop,ai-proposed", "per_page": "100", } @@ -242,14 +246,38 @@ def implement_commands() -> set[str]: return {command.strip() for command in configured.split(",") if command.strip()} -def last_implement_command(comments: list[dict[str, Any]]) -> dict[str, Any] | None: +def revise_command() -> str: + return env("DEVLOOP_REVISE_COMMAND", DEFAULT_REVISE_COMMAND) or DEFAULT_REVISE_COMMAND + + +def comment_command_body(comment: dict[str, Any]) -> str: + return (comment.get("body") or "").strip() + + +def is_implement_command(comment: dict[str, Any]) -> bool: commands = implement_commands() + return comment_command_body(comment) in commands + + +def is_revise_command(comment: dict[str, Any]) -> bool: + body = comment_command_body(comment) + command = revise_command() + return body == command or body.startswith(f"{command}\n") + + +def command_sort_key(comment: dict[str, Any]) -> tuple[str, int]: + return (str(comment.get("created_at", "")), int(comment.get("id", 0))) + + +def last_runner_command(comments: list[dict[str, Any]]) -> tuple[str, dict[str, Any]] | None: matching = [ - comment + ("revise" if is_revise_command(comment) else "implement", comment) for comment in comments - if (comment.get("body") or "").strip() in commands and is_allowed_approver(comment) + if (is_implement_command(comment) or is_revise_command(comment)) and is_allowed_approver(comment) ] - return matching[-1] if matching else None + if not matching: + return None + return sorted(matching, key=lambda item: command_sort_key(item[1]))[-1] def has_runner_marker_after(comments: list[dict[str, Any]], marker: str, created_at: str) -> bool: @@ -276,26 +304,48 @@ def find_candidates( ) -> list[Candidate]: pulls = client.list_open_pull_requests() candidates: list[Candidate] = [] - for issue in client.list_open_devloop_issues(): + for issue in client.list_devloop_issues(): issue_number = int(issue["number"]) - comments = client.list_comments(issue_number) - command = last_implement_command(comments) - if not command: + issue_comments = client.list_comments(issue_number) + pull = find_issue_pr(issue_number, pulls) + if not pull: continue + + pr_number = int(pull["number"]) + pr_comments = client.list_comments(pr_number) if pr_number != issue_number else [] + command_candidates: list[tuple[str, dict[str, Any], int, list[dict[str, Any]]]] = [] + issue_command = last_runner_command(issue_comments) + if issue_command: + command_candidates.append((*issue_command, issue_number, issue_comments)) + pr_command = last_runner_command(pr_comments) + if pr_command: + command_candidates.append((*pr_command, pr_number, pr_comments)) + if not command_candidates: + continue + + command_kind, command, command_thread_number, command_comments = sorted( + command_candidates, + key=lambda item: command_sort_key(item[1]), + )[-1] command_id = int(command["id"]) if not force and command_id in state.processed_comment_ids: 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"]) + has_runner_marker_after(command_comments, START_MARKER, command["created_at"]) + or has_runner_marker_after(command_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)) + candidates.append( + Candidate( + issue=issue, + command_comment=command, + pull_request=pull, + command_kind=command_kind, + command_thread_number=command_thread_number, + ) + ) return candidates @@ -336,7 +386,40 @@ 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: +def revise_feedback(comment: dict[str, Any]) -> str: + body = comment_command_body(comment) + command = revise_command() + if body == command: + return "사용자가 구체적인 보완 지시 없이 기존 PR을 다시 검토하고 품질을 개선해 달라고 요청했습니다." + return body.removeprefix(command).strip() + + +def build_codex_prompt( + issue_number: int, + plan_path: Path, + command_kind: str, + command_comment: dict[str, Any], +) -> str: + if command_kind == "revise": + feedback = revise_feedback(command_comment) + return textwrap.dedent( + f""" + `AI_POLICY.md`, `AGENTS.md`, and `{plan_path.relative_to(plan_path.parents[2])}`를 읽고 + 승인된 DevLoop 아이디어 #{issue_number}의 현재 PR 구현을 보충해줘. + + 사용자 피드백: + {feedback} + + 필수 제약: + - 현재 브랜치의 기존 구현을 통째로 폐기하지 말고 필요한 보충/수정 커밋만 만든다. + - 승인된 아이디어와 사용자 피드백 범위 밖으로 확장하지 않는다. + - 피드백이 인증, 인가, 결제, 데이터베이스 마이그레이션, 인프라 설정 변경을 요구하면 구현하지 말고 그 이유를 마지막 메시지에 남긴다. + - 자동 머지나 main 직접 push는 하지 않는다. + - 구현 후 관련 테스트나 정적 검사를 실행한다. + - 커밋이나 push는 하지 않는다. 변경과 검증 결과만 남긴다. + """ + ).strip() + return textwrap.dedent( f""" `AI_POLICY.md`, `AGENTS.md`, and `{plan_path.relative_to(plan_path.parents[2])}`를 읽고 @@ -353,7 +436,13 @@ def build_codex_prompt(issue_number: int, plan_path: Path) -> str: ).strip() -def run_codex(workspace: Path, issue_number: int, model: str | None) -> Path: +def run_codex( + workspace: Path, + issue_number: int, + command_kind: str, + command_comment: dict[str, Any], + 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}") @@ -372,12 +461,18 @@ def run_codex(workspace: Path, issue_number: int, model: str | None) -> Path: ] if model: command.extend(["--model", model]) - command.append(build_codex_prompt(issue_number, plan_path)) + command.append(build_codex_prompt(issue_number, plan_path, command_kind, command_comment)) 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: +def commit_and_push( + workspace: Path, + issue_number: int, + command_kind: str, + branch: str, + push: bool, +) -> str: files = changed_files(workspace) if not files: return "No changes were produced by Codex." @@ -389,7 +484,8 @@ def commit_and_push(workspace: Path, issue_number: int, branch: str, push: bool) ) run_command(["git", "add", "-A"], cwd=workspace, capture_output=False) - run_command(["git", "commit", "-m", f"Implement DevLoop idea #{issue_number}"], cwd=workspace) + action = "Revise" if command_kind == "revise" else "Implement" + run_command(["git", "commit", "-m", f"{action} 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}`." @@ -410,34 +506,41 @@ def process_candidate( 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}") + print(f"[dry-run] Would {candidate.command_kind} issue #{issue_number} on {branch}: {pr_url}") return assert_clean_workspace(workspace) client.comment( - issue_number, + candidate.command_thread_number, textwrap.dedent( f""" {START_MARKER} - DevLoop runner가 미니 PC에서 구현을 시작합니다. + DevLoop runner가 미니 PC에서 {'피드백 반영' if candidate.command_kind == 'revise' else '구현'}을 시작합니다. - PR: {pr_url} - 브랜치: `{branch}` + - 명령: `{comment_command_body(candidate.command_comment).splitlines()[0]}` """ ).strip(), ) checkout_branch(workspace, branch) - output_path = run_codex(workspace, issue_number, model) + output_path = run_codex( + workspace=workspace, + issue_number=issue_number, + command_kind=candidate.command_kind, + command_comment=candidate.command_comment, + model=model, + ) files = changed_files(workspace) if commit: - result = commit_and_push(workspace, issue_number, branch, push) + result = commit_and_push(workspace, issue_number, candidate.command_kind, branch, push) else: result = "Codex 실행은 완료했지만 commit 옵션이 꺼져 있어 커밋하지 않았습니다." file_lines = "\n".join(f"- `{file}`" for file in files) if files else "- 변경 파일 없음" client.comment( - issue_number, + candidate.command_thread_number, textwrap.dedent( f""" {DONE_MARKER} @@ -445,6 +548,7 @@ def process_candidate( - PR: {pr_url} - 브랜치: `{branch}` + - 명령: `{comment_command_body(candidate.command_comment).splitlines()[0]}` - 결과: {result} - Codex 마지막 메시지: `{output_path}`