Skip to content

[NoQA] Re-run Claude PR reviewer via repository_dispatch on C+ approval#88927

Open
mountiny wants to merge 5 commits into
mainfrom
mountiny-claude-review-dispatch
Open

[NoQA] Re-run Claude PR reviewer via repository_dispatch on C+ approval#88927
mountiny wants to merge 5 commits into
mainfrom
mountiny-claude-review-dispatch

Conversation

@mountiny
Copy link
Copy Markdown
Contributor

@mountiny mountiny commented Apr 27, 2026

Explanation of Change

Replaces the pull_request_review trigger added in #88584 with a repository_dispatch trigger so the Claude PR reviewer can also re-run on Contributor+ approval for fork PRs — which is most external contributors.

Why the previous trigger was wrong

pull_request_review from a fork is treated by GitHub as pull_request from a fork: no secrets, read-only GITHUB_TOKEN, and permissions: cannot elevate it. So OS_BOTIFY_TOKEN, ANTHROPIC_API_KEY, and pull-requests: write were all silently unavailable on exactly the PRs we most wanted C+ re-reviews on.

New flow

  1. An Expensify-controlled GitHub webhook receives pull_request_review + submitted and verifies that the reviewer is a member of the Contributor+ team.
  2. If so, it fires a repository_dispatch event of type claude-review-request against Expensify/App with { pr_number, reviewer }.
  3. This workflow handles repository_dispatch and runs in the base-repo context with full secrets, regardless of fork status.

Hardening on the App side

  • POST /repos/Expensify/App/dispatches requires Contents: write on this repo, so externals/forks cannot call it.
  • github.actor must be exactly expensify-bot[bot]. Any other dispatcher fails the check.
  • client_payload.pr_number and client_payload.reviewer are passed via env vars and validated with regex before reaching shell.
  • PR metadata (headRefOid, baseRefName, isDraft, title) is re-resolved via gh pr view rather than trusted from the payload, so a forged payload can only redirect Claude to a real PR.

Cleanup

  • Removes the isContributorPlus composite action (the membership check now lives upstream of the dispatch).
  • The existing pull_request_target flow (PR opened / ready_for_review) is unchanged.

Fixed Issues

$ #88582

PROPOSAL:

Tests

