diff --git a/devloop b/devloop index 5002664..1688b38 100755 --- a/devloop +++ b/devloop @@ -2264,7 +2264,7 @@ run_devloop() { if [ "$create_pr" = true ] && [ -n "$PASS_COMMIT" ]; then local pr_id="pull-request-$pass" event_step "$pr_id" "pushing branch and opening draft PR" - if create_pull_request "$repo" "$FINAL_BRANCH" "$base" "$spec" "$criteria_file" "$FINAL_COMMIT"; then + if create_pull_request "$repo" "$FINAL_BRANCH" "$base" "$spec" "$criteria_file" "$FINAL_COMMIT" "$SOURCE_REPO"; then event_done "$pr_id" true "draft PR ready: $PULL_REQUEST" else STATUS="pr-error" @@ -3648,7 +3648,8 @@ create_pull_request() { local spec="${4:-}" local criteria_file="${5:-}" local commit="${6:-}" - ensure_pull_request "$repo" "$branch" "$base" "$spec" "$criteria_file" "$commit" + local source_repo="${7:-}" + ensure_pull_request "$repo" "$branch" "$base" "$spec" "$criteria_file" "$commit" "$source_repo" } push_pull_request_branch() { @@ -3710,19 +3711,19 @@ draft_pull_request_body() { local spec="$1" local criteria_file="$2" local commit="$3" - local title problem outcome + local source_repo="${4:-}" + local title problem outcome spec_backlink title="$(spec_title "$spec" 2>/dev/null || true)" problem="$(spec_section "$spec" "Problem" 2>/dev/null || true)" outcome="$(spec_section "$spec" "Outcome" 2>/dev/null || true)" if [ -z "$title" ]; then title="Devloop change"; fi if [ -z "$problem" ]; then problem="_No problem statement in the source spec._"; fi if [ -z "$outcome" ]; then outcome="_No outcome described in the source spec._"; fi - if [ -z "$commit" ]; then commit="none"; fi - cat </dev/null || true)" + { + cat </dev/null 2>&1 && pwd -P)" || return 0 + spec_abs="$(absolute_existing_file "$spec" 2>/dev/null)" || return 0 + case "$spec_abs" in + "$repo_abs"/*) rel="${spec_abs#"$repo_abs"/}" ;; + *) return 0 ;; + esac + [ -n "$rel" ] || return 0 + git -C "$repo_abs" cat-file -e "$commit:$rel" 2>/dev/null || return 0 + name_with_owner="$(cd "$repo_abs" >/dev/null 2>&1 && gh repo view --json nameWithOwner -q .nameWithOwner 2>/dev/null)" || return 0 + name_with_owner="$(printf '%s\n' "$name_with_owner" | sed '/^[[:space:]]*$/d' | head -n 1)" + [ -n "$name_with_owner" ] || return 0 + printf 'Spec: [%s](https://github.com/%s/blob/%s/%s)\n' "$rel" "$name_with_owner" "$commit" "$rel" } create_draft_pull_request() { @@ -3752,12 +3776,13 @@ create_draft_pull_request() { local spec="${4:-}" local criteria_file="${5:-}" local commit="${6:-}" + local source_repo="${7:-}" local body_file out if ! body_file="$(mktemp "${TMPDIR:-/tmp}/devloop-pr-create.XXXXXX")"; then PULL_REQUEST_ERROR="PR body file failed: mktemp failed" return 1 fi - if ! draft_pull_request_body "$spec" "$criteria_file" "$commit" > "$body_file"; then + if ! draft_pull_request_body "$spec" "$criteria_file" "$commit" "$source_repo" > "$body_file"; then PULL_REQUEST_ERROR="PR body file failed" rm -f "$body_file" return 1 @@ -3781,11 +3806,12 @@ ensure_pull_request() { local spec="${4:-}" local criteria_file="${5:-}" local commit="${6:-}" + local source_repo="${7:-}" 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" "$spec" "$criteria_file" "$commit" + create_draft_pull_request "$repo" "$branch" "$base" "$spec" "$criteria_file" "$commit" "$source_repo" } round_review_comment_body() { diff --git a/scripts/devloop_test.sh b/scripts/devloop_test.sh index a289c16..368a2b9 100755 --- a/scripts/devloop_test.sh +++ b/scripts/devloop_test.sh @@ -1266,6 +1266,46 @@ esac GH chmod +x "$fake_bin/gh" chmod +x "$fake_bin/codex" "$fake_bin/claude" + +backlink_repo="$work/spec-backlink-repo" +git init -q "$backlink_repo" +git -C "$backlink_repo" config user.email devloop-test@example.com +git -C "$backlink_repo" config user.name "devloop test" +mkdir -p "$backlink_repo/specs" +printf '%s\n' "# Committed Spec" > "$backlink_repo/specs/committed.md" +git -C "$backlink_repo" add specs/committed.md +git -C "$backlink_repo" commit -q -m "add committed spec" +backlink_commit="$(git -C "$backlink_repo" rev-parse HEAD)" +printf '%s\n' "# Uncommitted Spec" > "$backlink_repo/specs/uncommitted.md" +outside_spec="$work/outside-spec.md" +printf '%s\n' "# Outside Spec" > "$outside_spec" +command_spec="$backlink_repo/specs/command.md" +cat > "$command_spec" <<'MARKDOWN' +# Command Spec + +## Problem + +Generated by `devloop --create-pr`. + +## Outcome + +The rendered PR body omits the raw command. +MARKDOWN +old_path="$PATH" +PATH="$fake_bin:$PATH" +expected_backlink="Spec: [specs/committed.md](https://github.com/satyaborg/devloop/blob/$backlink_commit/specs/committed.md)" +equals "$(pr_spec_backlink "$backlink_repo" "$backlink_repo/specs/committed.md" "$backlink_commit")" "$expected_backlink" "spec backlink committed" +equals "$(pr_spec_backlink "$backlink_repo" "$backlink_repo/specs/uncommitted.md" "$backlink_commit")" "" "spec backlink uncommitted" +equals "$(pr_spec_backlink "$backlink_repo" "$outside_spec" "$backlink_commit")" "" "spec backlink outside repo" +backlink_body="$(draft_pull_request_body "$backlink_repo/specs/committed.md" "" "$backlink_commit" "$backlink_repo")" +contains "$backlink_body" "$expected_backlink" "spec backlink body" +backlink_body_spec_line="$(printf '%s\n' "$backlink_body" | grep -nF "$expected_backlink" | cut -d: -f1 | head -n 1)" +backlink_body_footer_line="$(printf '%s\n' "$backlink_body" | grep -nFx "Generated by [devloop.sh](https://devloop.sh)" | cut -d: -f1 | tail -n 1)" +[[ -n "$backlink_body_spec_line" && -n "$backlink_body_footer_line" && "$backlink_body_spec_line" -lt "$backlink_body_footer_line" ]] || fail "spec backlink body footer ordering" +not_contains "$(draft_pull_request_body "$command_spec" "" "$backlink_commit" "$backlink_repo")" "devloop --create-pr" "spec backlink body raw command" +PATH="$old_path" +ok "spec backlink" + 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" contains "$doctor_output" "Required dependencies" "doctor" @@ -1782,9 +1822,15 @@ contains "$pr_body" "## Problem" "created PR body" contains "$pr_body" "The result file is never written during the loop." "created PR body" contains "$pr_body" "## Outcome" "created PR body" contains "$pr_body" "The loop writes the result file on accept." "created PR body" -contains "$pr_body" "Latest commit" "created PR body" -if ! printf '%s\n' "$pr_body" | grep -Eq 'Latest commit:[[:space:]]*[0-9a-f]{7,}'; then fail "created PR body missing commit hash"; fi contains "$pr_body" "Write the result file." "created PR body" +contains "$pr_body" "Generated by [devloop.sh](https://devloop.sh)" "created PR body" +not_contains "$pr_body" "devloop --create-pr" "created PR body" +not_contains "$pr_body" "Latest commit:" "created PR body" +not_contains "$pr_body" "Spec:" "created PR body" +not_contains "$pr_body" "blob/" "created PR body" +pr_body_footer="$(printf '%s\n' "$pr_body" | awk 'NF { line = $0 } END { print line }')" +equals "$pr_body_footer" "Generated by [devloop.sh](https://devloop.sh)" "created PR body footer" +if printf '%s\n' "$pr_body" | grep -Eq '^[0-9a-f]{7,40}$'; then fail "created PR body leaked bare commit hash"; fi if printf '%s\n' "$pr_body" | grep -q '/Users/'; then fail "created PR body leaked absolute local path"; fi 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"