Skip to content

Add independent peer review validation workflow#64

Draft
AndrewGable wants to merge 65 commits into
mainfrom
andrew-validate-independent-peer-review
Draft

Add independent peer review validation workflow#64
AndrewGable wants to merge 65 commits into
mainfrom
andrew-validate-independent-peer-review

Conversation

@AndrewGable

Copy link
Copy Markdown
Contributor

Details

Adds a ruleset-compatible workflow that validates pull requests have enough independent Expensify employee approvals before merge. The validator checks PR commits/co-authors, latest approving reviews, Expensify org membership, and repository write permission using Octokit REST APIs.

Related Issues

Prevent merging PRs that aren't peer reviewed

Manual Tests

Ran TypeScript and workflow validation locally:

npm exec tsc -- --noEmit --module NodeNext --moduleResolution NodeNext --target ES2022 --types node scripts/validateIndependentPeerReview.ts
npm exec --yes --package ajv-cli@5.0.0 -- ./scripts/validateWorkflowSchemas.sh
./scripts/actionlint.sh
./scripts/validateImmutableActionRefs.sh

Smoke tested the validator against real PRs using synthetic pull_request payloads. Note: my local token could not read branch protection settings, so these runs exercised the fallback path requiring one independent approval.

===== Expensify/App#86420 (known-self-review) =====
GET /repos/Expensify/App/branches/main/protection/required_pull_request_reviews - 404 with id EE1A:CC534:209938B:20E07F3:6A04E67D in 405ms
Expensify/App@main did not return a branch protection review count; requiring 1 independent approval(s).
::error::Expensify/App#86420 does not have enough independent Expensify employee approvals.%0ARequired independent approvals: 1%0ACommit authors/co-authors: grgia, MelvinBot%0AApprovers: grgia, sobitneupane%0AIndependent employee approvers: (none)
exit_code=1
===== Expensify/App#90237 (known-self-review) =====
GET /repos/Expensify/App/branches/main/protection/required_pull_request_reviews - 404 with id 7ED3:BC6DC:20F0712:2137A8F:6A04E67F in 589ms
Expensify/App@main did not return a branch protection review count; requiring 1 independent approval(s).
::error::Expensify/App#90237 does not have enough independent Expensify employee approvals.%0ARequired independent approvals: 1%0ACommit authors/co-authors: Beamanator, MelvinBot%0AApprovers: Beamanator, Pujan92%0AIndependent employee approvers: (none)
exit_code=1
===== Expensify/Auth#21136 (known-self-review) =====
GET /repos/Expensify/Auth/branches/main/protection/required_pull_request_reviews - 404 with id FDC4:29FBCC:213EBBA:2185FA8:6A04E681 in 469ms
Expensify/Auth@main did not return a branch protection review count; requiring 1 independent approval(s).
::error::Expensify/Auth#21136 does not have enough independent Expensify employee approvals.%0ARequired independent approvals: 1%0ACommit authors/co-authors: AndrewGable, MelvinBot%0AApprovers: AndrewGable%0AIndependent employee approvers: (none)
exit_code=1
===== Expensify/App#90528 (recent-app) =====
GET /repos/Expensify/App/branches/main/protection/required_pull_request_reviews - 404 with id D566:7C9BC:1F566BA:1F9CFF1:6A04E682 in 439ms
Expensify/App@main did not return a branch protection review count; requiring 1 independent approval(s).
Expensify/App#90528 has 1 independent Expensify employee approval(s).
exit_code=0
===== Expensify/Auth#21591 (recent-auth) =====
GET /repos/Expensify/Auth/branches/main/protection/required_pull_request_reviews - 404 with id E95D:7C9BC:1F57666:1F9DF9C:6A04E683 in 435ms
Expensify/Auth@main did not return a branch protection review count; requiring 1 independent approval(s).
Expensify/Auth#21591 has 1 independent Expensify employee approval(s).
exit_code=0
===== Expensify/GitHub-Actions#63 (recent-actions) =====
GET /repos/Expensify/GitHub-Actions/branches/main/protection/required_pull_request_reviews - 404 with id EF12:29906A:1F91F48:1FD97DD:6A04E685 in 408ms
Expensify/GitHub-Actions@main did not return a branch protection review count; requiring 1 independent approval(s).
Expensify/GitHub-Actions#63 has 1 independent Expensify employee approval(s).
exit_code=0

