fix(ui): keep the model picker from vanishing #5801
Workflow file for this run
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: PR Approval Agent | |
| on: | |
| pull_request: | |
| types: [labeled, ready_for_review, synchronize] | |
| permissions: | |
| contents: read | |
| pull-requests: write | |
| concurrency: | |
| group: pr-approval-${{ github.event.pull_request.number }} | |
| cancel-in-progress: true | |
| jobs: | |
| review: | |
| # Write access is required to apply the Stamphog label, so no | |
| # additional author_association check is needed. Bot-authored PRs are | |
| # hard-excluded regardless of who applies the label — see the | |
| # bot-author-skip job, which strips the label and explains why. | |
| # Triggers: explicit `Stamphog` label, ready_for_review with the | |
| # label already present, or `synchronize` where decide-delta | |
| # asked for re-review (or itself failed — fail closed for safety). | |
| needs: [decide-delta, dismiss] | |
| if: >- | |
| always() | |
| && !github.event.pull_request.draft | |
| && github.event.pull_request.user.type != 'Bot' | |
| && !contains(github.event.pull_request.user.login, '[bot]') | |
| && github.event.pull_request.user.login != 'posthog-bot' | |
| && ( | |
| github.event.label.name == 'Stamphog' | |
| || (github.event.action == 'ready_for_review' && contains(github.event.pull_request.labels.*.name, 'Stamphog')) | |
| || needs.decide-delta.outputs.run_review == 'true' | |
| || needs.decide-delta.result == 'failure' | |
| ) | |
| runs-on: ubuntu-latest | |
| # Budget for the in-flight-bot-review wait (5 min of sleeps plus the | |
| # per-poll refetches, so wall-clock exceeds it) plus the LLM review — | |
| # see BOT_REVIEW_WAIT_BUDGET_SECONDS in review_pr.py. The cap costs | |
| # nothing unless a run hangs; runners bill actual minutes. | |
| timeout-minutes: 20 | |
| steps: | |
| - name: Get app token | |
| id: app-token | |
| uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 | |
| with: | |
| client-id: ${{ secrets.GH_APP_PR_APPROVAL_AGENT_APP_ID }} | |
| private-key: ${{ secrets.GH_APP_PR_APPROVAL_AGENT_PRIVATE_KEY }} | |
| # Always run the approval script from main — hardcoded so a PR | |
| # targeting a non-main branch can't supply a tampered script. | |
| - name: Checkout main (blobless, full history) | |
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| with: | |
| token: ${{ steps.app-token.outputs.token }} | |
| ref: main | |
| filter: blob:none | |
| fetch-depth: 0 | |
| - name: Fetch PR head ref | |
| run: git fetch --filter=blob:none origin pull/${{ github.event.pull_request.number }}/head | |
| - name: Install uv | |
| uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 | |
| with: | |
| version: '0.11.28' # pinned: unpinned setup-uv calls GH API on every job, exhausts rate limit | |
| enable-cache: false | |
| - name: Run review | |
| env: | |
| # Dedicated key so a rotation of the shared ANTHROPIC_API_KEY | |
| # doesn't break stamphog (and vice versa). The env var name | |
| # stays ANTHROPIC_API_KEY — that's what the Claude Agent SDK reads. | |
| ANTHROPIC_API_KEY: ${{ secrets.STAMPHOG_ANTHROPIC_API_KEY }} | |
| # ai-gateway cutover: when both are set, the review routes through | |
| # the Go ai-gateway (phs_ secret) instead of Anthropic directly. | |
| # Unset = direct path, so setting them is the cutover. | |
| AI_GATEWAY_URL: ${{ secrets.STAMPHOG_AI_GATEWAY_URL }} | |
| AI_GATEWAY_API_KEY: ${{ secrets.STAMPHOG_AI_GATEWAY_API_KEY }} | |
| POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_TOKEN }} | |
| GH_TOKEN: ${{ steps.app-token.outputs.token }} | |
| run: | | |
| uv run tools/pr-approval-agent/review_pr.py \ | |
| ${{ github.event.pull_request.number }} \ | |
| --repo ${{ github.repository }} \ | |
| --output-json /tmp/review.json | |
| - name: Post review | |
| if: always() | |
| env: | |
| # Local difference from upstream: approvals post with | |
| # GITHUB_TOKEN so github-actions[bot] is the reviewer — its | |
| # approvals are confirmed to count toward this repo's branch | |
| # ruleset. Upstream posthog switched to a single app approval | |
| # after confirming their app unblocks PRs; flip this once the | |
| # same is confirmed here. Everything else (sticky comment, | |
| # label strip) posts as the app (GH_TOKEN). | |
| GH_TOKEN_APPROVE: ${{ github.token }} | |
| GH_TOKEN: ${{ steps.app-token.outputs.token }} | |
| # Derived from the token step so the sticky-comment author | |
| # filter tracks an app rename instead of failing silent. | |
| BOT_LOGIN: ${{ steps.app-token.outputs.app-slug }}[bot] | |
| run: | | |
| PR=${{ github.event.pull_request.number }} | |
| REPO=${{ github.repository }} | |
| VERDICT=$(jq -r '.final_verdict // ""' /tmp/review.json 2>/dev/null || echo "") | |
| # Prefer the structured comment body (reasoning + judgment | |
| # bullets + folded gate mechanics); fall back to the bare | |
| # reasoning when the script predates the field. | |
| REASONING=$(jq -r '.review_body // .reviewer.reasoning // ""' /tmp/review.json 2>/dev/null || echo "") | |
| REVIEWED_SHA=$(jq -r '.head_sha // ""' /tmp/review.json 2>/dev/null || echo "") | |
| # Lock the review to the sha the LLM actually saw — `gh pr | |
| # review` records against the head at API-call time, which | |
| # drifts mid-LLM-roundtrip if the author force-pushes. | |
| SHA_ARGS=() | |
| if [ -n "$REVIEWED_SHA" ]; then | |
| SHA_ARGS=(-f "commit_id=$REVIEWED_SHA") | |
| fi | |
| if [ "$VERDICT" = "APPROVED" ]; then | |
| # Single github-actions[bot] approval, carrying the review | |
| # body. Fatal on failure (the step runs under `set -e`): | |
| # this is the only approval, so if it doesn't post the PR | |
| # stays blocked and that must surface as a red run. | |
| GH_TOKEN="$GH_TOKEN_APPROVE" gh api \ | |
| -X POST "repos/$REPO/pulls/$PR/reviews" \ | |
| "${SHA_ARGS[@]}" \ | |
| -f event=APPROVE \ | |
| -f body="$REASONING" | |
| else | |
| # Non-approve verdicts share ONE sticky comment, updated in | |
| # place, instead of a fresh COMMENT review per run. Review | |
| # bodies can't be minimized or deleted, so when an automation | |
| # loop keeps re-applying the label against a deterministic | |
| # denial (e.g. the size gate) the refusals pile up — one PR | |
| # collected 60+ near-identical stamphog reviews. | |
| MARKER='<!-- stamphog-sticky-review -->' | |
| RUN_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" | |
| # Author + leading-marker filter: on a public repo anyone can | |
| # post a comment containing the marker; without the filter the | |
| # bot would PATCH (clobber) a third-party comment. Slurp so the | |
| # jq runs once over all pages (per-page filtering could emit | |
| # one object per page); gh rejects --slurp combined with --jq, | |
| # so pipe to standalone jq. The || guard keeps a transient | |
| # lookup failure from aborting the step before the label strip | |
| # below (it degrades to posting a fresh comment instead). | |
| EXISTING=$(gh api "repos/$REPO/issues/$PR/comments" --paginate --slurp \ | |
| | jq "[.[][] | select(.user.login == \"$BOT_LOGIN\" and (.body | startswith(\"$MARKER\")))][0] // empty" \ | |
| || echo "") | |
| COMMENT_ID="" | |
| EXISTING_BODY="" | |
| PREV_COUNT=0 | |
| if [ -n "$EXISTING" ]; then | |
| COMMENT_ID=$(jq -r '.id' <<<"$EXISTING") | |
| EXISTING_BODY=$(jq -r '.body' <<<"$EXISTING") | |
| PREV_COUNT=$(jq -r '.body | (match("stamphog-review-count:([0-9]+)").captures[0].string // "0")' <<<"$EXISTING") | |
| fi | |
| # The append path requires an actual verdict in the prior body — | |
| # a crash-only prior comment goes through the rebuild branch so | |
| # it can't end up claiming "the verdict above" over no verdict. | |
| if [ -z "$REASONING" ] && [ -n "$COMMENT_ID" ] \ | |
| && grep -q 'verdict: \*\*' <<<"$EXISTING_BODY"; then | |
| # Verdictless crash with a prior verdict on record: append the | |
| # failure note below it rather than clobbering the reasoning | |
| # the author still needs to act on. The anchored grep dedups | |
| # only the note itself, never verdict reasoning that happens | |
| # to mention review failures. | |
| { grep -v '^> ⚠️ A later review run failed' <<<"$EXISTING_BODY" || true; } > /tmp/stamphog-comment.md | |
| { | |
| echo | |
| echo "> ⚠️ A later review run failed before producing a verdict — check the [workflow run]($RUN_URL). The verdict above is from an earlier run." | |
| } >> /tmp/stamphog-comment.md | |
| else | |
| # The counter tracks verdicts only — a crash rebuild keeps | |
| # the previous count so repeated failures don't inflate it. | |
| if [ -n "$REASONING" ]; then | |
| COUNT=$((PREV_COUNT + 1)) | |
| HEADLINE="stamphog reviewed \`${REVIEWED_SHA:-unknown}\` — verdict: **${VERDICT:-unknown}**" | |
| DETAIL="$REASONING" | |
| else | |
| COUNT=$PREV_COUNT | |
| HEADLINE="stamphog review failed before producing a verdict" | |
| DETAIL="Check the [workflow run]($RUN_URL) and re-apply the label to retry." | |
| fi | |
| { | |
| echo "$MARKER" | |
| echo "<!-- stamphog-review-count:$COUNT -->" | |
| echo "> [!NOTE]" | |
| echo "> 🤖 $HEADLINE" | |
| echo | |
| echo "$DETAIL" | |
| if [ -n "$COMMENT_ID" ]; then | |
| echo | |
| [ "$PREV_COUNT" -gt 0 ] \ | |
| && echo "_Updated in place — this replaces $PREV_COUNT earlier stamphog review(s) on this PR._" \ | |
| || echo "_Updated in place from an earlier stamphog review._" | |
| fi | |
| } > /tmp/stamphog-comment.md | |
| fi | |
| # A failed PATCH (comment deleted, transient API error) falls | |
| # back to posting fresh, so the only unguarded API call left | |
| # ahead of the label strip is the same single POST the old | |
| # COMMENT-review path had. | |
| if [ -n "$COMMENT_ID" ]; then | |
| gh api -X PATCH "repos/$REPO/issues/comments/$COMMENT_ID" \ | |
| -F "body=@/tmp/stamphog-comment.md" || COMMENT_ID="" | |
| fi | |
| if [ -z "$COMMENT_ID" ]; then | |
| gh api -X POST "repos/$REPO/issues/$PR/comments" \ | |
| -F "body=@/tmp/stamphog-comment.md" | |
| fi | |
| fi | |
| # Only the two substantive verdicts strip the label — a | |
| # REFUSE/ESCALATE means the author must address feedback and | |
| # re-apply. Everything else fails toward retention: ERROR | |
| # (LLM backend down), WAIT (reviewer bot mid-review), and an | |
| # empty verdict (script crash / job cancelled before | |
| # review.json was written) are not verdicts on the PR, and | |
| # retention just means the next push retries. An explicit | |
| # strip-list also means future verdicts keep the label by | |
| # default instead of everyone remembering to extend a | |
| # keep-list. | |
| if [ "$VERDICT" = "REFUSED" ] || [ "$VERDICT" = "ESCALATE" ]; then | |
| gh pr edit "$PR" --remove-label Stamphog \ | |
| --repo "$REPO" | |
| fi | |
| - name: Upload evidence | |
| if: always() | |
| uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 | |
| with: | |
| name: review-${{ github.event.pull_request.number }} | |
| path: /tmp/review.json | |
| retention-days: 30 | |
| # Defense-in-depth: main ruleset has dismiss_stale_reviews_on_push=false | |
| # and require_last_push_approval=false, so a stale bot approval could | |
| # otherwise inherit malicious commits. Two-step gate: decide-delta | |
| # classifies the new commits since the last bot approval, dismiss only | |
| # runs when the delta is non-trivial. Trivial deltas (test/docs/lockfile | |
| # /generated paths and clean merges from the base branch) retain the | |
| # prior approval — a comment on the PR records the reason. The stamphog | |
| # label stays sticky across pushes; the review job's existing | |
| # non-APPROVED label-strip is the auto-loop's escape hatch. | |
| decide-delta: | |
| if: >- | |
| github.event.action == 'synchronize' | |
| && !github.event.pull_request.draft | |
| && github.event.pull_request.user.type != 'Bot' | |
| && !contains(github.event.pull_request.user.login, '[bot]') | |
| && github.event.pull_request.user.login != 'posthog-bot' | |
| && contains(github.event.pull_request.labels.*.name, 'Stamphog') | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| outputs: | |
| dismiss_approval: ${{ steps.decide.outputs.dismiss_approval }} | |
| run_review: ${{ steps.decide.outputs.run_review }} | |
| reason: ${{ steps.decide.outputs.reason }} | |
| last_approved_sha: ${{ steps.decide.outputs.last_approved_sha }} | |
| steps: | |
| - name: Get app token | |
| id: app-token | |
| uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 | |
| with: | |
| client-id: ${{ secrets.GH_APP_PR_APPROVAL_AGENT_APP_ID }} | |
| private-key: ${{ secrets.GH_APP_PR_APPROVAL_AGENT_PRIVATE_KEY }} | |
| - name: Checkout main (full history) | |
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| with: | |
| token: ${{ steps.app-token.outputs.token }} | |
| ref: main | |
| filter: blob:none | |
| fetch-depth: 0 | |
| - name: Fetch PR head | |
| run: git fetch --filter=blob:none origin pull/${{ github.event.pull_request.number }}/head | |
| - name: Install uv | |
| uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 | |
| with: | |
| version: '0.11.28' # pinned: unpinned setup-uv calls GH API on every job, exhausts rate limit | |
| enable-cache: false | |
| - name: Decide retain vs dismiss | |
| id: decide | |
| env: | |
| GH_TOKEN: ${{ steps.app-token.outputs.token }} | |
| REPO: ${{ github.repository }} | |
| PR_NUMBER: ${{ github.event.pull_request.number }} | |
| HEAD_SHA: ${{ github.event.pull_request.head.sha }} | |
| BASE_REF: origin/${{ github.event.pull_request.base.ref }} | |
| run: | | |
| set -euo pipefail | |
| decision=$(uv run tools/pr-approval-agent/dismiss_check.py) | |
| echo "$decision" | |
| echo "dismiss_approval=$(echo "$decision" | jq -r .dismiss_approval)" >> "$GITHUB_OUTPUT" | |
| echo "run_review=$(echo "$decision" | jq -r .run_review)" >> "$GITHUB_OUTPUT" | |
| echo "reason=$(echo "$decision" | jq -r .reason)" >> "$GITHUB_OUTPUT" | |
| echo "last_approved_sha=$(echo "$decision" | jq -r '.last_approved_sha // ""')" >> "$GITHUB_OUTPUT" | |
| # Only post the comment on actual retention reasons — not on | |
| # no_prior_approval (nothing to retain) or empty_delta (HEAD | |
| # didn't move, comment would be noise). | |
| - name: Note retained approval | |
| if: contains(fromJSON('["trivial_paths", "merge_only", "mixed_trivial"]'), steps.decide.outputs.reason) | |
| env: | |
| GH_TOKEN: ${{ steps.app-token.outputs.token }} | |
| PR: ${{ github.event.pull_request.number }} | |
| REPO: ${{ github.repository }} | |
| REASON: ${{ steps.decide.outputs.reason }} | |
| run: | | |
| gh pr comment "$PR" --repo "$REPO" \ | |
| --body "Retaining stamphog approval — delta since last review classified as \`$REASON\`." | |
| dismiss: | |
| needs: decide-delta | |
| # Fail closed on three cases: | |
| # - decide-delta said dismiss (smart path) | |
| # - decide-delta failed (uv install / checkout / fetch timeout) | |
| # - decide-delta was skipped (label removed out-of-band) — mirrors | |
| # the pre-PR unconditional dismiss-on-push behavior so a stale | |
| # bot approval can't outlive the label under main ruleset's | |
| # dismiss_stale_reviews_on_push=false / require_last_push_approval=false | |
| # Explicit synchronize + draft gates stop spurious dismissal on | |
| # labeled / ready_for_review events where decide-delta's result is | |
| # also 'skipped'. | |
| # Bot authors are deliberately NOT excluded here: a bot-authored PR may | |
| # still carry a stale github-actions[bot] approval from before the | |
| # bot-author gate landed, and it must get dismissed on push. decide-delta | |
| # is skipped for bots, which routes here via the 'skipped' fail-closed | |
| # path. This only ever touches stamphog bot approvals, so it's a | |
| # no-op when there's nothing to dismiss. | |
| # Same-repo only: forked / Dependabot PRs don't receive the app secrets, | |
| # so the app-token step below would fail on every such synchronize. There | |
| # is nothing to dismiss there anyway, since stamphog can never have | |
| # approved a fork PR (the review job's app-token step is equally starved | |
| # of secrets on forks), so gate the whole job to head branches in this repo. | |
| if: >- | |
| always() | |
| && github.event.action == 'synchronize' | |
| && !github.event.pull_request.draft | |
| && github.event.pull_request.head.repo.full_name == github.repository | |
| && ( | |
| needs.decide-delta.outputs.dismiss_approval == 'true' | |
| || needs.decide-delta.result == 'failure' | |
| || needs.decide-delta.result == 'skipped' | |
| ) | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| steps: | |
| - name: Get app token | |
| id: app-token | |
| uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 | |
| with: | |
| client-id: ${{ secrets.GH_APP_PR_APPROVAL_AGENT_APP_ID }} | |
| private-key: ${{ secrets.GH_APP_PR_APPROVAL_AGENT_PRIVATE_KEY }} | |
| - name: Dismiss stale bot approvals | |
| env: | |
| # Act as the Stamphog app for consistency with every other | |
| # write. pull-requests:write can dismiss any review regardless | |
| # of its author, so the app dismisses both its own approvals | |
| # and any legacy github-actions[bot] ones. | |
| GH_TOKEN: ${{ steps.app-token.outputs.token }} | |
| PR: ${{ github.event.pull_request.number }} | |
| REPO: ${{ github.repository }} | |
| REASON: ${{ needs.decide-delta.outputs.reason || (needs.decide-delta.result == 'skipped' && 'label_absent') || 'decide_delta_failed' }} | |
| run: | | |
| set -euo pipefail | |
| # Dismiss APPROVED reviews from either Stamphog identity — | |
| # github-actions[bot] (this repo's approval identity) and | |
| # stamphog[bot] (the app, in case approvals move there). | |
| # Human reviews and non-approval reviews are untouched. | |
| mapfile -t REVIEW_IDS < <( | |
| gh api "repos/$REPO/pulls/$PR/reviews" --paginate \ | |
| --jq '.[] | select((.user.login == "github-actions[bot]" or .user.login == "stamphog[bot]") and .state == "APPROVED") | .id' | |
| ) | |
| for id in "${REVIEW_IDS[@]}"; do | |
| [ -z "$id" ] && continue | |
| gh api -X PUT "repos/$REPO/pulls/$PR/reviews/$id/dismissals" \ | |
| -f message="New commits pushed (delta classified \`$REASON\`) — stamphog approval dismissed; re-review running automatically." \ | |
| -f event=DISMISS | |
| done | |
| # stamphog never reviews bot-authored PRs (dependabot, mendral, other | |
| # agents). A human applying the label can't override this — bot output | |
| # isn't a trusted basis for an auto-approval. review / decide-delta are | |
| # gated to skip bot authors; this job runs instead to strip the label so | |
| # it never lingers. Same bot definition as those job gates, and as the | |
| # review script's defense-in-depth REFUSE. | |
| # Fires on labeled / ready_for_review (the apply paths — comment + strip) | |
| # and on synchronize when the label is still present (a leftover from before | |
| # this gate landed, or a failed earlier strip — clean it up, no comment). | |
| bot-author-skip: | |
| if: >- | |
| !github.event.pull_request.draft | |
| && ( | |
| github.event.pull_request.user.type == 'Bot' | |
| || contains(github.event.pull_request.user.login, '[bot]') | |
| || github.event.pull_request.user.login == 'posthog-bot' | |
| ) | |
| && ( | |
| github.event.label.name == 'Stamphog' | |
| || (github.event.action != 'labeled' && contains(github.event.pull_request.labels.*.name, 'Stamphog')) | |
| ) | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| steps: | |
| - name: Get app token | |
| id: app-token | |
| uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 | |
| with: | |
| client-id: ${{ secrets.GH_APP_PR_APPROVAL_AGENT_APP_ID }} | |
| private-key: ${{ secrets.GH_APP_PR_APPROVAL_AGENT_PRIVATE_KEY }} | |
| - name: Comment and strip label | |
| env: | |
| GH_TOKEN: ${{ steps.app-token.outputs.token }} | |
| PR: ${{ github.event.pull_request.number }} | |
| REPO: ${{ github.repository }} | |
| ACTION: ${{ github.event.action }} | |
| run: | | |
| set -euo pipefail | |
| # Explain only on the apply paths (a human just labeled it, or | |
| # marked the PR ready). On synchronize the note was already | |
| # posted — or the label predates this gate — so just clean up. | |
| if [ "$ACTION" != "synchronize" ]; then | |
| gh pr comment "$PR" --repo "$REPO" \ | |
| --body "stamphog does not review bot-authored PRs — removing the \`Stamphog\` label. This change needs a human reviewer." | |
| fi | |
| gh pr edit "$PR" --repo "$REPO" --remove-label Stamphog |