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..f62771c --- /dev/null +++ b/.github/workflows/auto-engineer.yml @@ -0,0 +1,335 @@ +--- +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' + 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' + 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 + 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 }} + TRUSTED_AUTHOR_ASSOCIATIONS: ${{ inputs.trusted_author_associations }} + + - 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 }} + TRUSTED_AUTHOR_ASSOCIATIONS: ${{ inputs.trusted_author_associations }} + + - 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 + 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 }} + TRUSTED_AUTHOR_ASSOCIATIONS: ${{ inputs.trusted_author_associations }} + + - 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 }} + TRUSTED_AUTHOR_ASSOCIATIONS: ${{ inputs.trusted_author_associations }} + + - 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 + BLENDER_FORBIDDEN_PATHS: ${{ inputs.forbidden_paths }} + 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 + 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..07e559f 100644 --- a/config/defaults.yml +++ b/config/defaults.yml @@ -21,3 +21,16 @@ investigate: run_tests: true severity_threshold: "" dismiss_unaffected: false + +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 + 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..2b98ef1 --- /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 the issue author. + +### 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/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/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..9c90ca3 --- /dev/null +++ b/scripts/gather-implement-context.sh @@ -0,0 +1,77 @@ +#!/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. +# 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) +# 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 + +echo "BLEnder gather-implement-context: issue #${ISSUE_NUMBER} repo=${REPO}" + +# --- Read plan file (BLEnder-authored, no sanitization needed) --- +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") + +# 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}" +prompt="${prompt//\{\{PLAN_CONTENT\}\}/$plan_content}" +prompt="${prompt//\{\{REVIEW_COMMENTS\}\}/}" + +# Write prompt to file for run-claude.sh +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 new file mode 100755 index 0000000..cec334e --- /dev/null +++ b/scripts/gather-issue-context.sh @@ -0,0 +1,101 @@ +#!/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. +# Only includes comments from trusted author associations. +# +# 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) +# TRUSTED_AUTHOR_ASSOCIATIONS -- Comma-separated list of trusted associations (default: OWNER) + +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 + +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:-}" +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 (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" \ + --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") + +# 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}" +prompt="${prompt//\{\{ISSUE_COMMENTS\}\}/$issue_comments}" +prompt="${prompt//\{\{REPO_TREE\}\}/$repo_tree}" + +# Write prompt to file for run-claude.sh +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 new file mode 100755 index 0000000..bb51b29 --- /dev/null +++ b/scripts/gather-review-context.sh @@ -0,0 +1,145 @@ +#!/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. +# 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. +# +# 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) +# TRUSTED_AUTHOR_ASSOCIATIONS -- Comma-separated list of trusted associations (default: OWNER) + +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 + +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(. != ""))') + +# 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 + 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 (trusted authors, unresolved only) --- +echo "Fetching review comments on PR #${PR_NUMBER} (trusted + unresolved)..." +review_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) | 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 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) { + 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 — trusted authors only +pr_reviews=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}/reviews" --paginate \ + --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 from trusted reviewers)" +fi + +# --- Build the prompt --- +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}" +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 +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 new file mode 100755 index 0000000..1a6d71d --- /dev/null +++ b/scripts/gather-self-review-context.sh @@ -0,0 +1,64 @@ +#!/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 + +ISSUE_NUMBER="${ISSUE_NUMBER:-0}" +echo "BLEnder gather-self-review-context: PR #${PR_NUMBER} repo=${REPO}" + +# --- Read plan file (BLEnder-authored, no sanitization needed) --- +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") + +prompt="${prompt//\{\{PLAN_CONTENT\}\}/$plan_content}" +prompt="${prompt//\{\{PR_DIFF\}\}/$pr_diff}" + +# Write prompt to file for run-claude.sh +printf '%s\n' "$prompt" > .blender-prompt +echo "Prompt written to .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 46a8ede..fb02494 100755 --- a/scripts/run-claude.sh +++ b/scripts/run-claude.sh @@ -48,7 +48,30 @@ CLAUDE_LOG=$(mktemp /tmp/blender-claude-XXXXXX.log) trap 'rm -f "$CLAUDE_LOG"' EXIT # --- Mode-specific settings --- -if [ "$BLENDER_MODE" = "investigate" ]; then +# 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}" + 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}" + 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}" + 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 +129,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 +164,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 +220,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 @@ -208,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 163078e..6ac96a8 100644 --- a/scripts/sweep.py +++ b/scripts/sweep.py @@ -35,9 +35,11 @@ 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]" @@ -51,9 +53,14 @@ _INVESTIGATED_TAG_RE = re.compile(r"^investigated/(.+)/(\d+)$") +AUTO_ENGINEER_LABEL = "blender:auto-engineer" +AUTO_ENGINEER_BRANCH_PREFIX = "blender/auto-engineer/" +PLAN_COMMIT_PREFIX = "BLEnder plan(" + + @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 +69,11 @@ 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 + trusted_author_associations: str | None = None + forbidden_paths: str | None = None def to_dict(self) -> dict: d: dict = { @@ -76,6 +88,14 @@ 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 + 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 @@ -131,6 +151,234 @@ def discover_repos( return results +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(PLAN_COMMIT_PREFIX): + 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 is_bot(c.user.login): + continue + if c.created_at >= latest_commit_date: + return True + for r in pr.get_reviews(): + 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", {}) + if not ae_config.get("enabled", False): + return [] + + 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 = [ + pr + for pr in repo.get_pulls(state="open") + if any(label.name == AUTO_ENGINEER_LABEL for label in pr.labels) + ] + + if open_prs: + pr = open_prs[0] + print(f" Auto-engineer PR #{pr.number} exists") + 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, + ) + ) + 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 + 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 + + 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 + break + 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])) + labeled_issues = [i for i in labeled_issues if i.pull_request is None] + labeled_issues = [i for i in labeled_issues if not i.assignees] + # 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 + if not any( + b.startswith(f"{AUTO_ENGINEER_BRANCH_PREFIX}{i.number}-") + for b in existing_branches + ) + ] + + if labeled_issues: + # 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( + 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 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( + 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 +483,23 @@ 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) + # 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}") + return actions diff --git a/scripts/trigger-workflows.py b/scripts/trigger-workflows.py index 62e0355..ae74549 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,38 @@ 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", + 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( + 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..8d8a2bd 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,28 @@ 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, + 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 []): + 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..912e037 --- /dev/null +++ b/tests/scripts/test_auto_engineer_sweep.py @@ -0,0 +1,331 @@ +"""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] + + 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: + 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 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=[]) + 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 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_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"]) + 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 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): + """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 == [] + + +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