Linked PRs

N/A

Comment thread scripts/validateIndependentPeerReview.ts Outdated
@AndrewGable AndrewGable marked this pull request as ready for review May 15, 2026 23:16
Comment thread scripts/verifyPeerReview.ts Outdated
Comment on lines +274 to +281
if (requiredApprovingReviewCount === 0) {
console.log(`${owner}/${repo}#${number} targets ${context.baseRef}, which does not require approving reviews.`);
return;
}
if (approvers.length === 0) {
console.log(`${owner}/${repo}#${number} has no approving reviews from writers; regular branch protection will block merge until an approval exists.`);
return;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Given this will cause the check to pass when they are are no reviews, we might need to run this on pull_request_review events too

Comment thread .github/workflows/verifyPeerReview.yml
Comment thread scripts/verifyPeerReview.ts
@roryabraham roryabraham changed the title Add independent peer review validation workflow [WIP] Add independent peer review validation workflow Jun 10, 2026
@roryabraham

Copy link
Copy Markdown
Contributor

Making this WIP while I work on it

roryabraham and others added 4 commits June 10, 2026 16:07
@roryabraham roryabraham requested a review from grgia June 26, 2026 06:16
@roryabraham

roryabraham commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Ready for review. Since this was the first PR in this repo adding any JS/TS code, this PR also sets up:

  • typechecking
  • ESLint (this is one of the only repos outside App that uses typescript, so I put some work in eslint-config-expensify to make it more portable)
  • prettier
  • tests (using Node's built-in test runner)

Comment on lines +37 to +40
return response.repository?.ref?.branchProtectionRule?.requiredApprovingReviewCount ?? 0;
} catch (error: unknown) {
if (isPermissionError(error)) {
throw new Error(`Unable to read branch protection rules for ${owner}/${repo}@${baseRef}. Ensure the GitHub App has administration:read permission.`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

are branch protection rules also org wide? Do we need to enable this in app?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

we only read branchprotectionrule but may want to also support rulesets

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

are branch protection rules also org wide?

They are not. Branch protection rules are configured per repo AFAIK. That said, a protected main branch is standard in all our repos. In App, staging and prod are protected as well, because pushing to them automatically triggers deploys.

we only read branchprotectionrule but may want to also support rulesets

Stepping back, the purpose of this function is to figure out how many reviews are needed for a given branch. If more than 0 reviews are required by branch protection rules, then we don't allow self-merging. The reason we check is actually to prevent enforcing no self-merging if the base branch is some unprotected feature branch.

With that understanding, it's not clear to me why we would need to check rulesets, since they generally aren't what prevent merging pull requests against protected branches. The only exception I'm aware of is the ruleset for linting GitHub Actions and Workflows, but I'm not sure if that's relevant here.

Let me know if there is something I'm missing or some changes I should make. Happy to discuss further

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

A repo that requires reviews through rulesets instead of classic branch protection returns no branchProtectionRule, so the count is 0 and the whole check skips.

this case @roryabraham

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I thought app did

Comment thread scripts/libs/GitCommitUtils.ts Outdated
roryabraham and others added 2 commits June 29, 2026 10:54
Fail loudly when GitHub author login and commit author name are both
missing instead of silently continuing with an empty author.

Co-authored-by: Cursor <cursoragent@cursor.com>
@roryabraham

Copy link
Copy Markdown
Contributor

Discovered an existing bug we need to address before merge. Alternatively I can address in a follow-up as long as the ruleset isn't set to enforce yet. It will probably be smart to enable the ruleset in evaluate first anyways to make sure it's working before we start blocking PRs on it

@roryabraham roryabraham reopened this Jun 29, 2026
@roryabraham

Copy link
Copy Markdown
Contributor

@MelvinBot do a thorough analysis of the logic/queries introduced in this pull request. Compare them to the existing MissingIndependentPeerReview chore, and call out any differences.

Cherry-picked merge commits keep the original merger as git author, which
caused false peer-review failures when that person also approved. Expand
Merge pull request #N commits to source PR authors and skip merge clickers.

Co-authored-by: Cursor <cursoragent@cursor.com>
@roryabraham

Copy link
Copy Markdown
Contributor

Melvin was too slow, so here's my local agent summary of the differences between this PR and the existing chore.

1. Trigger Timing (Fundamental Difference)

PHP Chore TS Workflow
When Post-merge — detects violations after the fact Pre-merge — blocks violations before merge
Action on failure Creates a GitHub investigation issue assigned to a ring0 person Emits a workflow failure, preventing merge

This is by design. The two implementations are complementary: the TS workflow prevents self-merges going forward; the PHP chore catches any that slip through or occurred historically.


2. Merge Commit Handling (TS-only logic)

The TS version has two layers of merge commit handling that the PHP chore is entirely missing.

a) Skips bare git merge commits (commits with 2+ parents):

if (GitCommitUtils.isGitMergeCommit(commit)) {
    continue;
}

b) Recursively follows "Merge pull request #N" messages — when a commit message begins with Merge pull request #123 from ..., the TS version fetches the commits of PR #123 and collects their authors instead.

This handles staging/deploy-style PRs that land feature PRs via merge commits. Without this logic, all authors from every merged commit would be counted as authors of the deploy PR, making true "independence" nearly impossible to satisfy.

The PHP chore has no equivalent. On a deploy-style PR it would count every author whose commits appear in the PR's commit list.


3. Employee Detection (Different Sources of Truth)

PHP Chore TS Workflow
Method Github_Utils::isExpensifyEmployee() employeeLogins.has(approver)
Data source Internal Whitelist: checks whether the GitHub username maps to an @expensify.com email GitHub GraphQL: fetches all members of the expensify/expensify-expensify team

These sources can diverge. An ex-employee or contractor may exist in one but not the other.


4. Bot User Detection

Both share the same known-bot set (botify, MelvinBot, exfy-zapier). The TS version adds one additional check the PHP version lacks:

// TS only — catches GitHub App bots (e.g. "dependabot[bot]", "some-app[bot]")
if (login.endsWith('[bot]')) {
    return true;
}

The PHP version would not recognise GitHub App bots and could incorrectly treat them as human commit authors.


5. Co-Author Resolution (Different Fallback Strategies)

Both parse the same noreply-email pattern ((?:\d+\+)?(.+)@users.noreply.github.com). The divergence is in the fallback for real email addresses (e.g. andrew@expensify.com):

PHP Chore TS Workflow
Noreply → login Same regex Same regex
Real email fallback Whitelist lookup (github field keyed by lowercase email) Display name → login (matched case-insensitively against the employee team login set)
Unresolvable @expensify.com email Silently skipped (returns '', commit treated as if no co-author) Hard fail — stored as unresolvedExpensifyCoAuthors, causes the entire check to fail

The PHP fallback is more accurate when the Whitelist is current; the TS display-name fallback is more fragile (succeeds only if the display name matches a GitHub login exactly, or with whitespace stripped) but requires no internal data source.


6. "No Approvers" Case

PHP Chore TS Workflow
Behavior Falls through to the independent-approvers check, which fails → chore fires Skip: "has no approving reviews from writers; regular branch protection will block merge until an approval exists."

This difference is intentional. At pre-merge time the TS check defers to branch protection — the PR literally cannot merge without approval, so running the peer-review logic is pointless. Post-merge, the PHP chore still needs to flag the situation.


7. "All-Bot-Authors" Case

PHP Chore TS Workflow
Behavior Logs info that all authors are bots, then triggers the investigation chore Hard fail: "Unable to verify independent peer review because ... has no human commit authors or co-authors."

Both ultimately flag the PR, but the TS version surfaces it as a distinct, named error category via getFailureTitle.


8. Unresolvable Canonical Commit Author

// PHP: falls back to commit author name, uses empty string if both are missing — commit silently skipped
$authorLogin = $commit['author']['login'] ?? $commit['commit']['author']['name'] ?? '';
if ($authorLogin) {
    $authors[] = $authorLogin;
}
// TS: throws loudly — check fails with a named error category
throw new Error('Unable to resolve canonical commit author: missing GitHub author login and commit author name.');

9. Branch Protection Error Handling

PHP Chore TS Workflow
Permission error Logs info, defaults to 1 required approval Re-throws with "Ensure the GitHub App has administration:read permission."
Other errors Logs info, defaults to 1 Logs console.warn, defaults to 1

The TS version distinguishes permission errors from transient API errors, surfacing misconfigured app permissions rather than silently proceeding.



Summary Table

Dimension PHP Chore TS Workflow Notes
Trigger Post-merge Pre-merge By design
Merge commit traversal ❌ None ✅ Skips git merges, follows Merge pull request #N TS more accurate for deploy PRs
Employee source Internal Whitelist GitHub expensify-expensify team Could disagree on edge cases
[bot] suffix detection GitHub App bots not caught by PHP
Unresolvable @expensify.com co-author Silent skip Hard fail TS is stricter
No approvers Chore fires Skip Intentional per context
All-bot-authors Chore fires Hard fail Both flag it, different messaging
Missing commit author Silent skip Hard fail TS is stricter
Permission error on branch protection Default to 1 Throws TS makes misconfiguration visible
Co-author real-email fallback Whitelist lookup Display name → employee login Different strategies; TS has no Whitelist access
Review query latestOpinionatedReviews(writersOnly: true) Same Identical

@roryabraham

Copy link
Copy Markdown
Contributor
  1. Merge Commit Handling (TS-only logic)

This difference was reverted. Updating the above comment accordingly

Resolve README conflict by keeping both verifyPeerReview and
setup-composer-cache documentation sections.

Co-authored-by: Cursor <cursoragent@cursor.com>
@roryabraham roryabraham marked this pull request as draft July 2, 2026 22:32
@roryabraham

Copy link
Copy Markdown
Contributor

Per @rafecolton's request, I'm going to break this up to separate the less-critical CI/infra setup from the main logic of the check, with a series of smaller PRs that should be easier to review.

Comment thread scripts/libs/GitHubUtils/isBotUser.ts Outdated
Comment thread .github/workflows/verifyPeerReview.yml
roryabraham and others added 2 commits July 8, 2026 22:41
Integrate the split peer-review infrastructure PRs (#76-#81) while
keeping the branch's verifyPeerReview implementation and tests.

Conflict resolution favors main for tooling (Oxfmt, ESLint, CI
workflows, pull_request_target workflow) and keeps branch logic for
the peer review script, libs, and policy tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
Treat these Expensify GitHub App accounts as bots during peer review
author resolution.

Co-authored-by: Cursor <cursoragent@cursor.com>
Comment thread scripts/verifyPeerReview.ts Outdated
Comment on lines +70 to +71
return approvers.filter((approver) => !authorSet.has(approver) && employeeLogins.has(approver));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

case sensitive may fail

@rafecolton rafecolton Jul 10, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

👎 GH usernames are case sensitive so we do not need to lower case them

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

employee logins is lowercase no?

Reuse findAllowedLogin when checking independent employee approvals
so login casing mismatches do not skip valid reviewers or authors.

Co-authored-by: Cursor <cursoragent@cursor.com>

if (approvers.length === 0) {
return {
status: 'skip',

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Return a fail result instead of skip when approvers is empty

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.

4 participants