background_agent_mvp.yml

  1name: background_agent_mvp
  2
  3on:
  4  schedule:
  5    - cron: "0 16 * * 1-5"
  6  workflow_dispatch:
  7    inputs:
  8      crash_ids:
  9        description: "Optional comma-separated Sentry issue IDs (e.g. ZED-4VS,ZED-123)"
 10        required: false
 11        type: string
 12      reviewers:
 13        description: "Optional comma-separated GitHub reviewer handles"
 14        required: false
 15        type: string
 16      top:
 17        description: "Top N candidates when crash_ids is empty"
 18        required: false
 19        type: string
 20        default: "3"
 21
 22permissions:
 23  contents: write
 24  pull-requests: write
 25
 26env:
 27  FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }}
 28  DROID_MODEL: claude-opus-4-5-20251101
 29  SENTRY_ORG: zed-dev
 30
 31jobs:
 32  run-mvp:
 33    runs-on: ubuntu-latest
 34    timeout-minutes: 180
 35
 36    steps:
 37      - name: Checkout repository
 38        uses: actions/checkout@v4
 39        with:
 40          fetch-depth: 0
 41
 42      - name: Install Droid CLI
 43        run: |
 44          curl -fsSL https://app.factory.ai/cli | sh
 45          echo "${HOME}/.local/bin" >> "$GITHUB_PATH"
 46          echo "DROID_BIN=${HOME}/.local/bin/droid" >> "$GITHUB_ENV"
 47          "${HOME}/.local/bin/droid" --version
 48
 49      - name: Setup Python
 50        uses: actions/setup-python@v5
 51        with:
 52          python-version: "3.12"
 53
 54      - name: Resolve reviewers
 55        id: reviewers
 56        env:
 57          INPUT_REVIEWERS: ${{ inputs.reviewers }}
 58          DEFAULT_REVIEWERS: ${{ vars.BACKGROUND_AGENT_REVIEWERS }}
 59        run: |
 60          set -euo pipefail
 61          if [ -z "$DEFAULT_REVIEWERS" ]; then
 62            DEFAULT_REVIEWERS="eholk,morgankrey,osiewicz,bennetbo"
 63          fi
 64          REVIEWERS="${INPUT_REVIEWERS:-$DEFAULT_REVIEWERS}"
 65          REVIEWERS="$(echo "$REVIEWERS" | tr -d '[:space:]')"
 66          echo "reviewers=$REVIEWERS" >> "$GITHUB_OUTPUT"
 67
 68      - name: Select crash candidates
 69        id: candidates
 70        env:
 71          INPUT_CRASH_IDS: ${{ inputs.crash_ids }}
 72          INPUT_TOP: ${{ inputs.top }}
 73          SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_BACKGROUND_AGENT_MVP_TOKEN }}
 74        run: |
 75          set -euo pipefail
 76
 77          PREFETCH_DIR="/tmp/crash-data"
 78          ARGS=(--select-only --prefetch-dir "$PREFETCH_DIR" --org "$SENTRY_ORG")
 79          if [ -n "$INPUT_CRASH_IDS" ]; then
 80            ARGS+=(--crash-ids "$INPUT_CRASH_IDS")
 81          else
 82            TARGET_DRAFT_PRS="${INPUT_TOP:-3}"
 83            if ! [[ "$TARGET_DRAFT_PRS" =~ ^[0-9]+$ ]] || [ "$TARGET_DRAFT_PRS" -lt 1 ]; then
 84              TARGET_DRAFT_PRS="3"
 85            fi
 86            CANDIDATE_TOP=$((TARGET_DRAFT_PRS * 5))
 87            if [ "$CANDIDATE_TOP" -gt 100 ]; then
 88              CANDIDATE_TOP=100
 89            fi
 90            ARGS+=(--top "$CANDIDATE_TOP" --sample-size 100)
 91          fi
 92
 93          IDS="$(python3 script/run-background-agent-mvp-local "${ARGS[@]}")"
 94
 95          if [ -z "$IDS" ]; then
 96            echo "No candidates selected"
 97            exit 1
 98          fi
 99
