From d3bf74a89eeeba49feff01c5753ed68ee507cf49 Mon Sep 17 00:00:00 2001 From: satyaborg Date: Mon, 1 Jun 2026 16:24:22 +1000 Subject: [PATCH 1/2] feat: pr-review-iterations --- README.md | 10 +- devloop | 359 +++++++++++++++++++++++++++++++++++--- install.sh | 1 + skill_helpers.sh | 74 ++++++++ tests/devloop_test.sh | 396 +++++++++++++++++++++++++++++++++++++++++- 5 files changed, 812 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 4b23c21..2d6c3c5 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ By default, Codex makes the change, Claude Code reviews it, and Codex retries un Required dependencies: Bash, git, Homebrew, `codex`, `claude`, `gum`, and `fzf`. `install.sh` installs missing `gum` and `fzf` with Homebrew. Install the Codex and Claude Code CLIs before running a loop, then verify everything with `devloop doctor`. +The GitHub CLI is optional. Install `gh` and run `gh auth login` only when you want PR-backed loops. ```sh git clone https://github.com/satyaborg/devloop.git @@ -59,12 +60,14 @@ devloop .specs/change.md In an interactive terminal, `devloop` opens a shell in the generated worktree after the run finishes. Exiting that shell returns `devloop`'s final accepted/stalled/failure status. Use `--no-shell` when you want the command to return immediately instead. -Open a PR after an accepted run: +Open and maintain a draft PR during the loop: ```sh devloop --create-pr .specs/change.md ``` +A plain non-interactive `devloop ` remains local-only. This mode opens and maintains a draft PR during the loop. With `--create-pr`, the PR is canonical for review history; local `.codex/reviews/*.md` files are execution cache. + See tracked runs and cleanup candidates: ```sh @@ -124,7 +127,7 @@ devloop [options] [max=5] | `--report-format ` | Choose `html` or `markdown` | | `--timeout-minutes ` | Cap one run, default `30` | | `--in-place` | Run in the current worktree | -| `--create-pr`, `--pr` | Push the accepted branch and open a GitHub PR | +| `--create-pr`, `--pr` | Opens and maintains a draft PR during the loop | | `--no-strict` | Weaken strict review gates | | `--no-shell`, `--stay` | Do not open a shell in the generated worktree after completion | | `--shell`, `--enter-worktree` | Open a shell in the generated worktree after completion | @@ -150,7 +153,7 @@ Nested menu screens keep `Back` as the final option, and Esc/cancel also returns - Runs up to 5 passes by default, clamped between 1 and 10. - Stops a run after the configured timeout, default 30 minutes. - Uses isolated sibling git worktrees by default. -- Runs a small preflight before agents start: git identity, agent CLIs, installed skills, and `gh` when PR creation is enabled. +- Runs a small preflight before agents start: git identity, agent CLIs, installed skills, and GitHub PR readiness when PR creation is enabled. - Lints specs for a title, valid frontmatter type when frontmatter exists, and strict acceptance criteria. - Commits eligible changes after each coder pass. - Runs `.devloop/verify` after each coder pass when it exists and is executable. In strict mode, a failing hook blocks acceptance. @@ -158,6 +161,7 @@ Nested menu screens keep `Back` as the final option, and Esc/cancel also returns - Leaves generated worktrees and branches in place for inspection. - Drops you into the generated worktree shell after interactive runs, unless `--no-shell` is set. - Never pushes or opens a PR unless you pass `--create-pr`. +- Treats the PR as the durable review trail when `--create-pr` is active. Local `.codex/` artifacts remain disposable cache. ## Security diff --git a/devloop b/devloop index 4071dd0..3dc1432 100755 --- a/devloop +++ b/devloop @@ -194,7 +194,7 @@ Options: --timeout-minutes N cap one run, default 30 --no-strict weaken strict review gates --in-place run in the current worktree - --create-pr, --pr push accepted branch and open a PR + --create-pr, --pr open and maintain a draft PR during the loop --no-shell, --stay do not open a shell in the run worktree after completion --shell, --enter-worktree open a shell in the run worktree after completion -V, --version show version @@ -217,7 +217,7 @@ welcome_tui() { printf ' %-42s %s\n' "devloop menu" "open the guided UI" printf ' %-42s %s\n' 'devloop spec "add retry behavior"' "generate a spec" printf ' %-42s %s\n' "devloop .specs/change.md" "run a spec" - printf ' %-42s %s\n' "devloop --create-pr .specs/change.md" "open a PR after acceptance" + printf ' %-42s %s\n' "devloop --create-pr .specs/change.md" "open and maintain a draft PR during the loop" printf '\n' gum style --foreground "$UI_ACCENT_COLOR" --bold "Options" printf ' %-30s %s\n' "--tui" "force terminal UI output" @@ -228,7 +228,7 @@ welcome_tui() { printf ' %-30s %s\n' "--timeout-minutes N" "cap one run, default 30" printf ' %-30s %s\n' "--no-strict" "weaken strict review gates" printf ' %-30s %s\n' "--in-place" "run in the current worktree" - printf ' %-30s %s\n' "--create-pr, --pr" "push accepted branch and open a PR" + printf ' %-30s %s\n' "--create-pr, --pr" "open and maintain a draft PR during the loop" printf ' %-30s %s\n' "--no-shell, --stay" "do not open a shell after completion" printf ' %-30s %s\n' "--shell, --enter-worktree" "open a shell after completion" printf ' %-30s %s\n' "-V, --version" "show version" @@ -799,8 +799,20 @@ ui_choose() { ui_confirm() { local prompt="$1" + local default="${2:-false}" local answer if ui_has_gum; then + if [ "$default" = true ]; then + gum confirm \ + --default \ + --prompt.foreground "$UI_ACCENT_COLOR" \ + --selected.foreground "$UI_REC_COLOR" \ + --selected.background "" \ + --unselected.foreground "$UI_DIM_COLOR" \ + --unselected.background "" \ + "$prompt" + return $? + fi gum confirm \ --prompt.foreground "$UI_ACCENT_COLOR" \ --selected.foreground "$UI_REC_COLOR" \ @@ -811,12 +823,23 @@ ui_confirm() { return $? fi if [ "$USE_TUI" = true ] && [ -t 0 ]; then - printf '%s [y/N] ' "$prompt" >&2 + if [ "$default" = true ]; then + printf '%s [Y/n] ' "$prompt" >&2 + else + printf '%s [y/N] ' "$prompt" >&2 + fi read -r answer || return 1 case "$answer" in y|Y|yes|YES) return 0 ;; + n|N|no|NO) return 1 ;; + "") + [ "$default" = true ] + return $? + ;; esac + return 1 fi + if [ "$default" = true ]; then return 0; fi return 1 } @@ -1245,6 +1268,24 @@ interactive_settings() { done } +interactive_create_pr_choice() { + local repo="$1" + if github_pr_prompt_default "$repo"; then + if ui_confirm "Create a draft PR during this loop?" true; then + printf '%s\n' "true" + else + printf '%s\n' "false" + fi + return 0 + fi + UI_NOTICE="GitHub PR mode is unavailable in this repo; continuing local-only." + if ui_confirm "GitHub PR mode is unavailable. Continue local-only?" true; then + printf '%s\n' "false" + return 0 + fi + return 130 +} + interactive_run_setup() { local spec="$1" local report_format="html" @@ -1287,9 +1328,7 @@ interactive_run_setup() { fi case "$choice" in "Start run") - if [ "$create_pr" = true ] && ! ui_confirm "Push the accepted branch and open a PR after acceptance?"; then - continue - fi + create_pr="$(interactive_create_pr_choice "$PWD")" || continue run_header "$spec" "$max" "$report_format" "$strict" "$use_worktree" "$coder" "$reviewer" "$create_pr" "$timeout_minutes" run_devloop "$spec" "$max" "$report_format" "$strict" "$use_worktree" "$coder" "$reviewer" "$create_pr" "$timeout_minutes" code=$? @@ -2049,21 +2088,45 @@ run_devloop() { init_track "$repo/$TRACK" "$run_spec" "$spec" "$PWD" "$SOURCE_REPO" "$repo" "$base" "$source_branch" "$run_branch" "$max" "$report_format" "$strict" "$coder" "$reviewer" "$WORK_TYPE" "$WORK_BREAKING" "$create_pr" "$timeout_minutes" + if [ "$create_pr" = true ]; then + event_step "pull-request-lookup" "find existing PR" + if lookup_pull_request "$repo" "$run_branch"; then + event_done "pull-request-lookup" true "${PULL_REQUEST:-none}" + else + STATUS="pr-error" + event_done "pull-request-lookup" false "$PULL_REQUEST_ERROR" + fi + fi + local pass prior_hash prior_hash="" if [ "$start_pass" -gt 1 ] && [ -f "$repo/.codex/reviews/$slug-r$((start_pass - 1)).md" ]; then prior_hash="$(findings_hash "$repo/.codex/reviews/$slug-r$((start_pass - 1)).md")" fi + if [ "$STATUS" != "pr-error" ]; then for pass in $(seq "$start_pass" "$max"); do if run_deadline_reached; then STATUS="timeout" break fi PASSES="$pass" + local pr_review_context="" + if [ "$create_pr" = true ] && [ "$pass" -gt 1 ] && [ -n "$PULL_REQUEST" ]; then + local pr_context_id="pr-review-context-$pass" + event_step "$pr_context_id" "load latest PR review" + if pr_review_context="$(latest_pr_review_comment "$repo" "$PULL_REQUEST")" && [ -n "$pr_review_context" ]; then + event_done "$pr_context_id" true "loaded" + else + STATUS="pr-error" + if [ -z "$PULL_REQUEST_ERROR" ]; then PULL_REQUEST_ERROR="PR review lookup failed: no devloop round review comment found"; fi + event_done "$pr_context_id" false "$PULL_REQUEST_ERROR" + break + fi + fi local coder_log=".codex/logs/$slug-r$pass-coder.log" local coder_id="coder-$pass" event_step "$coder_id" "pass $pass/$max $(agent_label "$coder") implementation" - if run_agent "$coder" "$repo" "$repo/$coder_session" "$repo/$coder_log" "$(coder_prompt "$run_spec" "$TRACK" "$pass" "$strict" ".codex/reviews/$slug-r$((pass - 1)).md" "$criteria_file")" "$coder_id"; then + if run_agent "$coder" "$repo" "$repo/$coder_session" "$repo/$coder_log" "$(coder_prompt "$run_spec" "$TRACK" "$pass" "$strict" ".codex/reviews/$slug-r$((pass - 1)).md" "$criteria_file" "$pr_review_context")" "$coder_id"; then event_done "$coder_id" true "completed" else if [ "$RUN_TIMED_OUT" = true ]; then STATUS="timeout"; else STATUS="coder-error"; fi @@ -2094,6 +2157,18 @@ run_devloop() { break fi + if [ "$create_pr" = true ] && [ -n "$PASS_COMMIT" ]; then + local pr_id="pull-request-$pass" + event_step "$pr_id" "push branch and ensure draft PR" + if create_pull_request "$repo" "$FINAL_BRANCH" "$base"; then + event_done "$pr_id" true "$PULL_REQUEST" + else + STATUS="pr-error" + event_done "$pr_id" false "$PULL_REQUEST_ERROR" + break + fi + fi + local verify_id="verify-$pass" if verify_hook_configured "$repo" "$SOURCE_REPO"; then event_step "$verify_id" "pass $pass/$max verification" @@ -2134,6 +2209,18 @@ run_devloop() { break fi + if [ "$create_pr" = true ] && [ -n "$PULL_REQUEST" ]; then + local pr_comment_id="pr-review-comment-$pass" + event_step "$pr_comment_id" "post round review to PR" + if post_pr_round_review "$repo" "$PULL_REQUEST" "$repo/$review" "$pass" "$slug"; then + event_done "$pr_comment_id" true "posted" + else + STATUS="pr-error" + event_done "$pr_comment_id" false "$PULL_REQUEST_ERROR" + break + fi + fi + local verdict verdict="$(parse_verdict "$repo/$review")" if [ "$verdict" = "ACCEPT" ]; then @@ -2164,22 +2251,24 @@ run_devloop() { break fi done + fi if [ "$PASSES" -gt "$max" ]; then PASSES="$max"; fi - if [ "$create_pr" = true ] && [ "$STATUS" = "accepted" ]; then - if create_pull_request "$repo" "$FINAL_BRANCH" "$base"; then - event_gate "pull request" 1 "${PULL_REQUEST:-origin/$FINAL_BRANCH}" + CODER_SESSION_ID="$(read_first_line "$repo/$coder_session")" + REVIEWER_SESSION_ID="$(read_first_line "$repo/$reviewer_session")" + synthesize_report "$repo" "$slug" "$reviewer" "$run_spec" "$spec" "$spec_text" "$SOURCE_REPO" "$repo" "$TRACK" "$REPORT" "$STATUS" "$PASSES" "$max" "$base" "$source_branch" "$FINAL_BRANCH" "$FINAL_COMMIT" "$FINAL_COMMIT_MESSAGE" "$PULL_REQUEST" "$PULL_REQUEST_ERROR" "$coder" "$repo/$reviewer_session" "$CODER_SESSION_ID" "$REVIEWER_SESSION_ID" "$report_format" + + if [ "$create_pr" = true ] && [ -n "$PULL_REQUEST" ] && [ "$STATUS" != "pr-error" ]; then + event_step "pr-final-report" "post final report to PR" + if post_pr_final_report "$repo" "$PULL_REQUEST" "$slug" "$STATUS" "$PASSES" "$max" "$FINAL_BRANCH" "$REPORT" "$report_format"; then + event_done "pr-final-report" true "posted" else STATUS="pr-error" - event_gate "pull request" 0 "$PULL_REQUEST_ERROR" + event_done "pr-final-report" false "$PULL_REQUEST_ERROR" fi fi - CODER_SESSION_ID="$(read_first_line "$repo/$coder_session")" - REVIEWER_SESSION_ID="$(read_first_line "$repo/$reviewer_session")" - synthesize_report "$repo" "$slug" "$reviewer" "$run_spec" "$spec" "$spec_text" "$SOURCE_REPO" "$repo" "$TRACK" "$REPORT" "$STATUS" "$PASSES" "$max" "$base" "$source_branch" "$FINAL_BRANCH" "$FINAL_COMMIT" "$FINAL_COMMIT_MESSAGE" "$PULL_REQUEST" "$PULL_REQUEST_ERROR" "$coder" "$repo/$reviewer_session" "$CODER_SESSION_ID" "$REVIEWER_SESSION_ID" "$report_format" - print_result rm -f "$criteria_file" "$initial_dirty" return 0 @@ -2298,10 +2387,43 @@ preflight_run() { if ! preflight_agent "$reviewer"; then return 1; fi if ! preflight_agent_skills "$coder"; then return 1; fi if ! preflight_agent_skills "$reviewer"; then return 1; fi - if [ "$create_pr" = true ] && ! command -v gh >/dev/null 2>&1; then + if [ "$create_pr" = true ] && ! preflight_github_pr "$repo"; then return 1; fi +} + +gh_error_detail() { + local text="$1" + local detail + detail="$(printf '%s\n' "$text" | sed '/^[[:space:]]*$/d' | tail -n 1)" + if [ -z "$detail" ]; then detail="unknown gh error"; fi + printf '%s\n' "$detail" +} + +github_pr_prompt_default() { + local repo="$1" + command -v gh >/dev/null 2>&1 || return 1 + gh auth status >/dev/null 2>&1 || return 1 + git -C "$repo" remote get-url origin >/dev/null 2>&1 || return 1 +} + +preflight_github_pr() { + local repo="$1" + local out + if ! command -v gh >/dev/null 2>&1; then PREFLIGHT_ERROR="missing command: gh" return 1 fi + if ! out="$(gh auth status 2>&1)"; then + PREFLIGHT_ERROR="gh auth status failed: $(gh_error_detail "$out")" + return 1 + fi + if ! out="$(git -C "$repo" remote get-url origin 2>&1)"; then + PREFLIGHT_ERROR="missing origin remote: $(gh_error_detail "$out")" + return 1 + fi + if ! out="$(cd "$repo" >/dev/null 2>&1 && gh repo view 2>&1)"; then + PREFLIGHT_ERROR="GitHub repo lookup failed: $(gh_error_detail "$out")" + return 1 + fi } preflight_agent() { @@ -3063,6 +3185,7 @@ coder_prompt() { local strict="$4" local previous="$5" local criteria_file="$6" + local pr_review_context="${7:-}" local criteria criteria="$(criteria_block "$criteria_file")" local strict_block="" @@ -3095,17 +3218,24 @@ Constraints: - Do not revert unrelated dirty files. EOF else + local review_context + if [ -n "$pr_review_context" ]; then + review_context="Latest durable PR review comment (canonical when --create-pr is active): +$pr_review_context" + else + review_context="Review: $previous" + fi cat </dev/null 2>&1 && gh pr list --head "$branch" --state open --json url --jq '.[0].url // ""' 2>&1)"; then + PULL_REQUEST_ERROR="PR lookup failed: $(gh_error_detail "$out")" + return 1 + fi + PULL_REQUEST="$(printf '%s\n' "$out" | sed '/^[[:space:]]*$/d' | head -n 1)" +} + +create_draft_pull_request() { + local repo="$1" + local branch="$2" + local base="$3" + local out + if ! run_compact_command "$repo" "open draft pull request" gh pr create --draft --fill --base "$base" --head "$branch"; then out="$RUN_OUTPUT" - PULL_REQUEST_ERROR="pushed $remote/$branch, but PR creation failed: $(printf '%s\n' "$out" | sed '/^[[:space:]]*$/d' | tail -n 1)" + PULL_REQUEST_ERROR="PR creation failed: $(gh_error_detail "$out")" return 1 fi out="$RUN_OUTPUT" @@ -3422,6 +3576,167 @@ create_pull_request() { if [ -z "$PULL_REQUEST" ]; then PULL_REQUEST="$(printf '%s\n' "$out" | tail -n 1)"; fi } +ensure_pull_request() { + local repo="$1" + local branch="$2" + local base="$3" + PULL_REQUEST_ERROR="" + if ! push_pull_request_branch "$repo" "$branch"; then return 1; fi + if ! lookup_pull_request "$repo" "$branch"; then return 1; fi + if [ -n "$PULL_REQUEST" ]; then return 0; fi + create_draft_pull_request "$repo" "$branch" "$base" +} + +round_review_comment_body() { + local review_file="$1" + local pass="$2" + local slug="$3" + local verdict + verdict="$(parse_verdict "$review_file")" + if [ -z "$verdict" ]; then verdict="UNCLEAR"; fi + cat < "$body_file" + if ! out="$(cd "$repo" >/dev/null 2>&1 && gh pr comment "$pr" --body-file "$body_file" 2>&1)"; then + PULL_REQUEST_ERROR="PR comment failed: $(gh_error_detail "$out")" + rm -f "$body_file" + return 1 + fi + rm -f "$body_file" +} + +latest_pr_review_comment() { + local repo="$1" + local pr="$2" + local out + local query='[.comments[].body | select(contains("# Devloop Review Round "))] | last // ""' + if ! out="$(cd "$repo" >/dev/null 2>&1 && gh pr view "$pr" --json comments --jq "$query" 2>&1)"; then + PULL_REQUEST_ERROR="PR review lookup failed: $(gh_error_detail "$out")" + return 1 + fi + printf '%s\n' "$out" +} + +review_section() { + local file="$1" + local heading="$2" + [ -f "$file" ] || return 1 + awk -v heading="$heading" ' + BEGIN { inside = 0 } + $0 == "## " heading { inside = 1; print; next } + inside && /^##[[:space:]]+/ { exit } + inside { print } + ' "$file" +} + +final_pr_report_body() { + local repo="$1" + local slug="$2" + local status="$3" + local pass="$4" + local max="$5" + local pr="$6" + local branch="$7" + local report="$8" + local report_format="$9" + local latest_review="$repo/.codex/reviews/$slug-r$pass.md" + local final_verdict acceptance engineering html_path risk + final_verdict="" + if [ -f "$latest_review" ]; then final_verdict="$(parse_verdict "$latest_review")"; fi + if [ -z "$final_verdict" ]; then final_verdict="none"; fi + acceptance="$(review_section "$latest_review" "Acceptance matrix" || true)" + engineering="$(review_section "$latest_review" "Engineering quality matrix" || true)" + if [ -z "$acceptance" ]; then acceptance="- Not available."; fi + if [ -z "$engineering" ]; then engineering="- Not available."; fi + html_path="Not generated." + if [ "$report_format" = "html" ] && [ -f "$repo/$report" ]; then + html_path="$repo/$report" + fi + case "$status" in + accepted) risk="No blocking residual risk was recorded by the final review." ;; + *) risk="The run ended as $status. Inspect the latest findings and missing tests before merging." ;; + esac + cat < "$body_file" + if ! out="$(cd "$repo" >/dev/null 2>&1 && gh pr comment "$pr" --body-file "$body_file" 2>&1)"; then + PULL_REQUEST_ERROR="PR final report comment failed: $(gh_error_detail "$out")" + rm -f "$body_file" + return 1 + fi + rm -f "$body_file" +} + synthesize_report() { local repo="$1" local slug="$2" diff --git a/install.sh b/install.sh index b596fa4..520f5d7 100755 --- a/install.sh +++ b/install.sh @@ -64,6 +64,7 @@ ln -sfn "$SOURCE" "$TARGET" echo "installed devloop -> $SOURCE" install_required_ui_tools || TOOL_STATUS=$? devloop_install_skills "$ROOT" || SKILL_STATUS=$? +echo "optional for PR-backed loops: install GitHub CLI and run gh auth login" case ":${PATH:-}:" in *":$BIN_DIR:"*) ;; diff --git a/skill_helpers.sh b/skill_helpers.sh index 9ffaaf5..7cd74fa 100644 --- a/skill_helpers.sh +++ b/skill_helpers.sh @@ -224,6 +224,79 @@ devloop_doctor_skills_in_dir() { return "$status" } +devloop_doctor_github_line() { + local status="$1" + local label="$2" + local detail="$3" + printf '[%s] %s: %s\n' "$status" "$label" "$detail" +} + +devloop_doctor_github() { + local ready=0 + local gh_path="" + local repo="" + local out="" + local has_gh=false + local has_auth=false + local has_origin=false + + printf '\nGitHub PR integration\n' + gh_path="$(command -v gh 2>/dev/null || true)" + if [ -n "$gh_path" ]; then + has_gh=true + devloop_doctor_github_line "PASS" "gh installed" "$gh_path" + else + ready=1 + devloop_doctor_github_line "FAIL" "gh installed" "missing command: gh" + fi + + if [ "$has_gh" = true ]; then + if out="$(gh auth status 2>&1)"; then + has_auth=true + devloop_doctor_github_line "PASS" "gh authenticated" "ok" + else + ready=1 + out="$(printf '%s\n' "$out" | sed '/^[[:space:]]*$/d' | tail -n 1)" + devloop_doctor_github_line "FAIL" "gh authenticated" "${out:-gh auth status failed}" + fi + else + devloop_doctor_github_line "N/A" "gh authenticated" "gh unavailable" + fi + + if repo="$(git -C "$PWD" rev-parse --show-toplevel 2>/dev/null)"; then + if out="$(git -C "$repo" remote get-url origin 2>&1)"; then + has_origin=true + devloop_doctor_github_line "PASS" "current repo has origin" "$out" + else + ready=1 + out="$(printf '%s\n' "$out" | sed '/^[[:space:]]*$/d' | tail -n 1)" + devloop_doctor_github_line "FAIL" "current repo has origin" "${out:-origin remote missing}" + fi + else + ready=1 + devloop_doctor_github_line "N/A" "current repo has origin" "not inside a git repo" + fi + + if [ "$has_gh" = true ] && [ "$has_auth" = true ] && [ "$has_origin" = true ]; then + if out="$(cd "$repo" >/dev/null 2>&1 && gh repo view 2>&1)"; then + devloop_doctor_github_line "PASS" "current repo resolves on GitHub" "$(printf '%s\n' "$out" | sed -n '1p')" + else + ready=1 + out="$(printf '%s\n' "$out" | sed '/^[[:space:]]*$/d' | tail -n 1)" + devloop_doctor_github_line "FAIL" "current repo resolves on GitHub" "${out:-gh repo view failed}" + fi + else + devloop_doctor_github_line "N/A" "current repo resolves on GitHub" "prerequisite unavailable" + fi + + if [ "$ready" -eq 0 ]; then + printf '%s\n' "PR-backed loop readiness available" + else + printf '%s\n' "PR-backed loop readiness unavailable" + fi + return 0 +} + devloop_doctor() { local root="$1" local status=0 @@ -238,6 +311,7 @@ devloop_doctor() { devloop_doctor_command fzf || status=1 printf '\nSkills\n' devloop_doctor_skills "$root" || status=1 + devloop_doctor_github if [ "$status" -eq 0 ]; then printf 'devloop doctor: ready\n' diff --git a/tests/devloop_test.sh b/tests/devloop_test.sh index 0446591..60dc242 100755 --- a/tests/devloop_test.sh +++ b/tests/devloop_test.sh @@ -54,6 +54,7 @@ contains "$help" "devloop reports" "help" contains "$help" "devloop status" "help" contains "$help" "devloop clean" "help" contains "$help" "--create-pr" "help" +contains "$help" "draft PR during the loop" "help" contains "$help" "--no-shell" "help" contains "$help" "--enter-worktree" "help" contains "$help" "--version" "help" @@ -65,6 +66,13 @@ skill_path="$("$REPO_ROOT/devloop" spec --skill-path)" contains "$("$REPO_ROOT/devloop" spec --print-skill)" "name: devloop-spec" "spec skill" ok "spec skill path" +contains "$(cat "$REPO_ROOT/README.md")" "opens and maintains a draft PR during the loop" "README PR mode" +contains "$(cat "$REPO_ROOT/README.md")" "plain non-interactive" "README local-only" +contains "$(cat "$REPO_ROOT/README.md")" "remains local-only" "README local-only" +contains "$(cat "$REPO_ROOT/README.md")" "PR is canonical" "README PR canonical" +contains "$(cat "$REPO_ROOT/README.md")" "gh auth login" "README optional gh auth" +ok "README PR guidance" + for skill in "$REPO_ROOT"/skills/*/SKILL.md; do name="$(sed -n 's/^name: *//p' "$skill" | head -n 1)" description="$(sed -n 's/^description: *//p' "$skill" | head -n 1)" @@ -243,6 +251,7 @@ git -C "$branch_repo" branch feat/chat-retry equals "$(next_branch "$branch_repo" feat false chat-retry "")" "feat/chat-retry-2" "next_branch suffix" PULL_REQUEST_ERROR="" if create_pull_request "$branch_repo" "feat/chat-retry" "main" >/dev/null 2>&1; then fail "pull request creation unexpectedly passed without remote"; fi +contains "$PULL_REQUEST_ERROR" "branch push failed" "pull request push failure" contains "$PULL_REQUEST_ERROR" "repository exists" "pull request push failure" mkdir -p "$branch_repo/.codex/reports" "$branch_repo/.codex/tracks" "$branch_repo/.codex/reviews" printf '%s\n' "# Report" > "$branch_repo/.codex/reports/chat-retry.md" @@ -522,6 +531,7 @@ install_path="$tool_bin:/usr/bin:/bin:/usr/sbin:/sbin" DEVLOOP_BIN_DIR="$bin_dir" HOME="$install_home" PATH="$install_path" "$REPO_ROOT/install.sh" >/tmp/devloop-install-test.out [[ -x "$REPO_ROOT/devloop" ]] || fail "devloop is not executable" [[ -L "$bin_dir/devloop" ]] || fail "installer did not create symlink" +contains "$(cat /tmp/devloop-install-test.out)" "gh auth login" "installer optional gh auth" PATH="$install_path" command -v gum >/dev/null 2>&1 || fail "installer did not make gum available" PATH="$install_path" command -v fzf >/dev/null 2>&1 || fail "installer did not make fzf available" [[ -f "$install_home/.agents/skills/devloop-spec/SKILL.md" ]] || fail "installer did not install Codex spec skill" @@ -548,6 +558,113 @@ fake_bin="$work/fake-bin" mkdir -p "$fake_bin" printf '#!/usr/bin/env bash\nexit 0\n' > "$fake_bin/codex" printf '#!/usr/bin/env bash\nexit 0\n' > "$fake_bin/claude" +cat > "$fake_bin/gh" <<'GH' +#!/usr/bin/env bash +set -euo pipefail + +state="${DEVLOOP_GH_STATE:-${TMPDIR:-/tmp}/devloop-gh-state}" +mkdir -p "$state/comments" +if [ -n "${DEVLOOP_GH_LOG:-}" ]; then + printf 'gh %s\n' "$*" >> "$DEVLOOP_GH_LOG" +fi + +case "${1:-}" in + auth) + if [ "${2:-}" != "status" ]; then exit 1; fi + if [ "${DEVLOOP_GH_AUTH_FAIL:-0}" = "1" ]; then + printf '%s\n' "gh auth exploded" >&2 + exit 1 + fi + printf '%s\n' "Logged in to github.com" + ;; + repo) + if [ "${2:-}" != "view" ]; then exit 1; fi + if [ "${DEVLOOP_GH_REPO_FAIL:-0}" = "1" ]; then + printf '%s\n' "gh repo exploded" >&2 + exit 1 + fi + printf '%s\n' "satyaborg/devloop" + ;; + pr) + shift + case "${1:-}" in + list) + if [ "${DEVLOOP_GH_LOOKUP_FAIL:-0}" = "1" ]; then + printf '%s\n' "gh pr lookup exploded" >&2 + exit 1 + fi + if [ -f "$state/pr_url" ]; then cat "$state/pr_url"; fi + ;; + create) + if [ "${DEVLOOP_GH_CREATE_FAIL:-0}" = "1" ]; then + printf '%s\n' "gh pr create exploded" >&2 + exit 1 + fi + head="" + while [ "$#" -gt 0 ]; do + case "$1" in + --head) + shift + head="${1:-}" + ;; + esac + shift || true + done + if [ -n "$head" ] && ! git ls-remote --heads origin "$head" | grep -q .; then + printf 'head branch was not pushed before PR creation: %s\n' "$head" >&2 + exit 1 + fi + url="${DEVLOOP_GH_PR_URL:-https://github.com/satyaborg/devloop/pull/123}" + printf '%s\n' "$url" > "$state/pr_url" + printf '%s\n' "$url" + ;; + comment) + if [ "${DEVLOOP_GH_COMMENT_FAIL:-0}" = "1" ]; then + printf '%s\n' "gh comment exploded" >&2 + exit 1 + fi + body_file="" + while [ "$#" -gt 0 ]; do + case "$1" in + --body-file) + shift + body_file="${1:-}" + ;; + esac + shift || true + done + [ -n "$body_file" ] || exit 1 + count="$(find "$state/comments" -type f | wc -l | tr -d ' ')" + body="$state/comments/comment-$((count + 1)).md" + cp "$body_file" "$body" + if grep -q '^# Devloop Review Round ' "$body"; then + round_count="$(find "$state/comments" -name 'round-*.md' | wc -l | tr -d ' ')" + cp "$body" "$state/comments/round-$((round_count + 1)).md" + cp "$body" "$state/latest_round_comment" + elif grep -q '^# Devloop Final Report' "$body"; then + final_count="$(find "$state/comments" -name 'final-*.md' | wc -l | tr -d ' ')" + cp "$body" "$state/comments/final-$((final_count + 1)).md" + fi + printf '%s\n' "commented" + ;; + view) + if [ "${DEVLOOP_GH_VIEW_FAIL:-0}" = "1" ]; then + printf '%s\n' "gh pr view exploded" >&2 + exit 1 + fi + if [ -f "$state/latest_round_comment" ]; then cat "$state/latest_round_comment"; fi + ;; + *) + exit 1 + ;; + esac + ;; + *) + exit 1 + ;; +esac +GH +chmod +x "$fake_bin/gh" chmod +x "$fake_bin/codex" "$fake_bin/claude" doctor_output="$(HOME="$install_home" PATH="$bin_dir:$tool_bin:$fake_bin:$PATH" "$bin_dir/devloop" doctor 2>&1)" contains "$doctor_output" "devloop doctor: ready" "doctor" @@ -557,11 +674,27 @@ contains "$doctor_output" "[ok] claude:" "doctor" contains "$doctor_output" "[ok] skill devloop-spec" "doctor" contains "$doctor_output" "[ok] gum:" "doctor" contains "$doctor_output" "[ok] fzf:" "doctor" +contains "$doctor_output" "GitHub PR integration" "doctor" +contains "$doctor_output" "[PASS] gh installed" "doctor" +contains "$doctor_output" "[PASS] gh authenticated" "doctor" +contains "$doctor_output" "[PASS] current repo has origin" "doctor" +contains "$doctor_output" "[PASS] current repo resolves on GitHub" "doctor" not_contains "$doctor_output" "Optional UI" "doctor" contains "$doctor_output" "$install_home/.agents/skills/devloop-spec" "doctor Codex skill" contains "$doctor_output" "$install_home/.claude/skills/devloop-spec" "doctor Claude skill" ok "doctor" +no_gh_bin="$work/no-gh-bin" +mkdir -p "$no_gh_bin" +printf '#!/usr/bin/env bash\nexit 0\n' > "$no_gh_bin/codex" +printf '#!/usr/bin/env bash\nexit 0\n' > "$no_gh_bin/claude" +chmod +x "$no_gh_bin/codex" "$no_gh_bin/claude" +doctor_no_gh_output="$(HOME="$install_home" PATH="$bin_dir:$tool_bin:$no_gh_bin:/usr/bin:/bin" "$bin_dir/devloop" doctor 2>&1)" || fail "doctor failed when gh was unavailable" +contains "$doctor_no_gh_output" "devloop doctor: ready" "doctor no gh" +contains "$doctor_no_gh_output" "[FAIL] gh installed" "doctor no gh" +contains "$doctor_no_gh_output" "PR-backed loop readiness unavailable" "doctor no gh" +ok "doctor optional GitHub readiness" + agent="$work/spec-agent" cat > "$agent" <<'AGENT' #!/usr/bin/env bash @@ -580,9 +713,7 @@ old_use_tui="$USE_TUI" USE_TUI=false ( cd "$repo" - HOME="$spec_home" - export HOME - main spec --agent "$agent" "Keep devloop as Bash." >/tmp/devloop-spec-test.out + HOME="$spec_home" main spec --agent "$agent" "Keep devloop as Bash." >/tmp/devloop-spec-test.out ) USE_TUI="$old_use_tui" contains "$(cat /tmp/devloop-spec-test.out)" "spec:" "spec command" @@ -603,6 +734,16 @@ fi pass="$(printf '%s\n' "$prompt" | sed -nE 's/^Pass: ([0-9]+).*/\1/p' | head -n 1)" track="$(printf '%s\n' "$prompt" | sed -nE 's/^Track: (.+)$/\1/p' | head -n 1)" mode="${DEVLOOP_FAKE_MODE:-accept}" +if [ -n "${DEVLOOP_AGENT_LOG:-}" ]; then + printf 'agent coder %s\n' "${pass:-1}" >> "$DEVLOOP_AGENT_LOG" + if [ "${pass:-1}" = "2" ]; then + if printf '%s\n' "$prompt" | grep -q "# Devloop Review Round 1"; then + printf '%s\n' "coder-pr-prior:yes" >> "$DEVLOOP_AGENT_LOG" + else + printf '%s\n' "coder-pr-prior:no" >> "$DEVLOOP_AGENT_LOG" + fi + fi +fi case "$mode" in no-changes) ;; *) printf 'pass %s\n' "${pass:-1}" >> result.txt ;; @@ -630,6 +771,9 @@ fi output="$(printf '%s\n' "$prompt" | sed -nE 's/^Output path: (.+)$/\1/p' | head -n 1)" pass="$(printf '%s\n' "$prompt" | sed -nE 's/^Pass: ([0-9]+).*/\1/p' | head -n 1)" mode="${DEVLOOP_FAKE_MODE:-accept}" +if [ -n "${DEVLOOP_AGENT_LOG:-}" ] && [ -n "$pass" ]; then + printf 'agent reviewer %s\n' "$pass" >> "$DEVLOOP_AGENT_LOG" +fi if [ "$mode" = "missing-review" ]; then exit 0; fi verdict="ACCEPT" ac_status="PASS" @@ -736,6 +880,17 @@ pr: null MARKDOWN } +add_origin_remote() { + local repo_path="$1" + local remote_path="$2" + local branch + git init -q --bare "$remote_path" + branch="$(git -C "$repo_path" branch --show-current)" + git -C "$repo_path" remote add origin "$remote_path" + git -C "$repo_path" push -q -u origin "$branch" + git -C "$remote_path" symbolic-ref HEAD "refs/heads/$branch" +} + naming_repo="$work/naming-repo" mkdir -p "$naming_repo" naming_spec="$naming_repo/partial.md" @@ -845,8 +1000,60 @@ continue_track_with_fake_agents() { return "$code" } +preflight_pr_repo="$work/preflight-pr-repo" +make_loop_repo "$preflight_pr_repo" "preflight-pr" "Preflight PR" +add_origin_remote "$preflight_pr_repo" "$work/preflight-pr-remote.git" +old_home="$HOME" +old_path="$PATH" +HOME="$install_home" +PATH="$no_gh_bin:$bin_dir:$tool_bin:/usr/bin:/bin" +export HOME PATH +if preflight_run "$preflight_pr_repo" codex claude true >/dev/null 2>&1; then fail "PR preflight accepted missing gh"; fi +contains "$PREFLIGHT_ERROR" "missing command: gh" "PR preflight missing gh" +PATH="$fake_bin:$bin_dir:$tool_bin:$old_path" +DEVLOOP_GH_AUTH_FAIL=1 +export DEVLOOP_GH_AUTH_FAIL +if preflight_run "$preflight_pr_repo" codex claude true >/dev/null 2>&1; then fail "PR preflight accepted failed gh auth"; fi +contains "$PREFLIGHT_ERROR" "gh auth status failed: gh auth exploded" "PR preflight auth" +unset DEVLOOP_GH_AUTH_FAIL +preflight_no_origin="$work/preflight-no-origin" +make_loop_repo "$preflight_no_origin" "preflight-no-origin" "Preflight No Origin" +if preflight_run "$preflight_no_origin" codex claude true >/dev/null 2>&1; then fail "PR preflight accepted missing origin"; fi +contains "$PREFLIGHT_ERROR" "missing origin remote" "PR preflight origin" +DEVLOOP_GH_REPO_FAIL=1 +export DEVLOOP_GH_REPO_FAIL +if preflight_run "$preflight_pr_repo" codex claude true >/dev/null 2>&1; then fail "PR preflight accepted failed repo lookup"; fi +contains "$PREFLIGHT_ERROR" "GitHub repo lookup failed: gh repo exploded" "PR preflight repo" +unset DEVLOOP_GH_REPO_FAIL +PATH="$old_path" +HOME="$old_home" +export HOME PATH +ok "PR preflight failures" + +interactive_pr_repo="$work/interactive-pr-repo" +make_loop_repo "$interactive_pr_repo" "interactive-pr" "Interactive PR" +add_origin_remote "$interactive_pr_repo" "$work/interactive-pr-remote.git" +old_home="$HOME" +old_path="$PATH" +old_use_tui="$USE_TUI" +HOME="$install_home" +PATH="$fake_bin:$bin_dir:$tool_bin:$PATH" +USE_TUI=false +export HOME PATH +equals "$(cd "$interactive_pr_repo" && interactive_create_pr_choice "$interactive_pr_repo")" "true" "interactive PR prompt defaults yes when ready" +PATH="$no_gh_bin:$bin_dir:$tool_bin:/usr/bin:/bin" +equals "$(cd "$interactive_pr_repo" && interactive_create_pr_choice "$interactive_pr_repo")" "false" "interactive PR prompt falls back local-only when unavailable" +PATH="$old_path" +HOME="$old_home" +USE_TUI="$old_use_tui" +export HOME PATH +ok "interactive PR prompt" + loop_repo="$work/loop-accept" make_loop_repo "$loop_repo" "e2e-accept" "E2E Accept" +no_pr_gh_log="$work/no-pr-gh.log" +DEVLOOP_GH_LOG="$no_pr_gh_log" +export DEVLOOP_GH_LOG mkdir -p "$loop_repo/.devloop" cat > "$loop_repo/.devloop/verify" <<'VERIFY' #!/usr/bin/env bash @@ -871,6 +1078,8 @@ fi contains "$continue_output" "accepted" "continue run" contains "$(run_repo_main "$loop_repo" reports)" ".codex/reports/e2e-accept" "reports command" contains "$(run_repo_main "$loop_repo" continue)" ".codex/tracks/e2e-accept.md" "continue command lists tracks" +if grep -Eq '^gh pr (create|comment|list|view)' "$no_pr_gh_log" 2>/dev/null; then fail "local-only loop touched PR commands"; fi +unset DEVLOOP_GH_LOG ok "e2e accept and verify" loop_repo="$work/loop-retry" @@ -883,6 +1092,187 @@ contains "$retry_output" "accepted" "retry loop" contains "$retry_output" "2 / 2" "retry loop passes" ok "e2e reject then accept" +pr_repo="$work/loop-pr-accept" +make_loop_repo "$pr_repo" "e2e-pr-accept" "E2E PR Accept" +add_origin_remote "$pr_repo" "$work/loop-pr-accept-remote.git" +pr_state="$work/gh-pr-accept" +pr_log="$work/gh-pr-accept.log" +rm -rf "$pr_state" +mkdir -p "$pr_state" +DEVLOOP_GH_STATE="$pr_state" +DEVLOOP_GH_LOG="$pr_log" +DEVLOOP_AGENT_LOG="$pr_log" +export DEVLOOP_GH_STATE DEVLOOP_GH_LOG DEVLOOP_AGENT_LOG +if ! pr_accept_output="$(run_loop "$pr_repo" "e2e-pr-accept" accept 1 "--create-pr" 2>&1)"; then + printf '%s\n' "$pr_accept_output" >&2 + fail "PR accept loop failed" +fi +contains "$pr_accept_output" "accepted" "PR accept loop" +contains "$pr_accept_output" "pr:" "PR accept loop" +create_line="$(grep -n 'gh pr create' "$pr_log" | cut -d: -f1 | head -n 1)" +review_line="$(grep -n 'agent reviewer 1' "$pr_log" | cut -d: -f1 | head -n 1)" +[[ -n "$create_line" && -n "$review_line" && "$create_line" -lt "$review_line" ]] || fail "PR was not created before reviewer pass 1" +equals "$(find "$pr_state/comments" -name 'round-*.md' | wc -l | tr -d ' ')" "1" "one round PR comment" +equals "$(find "$pr_state/comments" -name 'final-*.md' | wc -l | tr -d ' ')" "1" "one final PR comment" +round_body="$(cat "$pr_state/comments/round-1.md")" +contains "$round_body" "# Devloop Review Round 1" "round PR comment" +contains "$round_body" "Verdict: ACCEPT" "round PR comment" +contains "$round_body" "## Acceptance matrix" "round PR comment" +contains "$round_body" "| AC1 | PASS |" "round PR comment" +contains "$round_body" "## Engineering quality matrix" "round PR comment" +contains "$round_body" "| Security | N/A |" "round PR comment" +contains "$round_body" "## Review flags" "round PR comment" +contains "$round_body" "## Findings" "round PR comment" +contains "$round_body" "## Missing tests" "round PR comment" +contains "$round_body" "## Fix instructions" "round PR comment" +contains "$round_body" "## Notes" "round PR comment" +final_body="$(cat "$pr_state/comments/final-1.md")" +contains "$final_body" "# Devloop Final Report" "final PR comment" +contains "$final_body" "Final status" "final PR comment" +contains "$final_body" "Pass count" "final PR comment" +contains "$final_body" "Final verdict" "final PR comment" +contains "$final_body" "Acceptance Matrix Summary" "final PR comment" +contains "$final_body" "Engineering Quality Summary" "final PR comment" +contains "$final_body" "Implementation Summary" "final PR comment" +contains "$final_body" "Tests Run" "final PR comment" +contains "$final_body" "Residual Risk" "final PR comment" +contains "$final_body" "PR URL" "final PR comment" +contains "$final_body" "Branch" "final PR comment" +contains "$final_body" "Commit References" "final PR comment" +contains "$final_body" ".codex/reports/e2e-pr-accept.html" "final PR comment" +if printf '%s\n' "$final_body" | grep -Eq '<(html|script|style)'; then fail "final PR comment embedded standalone HTML"; fi +unset DEVLOOP_GH_STATE DEVLOOP_GH_LOG DEVLOOP_AGENT_LOG +ok "PR-backed accept comments" + +pr_repo="$work/loop-pr-terminal" +make_loop_repo "$pr_repo" "e2e-pr-terminal" "E2E PR Terminal" +add_origin_remote "$pr_repo" "$work/loop-pr-terminal-remote.git" +pr_state="$work/gh-pr-terminal" +pr_log="$work/gh-pr-terminal.log" +rm -rf "$pr_state" +mkdir -p "$pr_state" +DEVLOOP_GH_STATE="$pr_state" +DEVLOOP_GH_LOG="$pr_log" +DEVLOOP_AGENT_LOG="$pr_log" +export DEVLOOP_GH_STATE DEVLOOP_GH_LOG DEVLOOP_AGENT_LOG +if pr_terminal_output="$(run_loop "$pr_repo" "e2e-pr-terminal" bad-ac 1 "--create-pr" 2>&1)"; then + printf '%s\n' "$pr_terminal_output" >&2 + fail "PR terminal failure loop unexpectedly passed" +fi +contains "$pr_terminal_output" "unclear" "PR terminal failure" +equals "$(find "$pr_state/comments" -name 'round-*.md' | wc -l | tr -d ' ')" "1" "terminal round PR comment" +equals "$(find "$pr_state/comments" -name 'final-*.md' | wc -l | tr -d ' ')" "1" "terminal final PR comment" +contains "$(cat "$pr_state/comments/final-1.md")" "| Final status | unclear |" "terminal final PR comment" +unset DEVLOOP_GH_STATE DEVLOOP_GH_LOG DEVLOOP_AGENT_LOG +ok "PR-backed terminal final comment" + +pr_repo="$work/loop-pr-existing" +make_loop_repo "$pr_repo" "e2e-pr-existing" "E2E PR Existing" +add_origin_remote "$pr_repo" "$work/loop-pr-existing-remote.git" +pr_state="$work/gh-pr-existing" +pr_log="$work/gh-pr-existing.log" +rm -rf "$pr_state" +mkdir -p "$pr_state" +printf '%s\n' "https://github.com/satyaborg/devloop/pull/456" > "$pr_state/pr_url" +DEVLOOP_GH_STATE="$pr_state" +DEVLOOP_GH_LOG="$pr_log" +DEVLOOP_AGENT_LOG="$pr_log" +export DEVLOOP_GH_STATE DEVLOOP_GH_LOG DEVLOOP_AGENT_LOG +if ! pr_existing_output="$(run_loop "$pr_repo" "e2e-pr-existing" accept 1 "--create-pr" 2>&1)"; then + printf '%s\n' "$pr_existing_output" >&2 + fail "existing PR loop failed" +fi +contains "$pr_existing_output" "https://github.com/satyaborg/devloop/pull/456" "existing PR loop" +if grep -q 'gh pr create' "$pr_log"; then fail "existing PR loop created a duplicate PR"; fi +contains "$(cat "$pr_log")" "gh pr list" "existing PR lookup" +equals "$(find "$pr_state/comments" -name 'round-*.md' | wc -l | tr -d ' ')" "1" "existing PR round comment" +unset DEVLOOP_GH_STATE DEVLOOP_GH_LOG DEVLOOP_AGENT_LOG +ok "PR-backed existing PR reuse" + +pr_repo="$work/loop-pr-retry" +make_loop_repo "$pr_repo" "e2e-pr-retry" "E2E PR Retry" +add_origin_remote "$pr_repo" "$work/loop-pr-retry-remote.git" +pr_state="$work/gh-pr-retry" +pr_log="$work/gh-pr-retry.log" +rm -rf "$pr_state" +mkdir -p "$pr_state" +DEVLOOP_GH_STATE="$pr_state" +DEVLOOP_GH_LOG="$pr_log" +DEVLOOP_AGENT_LOG="$pr_log" +export DEVLOOP_GH_STATE DEVLOOP_GH_LOG DEVLOOP_AGENT_LOG +if ! pr_retry_output="$(run_loop "$pr_repo" "e2e-pr-retry" reject-then-accept 2 "--create-pr" 2>&1)"; then + printf '%s\n' "$pr_retry_output" >&2 + fail "PR retry loop failed" +fi +contains "$pr_retry_output" "accepted" "PR retry loop" +contains "$(cat "$pr_log")" "coder-pr-prior:yes" "PR retry prior review" +equals "$(find "$pr_state/comments" -name 'round-*.md' | wc -l | tr -d ' ')" "2" "two round PR comments" +unset DEVLOOP_GH_STATE DEVLOOP_GH_LOG DEVLOOP_AGENT_LOG +ok "PR-backed retry uses durable PR review" + +pr_repo="$work/loop-pr-comment-fail" +make_loop_repo "$pr_repo" "e2e-pr-comment-fail" "E2E PR Comment Fail" +add_origin_remote "$pr_repo" "$work/loop-pr-comment-fail-remote.git" +pr_state="$work/gh-pr-comment-fail" +pr_log="$work/gh-pr-comment-fail.log" +rm -rf "$pr_state" +mkdir -p "$pr_state" +DEVLOOP_GH_STATE="$pr_state" +DEVLOOP_GH_LOG="$pr_log" +DEVLOOP_AGENT_LOG="$pr_log" +DEVLOOP_GH_COMMENT_FAIL=1 +export DEVLOOP_GH_STATE DEVLOOP_GH_LOG DEVLOOP_AGENT_LOG DEVLOOP_GH_COMMENT_FAIL +if pr_comment_fail_output="$(run_loop "$pr_repo" "e2e-pr-comment-fail" accept 1 "--create-pr" 2>&1)"; then + printf '%s\n' "$pr_comment_fail_output" >&2 + fail "PR comment failure loop unexpectedly passed" +fi +contains "$pr_comment_fail_output" "pr-error" "PR comment failure" +contains "$pr_comment_fail_output" "PR comment failed: gh comment exploded" "PR comment failure" +unset DEVLOOP_GH_STATE DEVLOOP_GH_LOG DEVLOOP_AGENT_LOG DEVLOOP_GH_COMMENT_FAIL +ok "PR comment failure handling" + +pr_repo="$work/loop-pr-create-fail" +make_loop_repo "$pr_repo" "e2e-pr-create-fail" "E2E PR Create Fail" +add_origin_remote "$pr_repo" "$work/loop-pr-create-fail-remote.git" +pr_state="$work/gh-pr-create-fail" +pr_log="$work/gh-pr-create-fail.log" +rm -rf "$pr_state" +mkdir -p "$pr_state" +DEVLOOP_GH_STATE="$pr_state" +DEVLOOP_GH_LOG="$pr_log" +DEVLOOP_AGENT_LOG="$pr_log" +DEVLOOP_GH_CREATE_FAIL=1 +export DEVLOOP_GH_STATE DEVLOOP_GH_LOG DEVLOOP_AGENT_LOG DEVLOOP_GH_CREATE_FAIL +if pr_create_fail_output="$(run_loop "$pr_repo" "e2e-pr-create-fail" accept 1 "--create-pr" 2>&1)"; then + printf '%s\n' "$pr_create_fail_output" >&2 + fail "PR creation failure loop unexpectedly passed" +fi +contains "$pr_create_fail_output" "pr-error" "PR creation failure" +contains "$pr_create_fail_output" "PR creation failed: gh pr create exploded" "PR creation failure" +unset DEVLOOP_GH_STATE DEVLOOP_GH_LOG DEVLOOP_AGENT_LOG DEVLOOP_GH_CREATE_FAIL +ok "PR creation failure handling" + +pr_repo="$work/loop-pr-lookup-fail" +make_loop_repo "$pr_repo" "e2e-pr-lookup-fail" "E2E PR Lookup Fail" +add_origin_remote "$pr_repo" "$work/loop-pr-lookup-fail-remote.git" +pr_state="$work/gh-pr-lookup-fail" +pr_log="$work/gh-pr-lookup-fail.log" +rm -rf "$pr_state" +mkdir -p "$pr_state" +DEVLOOP_GH_STATE="$pr_state" +DEVLOOP_GH_LOG="$pr_log" +DEVLOOP_AGENT_LOG="$pr_log" +DEVLOOP_GH_LOOKUP_FAIL=1 +export DEVLOOP_GH_STATE DEVLOOP_GH_LOG DEVLOOP_AGENT_LOG DEVLOOP_GH_LOOKUP_FAIL +if pr_lookup_fail_output="$(run_loop "$pr_repo" "e2e-pr-lookup-fail" accept 1 "--create-pr" 2>&1)"; then + printf '%s\n' "$pr_lookup_fail_output" >&2 + fail "PR lookup failure loop unexpectedly passed" +fi +contains "$pr_lookup_fail_output" "pr-error" "PR lookup failure" +contains "$pr_lookup_fail_output" "PR lookup failed: gh pr lookup exploded" "PR lookup failure" +unset DEVLOOP_GH_STATE DEVLOOP_GH_LOG DEVLOOP_AGENT_LOG DEVLOOP_GH_LOOKUP_FAIL +ok "PR lookup failure handling" + loop_repo="$work/loop-bad-ac" make_loop_repo "$loop_repo" "e2e-bad-ac" "E2E Bad AC" if bad_ac_output="$(run_loop "$loop_repo" "e2e-bad-ac" bad-ac 1 2>&1)"; then From cda2477d38e0a4886c300d56a4f2c24b6629fd6e Mon Sep 17 00:00:00 2001 From: satyaborg Date: Mon, 1 Jun 2026 17:47:39 +1000 Subject: [PATCH 2/2] fix: clarify pr mode docs --- README.md | 2 +- tests/devloop_test.sh | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 2d6c3c5..8a7b03f 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ Open and maintain a draft PR during the loop: devloop --create-pr .specs/change.md ``` -A plain non-interactive `devloop ` remains local-only. This mode opens and maintains a draft PR during the loop. With `--create-pr`, the PR is canonical for review history; local `.codex/reviews/*.md` files are execution cache. +A plain non-interactive `devloop ` remains local-only. With `--create-pr`, `devloop` opens and maintains a draft PR during the loop, and the PR is canonical for review history; local `.codex/reviews/*.md` files are execution cache. See tracked runs and cleanup candidates: diff --git a/tests/devloop_test.sh b/tests/devloop_test.sh index 60dc242..85af3aa 100755 --- a/tests/devloop_test.sh +++ b/tests/devloop_test.sh @@ -69,6 +69,8 @@ ok "spec skill path" contains "$(cat "$REPO_ROOT/README.md")" "opens and maintains a draft PR during the loop" "README PR mode" contains "$(cat "$REPO_ROOT/README.md")" "plain non-interactive" "README local-only" contains "$(cat "$REPO_ROOT/README.md")" "remains local-only" "README local-only" +contains "$(cat "$REPO_ROOT/README.md")" "With \`--create-pr\`, \`devloop\` opens and maintains a draft PR during the loop" "README PR mode" +not_contains "$(tr '\n' ' ' < "$REPO_ROOT/README.md")" "A plain non-interactive \`devloop \` remains local-only. This mode opens and maintains a draft PR during the loop." "README local-only coherence" contains "$(cat "$REPO_ROOT/README.md")" "PR is canonical" "README PR canonical" contains "$(cat "$REPO_ROOT/README.md")" "gh auth login" "README optional gh auth" ok "README PR guidance"