fix(model): preserve database column case for auto-derived properties #2033
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
| name: Wheels Bot — Reviewer A | |
| # Two trigger paths into this workflow: | |
| # 1. pull_request — the standard path (PR opens, syncs, or marks ready). | |
| # Reviewer A submits its initial review of the diff via /review-pr. | |
| # 2. issue_comment — the convergence-loop path. When Reviewer B posts a | |
| # not-yet-aligned critique (matches `wheels-bot:review-b:` but NOT | |
| # `wheels-bot:converged-` and NOT `:terminal`), A responds via | |
| # /respond-to-critique. The response is itself a review (state=COMMENT), | |
| # which triggers Reviewer B's next round — loop continues until B | |
| # emits a converged-* marker or the round cap fires. | |
| on: | |
| pull_request: | |
| types: [opened, synchronize, ready_for_review] | |
| branches: [develop] | |
| issue_comment: | |
| types: [created] | |
| permissions: | |
| contents: read | |
| concurrency: | |
| group: wheels-bot-review-a-${{ github.event.pull_request.number || github.event.issue.number }} | |
| cancel-in-progress: false | |
| jobs: | |
| review: | |
| name: Reviewer A | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 20 | |
| # Initial review: bot PRs (even draft) OR human ready-for-review PRs. | |
| # Response: bot's own comment from Reviewer B that is NOT a converged | |
| # marker AND NOT terminal — i.e., B is signalling "more discussion | |
| # needed." | |
| # The bot-identity check on the comment path is load-bearing: prevents | |
| # humans from quoting a marker in a reply to trigger the response. | |
| if: | | |
| vars.WHEELS_BOT_ENABLED == 'true' | |
| && ( | |
| (github.event_name == 'pull_request' | |
| && (github.event.pull_request.user.login == 'wheels-bot[bot]' | |
| || github.event.pull_request.draft == false)) | |
| || (github.event_name == 'issue_comment' | |
| && github.event.comment.user.login == 'wheels-bot[bot]' | |
| && contains(github.event.comment.body, 'wheels-bot:review-b:') | |
| && !contains(github.event.comment.body, 'wheels-bot:converged-') | |
| && !contains(github.event.comment.body, ':terminal')) | |
| ) | |
| steps: | |
| - name: Generate App token | |
| id: app-token | |
| uses: actions/create-github-app-token@v2 | |
| with: | |
| app-id: ${{ secrets.WHEELS_BOT_APP_ID }} | |
| private-key: ${{ secrets.WHEELS_BOT_PRIVATE_KEY }} | |
| - name: Resolve PR info | |
| id: pr | |
| env: | |
| GH_TOKEN: ${{ steps.app-token.outputs.token }} | |
| PR_FROM_PR_EVENT: ${{ github.event.pull_request.number }} | |
| PR_FROM_COMMENT_EVENT: ${{ github.event.issue.number }} | |
| run: | | |
| set -euo pipefail | |
| if [ "${GITHUB_EVENT_NAME}" = "pull_request" ]; then | |
| pr_num="$PR_FROM_PR_EVENT" | |
| sha="${{ github.event.pull_request.head.sha }}" | |
| mode="initial" | |
| else | |
| pr_num="$PR_FROM_COMMENT_EVENT" | |
| # Validate numeric. | |
| if ! [[ "$pr_num" =~ ^[0-9]+$ ]]; then | |
| echo "::error::Invalid PR number from issue context: $pr_num" | |
| exit 1 | |
| fi | |
| sha=$(gh pr view "$pr_num" --repo wheels-dev/wheels --json headRefOid -q '.headRefOid') | |
| mode="response" | |
| fi | |
| echo "pr_num=$pr_num" >> "$GITHUB_OUTPUT" | |
| echo "sha=$sha" >> "$GITHUB_OUTPUT" | |
| echo "mode=$mode" >> "$GITHUB_OUTPUT" | |
| - name: Checkout PR head | |
| uses: actions/checkout@v6 | |
| with: | |
| ref: ${{ steps.pr.outputs.sha }} | |
| fetch-depth: 0 | |
| - name: Skip check (initial review only — response mode handles its own idempotency) | |
| if: github.event_name == 'pull_request' | |
| id: gate | |
| uses: ./.github/actions/wheels-bot-skip-check | |
| with: | |
| target-type: pr | |
| target-number: ${{ steps.pr.outputs.pr_num }} | |
| # No trailing colon: the initial review marker is | |
| # `wheels-bot:review-a:<pr>:<sha>` with no suffix. Adding a trailing | |
| # `:` made this a no-op gate (issue #2558). Response markers use the | |
| # distinct `wheels-bot:review-a-response:` prefix and won't false-match. | |
| marker-pattern: 'wheels-bot:review-a:${{ steps.pr.outputs.pr_num }}:${{ steps.pr.outputs.sha }}' | |
| github-token: ${{ steps.app-token.outputs.token }} | |
| - name: Determine prompt | |
| id: cmd | |
| run: | | |
| if [ "${{ steps.pr.outputs.mode }}" = "initial" ]; then | |
| echo "cmd=/review-pr ${{ steps.pr.outputs.pr_num }}" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "cmd=/respond-to-critique ${{ steps.pr.outputs.pr_num }}" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Run Reviewer A | |
| if: github.event_name == 'issue_comment' || steps.gate.outputs.skip == 'false' | |
| uses: anthropics/claude-code-action@v1 | |
| with: | |
| # Allows the App's bot identity (and github-actions[bot] for | |
| # consistency). Reviewer A runs on bot's own PRs and responds | |
| # to its own Reviewer B comments — both legitimately bot-driven. | |
| allowed_bots: 'wheels-bot[bot],github-actions[bot]' | |
| anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} | |
| github_token: ${{ steps.app-token.outputs.token }} | |
| prompt: | | |
| ${{ steps.cmd.outputs.cmd }} | |
| claude_args: | | |
| --model claude-sonnet-4-6 | |
| --max-turns 250 | |
| --allowedTools "Bash(gh:*),Bash(git log:*),Bash(git diff:*),Bash(git show:*),Bash(git grep:*),Bash(git status),Read,Grep,Glob" | |
| # Post-submission guard (issue #2558). Reviewer A is trusted to issue a | |
| # single, substantive `gh pr review` per session. When the model misbehaves | |
| # — e.g. probing the CLI with `--body "test body"` before issuing the | |
| # real review — the placeholder leaks out as a public review. This step | |
| # scans wheels-bot reviews on the current SHA after the model exits and | |
| # auto-dismisses any that look like probes (too short, or missing the | |
| # canonical `wheels-bot:review-a` marker). Runs on `always()` so it still | |
| # fires when the Claude step itself failed mid-session. | |
| - name: Validate Reviewer A output | |
| if: always() && (github.event_name == 'issue_comment' || steps.gate.outputs.skip == 'false') | |
| env: | |
| GH_TOKEN: ${{ steps.app-token.outputs.token }} | |
| PR_NUMBER: ${{ steps.pr.outputs.pr_num }} | |
| HEAD_SHA: ${{ steps.pr.outputs.sha }} | |
| run: | | |
| set -euo pipefail | |
| # Allow-list APPROVED and CHANGES_REQUESTED only: those are the | |
| # exclusive set of states that (a) GitHub's dismiss API accepts and | |
| # (b) gate merging. Feeding the dismiss API any other state returns | |
| # HTTP 422 — e.g. "Can not dismiss a commented pull request review" | |
| # for COMMENTED, the same shape for PENDING — which would fail this | |
| # step and red-X the check. Originally observed on PR #2795 commit | |
| # 0db188a5 when a COMMENTED placeholder leaked through. | |
| reviews=$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews" --paginate \ | |
| | jq -c --arg sha "$HEAD_SHA" \ | |
| '[.[] | select(.user.login == "wheels-bot[bot]") | select(.commit_id == $sha) | select(.state == "APPROVED" or .state == "CHANGES_REQUESTED")]') | |
| count=$(echo "$reviews" | jq 'length') | |
| if [[ "$count" == "0" ]]; then | |
| echo "::notice::No active wheels-bot reviews on ${HEAD_SHA} to validate" | |
| exit 0 | |
| fi | |
| # Dismissal criteria: body must be (a) >= 200 chars AND (b) contain | |
| # `wheels-bot:review-a` as a substring. Both conditions are required — | |
| # a long body without the marker still gets dismissed, because a | |
| # marker-less review breaks downstream idempotency (the skip-check | |
| # action greps for the marker on subsequent runs) and is itself a | |
| # signal that the prompt was not followed. Belt-and-suspenders: the | |
| # guard treats prompt compliance as load-bearing, not advisory. | |
| dismissed=0 | |
| while IFS= read -r row; do | |
| id=$(echo "$row" | jq -r '.id') | |
| body=$(echo "$row" | jq -r '.body') | |
| body_len=${#body} | |
| if [[ "$body_len" -lt 200 ]] || ! grep -q 'wheels-bot:review-a' <<<"$body"; then | |
| echo "::warning::Dismissing bogus Reviewer A review id=${id} len=${body_len}" | |
| gh api -X PUT \ | |
| "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews/${id}/dismissals" \ | |
| -f message="Auto-dismissed by Reviewer A guard: body is shorter than 200 characters or missing the canonical \`wheels-bot:review-a\` marker. See wheels-dev/wheels#2558 for context." | |
| dismissed=$((dismissed + 1)) | |
| fi | |
| done < <(echo "$reviews" | jq -c '.[]') | |
| if [[ "$dismissed" -gt 0 ]]; then | |
| # Idempotency check: a manual workflow re-trigger combined with | |
| # new bogus reviews on the same SHA could otherwise produce | |
| # duplicate guard comments. Skip if a guard comment for this | |
| # exact PR + SHA already exists. | |
| guard_marker="wheels-bot:review-a-guard:${PR_NUMBER}:${HEAD_SHA}" | |
| existing=$(gh api "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" --paginate \ | |
| | jq -r --arg m "$guard_marker" '[.[] | select(.body | contains($m))] | length') | |
| if [[ "$existing" == "0" ]]; then | |
| short_sha=${HEAD_SHA:0:7} | |
| gh pr comment "$PR_NUMBER" --body "## Wheels Bot — Reviewer A guard | |
| Detected and dismissed ${dismissed} bogus Reviewer A review(s) on commit \`${short_sha}\`. Cause: review body shorter than 200 characters or missing the canonical \`wheels-bot:review-a\` marker. See [wheels-dev/wheels#2558](https://github.com/wheels-dev/wheels/issues/2558) for context. | |
| <!-- ${guard_marker} -->" | |
| else | |
| echo "::notice::Guard comment already present for ${PR_NUMBER}@${HEAD_SHA}; skipping duplicate" | |
| fi | |
| fi |