100          echo "Using crash IDs: $IDS"
101          echo "ids=$IDS" >> "$GITHUB_OUTPUT"
102
103      - name: Run background agent pipeline per crash
104        id: pipeline
105        env:
106          GH_TOKEN: ${{ github.token }}
107          REVIEWERS: ${{ steps.reviewers.outputs.reviewers }}
108          CRASH_IDS: ${{ steps.candidates.outputs.ids }}
109          TARGET_DRAFT_PRS_INPUT: ${{ inputs.top }}
110        run: |
111          set -euo pipefail
112
113          git config user.name "factory-droid[bot]"
114          git config user.email "138933559+factory-droid[bot]@users.noreply.github.com"
115
116          # Crash ID format validation regex
117          CRASH_ID_PATTERN='^[A-Za-z0-9]+-[A-Za-z0-9]+$'
118          TARGET_DRAFT_PRS="${TARGET_DRAFT_PRS_INPUT:-3}"
119          if ! [[ "$TARGET_DRAFT_PRS" =~ ^[0-9]+$ ]] || [ "$TARGET_DRAFT_PRS" -lt 1 ]; then
120            TARGET_DRAFT_PRS="3"
121          fi
122          CREATED_DRAFT_PRS=0
123
124          IFS=',' read -r -a CRASH_ID_ARRAY <<< "$CRASH_IDS"
125
126          for CRASH_ID in "${CRASH_ID_ARRAY[@]}"; do
127            if [ "$CREATED_DRAFT_PRS" -ge "$TARGET_DRAFT_PRS" ]; then
128              echo "Reached target draft PR count ($TARGET_DRAFT_PRS), stopping candidate processing"
129              break
130            fi
131
132            CRASH_ID="$(echo "$CRASH_ID" | xargs)"
133            [ -z "$CRASH_ID" ] && continue
134
135            # Validate crash ID format to prevent injection via branch names or prompts
136            if ! [[ "$CRASH_ID" =~ $CRASH_ID_PATTERN ]]; then
137              echo "ERROR: Invalid crash ID format: '$CRASH_ID' — skipping"
138              continue
139            fi
140
141            BRANCH="background-agent/mvp-${CRASH_ID,,}-$(date +%Y%m%d)"
142            echo "Running crash pipeline for $CRASH_ID on $BRANCH"
143
144            # Deduplication: skip if a draft PR already exists for this crash
145            EXISTING_BRANCH_PR="$(gh pr list --head "$BRANCH" --state open --json number --jq '.[0].number' || echo "")"
146            if [ -n "$EXISTING_BRANCH_PR" ]; then
147              echo "Draft PR #$EXISTING_BRANCH_PR already exists for $CRASH_ID — skipping"
148              continue
149            fi
150
151            git config --local http.https://github.com/.extraheader "AUTHORIZATION: bearer ${GH_TOKEN}"
152
153            if ! git fetch origin main; then
154              echo "WARNING: Failed to fetch origin/main for $CRASH_ID — skipping"
155              continue
156            fi
157
158            if ! git checkout -B "$BRANCH" origin/main; then
159              echo "WARNING: Failed to create checkout branch $BRANCH for $CRASH_ID — skipping"
160              continue
161            fi
162
163            CRASH_DATA_FILE="/tmp/crash-data/crash-${CRASH_ID}.md"
164            if [ ! -f "$CRASH_DATA_FILE" ]; then
165              echo "WARNING: No pre-fetched crash data for $CRASH_ID at $CRASH_DATA_FILE — skipping"
166              continue
167            fi
168
169            python3 -c "
170          import sys
171          crash_id, data_file = sys.argv[1], sys.argv[2]
172          prompt = f'''You are running the weekly background crash-fix MVP pipeline for crash {crash_id}.
173
174          The crash report has been pre-fetched and is available at: {data_file}
175          Read this file to get the crash data. Do not call script/sentry-fetch.
176
177          Required workflow:
178          1. Read the crash report from {data_file}
179          2. Read and follow .rules.
180          3. Follow .factory/prompts/crash/investigate.md and write ANALYSIS.md
181          4. Follow .factory/prompts/crash/link-issues.md and write LINKED_ISSUES.md
182          5. Follow .factory/prompts/crash/fix.md to implement a minimal fix with tests
183          6. Run validators required by the fix prompt for the affected code paths
184          7. Write PR_BODY.md with sections:
185             - Crash Summary
186             - Root Cause
187             - Fix
188             - Validation
189             - Potentially Related Issues (High/Medium/Low from LINKED_ISSUES.md)
190             - Reviewer Checklist
191             - Release Notes (final section; format as Release Notes:, then a blank line, then one bullet like - N/A)
192
193          Constraints:
194          - Do not merge or auto-approve.
195          - Keep changes narrowly scoped to this crash.
196          - Do not modify files in .github/, .factory/, or script/ directories.
197          - When investigating git history, limit your search to the last 2 weeks of commits. Do not traverse older history.
198          - If the crash is not solvable with available context, write a clear blocker summary to PR_BODY.md.
199          '''
200          import textwrap
201          with open('/tmp/background-agent-prompt.md', 'w') as f:
202              f.write(textwrap.dedent(prompt))
203          " "$CRASH_ID" "$CRASH_DATA_FILE"
204
205            if ! "$DROID_BIN" exec --auto medium -m "$DROID_MODEL" -f /tmp/background-agent-prompt.md; then
206              echo "Droid execution failed for $CRASH_ID, continuing to next candidate"
207              continue
208            fi
209
210            for REPORT_FILE in ANALYSIS.md LINKED_ISSUES.md PR_BODY.md; do
211              if [ -f "$REPORT_FILE" ]; then
212                echo "::group::${CRASH_ID} ${REPORT_FILE}"
213                cat "$REPORT_FILE"
214                echo "::endgroup::"
215              fi
216            done
217
218            if git diff --quiet; then
219              echo "No code changes produced for $CRASH_ID"
220              continue
221            fi
222
223            # Stage only expected file types — not git add -A
224            git add -- '*.rs' '*.toml' 'Cargo.lock' 'ANALYSIS.md' 'LINKED_ISSUES.md' 'PR_BODY.md'
225
226            # Reject changes to protected paths
227            PROTECTED_CHANGES="$(git diff --cached --name-only | grep -E '^(\.github/|\.factory/|script/)' || true)"
228            if [ -n "$PROTECTED_CHANGES" ]; then
229              echo "ERROR: Agent modified protected paths — aborting commit for $CRASH_ID:"
230              echo "$PROTECTED_CHANGES"
231              git reset HEAD -- .
232              continue
233            fi
234
235            if ! git diff --cached --quiet; then
236              git commit -m "Fix crash ${CRASH_ID}"
237            fi
238
239            git push -u origin "$BRANCH"
240
241            CRATE_PREFIX=""
242            CHANGED_CRATES="$(git diff --cached --name-only | awk -F/ '/^crates\/[^/]+\// {print $2}' | sort -u)"
243            if [ -n "$CHANGED_CRATES" ] && [ "$(printf "%s\n" "$CHANGED_CRATES" | wc -l | tr -d ' ')" -eq 1 ]; then
244              CRATE_PREFIX="${CHANGED_CRATES}: "
245            fi
246
247            TITLE="${CRATE_PREFIX}Fix crash ${CRASH_ID}"
248            BODY_FILE="PR_BODY.md"
249            if [ ! -f "$BODY_FILE" ]; then
250              BODY_FILE="/tmp/pr-body-${CRASH_ID}.md"
251              printf "Automated draft crash-fix pipeline output for %s.\n\nNo PR_BODY.md was generated by the agent; please review commit and linked artifacts manually.\n" "$CRASH_ID" > "$BODY_FILE"
252            fi
253
254            python3 -c '
255            import re
256            import sys
257
258            path = sys.argv[1]
259            body = open(path, encoding="utf-8").read()
260            pattern = re.compile(r"(^|\n)Release Notes:\r?\n(?:\r?\n)*(?P<bullets>(?:\s*-\s+.*(?:\r?\n|$))+)", re.MULTILINE)
261            match = pattern.search(body)
262
263            if match:
264                bullets = [
265                    re.sub(r"^\s*", "", bullet)
266                    for bullet in re.findall(r"^\s*-\s+.*$", match.group("bullets"), re.MULTILINE)
267                ]
268                if not bullets:
269                    bullets = ["- N/A"]
270                section = "Release Notes:\n\n" + "\n".join(bullets)
271                body_without_release_notes = (body[: match.start()] + body[match.end() :]).rstrip()
272                if body_without_release_notes:
273                    normalized_body = f"{body_without_release_notes}\n\n{section}\n"
274                else:
275                    normalized_body = f"{section}\n"
276            else:
277                normalized_body = body.rstrip() + "\n\nRelease Notes:\n\n- N/A\n"
278
279            with open(path, "w", encoding="utf-8") as file:
280                file.write(normalized_body)
281            ' "$BODY_FILE"
282
283            EXISTING_PR="$(gh pr list --head "$BRANCH" --json number --jq '.[0].number')"
284            if [ -n "$EXISTING_PR" ]; then
285              gh pr edit "$EXISTING_PR" --title "$TITLE" --body-file "$BODY_FILE"
286              PR_NUMBER="$EXISTING_PR"
287            else
288              PR_URL="$(gh pr create --draft --base main --head "$BRANCH" --title "$TITLE" --body-file "$BODY_FILE")"
289              PR_NUMBER="$(basename "$PR_URL")"
290            fi
291
292            if [ -n "$REVIEWERS" ]; then
293              IFS=',' read -r -a REVIEWER_ARRAY <<< "$REVIEWERS"
294              for REVIEWER in "${REVIEWER_ARRAY[@]}"; do
295                [ -z "$REVIEWER" ] && continue
296                gh pr edit "$PR_NUMBER" --add-reviewer "$REVIEWER" || true
297              done
298            fi
299
300            CREATED_DRAFT_PRS=$((CREATED_DRAFT_PRS + 1))
301            echo "Created/updated draft PRs this run: $CREATED_DRAFT_PRS/$TARGET_DRAFT_PRS"
302          done
303
304          echo "created_draft_prs=$CREATED_DRAFT_PRS" >> "$GITHUB_OUTPUT"
305          echo "target_draft_prs=$TARGET_DRAFT_PRS" >> "$GITHUB_OUTPUT"
306
307      - name: Cleanup pre-fetched crash data
308        if: always()
309        run: rm -rf /tmp/crash-data
310
311      - name: Workflow summary
312        if: always()
313        env:
314          SUMMARY_CRASH_IDS: ${{ steps.candidates.outputs.ids }}
315          SUMMARY_REVIEWERS: ${{ steps.reviewers.outputs.reviewers }}
316          SUMMARY_CREATED_DRAFT_PRS: ${{ steps.pipeline.outputs.created_draft_prs }}
317          SUMMARY_TARGET_DRAFT_PRS: ${{ steps.pipeline.outputs.target_draft_prs }}
318        run: |
319          {
320            echo "## Background Agent MVP"
321            echo ""
322            echo "- Crash IDs: ${SUMMARY_CRASH_IDS:-none}"
323            echo "- Reviewer routing: ${SUMMARY_REVIEWERS:-NOT CONFIGURED}"
324            echo "- Draft PRs created: ${SUMMARY_CREATED_DRAFT_PRS:-0}/${SUMMARY_TARGET_DRAFT_PRS:-3}"
325            echo "- Pipeline: investigate -> link-issues -> fix -> draft PR"
326          } >> "$GITHUB_STEP_SUMMARY"
327
328concurrency:
329  group: background-agent-mvp
330  cancel-in-progress: false