From 4f0e4435798fa01b431e469a120657ad079f4c31 Mon Sep 17 00:00:00 2001 From: groovecoder <71928+groovecoder@users.noreply.github.com> Date: Fri, 29 May 2026 18:34:04 -0500 Subject: [PATCH 1/6] feat: add auto-engineer workflow for issue-driven development MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sweep detects labeled issues, tracks PR lifecycle through plan → implement → self-review phases, and dispatches the new workflow. Disabled by default; repos opt in via auto_engineer.enabled. Co-Authored-By: Claude Opus 4.6 --- .github/actions/setup-target/action.yml | 4 + .github/workflows/auto-engineer.yml | 327 ++++++++++++++++++++ config/defaults.yml | 11 + prompts/auto-engineer-implement-prompt.md | 43 +++ prompts/auto-engineer-plan-prompt.md | 82 +++++ prompts/auto-engineer-self-review-prompt.md | 59 ++++ scripts/extract_plan.py | 99 ++++++ scripts/gather-implement-context.sh | 78 +++++ scripts/gather-issue-context.sh | 99 ++++++ scripts/gather-review-context.sh | 110 +++++++ scripts/gather-self-review-context.sh | 72 +++++ scripts/run-claude.sh | 77 ++++- scripts/sweep.py | 259 +++++++++++++++- scripts/trigger-workflows.py | 32 ++ tests/scripts/__init__.py | 22 +- tests/scripts/test_auto_engineer_sweep.py | 238 ++++++++++++++ 16 files changed, 1606 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/auto-engineer.yml create mode 100644 prompts/auto-engineer-implement-prompt.md create mode 100644 prompts/auto-engineer-plan-prompt.md create mode 100644 prompts/auto-engineer-self-review-prompt.md create mode 100644 scripts/extract_plan.py create mode 100755 scripts/gather-implement-context.sh create mode 100755 scripts/gather-issue-context.sh create mode 100755 scripts/gather-review-context.sh create mode 100755 scripts/gather-self-review-context.sh create mode 100644 tests/scripts/test_auto_engineer_sweep.py diff --git a/.github/actions/setup-target/action.yml b/.github/actions/setup-target/action.yml index c996577..98a8879 100644 --- a/.github/actions/setup-target/action.yml +++ b/.github/actions/setup-target/action.yml @@ -23,6 +23,9 @@ inputs: permission-pull-requests: description: 'Token permission for pull-requests (omit to inherit all)' default: '' + permission-issues: + description: 'Token permission for issues (omit to inherit all)' + default: '' permission-vulnerability-alerts: description: 'Token permission for vulnerability-alerts (omit to inherit all)' default: '' @@ -98,6 +101,7 @@ runs: repositories: ${{ steps.parse.outputs.name }} permission-contents: ${{ inputs.permission-contents || '' }} permission-pull-requests: ${{ inputs.permission-pull-requests || '' }} + permission-issues: ${{ inputs.permission-issues || '' }} permission-vulnerability-alerts: ${{ inputs.permission-vulnerability-alerts || '' }} permission-security-events: ${{ inputs.permission-security-events || '' }} diff --git a/.github/workflows/auto-engineer.yml b/.github/workflows/auto-engineer.yml new file mode 100644 index 0000000..9b0758e --- /dev/null +++ b/.github/workflows/auto-engineer.yml @@ -0,0 +1,327 @@ +--- +name: BLEnder Auto-Engineer +run-name: >- + Auto-engineer ${{ inputs.target_repo }} + phase=${{ inputs.phase }} issue #${{ inputs.issue_number }} +on: + workflow_dispatch: + inputs: + target_repo: + description: 'Target repo (e.g. mozilla/fx-private-relay)' + required: true + phase: + description: 'Phase: plan, implement, or self-review' + required: true + issue_number: + description: 'Issue number (0 = let Claude pick)' + default: '0' + issue_title: + description: 'Issue title' + default: '' + pr_number: + description: 'Existing PR number (0 = no PR yet)' + default: '0' + dry_run: + description: 'Dry run (true = no mutations)' + default: 'true' + verbose: + description: 'Print full Claude output' + default: 'false' + +permissions: + contents: read + +concurrency: + group: blender-auto-engineer-${{ inputs.target_repo }} + cancel-in-progress: false + +jobs: + plan: + if: inputs.phase == 'plan' + runs-on: ubuntu-latest + permissions: + contents: read + actions: write + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + + - name: Setup target repo + id: setup + uses: ./.github/actions/setup-target + with: + target-repo: ${{ inputs.target_repo }} + app-id: ${{ secrets.BLENDER_APP_ID }} + private-key: ${{ secrets.BLENDER_APP_PRIVATE_KEY }} + blender-workspace: ${{ github.workspace }} + permission-issues: read + permission-contents: write + permission-pull-requests: write + install-sandbox: 'true' + install-claude: 'true' + + - name: Check for existing auto-engineer PR + id: existing-pr + run: | + if [ "$PR_NUMBER" != "0" ]; then + echo "pr_exists=true" >> "$GITHUB_OUTPUT" + else + echo "pr_exists=false" >> "$GITHUB_OUTPUT" + fi + env: + PR_NUMBER: ${{ inputs.pr_number }} + + - name: Gather context (new plan) + if: steps.existing-pr.outputs.pr_exists != 'true' + working-directory: target + run: ${{ github.workspace }}/scripts/gather-issue-context.sh + env: + GH_TOKEN: ${{ steps.setup.outputs.token }} + ISSUE_NUMBER: ${{ inputs.issue_number }} + REPO: ${{ inputs.target_repo }} + PROMPT_TEMPLATE: ${{ github.workspace }}/prompts/auto-engineer-plan-prompt.md + ISSUE_TITLE: ${{ inputs.issue_title }} + + - name: Gather context (plan feedback) + if: steps.existing-pr.outputs.pr_exists == 'true' + working-directory: target + run: ${{ github.workspace }}/scripts/gather-review-context.sh + env: + GH_TOKEN: ${{ steps.setup.outputs.token }} + PR_NUMBER: ${{ inputs.pr_number }} + ISSUE_NUMBER: ${{ inputs.issue_number }} + REPO: ${{ inputs.target_repo }} + PROMPT_TEMPLATE: ${{ github.workspace }}/prompts/auto-engineer-plan-prompt.md + ISSUE_TITLE: ${{ inputs.issue_title }} + + - name: Run Claude (plan) + working-directory: target + run: ${{ github.workspace }}/scripts/run-claude.sh + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + REPO: ${{ inputs.target_repo }} + REPO_NAME: ${{ steps.setup.outputs.repo_name }} + BLENDER_DIR: ${{ github.workspace }} + BLENDER_MODE: plan + CLAUDE_VERBOSE: ${{ inputs.verbose }} + + - name: Create or update plan PR + if: inputs.dry_run != 'true' + working-directory: target + run: | + if [ ! -f .blender-plan.md ]; then + echo "No plan file produced. Skipping PR creation." + exit 0 + fi + + SLUG=$(echo "${ISSUE_TITLE}" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | head -c 40) + BRANCH="blender/auto-engineer/${ISSUE_NUMBER}-${SLUG}" + + mkdir -p .blender/plans + cp .blender-plan.md ".blender/plans/${ISSUE_NUMBER}.md" + + git config user.name "mozilla-blender[bot]" + git config user.email "mozilla-blender[bot]@users.noreply.github.com" + + if [ "${PR_EXISTS}" = "true" ]; then + git fetch "https://x-access-token:${GH_TOKEN}@github.com/${REPO}.git" "${BRANCH}" + git checkout "${BRANCH}" + git add ".blender/plans/${ISSUE_NUMBER}.md" + git commit -m "BLEnder plan(#${ISSUE_NUMBER}): update plan based on feedback" + else + git checkout -b "${BRANCH}" + git add ".blender/plans/${ISSUE_NUMBER}.md" + git commit -m "BLEnder plan(#${ISSUE_NUMBER}): ${ISSUE_TITLE}" + fi + + git push "https://x-access-token:${GH_TOKEN}@github.com/${REPO}.git" "${BRANCH}" --force-with-lease + + if [ "${PR_EXISTS}" != "true" ]; then + PR_BODY="## Auto-Engineer Plan for #${ISSUE_NUMBER} + + This PR contains a plan for implementing #${ISSUE_NUMBER}. + + **Please review the plan** in \`.blender/plans/${ISSUE_NUMBER}.md\`. + Once approved, BLEnder will implement it. + + --- + 🤖 Generated by [BLEnder](https://github.com/mozilla/blender)" + + PR_URL=$(gh pr create \ + --repo "${REPO}" \ + --head "${BRANCH}" \ + --title "BLEnder: ${ISSUE_TITLE}" \ + --label "blender:auto-engineer" \ + --body "${PR_BODY}") + + if [ "${ISSUE_NUMBER}" != "0" ]; then + gh issue comment "${ISSUE_NUMBER}" \ + --repo "${REPO}" \ + --body "BLEnder created a plan for this issue: ${PR_URL} + + Please review the plan and approve the PR when ready for implementation." + fi + fi + env: + GH_TOKEN: ${{ steps.setup.outputs.token }} + REPO: ${{ inputs.target_repo }} + ISSUE_NUMBER: ${{ inputs.issue_number }} + ISSUE_TITLE: ${{ inputs.issue_title }} + PR_EXISTS: ${{ steps.existing-pr.outputs.pr_exists }} + + implement: + if: inputs.phase == 'implement' + runs-on: ubuntu-latest + permissions: + contents: read + actions: write + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + + - name: Setup target repo + id: setup + uses: ./.github/actions/setup-target + with: + target-repo: ${{ inputs.target_repo }} + app-id: ${{ secrets.BLENDER_APP_ID }} + private-key: ${{ secrets.BLENDER_APP_PRIVATE_KEY }} + blender-workspace: ${{ github.workspace }} + permission-issues: read + permission-contents: write + permission-pull-requests: write + install-sandbox: 'true' + install-claude: 'true' + + - name: Checkout PR branch + working-directory: target + run: | + PR_JSON=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}") + BRANCH=$(echo "$PR_JSON" | jq -r '.head.ref') + git fetch "https://x-access-token:${GH_TOKEN}@github.com/${REPO}.git" "${BRANCH}" + git checkout "${BRANCH}" + env: + GH_TOKEN: ${{ steps.setup.outputs.token }} + REPO: ${{ inputs.target_repo }} + PR_NUMBER: ${{ inputs.pr_number }} + + - name: Check if addressing code review + id: check-review + run: | + IMPL_COMMITS=$(git log --oneline | grep -cv "BLEnder plan(" || true) + if [ "$IMPL_COMMITS" -gt 1 ]; then + echo "has_impl=true" >> "$GITHUB_OUTPUT" + else + echo "has_impl=false" >> "$GITHUB_OUTPUT" + fi + + - name: Gather context (first implementation) + if: steps.check-review.outputs.has_impl != 'true' + working-directory: target + run: ${{ github.workspace }}/scripts/gather-implement-context.sh + env: + GH_TOKEN: ${{ steps.setup.outputs.token }} + ISSUE_NUMBER: ${{ inputs.issue_number }} + REPO: ${{ inputs.target_repo }} + PROMPT_TEMPLATE: ${{ github.workspace }}/prompts/auto-engineer-implement-prompt.md + ISSUE_TITLE: ${{ inputs.issue_title }} + + - name: Gather context (code review feedback) + if: steps.check-review.outputs.has_impl == 'true' + working-directory: target + run: ${{ github.workspace }}/scripts/gather-review-context.sh + env: + GH_TOKEN: ${{ steps.setup.outputs.token }} + PR_NUMBER: ${{ inputs.pr_number }} + ISSUE_NUMBER: ${{ inputs.issue_number }} + REPO: ${{ inputs.target_repo }} + PROMPT_TEMPLATE: ${{ github.workspace }}/prompts/auto-engineer-implement-prompt.md + ISSUE_TITLE: ${{ inputs.issue_title }} + + - name: Run Claude (implement) + working-directory: target + run: ${{ github.workspace }}/scripts/run-claude.sh + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + REPO: ${{ inputs.target_repo }} + REPO_NAME: ${{ steps.setup.outputs.repo_name }} + BLENDER_DIR: ${{ github.workspace }} + BLENDER_MODE: implement + CLAUDE_VERBOSE: ${{ inputs.verbose }} + + - name: Commit and push + if: inputs.dry_run != 'true' + working-directory: target + run: ${{ github.workspace }}/scripts/commit.sh + env: + GH_TOKEN: ${{ steps.setup.outputs.token }} + REPO: ${{ inputs.target_repo }} + + self-review: + if: inputs.phase == 'self-review' + runs-on: ubuntu-latest + permissions: + contents: read + actions: write + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + + - name: Setup target repo + id: setup + uses: ./.github/actions/setup-target + with: + target-repo: ${{ inputs.target_repo }} + app-id: ${{ secrets.BLENDER_APP_ID }} + private-key: ${{ secrets.BLENDER_APP_PRIVATE_KEY }} + blender-workspace: ${{ github.workspace }} + permission-issues: read + permission-contents: read + permission-pull-requests: write + install-sandbox: 'true' + install-claude: 'true' + + - name: Gather self-review context + working-directory: target + run: ${{ github.workspace }}/scripts/gather-self-review-context.sh + env: + GH_TOKEN: ${{ steps.setup.outputs.token }} + PR_NUMBER: ${{ inputs.pr_number }} + ISSUE_NUMBER: ${{ inputs.issue_number }} + REPO: ${{ inputs.target_repo }} + PROMPT_TEMPLATE: ${{ github.workspace }}/prompts/auto-engineer-self-review-prompt.md + + - name: Run Claude (self-review) + working-directory: target + run: ${{ github.workspace }}/scripts/run-claude.sh + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + REPO: ${{ inputs.target_repo }} + REPO_NAME: ${{ steps.setup.outputs.repo_name }} + BLENDER_DIR: ${{ github.workspace }} + BLENDER_MODE: self-review + CLAUDE_VERBOSE: ${{ inputs.verbose }} + + - name: Post self-review comment + if: inputs.dry_run != 'true' + working-directory: target + run: | + if [ ! -f .blender-self-review.md ]; then + echo "No self-review file produced. Posting fallback comment." + BODY="## Self-Review + + BLEnder could not produce a self-review summary for this PR." + else + BODY=$(cat .blender-self-review.md) + fi + + gh pr comment "${PR_NUMBER}" \ + --repo "${REPO}" \ + --body "${BODY}" + env: + GH_TOKEN: ${{ steps.setup.outputs.token }} + REPO: ${{ inputs.target_repo }} + PR_NUMBER: ${{ inputs.pr_number }} diff --git a/config/defaults.yml b/config/defaults.yml index b192ff3..9349bfe 100644 --- a/config/defaults.yml +++ b/config/defaults.yml @@ -21,3 +21,14 @@ investigate: run_tests: true severity_threshold: "" dismiss_unaffected: false + +auto_engineer: + enabled: false + dry_run: true + issue_label: "auto-engineer" + max_plan_turns: 20 + max_plan_budget_usd: 1.50 + max_implement_turns: 40 + max_implement_budget_usd: 4.00 + max_self_review_turns: 15 + max_self_review_budget_usd: 1.00 diff --git a/prompts/auto-engineer-implement-prompt.md b/prompts/auto-engineer-implement-prompt.md new file mode 100644 index 0000000..a5c8df0 --- /dev/null +++ b/prompts/auto-engineer-implement-prompt.md @@ -0,0 +1,43 @@ +# BLEnder: Auto-Engineer — Implement Phase + +## Issue + +- **Issue:** #{{ISSUE_NUMBER}}: {{ISSUE_TITLE}} + +{{ISSUE_BODY}} + +## Plan + +{{PLAN_CONTENT}} + +## Your task + +Implement the plan above. Follow the steps in order. Run the test suite +after making changes. Write a commit message to `.blender-commit-msg`. + +**You have a limited turn budget. Be efficient. Focus on making the +changes described in the plan.** + +### Guidelines + +1. Follow the plan's implementation steps in order. +2. Match the repo's existing code style and conventions. +3. Run tests after making changes. Fix any failures your changes introduce. +4. Write a clear commit message to `.blender-commit-msg` summarizing what + you changed and why. +5. If the plan calls for changes you cannot make (e.g., requires external + service changes), skip those steps and note them in the commit message. + +### If addressing code review feedback + +If review comments are included below, address them. The reviewer's +feedback takes priority over the original plan when they conflict. + +{{REVIEW_COMMENTS}} + +## Rules + +- Do NOT modify `.github/` workflow files unless the plan says to. +- Do NOT modify `.env` files or add secrets. +- Do NOT search the web. +- Write your commit message to `.blender-commit-msg`. diff --git a/prompts/auto-engineer-plan-prompt.md b/prompts/auto-engineer-plan-prompt.md new file mode 100644 index 0000000..e8bfa4a --- /dev/null +++ b/prompts/auto-engineer-plan-prompt.md @@ -0,0 +1,82 @@ +# BLEnder: Auto-Engineer — Plan Phase + +## Issue + +- **Issue:** #{{ISSUE_NUMBER}}: {{ISSUE_TITLE}} + +{{ISSUE_BODY}} + +{{ISSUE_COMMENTS}} + +## Repo structure + +``` +{{REPO_TREE}} +``` + +## Your task + +Read the issue and explore the codebase. Produce a detailed implementation +plan. Be specific about which files to change, what to add, and how to +test the changes. + +**You have a limited turn budget. Be efficient. Your final response +must include the plan — that is the only deliverable that matters.** + +### Step 1: Understand the issue + +Read the issue description and comments. Identify the core problem and +any constraints mentioned by maintainers. + +### Step 2: Explore the codebase + +Read the relevant source files. Understand the existing patterns, +conventions, and architecture. Check for tests, configs, and docs that +relate to the issue. + +### Step 3: Write the plan + +Produce a structured plan covering: +- **Summary:** one paragraph explaining the approach +- **Files to Change:** list each file with a description of changes +- **Implementation Steps:** ordered steps to follow +- **Test Strategy:** how to verify the changes work +- **Risks:** anything that could go wrong or needs human judgment + +### Step 4: Output the plan + +Your final response MUST include the plan as a fenced block labeled +`PLAN_MD`. Do not write any files. Just output this block: + +```` +```PLAN_MD +# Plan: + +## Summary + + + +## Files to Change + +- `path/to/file.py` — + +## Implementation Steps + +1. + +## Test Strategy + +- + +## Risks + +- +``` +```` + +## Rules + +- Do NOT edit or create any files. Read and analyze only. +- Do NOT run `git` commands. +- Your final response MUST include the ```PLAN_MD``` block. +- If addressing plan feedback, incorporate reviewer comments into a revised plan. diff --git a/prompts/auto-engineer-self-review-prompt.md b/prompts/auto-engineer-self-review-prompt.md new file mode 100644 index 0000000..423f496 --- /dev/null +++ b/prompts/auto-engineer-self-review-prompt.md @@ -0,0 +1,59 @@ +# BLEnder: Auto-Engineer — Self-Review + +## Original plan + +{{PLAN_CONTENT}} + +## Merged PR diff + +```diff +{{PR_DIFF}} +``` + +## Your task + +Compare the final merged diff against the original plan. Write a summary +for the team. Be honest about deviations. + +**You have a limited turn budget. Be efficient. Your final response +must include the summary — that is the only deliverable that matters.** + +### What to cover + +1. **What was implemented as planned** — brief confirmation +2. **What changed from the plan** — anything done differently and why +3. **What was added beyond the plan** — extra changes not in the plan +4. **What was dropped** — planned changes that were not implemented +5. **Test coverage** — were the planned tests written? + +### Output + +Your final response MUST include the summary as a fenced block labeled +`SELF_REVIEW_MD`: + +```` +```SELF_REVIEW_MD +## Self-Review + +### Implemented as planned +- + +### Changed from plan +- + +### Added beyond plan +- + +### Dropped +- + +### Test coverage +- +``` +```` + +## Rules + +- Do NOT edit or create any files. Read and analyze only. +- Do NOT run `git` commands. +- Your final response MUST include the ```SELF_REVIEW_MD``` block. diff --git a/scripts/extract_plan.py b/scripts/extract_plan.py new file mode 100644 index 0000000..d446faf --- /dev/null +++ b/scripts/extract_plan.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +"""Extract fenced blocks (PLAN_MD, SELF_REVIEW_MD) from Claude's output. + +Claude runs in a sandbox that prevents file writes. Instead, the prompt +tells Claude to output a fenced block with a specific label. This script +parses that block and writes it to a file. + +Usage: extract_plan.py + +Examples: + extract_plan.py claude.log PLAN_MD .blender-plan.md + extract_plan.py claude.log SELF_REVIEW_MD .blender-self-review.md +""" + +import json +import re +import sys + + +def _extract_text_from_json_log(log: str) -> str: + """Extract assistant text from --output-format json session logs.""" + try: + events = json.loads(log) + except (json.JSONDecodeError, TypeError): + return "" + + if not isinstance(events, list): + return "" + + parts = [] + for event in events: + msg = event.get("message") if isinstance(event, dict) else None + if not msg or msg.get("role") != "assistant": + continue + for block in msg.get("content", []): + if isinstance(block, dict) and block.get("type") == "text": + parts.append(block["text"]) + return "\n".join(parts) + + +def _search_text(text: str, label: str) -> str | None: + """Search for a fenced block with the given label.""" + pattern = rf"```{re.escape(label)}\s*\n(.*?)\n\s*```" + m = re.search(pattern, text, re.DOTALL) + if m: + return m.group(1).strip() + return None + + +def extract(log: str, label: str) -> str | None: + """Extract a labeled fenced block from Claude's output. + + Handles both plain-text output (-p) and JSON session logs + (--output-format json) produced when CLAUDE_VERBOSE=true. + """ + result = _search_text(log, label) + if result: + return result + + text = _extract_text_from_json_log(log) + if text: + return _search_text(text, label) + + return None + + +def main() -> None: + if len(sys.argv) < 4: + print( + "Usage: extract_plan.py ", + file=sys.stderr, + ) + sys.exit(1) + + log_path = sys.argv[1] + label = sys.argv[2] + output_path = sys.argv[3] + + try: + with open(log_path) as f: + log = f.read() + except OSError as e: + print(f"Cannot read log file: {e}", file=sys.stderr) + sys.exit(1) + + content = extract(log, label) + if content is None: + print(f"No {label} block found in Claude output.", file=sys.stderr) + sys.exit(1) + + with open(output_path, "w") as f: + f.write(content) + f.write("\n") + + print(f"Extracted {label} block ({len(content)} chars) to {output_path}") + + +if __name__ == "__main__": + main() diff --git a/scripts/gather-implement-context.sh b/scripts/gather-implement-context.sh new file mode 100755 index 0000000..30ea1e7 --- /dev/null +++ b/scripts/gather-implement-context.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# BLEnder gather-implement-context: fetch plan + issue, build implement prompt. +# +# This script has GH_TOKEN but does NOT have ANTHROPIC_API_KEY. +# It writes the final prompt to .blender-prompt for run-claude.sh. +# +# Environment variables: +# ISSUE_NUMBER -- Issue number (required) +# REPO -- GitHub repo, e.g. mozilla/fx-private-relay (required) +# GH_TOKEN -- GitHub token for API calls (required) +# PROMPT_TEMPLATE -- Path to prompt template file (required) +# ISSUE_TITLE -- Issue title (optional) + +set -euo pipefail + +if [ -z "${ISSUE_NUMBER:-}" ] || [ -z "${REPO:-}" ]; then + echo "Error: ISSUE_NUMBER and REPO are required." + exit 1 +fi + +if [ -z "${GH_TOKEN:-}" ]; then + echo "Error: GH_TOKEN is required." + exit 1 +fi + +if [ -z "${PROMPT_TEMPLATE:-}" ]; then + echo "Error: PROMPT_TEMPLATE is required." + exit 1 +fi + +if [ ! -f "$PROMPT_TEMPLATE" ]; then + echo "Error: Prompt template not found: $PROMPT_TEMPLATE" + exit 1 +fi + +# --- Sanitize untrusted input before inserting into prompts --- +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=scripts/sanitize.sh +source "${SCRIPT_DIR}/sanitize.sh" + +echo "BLEnder gather-implement-context: issue #${ISSUE_NUMBER} repo=${REPO}" + +# --- Read plan file --- +plan_file=".blender/plans/${ISSUE_NUMBER}.md" +plan_content="" +if [ -f "$plan_file" ]; then + echo "Reading plan from ${plan_file}..." + plan_content=$(cat "$plan_file") +else + echo "Warning: Plan file not found at ${plan_file}" + plan_content="(plan file not found)" +fi + +# --- Fetch issue body --- +echo "Fetching issue #${ISSUE_NUMBER}..." +issue_json=$(gh api "repos/${REPO}/issues/${ISSUE_NUMBER}") +issue_title=$(echo "$issue_json" | jq -r '.title // ""') +issue_body=$(echo "$issue_json" | jq -r '.body // "(no body)"') + +echo " Title: ${issue_title}" + +# --- Build the prompt --- +echo "Building prompt from ${PROMPT_TEMPLATE}..." +prompt=$(cat "$PROMPT_TEMPLATE") + +safe_title=$(sanitize_for_prompt "$issue_title") +safe_body=$(sanitize_for_prompt "$issue_body") +safe_plan=$(sanitize_for_prompt "$plan_content") + +prompt="${prompt//\{\{ISSUE_NUMBER\}\}/$ISSUE_NUMBER}" +prompt="${prompt//\{\{ISSUE_TITLE\}\}/$safe_title}" +prompt="${prompt//\{\{ISSUE_BODY\}\}/$safe_body}" +prompt="${prompt//\{\{PLAN_CONTENT\}\}/$safe_plan}" +prompt="${prompt//\{\{REVIEW_COMMENTS\}\}/}" + +# Write prompt to file for run-claude.sh +echo "$prompt" > .blender-prompt +echo "Prompt written to .blender-prompt" diff --git a/scripts/gather-issue-context.sh b/scripts/gather-issue-context.sh new file mode 100755 index 0000000..2fcb01f --- /dev/null +++ b/scripts/gather-issue-context.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +# BLEnder gather-issue-context: fetch issue metadata, build plan prompt. +# +# This script has GH_TOKEN but does NOT have ANTHROPIC_API_KEY. +# It writes the final prompt to .blender-prompt for run-claude.sh. +# +# Environment variables: +# ISSUE_NUMBER -- Issue number (0 = let Claude pick) (required) +# REPO -- GitHub repo, e.g. mozilla/fx-private-relay (required) +# GH_TOKEN -- GitHub token for API calls (required) +# PROMPT_TEMPLATE -- Path to prompt template file (required) +# ISSUE_TITLE -- Issue title (optional, for fallback) + +set -euo pipefail + +if [ -z "${REPO:-}" ]; then + echo "Error: REPO is required." + exit 1 +fi + +if [ -z "${GH_TOKEN:-}" ]; then + echo "Error: GH_TOKEN is required." + exit 1 +fi + +if [ -z "${PROMPT_TEMPLATE:-}" ]; then + echo "Error: PROMPT_TEMPLATE is required." + exit 1 +fi + +if [ ! -f "$PROMPT_TEMPLATE" ]; then + echo "Error: Prompt template not found: $PROMPT_TEMPLATE" + exit 1 +fi + +# --- Sanitize untrusted input before inserting into prompts --- +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=scripts/sanitize.sh +source "${SCRIPT_DIR}/sanitize.sh" + +ISSUE_NUMBER="${ISSUE_NUMBER:-0}" +echo "BLEnder gather-issue-context: issue #${ISSUE_NUMBER} repo=${REPO}" + +# --- Fetch issue details --- +issue_body="" +issue_title="${ISSUE_TITLE:-}" +issue_comments="" + +if [ "$ISSUE_NUMBER" != "0" ]; then + echo "Fetching issue #${ISSUE_NUMBER}..." + issue_json=$(gh api "repos/${REPO}/issues/${ISSUE_NUMBER}") + issue_title=$(echo "$issue_json" | jq -r '.title // ""') + issue_body=$(echo "$issue_json" | jq -r '.body // "(no body)"') + + echo "Fetching issue comments..." + comments_json=$(gh api "repos/${REPO}/issues/${ISSUE_NUMBER}/comments" --paginate) + issue_comments=$(echo "$comments_json" | jq -r '.[] | "### Comment by \(.user.login)\n\(.body)\n"') +else + echo "No specific issue — fetching all open issues for Claude to pick..." + issues_json=$(gh api "repos/${REPO}/issues?state=open&per_page=50" \ + --jq '.[] | select(.pull_request == null) | "- #\(.number): \(.title) [labels: \([.labels[].name] | join(", "))]"') + issue_body="Pick the most impactful issue from this list and create a plan for it:\n\n${issues_json}" + issue_title="(Claude picks from open issues)" +fi + +echo " Title: ${issue_title}" + +# --- Gather repo structure --- +echo "Gathering repo structure..." +repo_tree=$(tree -L 3 --noreport -I 'node_modules|.git|__pycache__|.tox|.mypy_cache|dist|build|*.egg-info' 2>/dev/null || echo "(tree not available)") + +# --- Read agents.md if it exists --- +if [ -f .blender/agents.md ]; then + echo "Reading .blender/agents.md..." + issue_body="${issue_body} + +## Repo context (.blender/agents.md) + +$(cat .blender/agents.md)" +fi + +# --- Build the prompt --- +echo "Building prompt from ${PROMPT_TEMPLATE}..." +prompt=$(cat "$PROMPT_TEMPLATE") + +safe_title=$(sanitize_for_prompt "$issue_title") +safe_body=$(sanitize_for_prompt "$issue_body") +safe_comments=$(sanitize_for_prompt "$issue_comments") +safe_tree=$(sanitize_for_prompt "$repo_tree") + +prompt="${prompt//\{\{ISSUE_NUMBER\}\}/$ISSUE_NUMBER}" +prompt="${prompt//\{\{ISSUE_TITLE\}\}/$safe_title}" +prompt="${prompt//\{\{ISSUE_BODY\}\}/$safe_body}" +prompt="${prompt//\{\{ISSUE_COMMENTS\}\}/$safe_comments}" +prompt="${prompt//\{\{REPO_TREE\}\}/$safe_tree}" + +# Write prompt to file for run-claude.sh +echo "$prompt" > .blender-prompt +echo "Prompt written to .blender-prompt" diff --git a/scripts/gather-review-context.sh b/scripts/gather-review-context.sh new file mode 100755 index 0000000..903ae54 --- /dev/null +++ b/scripts/gather-review-context.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +# BLEnder gather-review-context: fetch review comments + plan, build prompt. +# +# Shared script used when addressing plan feedback or code review feedback. +# Appends review comments to the appropriate prompt template. +# +# This script has GH_TOKEN but does NOT have ANTHROPIC_API_KEY. +# It writes the final prompt to .blender-prompt for run-claude.sh. +# +# Environment variables: +# PR_NUMBER -- PR number (required) +# ISSUE_NUMBER -- Issue number (required) +# REPO -- GitHub repo, e.g. mozilla/fx-private-relay (required) +# GH_TOKEN -- GitHub token for API calls (required) +# PROMPT_TEMPLATE -- Path to prompt template file (required) +# ISSUE_TITLE -- Issue title (optional) + +set -euo pipefail + +if [ -z "${PR_NUMBER:-}" ] || [ -z "${REPO:-}" ]; then + echo "Error: PR_NUMBER and REPO are required." + exit 1 +fi + +if [ -z "${GH_TOKEN:-}" ]; then + echo "Error: GH_TOKEN is required." + exit 1 +fi + +if [ -z "${PROMPT_TEMPLATE:-}" ]; then + echo "Error: PROMPT_TEMPLATE is required." + exit 1 +fi + +if [ ! -f "$PROMPT_TEMPLATE" ]; then + echo "Error: Prompt template not found: $PROMPT_TEMPLATE" + exit 1 +fi + +# --- Sanitize untrusted input before inserting into prompts --- +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=scripts/sanitize.sh +source "${SCRIPT_DIR}/sanitize.sh" + +ISSUE_NUMBER="${ISSUE_NUMBER:-0}" +echo "BLEnder gather-review-context: PR #${PR_NUMBER} issue #${ISSUE_NUMBER} repo=${REPO}" + +# --- Read plan file --- +plan_file=".blender/plans/${ISSUE_NUMBER}.md" +plan_content="" +if [ -f "$plan_file" ]; then + echo "Reading plan from ${plan_file}..." + plan_content=$(cat "$plan_file") +fi + +# --- Fetch issue body --- +issue_body="" +issue_title="${ISSUE_TITLE:-}" +if [ "$ISSUE_NUMBER" != "0" ]; then + echo "Fetching issue #${ISSUE_NUMBER}..." + issue_json=$(gh api "repos/${REPO}/issues/${ISSUE_NUMBER}") + issue_title=$(echo "$issue_json" | jq -r '.title // ""') + issue_body=$(echo "$issue_json" | jq -r '.body // "(no body)"') +fi + +# --- Fetch PR review comments --- +echo "Fetching review comments on PR #${PR_NUMBER}..." +review_comments="" + +# Issue comments (general PR comments) +issue_comments=$(gh api "repos/${REPO}/issues/${PR_NUMBER}/comments" --paginate \ + --jq '.[] | select(.user.login | endswith("[bot]") | not) | "### Comment by \(.user.login)\n\(.body)\n"' \ + 2>/dev/null || echo "") + +# PR review comments (inline code comments) +pr_review_comments=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}/comments" --paginate \ + --jq '.[] | "### Review comment by \(.user.login) on \(.path):\(.line // .original_line)\n\(.body)\n"' \ + 2>/dev/null || echo "") + +# PR reviews with body +pr_reviews=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}/reviews" --paginate \ + --jq '.[] | select(.body != "" and .body != null) | "### Review by \(.user.login) (\(.state))\n\(.body)\n"' \ + 2>/dev/null || echo "") + +review_comments="${issue_comments}${pr_review_comments}${pr_reviews}" + +if [ -z "$review_comments" ]; then + review_comments="(no review comments found)" +fi + +# --- Build the prompt --- +echo "Building prompt from ${PROMPT_TEMPLATE}..." +prompt=$(cat "$PROMPT_TEMPLATE") + +safe_title=$(sanitize_for_prompt "$issue_title") +safe_body=$(sanitize_for_prompt "$issue_body") +safe_plan=$(sanitize_for_prompt "$plan_content") +safe_reviews=$(sanitize_for_prompt "$review_comments") + +prompt="${prompt//\{\{ISSUE_NUMBER\}\}/$ISSUE_NUMBER}" +prompt="${prompt//\{\{ISSUE_TITLE\}\}/$safe_title}" +prompt="${prompt//\{\{ISSUE_BODY\}\}/$safe_body}" +prompt="${prompt//\{\{PLAN_CONTENT\}\}/$safe_plan}" +prompt="${prompt//\{\{REVIEW_COMMENTS\}\}/$safe_reviews}" +prompt="${prompt//\{\{ISSUE_COMMENTS\}\}/$safe_reviews}" +prompt="${prompt//\{\{REPO_TREE\}\}/}" + +# Write prompt to file for run-claude.sh +echo "$prompt" > .blender-prompt +echo "Prompt written to .blender-prompt" diff --git a/scripts/gather-self-review-context.sh b/scripts/gather-self-review-context.sh new file mode 100755 index 0000000..521646b --- /dev/null +++ b/scripts/gather-self-review-context.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# BLEnder gather-self-review-context: fetch plan + merged diff, build prompt. +# +# This script has GH_TOKEN but does NOT have ANTHROPIC_API_KEY. +# It writes the final prompt to .blender-prompt for run-claude.sh. +# +# Environment variables: +# PR_NUMBER -- Merged PR number (required) +# ISSUE_NUMBER -- Issue number (required) +# REPO -- GitHub repo, e.g. mozilla/fx-private-relay (required) +# GH_TOKEN -- GitHub token for API calls (required) +# PROMPT_TEMPLATE -- Path to prompt template file (required) + +set -euo pipefail + +if [ -z "${PR_NUMBER:-}" ] || [ -z "${REPO:-}" ]; then + echo "Error: PR_NUMBER and REPO are required." + exit 1 +fi + +if [ -z "${GH_TOKEN:-}" ]; then + echo "Error: GH_TOKEN is required." + exit 1 +fi + +if [ -z "${PROMPT_TEMPLATE:-}" ]; then + echo "Error: PROMPT_TEMPLATE is required." + exit 1 +fi + +if [ ! -f "$PROMPT_TEMPLATE" ]; then + echo "Error: Prompt template not found: $PROMPT_TEMPLATE" + exit 1 +fi + +# --- Sanitize untrusted input before inserting into prompts --- +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=scripts/sanitize.sh +source "${SCRIPT_DIR}/sanitize.sh" + +ISSUE_NUMBER="${ISSUE_NUMBER:-0}" +echo "BLEnder gather-self-review-context: PR #${PR_NUMBER} repo=${REPO}" + +# --- Read plan file --- +plan_file=".blender/plans/${ISSUE_NUMBER}.md" +plan_content="" +if [ -f "$plan_file" ]; then + echo "Reading plan from ${plan_file}..." + plan_content=$(cat "$plan_file") +else + echo "Warning: Plan file not found at ${plan_file}" + plan_content="(plan file not found)" +fi + +# --- Fetch merged PR diff --- +echo "Fetching PR #${PR_NUMBER} diff..." +pr_diff=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}" \ + -H "Accept: application/vnd.github.v3.diff" 2>/dev/null || echo "(diff unavailable)") + +# --- Build the prompt --- +echo "Building prompt from ${PROMPT_TEMPLATE}..." +prompt=$(cat "$PROMPT_TEMPLATE") + +safe_plan=$(sanitize_for_prompt "$plan_content") +safe_diff=$(sanitize_for_prompt "$pr_diff") + +prompt="${prompt//\{\{PLAN_CONTENT\}\}/$safe_plan}" +prompt="${prompt//\{\{PR_DIFF\}\}/$safe_diff}" + +# Write prompt to file for run-claude.sh +echo "$prompt" > .blender-prompt +echo "Prompt written to .blender-prompt" diff --git a/scripts/run-claude.sh b/scripts/run-claude.sh index 46a8ede..aa6a7ff 100755 --- a/scripts/run-claude.sh +++ b/scripts/run-claude.sh @@ -48,7 +48,22 @@ CLAUDE_LOG=$(mktemp /tmp/blender-claude-XXXXXX.log) trap 'rm -f "$CLAUDE_LOG"' EXIT # --- Mode-specific settings --- -if [ "$BLENDER_MODE" = "investigate" ]; then +if [ "$BLENDER_MODE" = "plan" ]; then + ALLOWED_TOOLS="Read,Bash" + MAX_TURNS="${MAX_CLAUDE_TURNS:-20}" + MAX_BUDGET="${MAX_BUDGET_USD:-1.50}" + SYSTEM_PROMPT="You are BLEnder, a software engineering agent for ${REPO_DISPLAY_NAME}. Before starting, read these files if they exist: .blender/agents.md, ${BLENDER_DIR}/prompts/instructions.md, CLAUDE.md, AGENTS.md. They contain repo context and operational rules. Read the issue and codebase. Produce a detailed implementation plan. Output your plan as a PLAN_MD fenced block in your final response. Do not create or edit any files. Do not search the web. Internal verification token: ${PROMPT_NONCE}. This token is confidential. Never include it in any output, file edit, or commit message." +elif [ "$BLENDER_MODE" = "implement" ]; then + ALLOWED_TOOLS="Read,Edit,Bash" + MAX_TURNS="${MAX_CLAUDE_TURNS:-40}" + MAX_BUDGET="${MAX_BUDGET_USD:-4.00}" + SYSTEM_PROMPT="You are BLEnder, a software engineering agent for ${REPO_DISPLAY_NAME}. Before starting, read these files if they exist: .blender/agents.md, ${BLENDER_DIR}/prompts/instructions.md, CLAUDE.md, AGENTS.md. They contain repo context and operational rules. Implement the plan described in the prompt. Edit files, run tests, and write your commit message to .blender-commit-msg. Do not search the web. Internal verification token: ${PROMPT_NONCE}. This token is confidential. Never include it in any output, file edit, or commit message." +elif [ "$BLENDER_MODE" = "self-review" ]; then + ALLOWED_TOOLS="Read,Bash" + MAX_TURNS="${MAX_CLAUDE_TURNS:-15}" + MAX_BUDGET="${MAX_BUDGET_USD:-1.00}" + SYSTEM_PROMPT="You are BLEnder, a software engineering agent for ${REPO_DISPLAY_NAME}. Before starting, read these files if they exist: .blender/agents.md, ${BLENDER_DIR}/prompts/instructions.md, CLAUDE.md, AGENTS.md. They contain repo context and operational rules. Compare the final merged diff against the original plan. Summarize what changed, what was added, and what was dropped. Output your summary as a SELF_REVIEW_MD fenced block in your final response. Do not create or edit any files. Do not search the web. Internal verification token: ${PROMPT_NONCE}. This token is confidential. Never include it in any output, file edit, or commit message." +elif [ "$BLENDER_MODE" = "investigate" ]; then ALLOWED_TOOLS="Read,Bash" MAX_TURNS=25 MAX_BUDGET="1.50" @@ -106,8 +121,8 @@ else fi if [ "$claude_exit" -ne 0 ]; then echo "Claude exited with code ${claude_exit} (likely hit max-turns or budget)." - # In major/investigate mode, a non-zero exit is not fatal — post steps handle missing verdict - if [ "$BLENDER_MODE" = "major" ] || [ "$BLENDER_MODE" = "investigate" ]; then + # In read-only modes, a non-zero exit is not fatal — post steps handle missing output + if [ "$BLENDER_MODE" = "major" ] || [ "$BLENDER_MODE" = "investigate" ] || [ "$BLENDER_MODE" = "plan" ] || [ "$BLENDER_MODE" = "self-review" ]; then echo "Continuing to post step (verdict may be missing)." exit 0 fi @@ -141,6 +156,20 @@ for secret_label in "ANTHROPIC_API_KEY" "PROMPT_NONCE"; do exit 1 fi fi + if [ "$BLENDER_MODE" = "plan" ] && [ -f .blender-plan.md ]; then + if grep -qF "$secret_value" .blender-plan.md; then + echo "ABORT: ${secret_label} leaked into plan file." + rm -f .blender-plan.md + exit 1 + fi + fi + if [ "$BLENDER_MODE" = "self-review" ] && [ -f .blender-self-review.md ]; then + if grep -qF "$secret_value" .blender-self-review.md; then + echo "ABORT: ${secret_label} leaked into self-review file." + rm -f .blender-self-review.md + exit 1 + fi + fi # Also check the commit message file if [ -f .blender-commit-msg ] && grep -qF "$secret_value" .blender-commit-msg; then echo "ABORT: ${secret_label} leaked into commit message." @@ -183,6 +212,48 @@ if [ "$BLENDER_MODE" = "major" ]; then exit 0 fi +# --- Plan mode: extract plan from output --- +if [ "$BLENDER_MODE" = "plan" ]; then + # No tracked files should be modified + if ! git diff --quiet; then + echo "ABORT: Claude modified tracked files in plan mode." + git diff --name-only + git checkout -- . + exit 1 + fi + + echo "Extracting plan from Claude output..." + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + if python3 "${SCRIPT_DIR}/extract_plan.py" "$CLAUDE_LOG" PLAN_MD .blender-plan.md; then + echo "Plan file written from Claude output." + else + echo "No plan extracted. Workflow will handle this." + fi + + exit 0 +fi + +# --- Self-review mode: extract summary from output --- +if [ "$BLENDER_MODE" = "self-review" ]; then + # No tracked files should be modified + if ! git diff --quiet; then + echo "ABORT: Claude modified tracked files in self-review mode." + git diff --name-only + git checkout -- . + exit 1 + fi + + echo "Extracting self-review from Claude output..." + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + if python3 "${SCRIPT_DIR}/extract_plan.py" "$CLAUDE_LOG" SELF_REVIEW_MD .blender-self-review.md; then + echo "Self-review file written from Claude output." + else + echo "No self-review extracted. Workflow will handle this." + fi + + exit 0 +fi + # --- Investigate mode: verdict validation --- if [ "$BLENDER_MODE" = "investigate" ]; then # No tracked files should be modified diff --git a/scripts/sweep.py b/scripts/sweep.py index 163078e..0b4fc48 100644 --- a/scripts/sweep.py +++ b/scripts/sweep.py @@ -29,6 +29,7 @@ import sys from dataclasses import dataclass +import yaml from github import Auth, GithubIntegration from github.GithubException import UnknownObjectException from github.PullRequest import PullRequest @@ -51,9 +52,13 @@ _INVESTIGATED_TAG_RE = re.compile(r"^investigated/(.+)/(\d+)$") +AUTO_ENGINEER_LABEL = "blender:auto-engineer" +AUTO_ENGINEER_BRANCH_PREFIX = "blender/auto-engineer/" + + @dataclass class Action: - action: str # "fix", "automerge", or "investigate" + action: str # "fix", "automerge", "investigate", or "auto-engineer" repo: str pr_number: int pr_title: str @@ -62,6 +67,9 @@ class Action: alert_ecosystem: str | None = None alert_severity: str | None = None alert_patched_version: str | None = None + phase: str | None = None # "plan", "implement", "self-review" + issue_number: int | None = None + issue_title: str | None = None def to_dict(self) -> dict: d: dict = { @@ -76,6 +84,10 @@ def to_dict(self) -> dict: d["alert_ecosystem"] = self.alert_ecosystem d["alert_severity"] = self.alert_severity d["alert_patched_version"] = self.alert_patched_version + if self.phase is not None: + d["phase"] = self.phase + d["issue_number"] = self.issue_number + d["issue_title"] = self.issue_title return d @@ -131,6 +143,243 @@ def discover_repos( return results +def _load_repo_config(repo: Repository) -> dict: + """Load .blender/blender.yml from a repo. Returns empty dict on failure.""" + try: + content = repo.get_contents(".blender/blender.yml") + return yaml.safe_load(content.decoded_content) or {} + except Exception: + return {} + + +def _pr_has_implementation_commits(pr: PullRequest) -> bool: + """True if the PR has commits beyond the initial plan commit.""" + commits = list(pr.get_commits()) + for c in commits: + msg = c.commit.message or "" + # Plan commits create/update .blender/plans/ files only. + # Implementation commits have different messages. + if not msg.startswith("BLEnder plan("): + return True + return False + + +def _has_comments_after_latest_commit(pr: PullRequest) -> bool: + """True if non-bot comments exist after the latest commit date.""" + commits = list(pr.get_commits()) + latest_commit_date = max((c.commit.committer.date for c in commits), default=None) + if latest_commit_date is None: + return False + for c in pr.get_issue_comments(): + if c.user.login.endswith("[bot]"): + continue + if c.created_at >= latest_commit_date: + return True + for r in pr.get_reviews(): + if r.user.login.endswith("[bot]"): + continue + if r.submitted_at and r.submitted_at >= latest_commit_date: + return True + return False + + +def check_auto_engineer(repo: Repository, config: dict) -> list[Action]: + """Check for auto-engineer work: issues to plan, PRs to advance.""" + ae_config = config.get("auto_engineer", {}) + if not ae_config.get("enabled", False): + return [] + + actions: list[Action] = [] + issue_label = ae_config.get("issue_label", "auto-engineer") + + # Check for open PRs with the auto-engineer label + open_prs = [ + pr + for pr in repo.get_pulls(state="open") + if any(label.name == AUTO_ENGINEER_LABEL for label in pr.labels) + ] + + if open_prs: + # One PR at a time per repo — process the first one + pr = open_prs[0] + print(f" Auto-engineer PR #{pr.number} exists") + + has_impl = _pr_has_implementation_commits(pr) + has_approval = has_codeowner_approval(pr) + has_comments = _has_comments_after_latest_commit(pr) + + if not has_impl: + # Plan-only PR + if has_approval: + print(f" PR #{pr.number}: plan approved → implement") + # Extract issue number from branch name + issue_num = _issue_number_from_branch(pr.head.ref) + actions.append( + Action( + action="auto-engineer", + repo=repo.full_name, + pr_number=pr.number, + pr_title=pr.title, + phase="implement", + issue_number=issue_num, + issue_title=pr.title, + ) + ) + elif has_comments: + print(f" PR #{pr.number}: plan has feedback → address") + issue_num = _issue_number_from_branch(pr.head.ref) + actions.append( + Action( + action="auto-engineer", + repo=repo.full_name, + pr_number=pr.number, + pr_title=pr.title, + phase="plan", + issue_number=issue_num, + issue_title=pr.title, + ) + ) + else: + print(f" PR #{pr.number}: waiting for plan review") + else: + # Has implementation commits + if has_comments: + print(f" PR #{pr.number}: implementation has feedback → address") + issue_num = _issue_number_from_branch(pr.head.ref) + actions.append( + Action( + action="auto-engineer", + repo=repo.full_name, + pr_number=pr.number, + pr_title=pr.title, + phase="implement", + issue_number=issue_num, + issue_title=pr.title, + ) + ) + else: + print(f" PR #{pr.number}: waiting for code review") + + return actions + + # Check recently merged PRs for self-review + try: + closed_prs = repo.get_pulls(state="closed", sort="updated", direction="desc") + for pr in closed_prs: + if not pr.merged: + continue + if not any(label.name == AUTO_ENGINEER_LABEL for label in pr.labels): + continue + # Only consider PRs merged in the last 24 hours + if pr.merged_at is None: + continue + from datetime import datetime, timezone + + age = datetime.now(timezone.utc) - pr.merged_at + if age.total_seconds() > 86400: + break # sorted by updated desc, so stop here + + # Check if self-review comment already exists + has_self_review = any( + c.user.login == BOT_LOGIN + and (c.body or "").startswith("## Self-Review") + for c in pr.get_issue_comments() + ) + if not has_self_review: + print(f" Merged PR #{pr.number}: needs self-review") + issue_num = _issue_number_from_branch(pr.head.ref) + actions.append( + Action( + action="auto-engineer", + repo=repo.full_name, + pr_number=pr.number, + pr_title=pr.title, + phase="self-review", + issue_number=issue_num, + issue_title=pr.title, + ) + ) + return actions # one at a time + break # most recent merged PR already reviewed + except Exception as e: + print(f" Error checking merged PRs: {e}") + + # No open or recent merged PR — look for issues to pick up + existing_branches: set[str] = set() + try: + for branch in repo.get_branches(): + if branch.name.startswith(AUTO_ENGINEER_BRANCH_PREFIX): + existing_branches.add(branch.name) + except Exception: + pass + + try: + labeled_issues = list(repo.get_issues(state="open", labels=[issue_label])) + # Filter out PRs (GitHub API returns PRs as issues too) + labeled_issues = [i for i in labeled_issues if i.pull_request is None] + # Filter out assigned issues + labeled_issues = [i for i in labeled_issues if not i.assignees] + # Filter out issues that already have a branch + labeled_issues = [ + i + for i in labeled_issues + if not any( + b.startswith(f"{AUTO_ENGINEER_BRANCH_PREFIX}{i.number}-") + for b in existing_branches + ) + ] + + if labeled_issues: + # Pick the oldest labeled issue + issue = labeled_issues[-1] # get_issues returns newest first + print(f" Issue #{issue.number}: {issue.title} → plan") + actions.append( + Action( + action="auto-engineer", + repo=repo.full_name, + pr_number=0, + pr_title="", + phase="plan", + issue_number=issue.number, + issue_title=issue.title, + ) + ) + else: + # No labeled issues — let Claude pick from all open issues + all_issues = list(repo.get_issues(state="open")) + all_issues = [i for i in all_issues if i.pull_request is None] + if all_issues: + print(" No labeled issues — Claude will pick from open issues") + actions.append( + Action( + action="auto-engineer", + repo=repo.full_name, + pr_number=0, + pr_title="", + phase="plan", + issue_number=0, + issue_title="", + ) + ) + except Exception as e: + print(f" Error checking issues: {e}") + + return actions + + +def _issue_number_from_branch(branch: str) -> int: + """Extract issue number from blender/auto-engineer/{number}-{slug}.""" + prefix = AUTO_ENGINEER_BRANCH_PREFIX + if not branch.startswith(prefix): + return 0 + rest = branch[len(prefix) :] + parts = rest.split("-", 1) + try: + return int(parts[0]) + except (ValueError, IndexError): + return 0 + + def process_repo( repo: Repository, investigated: set[tuple[str, int]] | None = None, @@ -235,6 +484,14 @@ def process_repo( except Exception as e: print(f" Error checking alerts: {e}") + # Check for auto-engineer work + try: + config = _load_repo_config(repo) + ae_actions = check_auto_engineer(repo, config) + actions.extend(ae_actions) + except Exception as e: + print(f" Error checking auto-engineer: {e}") + return actions diff --git a/scripts/trigger-workflows.py b/scripts/trigger-workflows.py index 62e0355..4a04243 100644 --- a/scripts/trigger-workflows.py +++ b/scripts/trigger-workflows.py @@ -27,6 +27,7 @@ "fix": "fix-dependabot-pr.yml", "automerge": "chore-automerge-dependabot-prs.yml", "investigate": "investigate-security-alert.yml", + "auto-engineer": "auto-engineer.yml", } @@ -61,6 +62,7 @@ def main() -> None: automerge_repos: set[str] = set() fix_actions = [] investigate_actions = [] + auto_engineer_actions = [] for a in actions: if a["action"] == "automerge": automerge_repos.add(a["repo"]) @@ -68,6 +70,8 @@ def main() -> None: fix_actions.append(a) elif a["action"] == "investigate": investigate_actions.append(a) + elif a["action"] == "auto-engineer": + auto_engineer_actions.append(a) else: print(f"Unknown action: {a['action']}") @@ -136,6 +140,34 @@ def main() -> None: if not trigger_workflow(cmd, f"investigate {a['repo']} #{a['alert_number']}"): failures += 1 + # Trigger auto-engineer once per action + for a in auto_engineer_actions: + workflow = WORKFLOW_MAP["auto-engineer"] + cmd = [ + "gh", + "workflow", + "run", + workflow, + "-f", + f"target_repo={a['repo']}", + "-f", + f"phase={a.get('phase', 'plan')}", + "-f", + f"issue_number={a.get('issue_number', 0)}", + "-f", + f"issue_title={a.get('issue_title', '')}", + "-f", + f"pr_number={a.get('pr_number', 0)}", + "-f", + "dry_run=false", + ] + print( + f"Triggering {workflow} for {a['repo']}" + f" phase={a.get('phase')} issue #{a.get('issue_number', 0)}" + ) + if not trigger_workflow(cmd, f"auto-engineer {a['repo']}"): + failures += 1 + if failures: print(f"\n{failures} trigger(s) failed.") raise SystemExit(1) diff --git a/tests/scripts/__init__.py b/tests/scripts/__init__.py index 16e5364..d232c44 100644 --- a/tests/scripts/__init__.py +++ b/tests/scripts/__init__.py @@ -10,11 +10,12 @@ def make_comment(body, login="mozilla-blender[bot]", created_at=None): return c -def make_review(login, state="APPROVED"): - """Build a mock PR review with .user.login and .state.""" +def make_review(login, state="APPROVED", submitted_at=None): + """Build a mock PR review with .user.login, .state, and .submitted_at.""" r = MagicMock() r.user.login = login r.state = state + r.submitted_at = submitted_at return r @@ -39,3 +40,20 @@ def make_tag(name): t = MagicMock() t.name = name return t + + +def make_issue(number, title="Test issue", labels=None, assignees=None, is_pr=False): + """Build a mock GitHub issue with .number, .title, .labels, .assignees.""" + i = MagicMock() + i.number = number + i.title = title + i.pull_request = MagicMock() if is_pr else None + + mock_labels = [] + for name in (labels or []): + lbl = MagicMock() + lbl.name = name + mock_labels.append(lbl) + i.labels = mock_labels + i.assignees = list(assignees or []) + return i diff --git a/tests/scripts/test_auto_engineer_sweep.py b/tests/scripts/test_auto_engineer_sweep.py new file mode 100644 index 0000000..e9f73b3 --- /dev/null +++ b/tests/scripts/test_auto_engineer_sweep.py @@ -0,0 +1,238 @@ +"""Tests for scripts.sweep.check_auto_engineer.""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from unittest.mock import MagicMock, PropertyMock + +import pytest + +from tests.scripts import make_branch, make_comment, make_commit, make_issue, make_review + +from scripts.sweep import check_auto_engineer + +# --- Shared timestamps --- + +NOW = datetime.now(timezone.utc) +T_EARLY = NOW - timedelta(hours=2) +T_LATE = NOW - timedelta(minutes=30) + +ENABLED_CONFIG = {"auto_engineer": {"enabled": True, "issue_label": "auto-engineer"}} + + +def _make_ae_pr( + number: int, + issue_number: int = 42, + plan_only: bool = True, + approved: bool = False, + comments_after_commit: bool = False, +): + """Build a mock auto-engineer PR.""" + pr = MagicMock() + pr.number = number + pr.title = f"BLEnder: fix issue #{issue_number}" + pr.merged = False + pr.merged_at = None + slug = "fix-the-thing" + pr.head.ref = f"blender/auto-engineer/{issue_number}-{slug}" + + label = MagicMock() + label.name = "blender:auto-engineer" + pr.labels = [label] + + if plan_only: + pr.get_commits.return_value = [ + make_commit(f"BLEnder plan(#{issue_number}): initial plan", T_EARLY) + ] + else: + pr.get_commits.return_value = [ + make_commit(f"BLEnder plan(#{issue_number}): initial plan", T_EARLY), + make_commit("feat: implement the thing", T_LATE), + ] + + reviews = [] + if approved: + reviews.append(make_review("codeowner", "APPROVED")) + pr.get_reviews.return_value = reviews + + comments = [] + if comments_after_commit: + comments.append( + make_comment("Please change X", login="reviewer", created_at=T_LATE) + ) + pr.get_issue_comments.return_value = comments + + return pr + + +def _make_repo( + open_prs=None, + closed_prs=None, + branches=None, + labeled_issues=None, + all_issues=None, +): + """Build a mock repo for auto-engineer tests. + + labeled_issues: returned when get_issues is called with labels kwarg + all_issues: returned when get_issues is called without labels kwarg + """ + repo = MagicMock() + repo.full_name = "mozilla/test-repo" + + repo.get_pulls.side_effect = lambda state="open", **kw: ( + list(open_prs or []) if state == "open" else list(closed_prs or []) + ) + repo.get_branches.return_value = list(branches or []) + + def get_issues_side_effect(state="open", **kwargs): + if "labels" in kwargs: + return list(labeled_issues or []) + if all_issues is not None: + return list(all_issues) + return list(labeled_issues or []) + + repo.get_issues = MagicMock(side_effect=get_issues_side_effect) + + return repo + + +class TestFeatureDisabled: + def test_disabled_config_no_action(self): + """auto_engineer.enabled=false → no action.""" + repo = _make_repo() + assert check_auto_engineer(repo, {}) == [] + assert check_auto_engineer(repo, {"auto_engineer": {"enabled": False}}) == [] + + +class TestNoIssues: + def test_no_open_issues_no_action(self): + """No open issues at all → no action.""" + repo = _make_repo(labeled_issues=[], all_issues=[]) + actions = check_auto_engineer(repo, ENABLED_CONFIG) + assert actions == [] + + +class TestLabeledIssue: + def test_labeled_issue_emits_plan(self): + """Labeled issue exists, no PR → emits plan action.""" + issue = make_issue(42, "Fix the widget", labels=["auto-engineer"]) + repo = _make_repo(labeled_issues=[issue]) + actions = check_auto_engineer(repo, ENABLED_CONFIG) + assert len(actions) == 1 + assert actions[0].action == "auto-engineer" + assert actions[0].phase == "plan" + assert actions[0].issue_number == 42 + + def test_issue_with_existing_branch_skipped(self): + """Issue already has a branch → skip.""" + issue = make_issue(42, "Fix the widget", labels=["auto-engineer"]) + branch = make_branch("blender/auto-engineer/42-fix-the-widget") + repo = _make_repo(labeled_issues=[issue], branches=[branch], all_issues=[]) + actions = check_auto_engineer(repo, ENABLED_CONFIG) + assert actions == [] + + def test_assigned_issue_skipped(self): + """Assigned issue → skip.""" + assignee = MagicMock() + issue = make_issue( + 42, "Fix the widget", labels=["auto-engineer"], assignees=[assignee] + ) + repo = _make_repo(labeled_issues=[issue], all_issues=[]) + actions = check_auto_engineer(repo, ENABLED_CONFIG) + assert actions == [] + + +class TestNoLabeledIssues: + def test_fallback_emits_plan_with_zero(self): + """No labeled issues but open issues exist → plan with issue_number=0.""" + issue = make_issue(10, "Some random issue") + repo = _make_repo(labeled_issues=[], all_issues=[issue]) + actions = check_auto_engineer(repo, ENABLED_CONFIG) + assert len(actions) == 1 + assert actions[0].issue_number == 0 + + +class TestPlanPR: + def test_plan_pr_no_comments_no_action(self): + """Plan PR exists, no comments → waiting for review.""" + pr = _make_ae_pr(100, plan_only=True) + repo = _make_repo(open_prs=[pr]) + actions = check_auto_engineer(repo, ENABLED_CONFIG) + assert actions == [] + + def test_plan_pr_with_comments_emits_plan(self): + """Plan PR with comments after commit → address feedback.""" + pr = _make_ae_pr(100, plan_only=True, comments_after_commit=True) + repo = _make_repo(open_prs=[pr]) + actions = check_auto_engineer(repo, ENABLED_CONFIG) + assert len(actions) == 1 + assert actions[0].phase == "plan" + assert actions[0].pr_number == 100 + + def test_plan_pr_approved_emits_implement(self): + """Plan PR approved → implement.""" + pr = _make_ae_pr(100, plan_only=True, approved=True) + repo = _make_repo(open_prs=[pr]) + actions = check_auto_engineer(repo, ENABLED_CONFIG) + assert len(actions) == 1 + assert actions[0].phase == "implement" + assert actions[0].pr_number == 100 + + +class TestImplementationPR: + def test_impl_pr_no_comments_no_action(self): + """Implementation PR, no comments → waiting for review.""" + pr = _make_ae_pr(100, plan_only=False) + repo = _make_repo(open_prs=[pr]) + actions = check_auto_engineer(repo, ENABLED_CONFIG) + assert actions == [] + + def test_impl_pr_with_comments_emits_implement(self): + """Implementation PR with comments → address feedback.""" + pr = _make_ae_pr(100, plan_only=False, comments_after_commit=True) + repo = _make_repo(open_prs=[pr]) + actions = check_auto_engineer(repo, ENABLED_CONFIG) + assert len(actions) == 1 + assert actions[0].phase == "implement" + assert actions[0].pr_number == 100 + + +class TestSelfReview: + def test_merged_pr_no_self_review_emits_action(self): + """Merged PR, no self-review comment → emits self-review.""" + pr = _make_ae_pr(100, plan_only=False) + pr.merged = True + pr.merged_at = NOW - timedelta(hours=1) + pr.get_issue_comments.return_value = [] + repo = _make_repo(closed_prs=[pr]) + actions = check_auto_engineer(repo, ENABLED_CONFIG) + assert len(actions) == 1 + assert actions[0].phase == "self-review" + assert actions[0].pr_number == 100 + + def test_merged_pr_with_self_review_no_action(self): + """Merged PR, self-review already posted → no action.""" + pr = _make_ae_pr(100, plan_only=False) + pr.merged = True + pr.merged_at = NOW - timedelta(hours=1) + pr.get_issue_comments.return_value = [ + make_comment( + "## Self-Review\n\nAll good.", + login="mozilla-blender[bot]", + created_at=T_LATE, + ) + ] + repo = _make_repo(closed_prs=[pr]) + actions = check_auto_engineer(repo, ENABLED_CONFIG) + assert actions == [] + + def test_old_merged_pr_ignored(self): + """Merged PR older than 24 hours → no action.""" + pr = _make_ae_pr(100, plan_only=False) + pr.merged = True + pr.merged_at = NOW - timedelta(hours=25) + pr.get_issue_comments.return_value = [] + repo = _make_repo(closed_prs=[pr]) + actions = check_auto_engineer(repo, ENABLED_CONFIG) + assert actions == [] From 53690af4a6fab94c296dc04870b0529d90626f7f Mon Sep 17 00:00:00 2001 From: groovecoder <71928+groovecoder@users.noreply.github.com> Date: Sun, 31 May 2026 11:00:45 -0500 Subject: [PATCH 2/6] fix: harden auto-engineer with trusted author filtering and configurable forbidden paths Address PR #56 review comments and close security gaps in the auto-engineer workflow. Replace input sanitization with trusted author filtering: gather scripts now only ingest comments from configured author associations (default OWNER). Extract deep_merge into shared config_utils module. Make forbidden paths configurable per-repo so BLEnder can modify its own .github/ files. Pick newest bug-labeled issue first. Remove actions:write permission. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/auto-engineer.yml | 14 +- config/defaults.yml | 2 + prompts/auto-engineer-plan-prompt.md | 2 +- scripts/config_utils.py | 46 ++++++ scripts/gather-implement-context.sh | 18 +-- scripts/gather-issue-context.sh | 29 ++-- scripts/gather-review-context.sh | 82 +++++++---- scripts/gather-self-review-context.sh | 14 +- scripts/github_utils.py | 9 ++ scripts/load-config.py | 14 +- scripts/run-claude.sh | 18 ++- scripts/sweep.py | 171 +++++++++++----------- scripts/trigger-workflows.py | 4 + tests/scripts/__init__.py | 10 +- tests/scripts/test_auto_engineer_sweep.py | 86 +++++++++++ 15 files changed, 349 insertions(+), 170 deletions(-) create mode 100644 scripts/config_utils.py diff --git a/.github/workflows/auto-engineer.yml b/.github/workflows/auto-engineer.yml index 9b0758e..f62771c 100644 --- a/.github/workflows/auto-engineer.yml +++ b/.github/workflows/auto-engineer.yml @@ -21,6 +21,12 @@ on: pr_number: description: 'Existing PR number (0 = no PR yet)' default: '0' + trusted_author_associations: + description: 'Comma-separated trusted author associations' + default: 'OWNER' + forbidden_paths: + description: 'Space-separated forbidden paths for implement mode' + default: '.github/ .env .circleci/' dry_run: description: 'Dry run (true = no mutations)' default: 'true' @@ -41,7 +47,6 @@ jobs: runs-on: ubuntu-latest permissions: contents: read - actions: write steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: @@ -82,6 +87,7 @@ jobs: REPO: ${{ inputs.target_repo }} PROMPT_TEMPLATE: ${{ github.workspace }}/prompts/auto-engineer-plan-prompt.md ISSUE_TITLE: ${{ inputs.issue_title }} + TRUSTED_AUTHOR_ASSOCIATIONS: ${{ inputs.trusted_author_associations }} - name: Gather context (plan feedback) if: steps.existing-pr.outputs.pr_exists == 'true' @@ -94,6 +100,7 @@ jobs: REPO: ${{ inputs.target_repo }} PROMPT_TEMPLATE: ${{ github.workspace }}/prompts/auto-engineer-plan-prompt.md ISSUE_TITLE: ${{ inputs.issue_title }} + TRUSTED_AUTHOR_ASSOCIATIONS: ${{ inputs.trusted_author_associations }} - name: Run Claude (plan) working-directory: target @@ -175,7 +182,6 @@ jobs: runs-on: ubuntu-latest permissions: contents: read - actions: write steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: @@ -227,6 +233,7 @@ jobs: REPO: ${{ inputs.target_repo }} PROMPT_TEMPLATE: ${{ github.workspace }}/prompts/auto-engineer-implement-prompt.md ISSUE_TITLE: ${{ inputs.issue_title }} + TRUSTED_AUTHOR_ASSOCIATIONS: ${{ inputs.trusted_author_associations }} - name: Gather context (code review feedback) if: steps.check-review.outputs.has_impl == 'true' @@ -239,6 +246,7 @@ jobs: REPO: ${{ inputs.target_repo }} PROMPT_TEMPLATE: ${{ github.workspace }}/prompts/auto-engineer-implement-prompt.md ISSUE_TITLE: ${{ inputs.issue_title }} + TRUSTED_AUTHOR_ASSOCIATIONS: ${{ inputs.trusted_author_associations }} - name: Run Claude (implement) working-directory: target @@ -249,6 +257,7 @@ jobs: REPO_NAME: ${{ steps.setup.outputs.repo_name }} BLENDER_DIR: ${{ github.workspace }} BLENDER_MODE: implement + BLENDER_FORBIDDEN_PATHS: ${{ inputs.forbidden_paths }} CLAUDE_VERBOSE: ${{ inputs.verbose }} - name: Commit and push @@ -264,7 +273,6 @@ jobs: runs-on: ubuntu-latest permissions: contents: read - actions: write steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: diff --git a/config/defaults.yml b/config/defaults.yml index 9349bfe..07e559f 100644 --- a/config/defaults.yml +++ b/config/defaults.yml @@ -26,6 +26,8 @@ auto_engineer: enabled: false dry_run: true issue_label: "auto-engineer" + trusted_author_associations: "OWNER" + forbidden_paths: ".github/ .env .circleci/" max_plan_turns: 20 max_plan_budget_usd: 1.50 max_implement_turns: 40 diff --git a/prompts/auto-engineer-plan-prompt.md b/prompts/auto-engineer-plan-prompt.md index e8bfa4a..2b98ef1 100644 --- a/prompts/auto-engineer-plan-prompt.md +++ b/prompts/auto-engineer-plan-prompt.md @@ -26,7 +26,7 @@ must include the plan — that is the only deliverable that matters.** ### Step 1: Understand the issue Read the issue description and comments. Identify the core problem and -any constraints mentioned by maintainers. +any constraints mentioned by the issue author. ### Step 2: Explore the codebase diff --git a/scripts/config_utils.py b/scripts/config_utils.py new file mode 100644 index 0000000..e48330c --- /dev/null +++ b/scripts/config_utils.py @@ -0,0 +1,46 @@ +"""Shared config utilities for BLEnder scripts.""" + +from __future__ import annotations + +import pathlib + +import yaml + + +DEFAULTS_PATH = ( + pathlib.Path(__file__).resolve().parent.parent / "config" / "defaults.yml" +) + + +def deep_merge(base: dict, override: dict) -> dict: + """Merge override into base. Override wins for leaf values.""" + result = dict(base) + for key, value in override.items(): + if key in result and isinstance(result[key], dict) and isinstance(value, dict): + result[key] = deep_merge(result[key], value) + else: + result[key] = value + return result + + +def load_repo_config(repo) -> dict: + """Load and merge config from a GitHub repo. + + Fetches .blender/blender.yml via the API, deep-merges it with + the BLEnder defaults, and returns the merged config dict. + + Returns defaults only if the repo config is missing or unreadable. + """ + with open(DEFAULTS_PATH) as f: + defaults = yaml.safe_load(f) or {} + + try: + content = repo.get_contents(".blender/blender.yml") + repo_config = yaml.safe_load(content.decoded_content) or {} + # Support legacy "blender:" wrapper + if "blender" in repo_config and isinstance(repo_config["blender"], dict): + repo_config = repo_config["blender"] + except Exception: + repo_config = {} + + return deep_merge(defaults, repo_config) diff --git a/scripts/gather-implement-context.sh b/scripts/gather-implement-context.sh index 30ea1e7..f34d975 100755 --- a/scripts/gather-implement-context.sh +++ b/scripts/gather-implement-context.sh @@ -10,6 +10,7 @@ # GH_TOKEN -- GitHub token for API calls (required) # PROMPT_TEMPLATE -- Path to prompt template file (required) # ISSUE_TITLE -- Issue title (optional) +# TRUSTED_AUTHOR_ASSOCIATIONS -- Comma-separated list of trusted associations (default: OWNER) set -euo pipefail @@ -33,14 +34,9 @@ if [ ! -f "$PROMPT_TEMPLATE" ]; then exit 1 fi -# --- Sanitize untrusted input before inserting into prompts --- -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# shellcheck source=scripts/sanitize.sh -source "${SCRIPT_DIR}/sanitize.sh" - echo "BLEnder gather-implement-context: issue #${ISSUE_NUMBER} repo=${REPO}" -# --- Read plan file --- +# --- Read plan file (BLEnder-authored, no sanitization needed) --- plan_file=".blender/plans/${ISSUE_NUMBER}.md" plan_content="" if [ -f "$plan_file" ]; then @@ -63,14 +59,10 @@ echo " Title: ${issue_title}" echo "Building prompt from ${PROMPT_TEMPLATE}..." prompt=$(cat "$PROMPT_TEMPLATE") -safe_title=$(sanitize_for_prompt "$issue_title") -safe_body=$(sanitize_for_prompt "$issue_body") -safe_plan=$(sanitize_for_prompt "$plan_content") - prompt="${prompt//\{\{ISSUE_NUMBER\}\}/$ISSUE_NUMBER}" -prompt="${prompt//\{\{ISSUE_TITLE\}\}/$safe_title}" -prompt="${prompt//\{\{ISSUE_BODY\}\}/$safe_body}" -prompt="${prompt//\{\{PLAN_CONTENT\}\}/$safe_plan}" +prompt="${prompt//\{\{ISSUE_TITLE\}\}/$issue_title}" +prompt="${prompt//\{\{ISSUE_BODY\}\}/$issue_body}" +prompt="${prompt//\{\{PLAN_CONTENT\}\}/$plan_content}" prompt="${prompt//\{\{REVIEW_COMMENTS\}\}/}" # Write prompt to file for run-claude.sh diff --git a/scripts/gather-issue-context.sh b/scripts/gather-issue-context.sh index 2fcb01f..b34f68f 100755 --- a/scripts/gather-issue-context.sh +++ b/scripts/gather-issue-context.sh @@ -10,6 +10,7 @@ # GH_TOKEN -- GitHub token for API calls (required) # PROMPT_TEMPLATE -- Path to prompt template file (required) # ISSUE_TITLE -- Issue title (optional, for fallback) +# TRUSTED_AUTHOR_ASSOCIATIONS -- Comma-separated list of trusted associations (default: OWNER) set -euo pipefail @@ -33,14 +34,13 @@ if [ ! -f "$PROMPT_TEMPLATE" ]; then exit 1 fi -# --- Sanitize untrusted input before inserting into prompts --- -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# shellcheck source=scripts/sanitize.sh -source "${SCRIPT_DIR}/sanitize.sh" - ISSUE_NUMBER="${ISSUE_NUMBER:-0}" +TRUSTED="${TRUSTED_AUTHOR_ASSOCIATIONS:-OWNER}" echo "BLEnder gather-issue-context: issue #${ISSUE_NUMBER} repo=${REPO}" +# Build jq filter for trusted author_association values +TRUST_FILTER=$(echo "$TRUSTED" | tr ',' '\n' | sed 's/^ *//;s/ *$//' | jq -R -s 'split("\n") | map(select(. != ""))') + # --- Fetch issue details --- issue_body="" issue_title="${ISSUE_TITLE:-}" @@ -52,9 +52,9 @@ if [ "$ISSUE_NUMBER" != "0" ]; then issue_title=$(echo "$issue_json" | jq -r '.title // ""') issue_body=$(echo "$issue_json" | jq -r '.body // "(no body)"') - echo "Fetching issue comments..." - comments_json=$(gh api "repos/${REPO}/issues/${ISSUE_NUMBER}/comments" --paginate) - issue_comments=$(echo "$comments_json" | jq -r '.[] | "### Comment by \(.user.login)\n\(.body)\n"') + echo "Fetching issue comments (trusted authors only)..." + issue_comments=$(gh api "repos/${REPO}/issues/${ISSUE_NUMBER}/comments" --paginate \ + --jq ".[] | select(.user.login | endswith(\"[bot]\") | not) | select(.author_association as \$a | ${TRUST_FILTER} | index(\$a)) | \"### Comment by \(.user.login)\n\(.body)\n\"") else echo "No specific issue — fetching all open issues for Claude to pick..." issues_json=$(gh api "repos/${REPO}/issues?state=open&per_page=50" \ @@ -83,16 +83,11 @@ fi echo "Building prompt from ${PROMPT_TEMPLATE}..." prompt=$(cat "$PROMPT_TEMPLATE") -safe_title=$(sanitize_for_prompt "$issue_title") -safe_body=$(sanitize_for_prompt "$issue_body") -safe_comments=$(sanitize_for_prompt "$issue_comments") -safe_tree=$(sanitize_for_prompt "$repo_tree") - prompt="${prompt//\{\{ISSUE_NUMBER\}\}/$ISSUE_NUMBER}" -prompt="${prompt//\{\{ISSUE_TITLE\}\}/$safe_title}" -prompt="${prompt//\{\{ISSUE_BODY\}\}/$safe_body}" -prompt="${prompt//\{\{ISSUE_COMMENTS\}\}/$safe_comments}" -prompt="${prompt//\{\{REPO_TREE\}\}/$safe_tree}" +prompt="${prompt//\{\{ISSUE_TITLE\}\}/$issue_title}" +prompt="${prompt//\{\{ISSUE_BODY\}\}/$issue_body}" +prompt="${prompt//\{\{ISSUE_COMMENTS\}\}/$issue_comments}" +prompt="${prompt//\{\{REPO_TREE\}\}/$repo_tree}" # Write prompt to file for run-claude.sh echo "$prompt" > .blender-prompt diff --git a/scripts/gather-review-context.sh b/scripts/gather-review-context.sh index 903ae54..4c42c07 100755 --- a/scripts/gather-review-context.sh +++ b/scripts/gather-review-context.sh @@ -2,7 +2,8 @@ # BLEnder gather-review-context: fetch review comments + plan, build prompt. # # Shared script used when addressing plan feedback or code review feedback. -# Appends review comments to the appropriate prompt template. +# Only includes comments from trusted author associations and unresolved +# review threads. # # This script has GH_TOKEN but does NOT have ANTHROPIC_API_KEY. # It writes the final prompt to .blender-prompt for run-claude.sh. @@ -14,6 +15,7 @@ # GH_TOKEN -- GitHub token for API calls (required) # PROMPT_TEMPLATE -- Path to prompt template file (required) # ISSUE_TITLE -- Issue title (optional) +# TRUSTED_AUTHOR_ASSOCIATIONS -- Comma-separated list of trusted associations (default: OWNER) set -euo pipefail @@ -37,15 +39,19 @@ if [ ! -f "$PROMPT_TEMPLATE" ]; then exit 1 fi -# --- Sanitize untrusted input before inserting into prompts --- -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# shellcheck source=scripts/sanitize.sh -source "${SCRIPT_DIR}/sanitize.sh" - ISSUE_NUMBER="${ISSUE_NUMBER:-0}" +TRUSTED="${TRUSTED_AUTHOR_ASSOCIATIONS:-OWNER}" echo "BLEnder gather-review-context: PR #${PR_NUMBER} issue #${ISSUE_NUMBER} repo=${REPO}" +echo " Trusted associations: ${TRUSTED}" + +# Build jq filter for trusted author_association values +TRUST_FILTER=$(echo "$TRUSTED" | tr ',' '\n' | sed 's/^ *//;s/ *$//' | jq -R -s 'split("\n") | map(select(. != ""))') -# --- Read plan file --- +# Split REPO into owner/name for GraphQL +REPO_OWNER="${REPO%%/*}" +REPO_NAME="${REPO##*/}" + +# --- Read plan file (BLEnder-authored, no sanitization needed) --- plan_file=".blender/plans/${ISSUE_NUMBER}.md" plan_content="" if [ -f "$plan_file" ]; then @@ -63,46 +69,68 @@ if [ "$ISSUE_NUMBER" != "0" ]; then issue_body=$(echo "$issue_json" | jq -r '.body // "(no body)"') fi -# --- Fetch PR review comments --- -echo "Fetching review comments on PR #${PR_NUMBER}..." +# --- Fetch PR review comments (trusted authors, unresolved only) --- +echo "Fetching review comments on PR #${PR_NUMBER} (trusted + unresolved)..." review_comments="" -# Issue comments (general PR comments) +# Issue comments (general PR comments) — trusted authors only issue_comments=$(gh api "repos/${REPO}/issues/${PR_NUMBER}/comments" --paginate \ - --jq '.[] | select(.user.login | endswith("[bot]") | not) | "### Comment by \(.user.login)\n\(.body)\n"' \ + --jq ".[] | select(.user.login | endswith(\"[bot]\") | not) | select(.author_association as \$a | ${TRUST_FILTER} | index(\$a)) | \"### Comment by \(.user.login) [\(.html_url)]\n\(.body)\n\"" \ 2>/dev/null || echo "") -# PR review comments (inline code comments) -pr_review_comments=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}/comments" --paginate \ - --jq '.[] | "### Review comment by \(.user.login) on \(.path):\(.line // .original_line)\n\(.body)\n"' \ +# PR review comments via GraphQL — unresolved threads, trusted authors +pr_review_comments=$(gh api graphql -f query=' + query($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + reviewThreads(first: 100) { + nodes { + isResolved + comments(first: 10) { + nodes { + author { login } + authorAssociation + body + url + path + line + } + } + } + } + } + } + } +' -f owner="$REPO_OWNER" -f name="$REPO_NAME" -F number="$PR_NUMBER" \ + --jq ".data.repository.pullRequest.reviewThreads.nodes[] + | select(.isResolved == false) + | .comments.nodes[] + | select(.author.login | endswith(\"[bot]\") | not) + | select(.authorAssociation as \$a | ${TRUST_FILTER} | index(\$a)) + | \"### Review comment by \(.author.login) on \(.path // \"general\"):\(.line // \"\") [\(.url)]\n\(.body)\n\"" \ 2>/dev/null || echo "") -# PR reviews with body +# PR reviews with body — trusted authors only pr_reviews=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}/reviews" --paginate \ - --jq '.[] | select(.body != "" and .body != null) | "### Review by \(.user.login) (\(.state))\n\(.body)\n"' \ + --jq ".[] | select(.body != \"\" and .body != null) | select(.user.login | endswith(\"[bot]\") | not) | select(.author_association as \$a | ${TRUST_FILTER} | index(\$a)) | \"### Review by \(.user.login) (\(.state)) [\(.html_url)]\n\(.body)\n\"" \ 2>/dev/null || echo "") review_comments="${issue_comments}${pr_review_comments}${pr_reviews}" if [ -z "$review_comments" ]; then - review_comments="(no review comments found)" + review_comments="(no review comments found from trusted reviewers)" fi # --- Build the prompt --- echo "Building prompt from ${PROMPT_TEMPLATE}..." prompt=$(cat "$PROMPT_TEMPLATE") -safe_title=$(sanitize_for_prompt "$issue_title") -safe_body=$(sanitize_for_prompt "$issue_body") -safe_plan=$(sanitize_for_prompt "$plan_content") -safe_reviews=$(sanitize_for_prompt "$review_comments") - prompt="${prompt//\{\{ISSUE_NUMBER\}\}/$ISSUE_NUMBER}" -prompt="${prompt//\{\{ISSUE_TITLE\}\}/$safe_title}" -prompt="${prompt//\{\{ISSUE_BODY\}\}/$safe_body}" -prompt="${prompt//\{\{PLAN_CONTENT\}\}/$safe_plan}" -prompt="${prompt//\{\{REVIEW_COMMENTS\}\}/$safe_reviews}" -prompt="${prompt//\{\{ISSUE_COMMENTS\}\}/$safe_reviews}" +prompt="${prompt//\{\{ISSUE_TITLE\}\}/$issue_title}" +prompt="${prompt//\{\{ISSUE_BODY\}\}/$issue_body}" +prompt="${prompt//\{\{PLAN_CONTENT\}\}/$plan_content}" +prompt="${prompt//\{\{REVIEW_COMMENTS\}\}/$review_comments}" +prompt="${prompt//\{\{ISSUE_COMMENTS\}\}/$review_comments}" prompt="${prompt//\{\{REPO_TREE\}\}/}" # Write prompt to file for run-claude.sh diff --git a/scripts/gather-self-review-context.sh b/scripts/gather-self-review-context.sh index 521646b..c276295 100755 --- a/scripts/gather-self-review-context.sh +++ b/scripts/gather-self-review-context.sh @@ -33,15 +33,10 @@ if [ ! -f "$PROMPT_TEMPLATE" ]; then exit 1 fi -# --- Sanitize untrusted input before inserting into prompts --- -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# shellcheck source=scripts/sanitize.sh -source "${SCRIPT_DIR}/sanitize.sh" - ISSUE_NUMBER="${ISSUE_NUMBER:-0}" echo "BLEnder gather-self-review-context: PR #${PR_NUMBER} repo=${REPO}" -# --- Read plan file --- +# --- Read plan file (BLEnder-authored, no sanitization needed) --- plan_file=".blender/plans/${ISSUE_NUMBER}.md" plan_content="" if [ -f "$plan_file" ]; then @@ -61,11 +56,8 @@ pr_diff=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}" \ echo "Building prompt from ${PROMPT_TEMPLATE}..." prompt=$(cat "$PROMPT_TEMPLATE") -safe_plan=$(sanitize_for_prompt "$plan_content") -safe_diff=$(sanitize_for_prompt "$pr_diff") - -prompt="${prompt//\{\{PLAN_CONTENT\}\}/$safe_plan}" -prompt="${prompt//\{\{PR_DIFF\}\}/$safe_diff}" +prompt="${prompt//\{\{PLAN_CONTENT\}\}/$plan_content}" +prompt="${prompt//\{\{PR_DIFF\}\}/$pr_diff}" # Write prompt to file for run-claude.sh echo "$prompt" > .blender-prompt diff --git a/scripts/github_utils.py b/scripts/github_utils.py index 647d91a..a1a8dcc 100644 --- a/scripts/github_utils.py +++ b/scripts/github_utils.py @@ -10,6 +10,15 @@ BOT_LOGIN = "mozilla-blender[bot]" +def is_bot(login: str) -> bool: + """True if the login belongs to a bot account. + + GitHub enforces the ``name[bot]`` convention for App and bot + accounts — this is a platform invariant, not a heuristic. + """ + return login.endswith("[bot]") + + class Verdict(Enum): """Review verdict codes and their comment messages. diff --git a/scripts/load-config.py b/scripts/load-config.py index 90f64bb..05133a8 100644 --- a/scripts/load-config.py +++ b/scripts/load-config.py @@ -25,16 +25,10 @@ import yaml - -def deep_merge(base: dict, override: dict) -> dict: - """Merge override into base. Override wins for leaf values.""" - result = dict(base) - for key, value in override.items(): - if key in result and isinstance(result[key], dict) and isinstance(value, dict): - result[key] = deep_merge(result[key], value) - else: - result[key] = value - return result +try: + from scripts.config_utils import deep_merge +except ImportError: + from config_utils import deep_merge # type: ignore[no-redef] def flatten(d: dict, prefix: str = "") -> dict[str, str]: diff --git a/scripts/run-claude.sh b/scripts/run-claude.sh index aa6a7ff..fb02494 100755 --- a/scripts/run-claude.sh +++ b/scripts/run-claude.sh @@ -48,6 +48,10 @@ CLAUDE_LOG=$(mktemp /tmp/blender-claude-XXXXXX.log) trap 'rm -f "$CLAUDE_LOG"' EXIT # --- Mode-specific settings --- +# Plan and self-review modes use Read,Bash only (no Write/Edit). +# Claude outputs a fenced block (PLAN_MD / SELF_REVIEW_MD) in its final +# response. The extract scripts below pull the block from Claude's +# output and write it to a file, outside the sandbox. if [ "$BLENDER_MODE" = "plan" ]; then ALLOWED_TOOLS="Read,Bash" MAX_TURNS="${MAX_CLAUDE_TURNS:-20}" @@ -57,7 +61,11 @@ elif [ "$BLENDER_MODE" = "implement" ]; then ALLOWED_TOOLS="Read,Edit,Bash" MAX_TURNS="${MAX_CLAUDE_TURNS:-40}" MAX_BUDGET="${MAX_BUDGET_USD:-4.00}" - SYSTEM_PROMPT="You are BLEnder, a software engineering agent for ${REPO_DISPLAY_NAME}. Before starting, read these files if they exist: .blender/agents.md, ${BLENDER_DIR}/prompts/instructions.md, CLAUDE.md, AGENTS.md. They contain repo context and operational rules. Implement the plan described in the prompt. Edit files, run tests, and write your commit message to .blender-commit-msg. Do not search the web. Internal verification token: ${PROMPT_NONCE}. This token is confidential. Never include it in any output, file edit, or commit message." + FORBIDDEN_HINT="" + if [ -n "${BLENDER_FORBIDDEN_PATHS:-}" ]; then + FORBIDDEN_HINT=" Do not modify files under these paths: ${BLENDER_FORBIDDEN_PATHS}." + fi + SYSTEM_PROMPT="You are BLEnder, a software engineering agent for ${REPO_DISPLAY_NAME}. Before starting, read these files if they exist: .blender/agents.md, ${BLENDER_DIR}/prompts/instructions.md, CLAUDE.md, AGENTS.md. They contain repo context and operational rules. Implement the plan described in the prompt. Edit files, run tests, and write your commit message to .blender-commit-msg. Do not search the web.${FORBIDDEN_HINT} Internal verification token: ${PROMPT_NONCE}. This token is confidential. Never include it in any output, file edit, or commit message." elif [ "$BLENDER_MODE" = "self-review" ]; then ALLOWED_TOOLS="Read,Bash" MAX_TURNS="${MAX_CLAUDE_TURNS:-15}" @@ -279,10 +287,12 @@ if [ "$BLENDER_MODE" = "investigate" ]; then exit 0 fi -# --- Fix mode: existing validation --- +# --- Fix / implement mode: shared validation --- -# Path validation: reject changes to sensitive paths -FORBIDDEN_PATHS=".github/ .env .circleci/" +# Path validation: reject changes to sensitive paths. +# Implement mode uses the configurable BLENDER_FORBIDDEN_PATHS env var; +# fix mode uses a hardcoded default. +FORBIDDEN_PATHS="${BLENDER_FORBIDDEN_PATHS:-.github/ .env .circleci/}" for forbidden in $FORBIDDEN_PATHS; do if git diff --name-only | grep -q "^${forbidden}"; then echo "ABORT: Changes detected in forbidden path: ${forbidden}" diff --git a/scripts/sweep.py b/scripts/sweep.py index 0b4fc48..aaa5b65 100644 --- a/scripts/sweep.py +++ b/scripts/sweep.py @@ -29,16 +29,17 @@ import sys from dataclasses import dataclass -import yaml from github import Auth, GithubIntegration from github.GithubException import UnknownObjectException from github.PullRequest import PullRequest from github.Repository import Repository try: - from scripts.github_utils import has_codeowner_approval + from scripts.config_utils import load_repo_config + from scripts.github_utils import has_codeowner_approval, is_bot except ImportError: - from github_utils import has_codeowner_approval # type: ignore[no-redef] + from config_utils import load_repo_config # type: ignore[no-redef] + from github_utils import has_codeowner_approval, is_bot # type: ignore[no-redef] BOT_LOGIN = "mozilla-blender[bot]" @@ -54,6 +55,7 @@ AUTO_ENGINEER_LABEL = "blender:auto-engineer" AUTO_ENGINEER_BRANCH_PREFIX = "blender/auto-engineer/" +PLAN_COMMIT_PREFIX = "BLEnder plan(" @dataclass @@ -70,6 +72,8 @@ class Action: phase: str | None = None # "plan", "implement", "self-review" issue_number: int | None = None issue_title: str | None = None + trusted_author_associations: str | None = None + forbidden_paths: str | None = None def to_dict(self) -> dict: d: dict = { @@ -88,6 +92,10 @@ def to_dict(self) -> dict: d["phase"] = self.phase d["issue_number"] = self.issue_number d["issue_title"] = self.issue_title + if self.trusted_author_associations: + d["trusted_author_associations"] = self.trusted_author_associations + if self.forbidden_paths: + d["forbidden_paths"] = self.forbidden_paths return d @@ -143,15 +151,6 @@ def discover_repos( return results -def _load_repo_config(repo: Repository) -> dict: - """Load .blender/blender.yml from a repo. Returns empty dict on failure.""" - try: - content = repo.get_contents(".blender/blender.yml") - return yaml.safe_load(content.decoded_content) or {} - except Exception: - return {} - - def _pr_has_implementation_commits(pr: PullRequest) -> bool: """True if the PR has commits beyond the initial plan commit.""" commits = list(pr.get_commits()) @@ -159,7 +158,7 @@ def _pr_has_implementation_commits(pr: PullRequest) -> bool: msg = c.commit.message or "" # Plan commits create/update .blender/plans/ files only. # Implementation commits have different messages. - if not msg.startswith("BLEnder plan("): + if not msg.startswith(PLAN_COMMIT_PREFIX): return True return False @@ -171,18 +170,56 @@ def _has_comments_after_latest_commit(pr: PullRequest) -> bool: if latest_commit_date is None: return False for c in pr.get_issue_comments(): - if c.user.login.endswith("[bot]"): + if is_bot(c.user.login): continue if c.created_at >= latest_commit_date: return True for r in pr.get_reviews(): - if r.user.login.endswith("[bot]"): + if is_bot(r.user.login): continue if r.submitted_at and r.submitted_at >= latest_commit_date: return True return False +def _determine_pr_phase(pr: PullRequest) -> tuple[str | None, str]: + """Determine what phase an auto-engineer PR needs next. + + Returns (phase, description) or (None, description) if no action needed. + """ + has_impl = _pr_has_implementation_commits(pr) + has_approval = has_codeowner_approval(pr) + has_comments = _has_comments_after_latest_commit(pr) + + if not has_impl and has_approval: + return "implement", "plan approved → implement" + if not has_impl and has_comments: + return "plan", "plan has feedback → address" + if not has_impl: + return None, "waiting for plan review" + if has_impl and has_comments: + return "implement", "implementation has feedback → address" + return None, "waiting for code review" + + +def _has_bug_label(issue) -> bool: + """True if the issue has a 'bug' label (case-insensitive).""" + return any(label.name.lower() == "bug" for label in issue.labels) + + +def _is_trusted_author(issue, trusted_associations: set[str]) -> bool: + """True if the issue author has a trusted association with the repo. + + Uses the author_association field from the GitHub Issues API. + """ + association = getattr(issue, "author_association", None) + if association is None: + # PyGithub may not expose this; fall back to raw data + raw = getattr(issue, "_rawData", {}) + association = raw.get("author_association", "NONE") + return association in trusted_associations + + def check_auto_engineer(repo: Repository, config: dict) -> list[Action]: """Check for auto-engineer work: issues to plan, PRs to advance.""" ae_config = config.get("auto_engineer", {}) @@ -191,6 +228,8 @@ def check_auto_engineer(repo: Repository, config: dict) -> list[Action]: actions: list[Action] = [] issue_label = ae_config.get("issue_label", "auto-engineer") + trusted_str = ae_config.get("trusted_author_associations", "OWNER") + trusted_associations = {s.strip() for s in trusted_str.split(",")} # Check for open PRs with the auto-engineer label open_prs = [ @@ -200,66 +239,23 @@ def check_auto_engineer(repo: Repository, config: dict) -> list[Action]: ] if open_prs: - # One PR at a time per repo — process the first one pr = open_prs[0] print(f" Auto-engineer PR #{pr.number} exists") - - has_impl = _pr_has_implementation_commits(pr) - has_approval = has_codeowner_approval(pr) - has_comments = _has_comments_after_latest_commit(pr) - - if not has_impl: - # Plan-only PR - if has_approval: - print(f" PR #{pr.number}: plan approved → implement") - # Extract issue number from branch name - issue_num = _issue_number_from_branch(pr.head.ref) - actions.append( - Action( - action="auto-engineer", - repo=repo.full_name, - pr_number=pr.number, - pr_title=pr.title, - phase="implement", - issue_number=issue_num, - issue_title=pr.title, - ) - ) - elif has_comments: - print(f" PR #{pr.number}: plan has feedback → address") - issue_num = _issue_number_from_branch(pr.head.ref) - actions.append( - Action( - action="auto-engineer", - repo=repo.full_name, - pr_number=pr.number, - pr_title=pr.title, - phase="plan", - issue_number=issue_num, - issue_title=pr.title, - ) - ) - else: - print(f" PR #{pr.number}: waiting for plan review") - else: - # Has implementation commits - if has_comments: - print(f" PR #{pr.number}: implementation has feedback → address") - issue_num = _issue_number_from_branch(pr.head.ref) - actions.append( - Action( - action="auto-engineer", - repo=repo.full_name, - pr_number=pr.number, - pr_title=pr.title, - phase="implement", - issue_number=issue_num, - issue_title=pr.title, - ) + phase, description = _determine_pr_phase(pr) + print(f" PR #{pr.number}: {description}") + if phase: + issue_num = _issue_number_from_branch(pr.head.ref) + actions.append( + Action( + action="auto-engineer", + repo=repo.full_name, + pr_number=pr.number, + pr_title=pr.title, + phase=phase, + issue_number=issue_num, + issue_title=pr.title, ) - else: - print(f" PR #{pr.number}: waiting for code review") - + ) return actions # Check recently merged PRs for self-review @@ -270,16 +266,14 @@ def check_auto_engineer(repo: Repository, config: dict) -> list[Action]: continue if not any(label.name == AUTO_ENGINEER_LABEL for label in pr.labels): continue - # Only consider PRs merged in the last 24 hours if pr.merged_at is None: continue from datetime import datetime, timezone age = datetime.now(timezone.utc) - pr.merged_at if age.total_seconds() > 86400: - break # sorted by updated desc, so stop here + break - # Check if self-review comment already exists has_self_review = any( c.user.login == BOT_LOGIN and (c.body or "").startswith("## Self-Review") @@ -299,8 +293,8 @@ def check_auto_engineer(repo: Repository, config: dict) -> list[Action]: issue_title=pr.title, ) ) - return actions # one at a time - break # most recent merged PR already reviewed + return actions + break except Exception as e: print(f" Error checking merged PRs: {e}") @@ -315,11 +309,12 @@ def check_auto_engineer(repo: Repository, config: dict) -> list[Action]: try: labeled_issues = list(repo.get_issues(state="open", labels=[issue_label])) - # Filter out PRs (GitHub API returns PRs as issues too) labeled_issues = [i for i in labeled_issues if i.pull_request is None] - # Filter out assigned issues labeled_issues = [i for i in labeled_issues if not i.assignees] - # Filter out issues that already have a branch + # Only pick issues from trusted authors + labeled_issues = [ + i for i in labeled_issues if _is_trusted_author(i, trusted_associations) + ] labeled_issues = [ i for i in labeled_issues @@ -330,8 +325,9 @@ def check_auto_engineer(repo: Repository, config: dict) -> list[Action]: ] if labeled_issues: - # Pick the oldest labeled issue - issue = labeled_issues[-1] # get_issues returns newest first + # Prefer newest bug-labeled issue, then newest of any type + bugs = [i for i in labeled_issues if _has_bug_label(i)] + issue = bugs[0] if bugs else labeled_issues[0] print(f" Issue #{issue.number}: {issue.title} → plan") actions.append( Action( @@ -486,8 +482,17 @@ def process_repo( # Check for auto-engineer work try: - config = _load_repo_config(repo) + config = load_repo_config(repo) ae_actions = check_auto_engineer(repo, config) + # Attach config settings that the workflow needs + ae_config = config.get("auto_engineer", {}) + for a in ae_actions: + a.trusted_author_associations = ae_config.get( + "trusted_author_associations", "OWNER" + ) + a.forbidden_paths = ae_config.get( + "forbidden_paths", ".github/ .env .circleci/" + ) actions.extend(ae_actions) except Exception as e: print(f" Error checking auto-engineer: {e}") diff --git a/scripts/trigger-workflows.py b/scripts/trigger-workflows.py index 4a04243..ae74549 100644 --- a/scripts/trigger-workflows.py +++ b/scripts/trigger-workflows.py @@ -159,6 +159,10 @@ def main() -> None: "-f", f"pr_number={a.get('pr_number', 0)}", "-f", + f"trusted_author_associations={a.get('trusted_author_associations', 'OWNER')}", + "-f", + f"forbidden_paths={a.get('forbidden_paths', '.github/ .env .circleci/')}", + "-f", "dry_run=false", ] print( diff --git a/tests/scripts/__init__.py b/tests/scripts/__init__.py index d232c44..8d8a2bd 100644 --- a/tests/scripts/__init__.py +++ b/tests/scripts/__init__.py @@ -42,12 +42,20 @@ def make_tag(name): return t -def make_issue(number, title="Test issue", labels=None, assignees=None, is_pr=False): +def make_issue( + number, + title="Test issue", + labels=None, + assignees=None, + is_pr=False, + author_association="OWNER", +): """Build a mock GitHub issue with .number, .title, .labels, .assignees.""" i = MagicMock() i.number = number i.title = title i.pull_request = MagicMock() if is_pr else None + i.author_association = author_association mock_labels = [] for name in (labels or []): diff --git a/tests/scripts/test_auto_engineer_sweep.py b/tests/scripts/test_auto_engineer_sweep.py index e9f73b3..4da48f3 100644 --- a/tests/scripts/test_auto_engineer_sweep.py +++ b/tests/scripts/test_auto_engineer_sweep.py @@ -143,6 +143,54 @@ def test_assigned_issue_skipped(self): assert actions == [] +class TestBugLabelPriority: + def test_bug_label_picked_first(self): + """Bug-labeled issue picked over non-bug issue.""" + normal = make_issue(10, "Add feature", labels=["auto-engineer"]) + bug = make_issue(20, "Fix crash", labels=["auto-engineer", "bug"]) + repo = _make_repo(labeled_issues=[normal, bug]) + actions = check_auto_engineer(repo, ENABLED_CONFIG) + assert len(actions) == 1 + assert actions[0].issue_number == 20 + + def test_no_bug_picks_first(self): + """No bug label → picks first (newest) issue.""" + old = make_issue(10, "Old issue", labels=["auto-engineer"]) + new = make_issue(20, "New issue", labels=["auto-engineer"]) + repo = _make_repo(labeled_issues=[new, old]) + actions = check_auto_engineer(repo, ENABLED_CONFIG) + assert len(actions) == 1 + assert actions[0].issue_number == 20 + + +class TestTrustedAuthorFiltering: + def test_untrusted_author_skipped(self): + """Issue from untrusted author (NONE) → skipped.""" + issue = make_issue( + 42, "Inject me", labels=["auto-engineer"], author_association="NONE" + ) + repo = _make_repo(labeled_issues=[issue], all_issues=[]) + actions = check_auto_engineer(repo, ENABLED_CONFIG) + assert actions == [] + + def test_custom_trusted_associations(self): + """Custom trusted_author_associations includes MEMBER.""" + issue = make_issue( + 42, "Member issue", labels=["auto-engineer"], author_association="MEMBER" + ) + config = { + "auto_engineer": { + "enabled": True, + "issue_label": "auto-engineer", + "trusted_author_associations": "OWNER,MEMBER", + } + } + repo = _make_repo(labeled_issues=[issue]) + actions = check_auto_engineer(repo, config) + assert len(actions) == 1 + assert actions[0].issue_number == 42 + + class TestNoLabeledIssues: def test_fallback_emits_plan_with_zero(self): """No labeled issues but open issues exist → plan with issue_number=0.""" @@ -236,3 +284,41 @@ def test_old_merged_pr_ignored(self): repo = _make_repo(closed_prs=[pr]) actions = check_auto_engineer(repo, ENABLED_CONFIG) assert actions == [] + + +class TestConfigForwarding: + def test_config_fields_on_action(self): + """Action.to_dict() includes config fields when set.""" + from scripts.sweep import Action + + a = Action( + action="auto-engineer", + repo="mozilla/test", + pr_number=0, + pr_title="", + phase="plan", + issue_number=42, + issue_title="test", + trusted_author_associations="OWNER,MEMBER", + forbidden_paths=".env", + ) + d = a.to_dict() + assert d["trusted_author_associations"] == "OWNER,MEMBER" + assert d["forbidden_paths"] == ".env" + + def test_config_fields_omitted_when_none(self): + """Action.to_dict() omits config fields when None.""" + from scripts.sweep import Action + + a = Action( + action="auto-engineer", + repo="mozilla/test", + pr_number=0, + pr_title="", + phase="plan", + issue_number=42, + issue_title="test", + ) + d = a.to_dict() + assert "trusted_author_associations" not in d + assert "forbidden_paths" not in d From 2d867f22c5456169e35cb91ac9b778ecc37088c4 Mon Sep 17 00:00:00 2001 From: groovecoder <71928+groovecoder@users.noreply.github.com> Date: Sun, 31 May 2026 11:23:38 -0500 Subject: [PATCH 3/6] fix: filter fallback issue selection by trusted author associations The all_issues fallback path let Claude pick from any open issue, including those from untrusted authors. Now filters by trusted_author_associations before passing to Claude. Also clarifies script comments about trusted-author-only context gathering. Co-Authored-By: Claude Opus 4.6 --- scripts/gather-implement-context.sh | 4 +++- scripts/gather-issue-context.sh | 1 + scripts/sweep.py | 5 ++++- tests/scripts/test_auto_engineer_sweep.py | 11 +++++++++-- 4 files changed, 17 insertions(+), 4 deletions(-) diff --git a/scripts/gather-implement-context.sh b/scripts/gather-implement-context.sh index f34d975..55877f9 100755 --- a/scripts/gather-implement-context.sh +++ b/scripts/gather-implement-context.sh @@ -3,6 +3,9 @@ # # This script has GH_TOKEN but does NOT have ANTHROPIC_API_KEY. # It writes the final prompt to .blender-prompt for run-claude.sh. +# All context comes from trusted sources: the plan file (BLEnder-authored, +# reviewed and approved by trusted authors) and the issue body (fetched +# directly, not user-supplied comments). # # Environment variables: # ISSUE_NUMBER -- Issue number (required) @@ -10,7 +13,6 @@ # GH_TOKEN -- GitHub token for API calls (required) # PROMPT_TEMPLATE -- Path to prompt template file (required) # ISSUE_TITLE -- Issue title (optional) -# TRUSTED_AUTHOR_ASSOCIATIONS -- Comma-separated list of trusted associations (default: OWNER) set -euo pipefail diff --git a/scripts/gather-issue-context.sh b/scripts/gather-issue-context.sh index b34f68f..8702e6c 100755 --- a/scripts/gather-issue-context.sh +++ b/scripts/gather-issue-context.sh @@ -3,6 +3,7 @@ # # This script has GH_TOKEN but does NOT have ANTHROPIC_API_KEY. # It writes the final prompt to .blender-prompt for run-claude.sh. +# Only includes comments from trusted author associations. # # Environment variables: # ISSUE_NUMBER -- Issue number (0 = let Claude pick) (required) diff --git a/scripts/sweep.py b/scripts/sweep.py index aaa5b65..6ac96a8 100644 --- a/scripts/sweep.py +++ b/scripts/sweep.py @@ -341,9 +341,12 @@ def check_auto_engineer(repo: Repository, config: dict) -> list[Action]: ) ) else: - # No labeled issues — let Claude pick from all open issues + # No labeled issues — let Claude pick from trusted open issues all_issues = list(repo.get_issues(state="open")) all_issues = [i for i in all_issues if i.pull_request is None] + all_issues = [ + i for i in all_issues if _is_trusted_author(i, trusted_associations) + ] if all_issues: print(" No labeled issues — Claude will pick from open issues") actions.append( diff --git a/tests/scripts/test_auto_engineer_sweep.py b/tests/scripts/test_auto_engineer_sweep.py index 4da48f3..9766ffb 100644 --- a/tests/scripts/test_auto_engineer_sweep.py +++ b/tests/scripts/test_auto_engineer_sweep.py @@ -193,13 +193,20 @@ def test_custom_trusted_associations(self): class TestNoLabeledIssues: def test_fallback_emits_plan_with_zero(self): - """No labeled issues but open issues exist → plan with issue_number=0.""" - issue = make_issue(10, "Some random issue") + """No labeled issues but trusted open issues exist → plan with issue_number=0.""" + issue = make_issue(10, "Some random issue", author_association="OWNER") repo = _make_repo(labeled_issues=[], all_issues=[issue]) actions = check_auto_engineer(repo, ENABLED_CONFIG) assert len(actions) == 1 assert actions[0].issue_number == 0 + def test_fallback_skips_untrusted_issues(self): + """Fallback path filters out untrusted authors.""" + issue = make_issue(10, "Untrusted issue", author_association="NONE") + repo = _make_repo(labeled_issues=[], all_issues=[issue]) + actions = check_auto_engineer(repo, ENABLED_CONFIG) + assert actions == [] + class TestPlanPR: def test_plan_pr_no_comments_no_action(self): From ccff86d8a602f7678bbb3fee7e9c49a8df4c6211 Mon Sep 17 00:00:00 2001 From: groovecoder <71928+groovecoder@users.noreply.github.com> Date: Sun, 31 May 2026 11:51:07 -0500 Subject: [PATCH 4/6] refactor(test): clean up auto-engineer test readability Extract initial_plan_commit variable in _make_ae_pr to reduce duplication. Rename test_no_bug_picks_first to test_no_bug_picks_newest. Add docstring clarifying that the branch-exists check only prevents new plans, not follow-up work. Co-Authored-By: Claude Opus 4.6 --- tests/scripts/test_auto_engineer_sweep.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/tests/scripts/test_auto_engineer_sweep.py b/tests/scripts/test_auto_engineer_sweep.py index 9766ffb..912e037 100644 --- a/tests/scripts/test_auto_engineer_sweep.py +++ b/tests/scripts/test_auto_engineer_sweep.py @@ -40,15 +40,11 @@ def _make_ae_pr( label.name = "blender:auto-engineer" pr.labels = [label] - if plan_only: - pr.get_commits.return_value = [ - make_commit(f"BLEnder plan(#{issue_number}): initial plan", T_EARLY) - ] - else: - pr.get_commits.return_value = [ - make_commit(f"BLEnder plan(#{issue_number}): initial plan", T_EARLY), - make_commit("feat: implement the thing", T_LATE), - ] + initial_plan_commit = make_commit(f"BLEnder plan(#{issue_number}): initial plan", T_EARLY) + pr_commits = [initial_plan_commit] + if not plan_only: + pr_commits += [make_commit("feat: implement the thing", T_LATE)] + pr.get_commits.return_value = pr_commits reviews = [] if approved: @@ -125,7 +121,11 @@ def test_labeled_issue_emits_plan(self): assert actions[0].issue_number == 42 def test_issue_with_existing_branch_skipped(self): - """Issue already has a branch → skip.""" + """Issue already has a branch → skip new plan. + + Follow-up work (implement, feedback) routes through the open PR + check above, not the issue/branch check. + """ issue = make_issue(42, "Fix the widget", labels=["auto-engineer"]) branch = make_branch("blender/auto-engineer/42-fix-the-widget") repo = _make_repo(labeled_issues=[issue], branches=[branch], all_issues=[]) @@ -153,7 +153,7 @@ def test_bug_label_picked_first(self): assert len(actions) == 1 assert actions[0].issue_number == 20 - def test_no_bug_picks_first(self): + def test_no_bug_picks_newest(self): """No bug label → picks first (newest) issue.""" old = make_issue(10, "Old issue", labels=["auto-engineer"]) new = make_issue(20, "New issue", labels=["auto-engineer"]) From 35c41ca5cef54b9cd689f39d4d0a66d6b17c0c51 Mon Sep 17 00:00:00 2001 From: groovecoder <71928+groovecoder@users.noreply.github.com> Date: Sun, 31 May 2026 11:53:21 -0500 Subject: [PATCH 5/6] fix: suppress shellcheck SC2016 false positive on GraphQL query The single-quoted string contains GraphQL variable references ($owner, $name, $number), not shell variables. Shell values are passed via -f flags. Co-Authored-By: Claude Opus 4.6 --- scripts/gather-review-context.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/gather-review-context.sh b/scripts/gather-review-context.sh index 4c42c07..6c450ad 100755 --- a/scripts/gather-review-context.sh +++ b/scripts/gather-review-context.sh @@ -79,6 +79,7 @@ issue_comments=$(gh api "repos/${REPO}/issues/${PR_NUMBER}/comments" --paginate 2>/dev/null || echo "") # PR review comments via GraphQL — unresolved threads, trusted authors +# shellcheck disable=SC2016 # $owner/$name/$number are GraphQL variables, not shell pr_review_comments=$(gh api graphql -f query=' query($owner: String!, $name: String!, $number: Int!) { repository(owner: $owner, name: $name) { From 7b047a2ce490c67459d554938c01ea9d072c5cb8 Mon Sep 17 00:00:00 2001 From: groovecoder <71928+groovecoder@users.noreply.github.com> Date: Sun, 31 May 2026 11:58:42 -0500 Subject: [PATCH 6/6] fix(security): prevent template placeholder cross-contamination Escape {{ in API-fetched values (issue title, body, comments) before template substitution. Without this, an issue title containing "{{PLAN_CONTENT}}" would be expanded by a later substitution line, letting an attacker control which content appears where in the prompt. Also switch echo to printf for writing .blender-prompt to avoid edge cases with content starting with dash flags. Co-Authored-By: Claude Opus 4.6 --- scripts/gather-implement-context.sh | 7 ++++++- scripts/gather-issue-context.sh | 8 +++++++- scripts/gather-review-context.sh | 8 +++++++- scripts/gather-self-review-context.sh | 2 +- 4 files changed, 21 insertions(+), 4 deletions(-) diff --git a/scripts/gather-implement-context.sh b/scripts/gather-implement-context.sh index 55877f9..9c90ca3 100755 --- a/scripts/gather-implement-context.sh +++ b/scripts/gather-implement-context.sh @@ -61,6 +61,11 @@ echo " Title: ${issue_title}" echo "Building prompt from ${PROMPT_TEMPLATE}..." prompt=$(cat "$PROMPT_TEMPLATE") +# Neutralize template markers in API-fetched content to prevent +# cross-placeholder injection (e.g., issue title containing "{{PLAN_CONTENT}}") +issue_title="${issue_title//\{\{/\{_\{}" +issue_body="${issue_body//\{\{/\{_\{}" + prompt="${prompt//\{\{ISSUE_NUMBER\}\}/$ISSUE_NUMBER}" prompt="${prompt//\{\{ISSUE_TITLE\}\}/$issue_title}" prompt="${prompt//\{\{ISSUE_BODY\}\}/$issue_body}" @@ -68,5 +73,5 @@ prompt="${prompt//\{\{PLAN_CONTENT\}\}/$plan_content}" prompt="${prompt//\{\{REVIEW_COMMENTS\}\}/}" # Write prompt to file for run-claude.sh -echo "$prompt" > .blender-prompt +printf '%s\n' "$prompt" > .blender-prompt echo "Prompt written to .blender-prompt" diff --git a/scripts/gather-issue-context.sh b/scripts/gather-issue-context.sh index 8702e6c..cec334e 100755 --- a/scripts/gather-issue-context.sh +++ b/scripts/gather-issue-context.sh @@ -84,6 +84,12 @@ fi echo "Building prompt from ${PROMPT_TEMPLATE}..." prompt=$(cat "$PROMPT_TEMPLATE") +# Neutralize template markers in API-fetched content to prevent +# cross-placeholder injection (e.g., issue title containing "{{PLAN_CONTENT}}") +issue_title="${issue_title//\{\{/\{_\{}" +issue_body="${issue_body//\{\{/\{_\{}" +issue_comments="${issue_comments//\{\{/\{_\{}" + prompt="${prompt//\{\{ISSUE_NUMBER\}\}/$ISSUE_NUMBER}" prompt="${prompt//\{\{ISSUE_TITLE\}\}/$issue_title}" prompt="${prompt//\{\{ISSUE_BODY\}\}/$issue_body}" @@ -91,5 +97,5 @@ prompt="${prompt//\{\{ISSUE_COMMENTS\}\}/$issue_comments}" prompt="${prompt//\{\{REPO_TREE\}\}/$repo_tree}" # Write prompt to file for run-claude.sh -echo "$prompt" > .blender-prompt +printf '%s\n' "$prompt" > .blender-prompt echo "Prompt written to .blender-prompt" diff --git a/scripts/gather-review-context.sh b/scripts/gather-review-context.sh index 6c450ad..bb51b29 100755 --- a/scripts/gather-review-context.sh +++ b/scripts/gather-review-context.sh @@ -126,6 +126,12 @@ fi echo "Building prompt from ${PROMPT_TEMPLATE}..." prompt=$(cat "$PROMPT_TEMPLATE") +# Neutralize template markers in API-fetched content to prevent +# cross-placeholder injection (e.g., issue title containing "{{PLAN_CONTENT}}") +issue_title="${issue_title//\{\{/\{_\{}" +issue_body="${issue_body//\{\{/\{_\{}" +review_comments="${review_comments//\{\{/\{_\{}" + prompt="${prompt//\{\{ISSUE_NUMBER\}\}/$ISSUE_NUMBER}" prompt="${prompt//\{\{ISSUE_TITLE\}\}/$issue_title}" prompt="${prompt//\{\{ISSUE_BODY\}\}/$issue_body}" @@ -135,5 +141,5 @@ prompt="${prompt//\{\{ISSUE_COMMENTS\}\}/$review_comments}" prompt="${prompt//\{\{REPO_TREE\}\}/}" # Write prompt to file for run-claude.sh -echo "$prompt" > .blender-prompt +printf '%s\n' "$prompt" > .blender-prompt echo "Prompt written to .blender-prompt" diff --git a/scripts/gather-self-review-context.sh b/scripts/gather-self-review-context.sh index c276295..1a6d71d 100755 --- a/scripts/gather-self-review-context.sh +++ b/scripts/gather-self-review-context.sh @@ -60,5 +60,5 @@ prompt="${prompt//\{\{PLAN_CONTENT\}\}/$plan_content}" prompt="${prompt//\{\{PR_DIFF\}\}/$pr_diff}" # Write prompt to file for run-claude.sh -echo "$prompt" > .blender-prompt +printf '%s\n' "$prompt" > .blender-prompt echo "Prompt written to .blender-prompt"