CI-only change, exercised end-to-end through the paired backend PR (https://github.com/Expensify/Web-Expensify/pull/52390):

  1. C+ submits an approving review on a fork PR against Expensify/Appclaude-review-request is dispatched → this workflow runs, resolves the head SHA via gh pr view, paths-filter sees the actual PR diff, Claude posts inline comments.
  2. Internal employee submits an approving review → upstream C+ check fails → no dispatch.
  3. C+ self-approves their own PR → upstream guard rejects it → no dispatch.
  4. C+ submits "Request changes" → upstream guard rejects it → no dispatch.
  5. PR with Revert in the title → review job's if: filters it out.
  6. Draft PR → review job's if: filters it out.
  7. Manually call gh api repos/Expensify/App/dispatches -f event_type=claude-review-request ... from any account other than expensify-bot[bot]Verify dispatcher step fails fast.
  8. Manually call with client_payload[pr_number]=NOT_A_NUMBERValidate payload step fails fast.
  9. Existing pull_request_target flow (PR opened / ready_for_review) still works as before.
  • Verify that no errors appear in the JS console

Offline tests

N/A — CI workflow change.

QA Steps

N/A — CI workflow change with no user-facing impact. After the paired backend PR ships, real-world QA is a C+ approval on a fork PR against Expensify/App and confirming Claude re-runs against the latest commit.

  • Verify that no errors appear in the JS console

PR Author Checklist

  • I linked the correct issue in the ### Fixed Issues section above
  • I wrote clear testing steps that cover the changes made in this PR
    • I added steps for local testing in the Tests section
    • I added steps for the expected offline behavior in the Offline steps section
    • I added steps for Staging and/or Production testing in the QA steps section
    • I added steps to cover failure scenarios (i.e. verify an input displays the correct error message if the entered data is not correct)
    • I turned off my network connection and tested it while offline to ensure it matches the expected behavior (i.e. verify the default avatar icon is displayed if app is offline)
    • I tested this PR with a High Traffic account against the staging or production API to ensure there are no regressions (e.g. long loading states that impact usability).
  • I included screenshots or videos for tests on all platforms
  • I ran the tests on all platforms & verified they passed on:
    • Android: Native
    • Android: mWeb Chrome
    • iOS: Native
    • iOS: mWeb Safari
    • MacOS: Chrome / Safari
  • I verified there are no console errors (if there's a console error not related to the PR, report it or open an issue for it to be fixed)
  • I followed proper code patterns (see Reviewing the code)
    • I verified that any callback methods that were added or modified are named for what the method does and never what callback they handle (i.e. toggleReport and not onIconClick)
    • I verified that comments were added to code that is not self explanatory
    • I verified that any new or modified comments were clear, correct English, and explained "why" the code was doing something instead of only explaining "what" the code was doing.
    • I verified any copy / text shown in the product is localized by adding it to src/languages/* files and using the translation method
      • If any non-english text was added/modified, I used JaimeGPT to get English > Spanish translation. I then posted it in #expensify-open-source and it was approved by an internal Expensify engineer. Link to Slack message:
    • I verified all numbers, amounts, dates and phone numbers shown in the product are using the localization methods
    • I verified any copy / text that was added to the app is grammatically correct in English. It adheres to proper capitalization guidelines (note: only the first word of header/labels should be capitalized), and is either coming verbatim from figma or has been approved by marketing (in order to get marketing approval, ask the Bug Zero team member to add the Waiting for copy label to the issue)
    • I verified proper file naming conventions were followed for any new files or renamed files. All non-platform specific files are named after what they export and are not named "index.js". All platform-specific files are named for the platform the code supports as outlined in the README.
    • I verified the JSDocs style guidelines (in STYLE.md) were followed
  • If a new code pattern is added I verified it was agreed to be used by multiple Expensify engineers
  • I followed the guidelines as stated in the Review Guidelines
  • I tested other components that can be impacted by my changes (i.e. if the PR modifies a shared library or component like Avatar, I verified the components using Avatar are working as expected)
  • I verified all code is DRY (the PR doesn't include any logic written more than once, with the exception of tests)
  • I verified any variables that can be defined as constants (ie. in CONST.ts or at the top of the file that uses the constant) are defined as such
  • I verified that if a function's arguments changed that all usages have also been updated correctly
  • If any new file was added I verified that:
    • The file has a description of what it does and/or why is needed at the top of the file if the code is not self explanatory
  • If a new CSS style is added I verified that:
    • A similar style doesn't already exist
    • The style can't be created with an existing StyleUtils function (i.e. StyleUtils.getBackgroundAndBorderStyle(theme.componentBG))
  • If new assets were added or existing ones were modified, I verified that:
    • The assets are optimized and compressed (for SVG files, run npm run compress-svg)
    • The assets load correctly across all supported platforms.
  • If the PR modifies code that runs when editing or sending messages, I tested and verified there is no unexpected behavior for all supported markdown - URLs, single line code, code blocks, quotes, headings, bold, strikethrough, and italic.
  • If the PR modifies a generic component, I tested and verified that those changes do not break usages of that component in the rest of the App (i.e. if a shared library or component like Avatar is modified, I verified that Avatar is working as expected in all cases)
  • If the PR modifies a component related to any of the existing Storybook stories, I tested and verified all stories for that component are still working as expected.
  • If the PR modifies a component or page that can be accessed by a direct deeplink, I verified that the code functions as expected when the deeplink is used - from a logged in and logged out account.
  • If the PR modifies the UI (e.g. new buttons, new UI components, changing the padding/spacing/sizing, moving components, etc) or modifies the form input styles:
    • I verified that all the inputs inside a form are aligned with each other.
    • I added Design label and/or tagged @Expensify/design so the design team can review the changes.
  • If a new page is added, I verified it's using the ScrollView component to make it scrollable when more elements are added to the page.
  • I added unit tests for any new feature or bug fix in this PR to help automatically prevent regressions in this user flow.
  • If the main branch was merged into this PR after a review, I tested again and verified the outcome was still expected according to the Test steps.

Screenshots/Videos

Android: Native
Android: mWeb Chrome
iOS: Native
iOS: mWeb Safari
MacOS: Chrome / Safari

Replaces the pull_request_review trigger added in #88584. That trigger
cannot work for fork PRs because GitHub does not expose secrets to a
workflow when a fork PR fires pull_request_review, so OS_BOTIFY_TOKEN,
ANTHROPIC_API_KEY, and pull-requests: write are all unavailable on
exactly the PRs we most want C+ re-reviews for.

Instead, the GitHub webhook handled by Web-Expensify checks the C+ team
membership and (for an approving review on Expensify/App) fires a
repository_dispatch event of type claude-review-request against this
repo. repository_dispatch always runs in the base-repo context with full
secrets, including for fork PRs, and is gated by Contents: write so
externals/forks cannot trigger it.

Hardening:
 - github.actor must be OSBotify or a *[bot] account
 - client_payload values are passed via env vars and validated with
   regex before reaching shell
 - PR metadata (head SHA, base ref, draft state, title) is re-resolved
   via gh pr view rather than trusted from the payload

Removes the now-unused isContributorPlus composite action; the
membership check lives in Web-Expensify alongside other PHP-side team
lookups.

Paired with Expensify/Web-Expensify#52390.

Made-with: Cursor
GitHub's /dispatches permission check (Contents: write) already excludes
externals and forks, but several Expensify-controlled identities also
have that permission (org admins, write-collaborator PATs, other bots).
The PHP webhook in Web-Expensify is the only intended caller, and it
authenticates as App ID 179547 (https://github.com/apps/expensify-bot,
github.actor = "expensify-bot[bot]") via EXPENSIFY_GITHUB_APP_PRIVATE_KEY.

Pin the gate to that exact slug so a leaked PAT from any other code
path cannot route an arbitrary client_payload through this workflow,
which has access to ANTHROPIC_API_KEY and pull-requests: write.

Made-with: Cursor
@mountiny mountiny marked this pull request as ready for review April 27, 2026 15:09
@mountiny mountiny requested a review from a team as a code owner April 27, 2026 15:09
@melvin-bot melvin-bot Bot requested review from ahmedGaber93 and removed request for a team April 27, 2026 15:09
@mountiny mountiny requested review from a team and Copilot and removed request for ahmedGaber93 April 27, 2026 15:09
@melvin-bot
Copy link
Copy Markdown

melvin-bot Bot commented Apr 27, 2026

@ahmedGaber93 Please copy/paste the Reviewer Checklist from here into a new comment on this PR and complete it. If you have the K2 extension, you can simply click: [this button]

@melvin-bot melvin-bot Bot requested a review from mkhutornyi April 27, 2026 15:10
@mountiny
Copy link
Copy Markdown
Contributor Author

@codex review

@melvin-bot
Copy link
Copy Markdown

melvin-bot Bot commented Apr 27, 2026

@mkhutornyi Please copy/paste the Reviewer Checklist from here into a new comment on this PR and complete it. If you have the K2 extension, you can simply click: [this button]

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Updates the Claude PR review workflow so it can be re-run on Contributor+ approvals for fork PRs by switching from pull_request_review to a repository_dispatch trigger, enabling the run to execute in the base-repo context with secrets available.

Changes:

  • Replace pull_request_review trigger with repository_dispatch: claude-review-request and add a resolvePR job to re-resolve PR metadata via gh pr view.
  • Update concurrency grouping and pass resolved PR number/SHA/base ref into checkout + paths-filter.
  • Remove the now-unused isContributorPlus composite action.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.

File Description
.github/workflows/claude-review.yml Adds repository_dispatch trigger + resolvePR job and wires resolved PR metadata into checkout/filters/prompts.
.github/actions/composite/isContributorPlus/action.yml Removes the Contributor+ membership composite action (membership check moved upstream of dispatch).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 85 to 89
review:
needs: [validate, checkCPlusApproval]
needs: [validate, resolvePR]
if: |
!cancelled()
&& github.event.pull_request.draft != true
&& !contains(github.event.pull_request.title, 'Revert')
&& (
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

review has needs: [validate, resolvePR], but validate only runs on pull_request_target and resolvePR only runs on repository_dispatch. In GitHub Actions, a job with needs will be skipped when a required dependency is skipped unless the job-level if includes always(). As written, this risks review never running for one (or both) triggers. Consider adding always() to the review.if and then explicitly gating on needs.validate.result == 'success' for pull_request_target and needs.resolvePR.result == 'success' for repository_dispatch, or split into two trigger-specific review jobs.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The job already begins its if with !cancelled(), which is the documented override for the implicit success() requirement on needs:. Each branch of the OR explicitly checks needs.validate.outputs.IS_AUTHORIZED == 'true' for pull_request_target and needs.resolvePR.result == 'success' for repository_dispatch, so a skipped sibling cannot accidentally take the wrong branch. Leaving as-is.

Comment thread .github/workflows/claude-review.yml Outdated
Comment on lines +106 to +110
fetch-depth: 1
# For pull_request_target the default ref is fine; for repository_dispatch we
# need to explicitly point at the PR's head SHA so paths-filter and Claude see
# the code being reviewed (not the default branch).
ref: ${{ github.event.pull_request.head.sha || needs.resolvePR.outputs.HEAD_SHA }}
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This ref: expression will also be truthy on pull_request_target (since github.event.pull_request.head.sha exists), so it will checkout the PR's head commit. Checking out and executing repository code from an untrusted fork under pull_request_target is a security risk because subsequent steps run scripts from the checked-out ref and have access to secrets. To harden this, avoid checking out PR code on pull_request_target (checkout the base repo/default ref for trusted scripts), and if you need PR contents for Claude, fetch them via gh pr diff/view or checkout into a separate directory without executing any PR-provided scripts.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — accepting and fixed in 4975874. The ref: parameter has been removed from actions/checkout so the review job always runs against the trusted base ref, never fork-controlled PR-head code. Claude reads PR contents through its gh pr diff / gh pr view allowed tools, not from the workspace, so we never need fork code on disk.

Comment thread .github/workflows/claude-review.yml Outdated
Comment on lines +116 to +120
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
id: filter
with:
base: ${{ github.event.pull_request.base.ref || needs.resolvePR.outputs.BASE_REF }}
ref: ${{ github.event.pull_request.head.sha || needs.resolvePR.outputs.HEAD_SHA }}
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Setting base/ref forces paths-filter into a ref-vs-ref comparison. On the repository_dispatch path, this no longer has PR context, so it will likely rely on local git refs being available; with actions/checkout using fetch-depth: 1 and checking out a detached SHA, the base branch ref may not exist locally, which can make the filter produce incorrect results or fail. Consider either (a) ensuring the required refs are fetched (e.g., fetch base branch / use fetch-depth: 0), or (b) avoiding paths-filter here and instead deriving changed paths via the PR API (e.g., gh pr view --json files) so it doesn’t depend on local git history.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Accepting — naturally required by the pwn-request fix above. With the PR head no longer checked out, dorny/paths-filter with explicit base/ref would be unable to compare against the unfetched ref under repository_dispatch. Replaced the step in 4975874 with a small bash step that calls gh pr view --json files and sets the same code / docs outputs, so it works identically for both pull_request_target and repository_dispatch and doesn't depend on local git history.

@mkhutornyi
Copy link
Copy Markdown
Contributor

Please let me know when you wanna test so I can approve.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 12eb78e94f

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread .github/workflows/claude-review.yml Outdated
# For pull_request_target the default ref is fine; for repository_dispatch we
# need to explicitly point at the PR's head SHA so paths-filter and Claude see
# the code being reviewed (not the default branch).
ref: ${{ github.event.pull_request.head.sha || needs.resolvePR.outputs.HEAD_SHA }}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P0 Badge Stop checking out PR head in pull_request_target runs

Using ref: ${{ github.event.pull_request.head.sha }} here makes the pull_request_target path check out attacker-controlled fork code, but this same job then executes trusted steps (setupNode runs npm ci) with repository secrets and a writable PR token. That combination is a known privilege-escalation pattern: a malicious fork PR can modify build scripts and exfiltrate secrets or perform privileged GitHub API actions. This was safe before because pull_request_target used the default trusted ref.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 4975874. The ref: parameter was removed from actions/checkout so the review job now always runs against the trusted base ref under both pull_request_target and repository_dispatch. Claude reads PR contents only via the allowlisted gh pr diff/gh pr view tools, so we no longer need fork code on disk and the pwn-request path is closed. (As a knock-on, replaced dorny/paths-filter with a gh pr view --json files step so path detection still works without a PR-head checkout.)

@mountiny mountiny removed the request for review from mkhutornyi April 27, 2026 15:31
@mountiny
Copy link
Copy Markdown
Contributor Author

All good

Removes ref: from actions/checkout so the review job always runs against the
trusted base ref, addressing the pwn-request pattern where running repo
scripts against fork-controlled PR-head code would expose base-repo secrets.
Replaces dorny/paths-filter with gh pr view --json files so path detection
works the same way for both pull_request_target and repository_dispatch
without needing PR-head code on disk.

Made-with: Cursor
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants