Fix #38
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # fullsend-stage: fix | |
| # lint-workflow-size: max-lines=310 | |
| name: Fix | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| event_type: | |
| required: true | |
| type: string | |
| source_repo: | |
| required: true | |
| type: string | |
| event_payload: | |
| required: true | |
| type: string | |
| trigger_source: | |
| required: true | |
| type: string | |
| description: 'GitHub username that triggered the fix; bot accounts end with [bot]' | |
| pr_number: | |
| required: false | |
| type: string | |
| description: "PR number (workflow_dispatch manual fallback)" | |
| instruction: | |
| required: false | |
| type: string | |
| description: "Human instruction (workflow_dispatch manual fallback)" | |
| # Single concurrency group per PR. A human /fix cancels any running fix | |
| # (bot or human) so the human's instruction takes immediate effect. | |
| # Bot-triggered runs also cancel previous bot runs on the same PR. | |
| concurrency: | |
| group: >- | |
| fullsend-fix-${{ | |
| fromJSON(inputs.event_payload).pull_request.number | |
| || fromJSON(inputs.event_payload).issue.number | |
| || inputs.pr_number | |
| }} | |
| cancel-in-progress: true | |
| jobs: | |
| fix: | |
| name: Fix | |
| runs-on: ubuntu-latest | |
| permissions: | |
| actions: write | |
| contents: write | |
| id-token: write | |
| issues: write | |
| packages: read | |
| pull-requests: write | |
| steps: | |
| - name: Checkout .fullsend repository | |
| uses: actions/checkout@v6 | |
| - name: Validate enrollment and extract repo metadata | |
| id: repo-parts | |
| uses: ./.github/actions/validate-enrollment | |
| with: | |
| source_repo: ${{ inputs.source_repo }} | |
| - name: Generate sandbox token (read-only) | |
| id: sandbox-token | |
| uses: actions/create-github-app-token@v3 | |
| with: | |
| client-id: ${{ vars.FULLSEND_CODER_CLIENT_ID }} | |
| private-key: ${{ secrets.FULLSEND_CODER_APP_PRIVATE_KEY }} | |
| owner: ${{ github.repository_owner }} | |
| repositories: ${{ steps.repo-parts.outputs.name }} | |
| permission-contents: read | |
| permission-issues: read | |
| permission-pull-requests: read | |
| permission-metadata: read | |
| - name: Block fork PRs | |
| env: | |
| GH_TOKEN: ${{ steps.sandbox-token.outputs.token }} | |
| EVENT_PAYLOAD: ${{ inputs.event_payload }} | |
| SOURCE_REPO: ${{ inputs.source_repo }} | |
| PR_NUM_INPUT: ${{ inputs.pr_number }} | |
| run: | | |
| # Fork PRs must not trigger the fix agent (security: prevents | |
| # forked repos from running code with org-level tokens). | |
| HEAD_REPO="$(echo "${EVENT_PAYLOAD}" | jq -r '.pull_request.head.repo.full_name // empty')" | |
| BASE_REPO="$(echo "${EVENT_PAYLOAD}" | jq -r '.pull_request.base.repo.full_name // empty')" | |
| if [[ -n "${HEAD_REPO}" && -n "${BASE_REPO}" && "${HEAD_REPO}" != "${BASE_REPO}" ]]; then | |
| echo "::error::Fork PRs are not allowed to trigger the fix agent" | |
| exit 1 | |
| fi | |
| # Fallback for issue_comment events (payload has .issue, not .pull_request). | |
| if [[ -z "${HEAD_REPO}" && -z "${BASE_REPO}" ]]; then | |
| PR_NUM="$(echo "${EVENT_PAYLOAD}" | jq -r '.issue.number // empty')" | |
| PR_NUM="${PR_NUM:-${PR_NUM_INPUT:-}}" | |
| if [[ -n "${PR_NUM}" ]]; then | |
| IS_FORK="$(gh pr view "${PR_NUM}" --repo "${SOURCE_REPO}" \ | |
| --json isCrossRepository --jq '.isCrossRepository' 2>/dev/null || echo "false")" | |
| if [[ "${IS_FORK}" == "true" ]]; then | |
| echo "::error::Fork PRs are not allowed to trigger the fix agent" | |
| exit 1 | |
| fi | |
| fi | |
| fi | |
| - name: Extract PR number and context | |
| id: context | |
| env: | |
| GH_TOKEN: ${{ steps.sandbox-token.outputs.token }} | |
| EVENT_PAYLOAD: ${{ inputs.event_payload }} | |
| INPUT_PR_NUMBER: ${{ inputs.pr_number }} | |
| INPUT_INSTRUCTION: ${{ inputs.instruction }} | |
| TRIGGER_SOURCE: ${{ inputs.trigger_source }} | |
| SOURCE_REPO: ${{ inputs.source_repo }} | |
| run: | | |
| # Extract PR number from event payload or workflow_dispatch input. | |
| PR_NUM="$(echo "${EVENT_PAYLOAD}" | jq -r '.pull_request.number // empty')" | |
| if [[ -z "${PR_NUM}" ]]; then | |
| PR_NUM="$(echo "${EVENT_PAYLOAD}" | jq -r '.issue.number // empty')" | |
| fi | |
| if [[ -z "${PR_NUM}" && -n "${INPUT_PR_NUMBER}" ]]; then | |
| PR_NUM="${INPUT_PR_NUMBER}" | |
| fi | |
| if [[ -z "${PR_NUM}" || ! "${PR_NUM}" =~ ^[1-9][0-9]*$ ]]; then | |
| echo "::error::Could not extract valid PR number from event" | |
| exit 1 | |
| fi | |
| echo "pr_number=${PR_NUM}" >> "${GITHUB_OUTPUT}" | |
| # Resolve the PR head branch. For pull_request_review events the | |
| # payload has .pull_request.head.ref. For /fix comment events | |
| # (issue_comment) it does not — query the GitHub API instead. | |
| HEAD_REF="$(echo "${EVENT_PAYLOAD}" | jq -r '.pull_request.head.ref // empty')" | |
| BASE_REF="$(echo "${EVENT_PAYLOAD}" | jq -r '.pull_request.base.ref // empty')" | |
| if [[ -z "${HEAD_REF}" ]]; then | |
| HEAD_REF="$(gh pr view "${PR_NUM}" --repo "${SOURCE_REPO}" --json headRefName --jq '.headRefName' 2>/dev/null || true)" | |
| fi | |
| if [[ -z "${BASE_REF}" ]]; then | |
| BASE_REF="$(gh pr view "${PR_NUM}" --repo "${SOURCE_REPO}" --json baseRefName --jq '.baseRefName' 2>/dev/null || echo 'main')" | |
| fi | |
| if [[ -z "${HEAD_REF}" ]]; then | |
| echo "::error::Could not resolve PR head branch" | |
| exit 1 | |
| fi | |
| echo "head_ref=${HEAD_REF}" >> "${GITHUB_OUTPUT}" | |
| echo "base_ref=${BASE_REF}" >> "${GITHUB_OUTPUT}" | |
| echo "PR #${PR_NUM}: ${HEAD_REF} → ${BASE_REF}" | |
| # Extract human instruction (from /fix comment or workflow_dispatch). | |
| # Default to "none" so the env var is always non-empty (the fullsend | |
| # binary rejects empty runner_env values). The agent checks | |
| # TRIGGER_SOURCE before reading this value. | |
| INSTRUCTION="none" | |
| if [[ ! "${TRIGGER_SOURCE}" =~ \[bot\]$ ]]; then | |
| COMMENT_BODY="$(echo "${EVENT_PAYLOAD}" | jq -r '.comment.body // empty')" | |
| if [[ -n "${COMMENT_BODY}" ]]; then | |
| # Strip the /fix prefix to get the instruction. | |
| INSTRUCTION="$(echo "${COMMENT_BODY}" | sed 's|^/fix[[:space:]]*||')" | |
| fi | |
| if [[ -z "${INSTRUCTION}" && -n "${INPUT_INSTRUCTION}" ]]; then | |
| INSTRUCTION="${INPUT_INSTRUCTION}" | |
| fi | |
| if [[ -z "${INSTRUCTION}" ]]; then | |
| INSTRUCTION="none" | |
| fi | |
| fi | |
| # Use a random heredoc delimiter so a /fix comment containing | |
| # the literal delimiter string cannot inject GITHUB_OUTPUT values. | |
| DELIM="INSTRUCTION_$(openssl rand -hex 8)" | |
| { | |
| echo "instruction<<${DELIM}" | |
| echo "${INSTRUCTION}" | |
| echo "${DELIM}" | |
| } >> "${GITHUB_OUTPUT}" | |
| # Count previous fix-agent commits on this PR to determine iteration. | |
| # Uses the API (no checkout needed) to count commits authored by the | |
| # fix agent identity. Falls back to ITERATION_CAP if the query fails. | |
| # Note: TOCTOU — another run could push between this count and the | |
| # agent execution, allowing at most +1 overshoot. The concurrency | |
| # group's cancel-in-progress mostly prevents this. | |
| FIX_COMMITS="$(gh api "repos/${SOURCE_REPO}/pulls/${PR_NUM}/commits" \ | |
| --paginate --jq '[.[] | select(.commit.author.name == "fullsend-fix")] | length' 2>/dev/null)" \ | |
| || { echo "::warning::Could not count prior fix commits — defaulting to cap"; FIX_COMMITS="${ITERATION_CAP:-5}"; } | |
| ITERATION=$(( FIX_COMMITS + 1 )) | |
| echo "Fix iteration: ${ITERATION} (${FIX_COMMITS} previous fix commits)" >&2 | |
| echo "iteration=${ITERATION}" >> "${GITHUB_OUTPUT}" | |
| - name: Check fullsend-no-fix label | |
| env: | |
| GH_TOKEN: ${{ steps.sandbox-token.outputs.token }} | |
| TRIGGER_SOURCE: ${{ inputs.trigger_source }} | |
| PR_NUM: ${{ steps.context.outputs.pr_number }} | |
| SOURCE_REPO: ${{ inputs.source_repo }} | |
| run: | | |
| if [[ "${TRIGGER_SOURCE}" =~ \[bot\]$ ]]; then | |
| HAS_NO_FIX=$(gh pr view "${PR_NUM}" --repo "${SOURCE_REPO}" \ | |
| --json labels --jq '[.labels[].name] | any(. == "fullsend-no-fix")' 2>/dev/null || echo "false") | |
| if [[ "${HAS_NO_FIX}" == "true" ]]; then | |
| echo "::warning::PR #${PR_NUM} has 'fullsend-no-fix' label — skipping bot-triggered fix" | |
| exit 1 | |
| fi | |
| fi | |
| - name: Generate push token (write) | |
| id: push-token | |
| uses: actions/create-github-app-token@v3 | |
| with: | |
| client-id: ${{ vars.FULLSEND_CODER_CLIENT_ID }} | |
| private-key: ${{ secrets.FULLSEND_CODER_APP_PRIVATE_KEY }} | |
| owner: ${{ github.repository_owner }} | |
| repositories: ${{ steps.repo-parts.outputs.name }} | |
| permission-contents: write | |
| permission-pull-requests: write | |
| - name: Checkout target repository at PR HEAD | |
| uses: actions/checkout@v6 | |
| with: | |
| repository: ${{ inputs.source_repo }} | |
| token: ${{ steps.sandbox-token.outputs.token }} | |
| path: target-repo | |
| fetch-depth: 0 | |
| persist-credentials: false | |
| ref: ${{ steps.context.outputs.head_ref }} | |
| - name: Record pre-agent HEAD | |
| id: pre-agent | |
| working-directory: target-repo | |
| run: | | |
| SHA="$(git rev-parse HEAD)" | |
| echo "Pre-agent HEAD: ${SHA}" | |
| echo "head=${SHA}" >> "${GITHUB_OUTPUT}" | |
| - name: Pre-fetch review body | |
| id: review-body | |
| env: | |
| GH_TOKEN: ${{ steps.sandbox-token.outputs.token }} | |
| SOURCE_REPO: ${{ inputs.source_repo }} | |
| PR_NUM: ${{ steps.context.outputs.pr_number }} | |
| TRIGGER_SOURCE: ${{ inputs.trigger_source }} | |
| ORG_NAME: ${{ github.repository_owner }} | |
| run: | | |
| REVIEW_FILE="${GITHUB_WORKSPACE}/review-body.txt" | |
| REVIEW_BOT="${ORG_NAME}-review[bot]" | |
| gh api "repos/${SOURCE_REPO}/pulls/${PR_NUM}/reviews" \ | |
| --jq "[.[] | select(.state == \"CHANGES_REQUESTED\" and .user.login == \"${REVIEW_BOT}\")] | last | .body // \"\"" \ | |
| > "${REVIEW_FILE}" 2>/dev/null || echo "" > "${REVIEW_FILE}" | |
| BYTE_COUNT="$(wc -c < "${REVIEW_FILE}")" | |
| echo "Pre-fetched review body: ${BYTE_COUNT} bytes" | |
| MAX_REVIEW_BYTES=1048576 # 1 MB | |
| if [[ "${BYTE_COUNT}" -gt "${MAX_REVIEW_BYTES}" ]]; then | |
| echo "::error::Review body is ${BYTE_COUNT} bytes (max: ${MAX_REVIEW_BYTES})" | |
| exit 1 | |
| fi | |
| if [[ "${TRIGGER_SOURCE}" =~ \[bot\]$ && "${BYTE_COUNT}" -le 1 ]]; then | |
| echo "::error::Bot-triggered run but review body is empty — nothing to fix" | |
| exit 1 | |
| fi | |
| echo "review_file=${REVIEW_FILE}" >> "${GITHUB_OUTPUT}" | |
| - name: Validate inputs | |
| env: | |
| PR_NUMBER: ${{ steps.context.outputs.pr_number }} | |
| REPO_FULL_NAME: ${{ inputs.source_repo }} | |
| TRIGGER_SOURCE: ${{ inputs.trigger_source }} | |
| FIX_ITERATION: ${{ steps.context.outputs.iteration }} | |
| HUMAN_INSTRUCTION: ${{ steps.context.outputs.instruction }} | |
| run: bash scripts/pre-fix.sh | |
| - name: Setup GCP and prepare credentials | |
| uses: ./.github/actions/setup-gcp | |
| with: | |
| gcp_auth_mode: ${{ vars.FULLSEND_GCP_AUTH_MODE }} | |
| gcp_wif_provider: ${{ secrets.FULLSEND_GCP_WIF_PROVIDER }} | |
| gcp_wif_sa_email: ${{ secrets.FULLSEND_GCP_WIF_SA_EMAIL }} | |
| gcp_sa_key_json: ${{ secrets.FULLSEND_GCP_SA_KEY_JSON }} | |
| - name: Setup agent environment | |
| env: | |
| AGENT_PREFIX: FIX_ | |
| FIX_GH_TOKEN: ${{ steps.sandbox-token.outputs.token }} | |
| FIX_TARGET_REPO_DIR: target-repo | |
| FIX_ANTHROPIC_VERTEX_PROJECT_ID: ${{ secrets.FULLSEND_GCP_PROJECT_ID }} | |
| FIX_CLOUD_ML_REGION: ${{ vars.FULLSEND_GCP_REGION }} | |
| FIX_PR_NUMBER: ${{ steps.context.outputs.pr_number }} | |
| FIX_TRIGGER_SOURCE: ${{ inputs.trigger_source }} | |
| FIX_HUMAN_INSTRUCTION: ${{ steps.context.outputs.instruction }} | |
| FIX_FIX_ITERATION: ${{ steps.context.outputs.iteration }} | |
| FIX_REPO_FULL_NAME: ${{ inputs.source_repo }} | |
| run: bash .github/scripts/setup-agent-env.sh | |
| - name: Run fix agent | |
| uses: ./.github/actions/fullsend | |
| env: | |
| PR_NUMBER: ${{ steps.context.outputs.pr_number }} | |
| REPO_FULL_NAME: ${{ inputs.source_repo }} | |
| PUSH_TOKEN: ${{ steps.push-token.outputs.token }} | |
| PUSH_TOKEN_SOURCE: github-app | |
| TARGET_BRANCH: ${{ steps.context.outputs.base_ref }} | |
| TRIGGER_SOURCE: ${{ inputs.trigger_source }} | |
| HUMAN_INSTRUCTION: ${{ steps.context.outputs.instruction }} | |
| FIX_ITERATION: ${{ steps.context.outputs.iteration }} | |
| REVIEW_BODY_FILE: ${{ steps.review-body.outputs.review_file }} | |
| PRE_AGENT_HEAD: ${{ steps.pre-agent.outputs.head }} | |
| with: | |
| agent: fix |