diff --git a/Makefile b/Makefile index fb1c13d93..751825572 100644 --- a/Makefile +++ b/Makefile @@ -100,6 +100,7 @@ go-tidy: script-test: bash internal/scaffold/fullsend-repo/scripts/post-triage-test.sh bash internal/scaffold/fullsend-repo/scripts/validate-output-schema-test.sh + bash internal/scaffold/fullsend-repo/scripts/pre-code-test.sh test: lint go-vet go-test script-test diff --git a/internal/scaffold/fullsend-repo/scripts/pre-code-test.sh b/internal/scaffold/fullsend-repo/scripts/pre-code-test.sh new file mode 100644 index 000000000..ff146d4fe --- /dev/null +++ b/internal/scaffold/fullsend-repo/scripts/pre-code-test.sh @@ -0,0 +1,241 @@ +#!/usr/bin/env bash +# pre-code-test.sh — Test pre-code.sh with mock gh to verify existing-PR check. +# +# Uses a mock gh command to capture calls without hitting GitHub. +# Run from the repo root: bash internal/scaffold/fullsend-repo/scripts/pre-code-test.sh + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PRE_SCRIPT="${SCRIPT_DIR}/pre-code.sh" +FAILURES=0 + +# Create a temp directory for mock state. +TMPDIR="$(mktemp -d)" +trap 'rm -rf "${TMPDIR}"' EXIT + +# --- Helpers --- + +# build_mock creates a mock gh binary that returns preconfigured responses. +# Arguments: +# $1 — output to return for "gh pr list" calls (tab-separated lines +# matching what gh --jq would produce, or empty for no PRs) +build_mock() { + local pr_list_output="$1" + local mock_bin="${TMPDIR}/bin" + local gh_log="${TMPDIR}/gh-calls.log" + + rm -rf "${mock_bin}" + mkdir -p "${mock_bin}" + > "${gh_log}" + + # Write the pr list output to a file so the mock can read it. + printf '%s' "${pr_list_output}" > "${TMPDIR}/pr-list-output.txt" + + cat > "${mock_bin}/gh" <<'MOCKEOF' +#!/usr/bin/env bash +CALL_LOG="LOGFILE_PLACEHOLDER" +PR_OUTPUT="OUTPUT_PLACEHOLDER" + +echo "gh $*" >> "${CALL_LOG}" + +# Route by subcommand +if [[ "$1" == "pr" && "$2" == "list" ]]; then + cat "${PR_OUTPUT}" +elif [[ "$1" == "label" ]]; then + exit 0 +elif [[ "$1" == "api" ]]; then + exit 0 +elif [[ "$1" == "issue" && "$2" == "comment" ]]; then + # Consume stdin (body-file reads from stdin) + cat > /dev/null + exit 0 +fi +MOCKEOF + + # Patch placeholders with actual paths (avoid sed on source files, + # but this is a generated mock — not repo source code). + local escaped_log="${gh_log//\//\\/}" + local escaped_out="${TMPDIR//\//\\/}\/pr-list-output.txt" + perl -pi -e "s/LOGFILE_PLACEHOLDER/${escaped_log}/g" "${mock_bin}/gh" + perl -pi -e "s/OUTPUT_PLACEHOLDER/${escaped_out}/g" "${mock_bin}/gh" + + chmod +x "${mock_bin}/gh" + + echo "${mock_bin}" +} + +run_test() { + local test_name="$1" + local pr_list_output="$2" + local expected_pattern="$3" + local expect_exit="$4" # 0 = success, 1 = failure + local extra_env="${5:-}" # additional env vars (KEY=VAL KEY2=VAL2) + + local mock_bin + mock_bin="$(build_mock "${pr_list_output}")" + local gh_log="${TMPDIR}/gh-calls.log" + + # Set base env vars for the script. + local env_cmd=( + env + PATH="${mock_bin}:${PATH}" + ISSUE_NUMBER="42" + REPO_FULL_NAME="test-org/test-repo" + GITHUB_ISSUE_URL="https://github.com/test-org/test-repo/issues/42" + GH_TOKEN="fake-token" + ) + + # Add extra env vars if provided. + if [[ -n "${extra_env}" ]]; then + for kv in ${extra_env}; do + env_cmd+=("${kv}") + done + fi + + local exit_code=0 + "${env_cmd[@]}" bash "${PRE_SCRIPT}" > "${TMPDIR}/stdout.log" 2>&1 || exit_code=$? + + # Check exit code. + if [[ ${exit_code} -ne ${expect_exit} ]]; then + echo "FAIL: ${test_name} — expected exit ${expect_exit}, got ${exit_code}" + cat "${TMPDIR}/stdout.log" + FAILURES=$((FAILURES + 1)) + return + fi + + # Check expected pattern in gh calls (if provided). + if [[ -n "${expected_pattern}" ]]; then + if ! grep -qF "${expected_pattern}" "${gh_log}" 2>/dev/null; then + echo "FAIL: ${test_name} — expected gh call pattern '${expected_pattern}' not found" + echo "Actual calls:" + cat "${gh_log}" 2>/dev/null || echo "(no calls)" + FAILURES=$((FAILURES + 1)) + return + fi + fi + + echo "PASS: ${test_name}" +} + +# Check stdout contains a specific string. +run_test_stdout() { + local test_name="$1" + local pr_list_output="$2" + local expected_stdout="$3" + local expect_exit="$4" + local extra_env="${5:-}" + + local mock_bin + mock_bin="$(build_mock "${pr_list_output}")" + + local env_cmd=( + env + PATH="${mock_bin}:${PATH}" + ISSUE_NUMBER="42" + REPO_FULL_NAME="test-org/test-repo" + GITHUB_ISSUE_URL="https://github.com/test-org/test-repo/issues/42" + GH_TOKEN="fake-token" + ) + + if [[ -n "${extra_env}" ]]; then + for kv in ${extra_env}; do + env_cmd+=("${kv}") + done + fi + + local exit_code=0 + "${env_cmd[@]}" bash "${PRE_SCRIPT}" > "${TMPDIR}/stdout.log" 2>&1 || exit_code=$? + + if [[ ${exit_code} -ne ${expect_exit} ]]; then + echo "FAIL: ${test_name} — expected exit ${expect_exit}, got ${exit_code}" + cat "${TMPDIR}/stdout.log" + FAILURES=$((FAILURES + 1)) + return + fi + + if ! grep -qF "${expected_stdout}" "${TMPDIR}/stdout.log" 2>/dev/null; then + echo "FAIL: ${test_name} — expected stdout '${expected_stdout}' not found" + echo "Actual stdout:" + cat "${TMPDIR}/stdout.log" + FAILURES=$((FAILURES + 1)) + return + fi + + echo "PASS: ${test_name}" +} + +# --- Test cases --- + +# Tab character for readability. +TAB=$'\t' + +# No existing PRs → agent proceeds (exit 0, no label/comment). +run_test_stdout "no-existing-prs-proceeds" \ + "" \ + "No existing human PRs found" \ + 0 + +# Human PR exists → should apply label and comment, then exit 0. +run_test "human-pr-applies-label" \ + "99${TAB}human-dev${TAB}https://github.com/test-org/test-repo/pull/99" \ + "gh api repos/test-org/test-repo/issues/42/labels -f labels[]=pr-open --silent" \ + 0 + +run_test "human-pr-posts-comment" \ + "99${TAB}human-dev${TAB}https://github.com/test-org/test-repo/pull/99" \ + "gh issue comment 42 --repo test-org/test-repo --body-file -" \ + 0 + +run_test_stdout "human-pr-skips-agent" \ + "99${TAB}human-dev${TAB}https://github.com/test-org/test-repo/pull/99" \ + "Skipping code agent" \ + 0 + +# Bot PR only → gh --jq filters it out, so pr list returns empty → proceeds. +run_test_stdout "bot-pr-does-not-block" \ + "" \ + "No existing human PRs found" \ + 0 + +# CODE_FORCE=true → should skip check even with human PR. +run_test_stdout "force-override-skips-check" \ + "99${TAB}human-dev${TAB}https://github.com/test-org/test-repo/pull/99" \ + "CODE_FORCE=true" \ + 0 \ + "CODE_FORCE=true" + +# No GH_TOKEN → skips check entirely, exits 0. +run_test_stdout "no-gh-token-skips-check" \ + "" \ + "GH_TOKEN not set" \ + 0 \ + "GH_TOKEN=" + +# Multiple human PRs → should block and apply label. +run_test "multiple-human-prs-block" \ + "50${TAB}dev-a${TAB}https://github.com/test-org/test-repo/pull/50 +51${TAB}dev-b${TAB}https://github.com/test-org/test-repo/pull/51" \ + "gh api repos/test-org/test-repo/issues/42/labels -f labels[]=pr-open --silent" \ + 0 + +run_test_stdout "multiple-human-prs-notice" \ + "50${TAB}dev-a${TAB}https://github.com/test-org/test-repo/pull/50 +51${TAB}dev-b${TAB}https://github.com/test-org/test-repo/pull/51" \ + "Found existing human PR #50 by @dev-a" \ + 0 + +# PR label gets created. +run_test "pr-label-created" \ + "99${TAB}human-dev${TAB}https://github.com/test-org/test-repo/pull/99" \ + "gh label create pr-open --repo test-org/test-repo" \ + 0 + +# --- Summary --- + +echo "" +if [[ ${FAILURES} -gt 0 ]]; then + echo "${FAILURES} test(s) failed" + exit 1 +fi +echo "All tests passed" diff --git a/internal/scaffold/fullsend-repo/scripts/pre-code.sh b/internal/scaffold/fullsend-repo/scripts/pre-code.sh index eae9c65bd..4ff5bc8da 100755 --- a/internal/scaffold/fullsend-repo/scripts/pre-code.sh +++ b/internal/scaffold/fullsend-repo/scripts/pre-code.sh @@ -50,3 +50,68 @@ echo "Input validation passed:" echo " ISSUE_NUMBER=${ISSUE_NUMBER}" echo " REPO_FULL_NAME=${REPO_FULL_NAME}" echo " GITHUB_ISSUE_URL=${GITHUB_ISSUE_URL}" + +# --------------------------------------------------------------------------- +# Check for existing human PRs linked to this issue +# --------------------------------------------------------------------------- +# Skip if GH_TOKEN is not available (best-effort check). +if [[ -z "${GH_TOKEN:-}" ]]; then + echo "GH_TOKEN not set — skipping existing-PR check" + exit 0 +fi + +# Allow override via CODE_FORCE (set when /code --force is used). +if [[ "${CODE_FORCE:-}" == "true" ]]; then + echo "CODE_FORCE=true — skipping existing-PR check" + exit 0 +fi + +BOT_LOGIN="${FULLSEND_BOT_LOGIN:-fullsend-ai[bot]}" + +echo "Checking for existing open PRs linked to issue #${ISSUE_NUMBER}..." + +# Search for open PRs in the repo that mention the issue number. +# This catches PRs with "Closes #N", "Fixes #N", or "#N" in the body/title. +# Use gh's built-in --jq to filter out bot-authored PRs in one call. +HUMAN_PR_LINES="$(gh pr list --repo "${REPO_FULL_NAME}" --state open \ + --search "${ISSUE_NUMBER} in:body,title" \ + --json number,url,author \ + --jq "[.[] | select(.author.login != \"${BOT_LOGIN}\")] | .[] | \"\(.number)\t\(.author.login)\t\(.url)\"" \ + 2>/dev/null || true)" + +if [[ -n "${HUMAN_PR_LINES}" ]]; then + # Parse the first PR for the notice. + FIRST_PR_NUM="$(echo "${HUMAN_PR_LINES}" | head -1 | cut -f1)" + FIRST_PR_AUTHOR="$(echo "${HUMAN_PR_LINES}" | head -1 | cut -f2)" + + echo "::notice::Found existing human PR #${FIRST_PR_NUM} by @${FIRST_PR_AUTHOR}" + + # Apply pr-open label to signal work is already underway. + gh label create "pr-open" --repo "${REPO_FULL_NAME}" \ + --description "An open PR already addresses this issue" --color "D4C5F9" \ + --force 2>/dev/null || true + gh api "repos/${REPO_FULL_NAME}/issues/${ISSUE_NUMBER}/labels" \ + -f "labels[]=pr-open" --silent 2>/dev/null || true + + # Build a markdown list of existing PRs. + PR_LIST_MD="" + while IFS=$'\t' read -r pr_num pr_author pr_url; do + PR_LIST_MD="${PR_LIST_MD} +- #${pr_num} by @${pr_author}" + done <<< "${HUMAN_PR_LINES}" + + COMMENT_BODY="An open PR already addresses this issue — skipping automated implementation. +${PR_LIST_MD} + +To override, comment \`/code --force\` on this issue. + +Posted by fullsend pre-code check" + + printf '%s' "${COMMENT_BODY}" | gh issue comment "${ISSUE_NUMBER}" \ + --repo "${REPO_FULL_NAME}" --body-file - 2>/dev/null || true + + echo "Skipping code agent — existing PR(s) found for issue #${ISSUE_NUMBER}" + exit 0 +fi + +echo "No existing human PRs found — proceeding with code agent"