diff --git a/.github/workflows/real-behavior-proof.yml b/.github/workflows/real-behavior-proof.yml index 6c41ca5d929..6b02f92bd58 100644 --- a/.github/workflows/real-behavior-proof.yml +++ b/.github/workflows/real-behavior-proof.yml @@ -25,5 +25,20 @@ jobs: with: ref: ${{ github.event.pull_request.base.sha }} persist-credentials: false + - uses: actions/create-github-app-token@v3 + id: app-token + continue-on-error: true + with: + app-id: "2729701" + private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + - uses: actions/create-github-app-token@v3 + id: app-token-fallback + if: steps.app-token.outcome == 'failure' + continue-on-error: true + with: + app-id: "2971289" + private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }} - name: Check real behavior proof + env: + GH_APP_TOKEN: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }} run: node scripts/github/real-behavior-proof-check.mjs diff --git a/scripts/github/real-behavior-proof-check.mjs b/scripts/github/real-behavior-proof-check.mjs index 7c705c72753..fce26ac30d9 100644 --- a/scripts/github/real-behavior-proof-check.mjs +++ b/scripts/github/real-behavior-proof-check.mjs @@ -1,6 +1,9 @@ #!/usr/bin/env node import { readFileSync } from "node:fs"; -import { evaluateRealBehaviorProof } from "./real-behavior-proof-policy.mjs"; +import { + evaluateRealBehaviorProof, + isMaintainerTeamMember, +} from "./real-behavior-proof-policy.mjs"; function escapeCommandValue(value) { return String(value) @@ -23,6 +26,24 @@ if (!pullRequest) { process.exit(0); } +const token = process.env.GH_APP_TOKEN; +const org = event.repository?.owner?.login; +const authorLogin = pullRequest.user?.login; +if (token && org && authorLogin) { + try { + if (await isMaintainerTeamMember({ token, org, login: authorLogin })) { + console.log( + `PR author @${authorLogin} is an active member of the ${org}/maintainer team; skipping real behavior proof gate.`, + ); + process.exit(0); + } + } catch (error) { + console.warn( + `::warning title=Maintainer membership check failed::${escapeCommandValue(error?.message ?? String(error))}`, + ); + } +} + const evaluation = evaluateRealBehaviorProof({ pullRequest }); if (evaluation.passed) { console.log(evaluation.reason); diff --git a/scripts/github/real-behavior-proof-policy.mjs b/scripts/github/real-behavior-proof-policy.mjs index b120d36394e..24980ea4e4d 100644 --- a/scripts/github/real-behavior-proof-policy.mjs +++ b/scripts/github/real-behavior-proof-policy.mjs @@ -3,7 +3,7 @@ export const PROOF_SUPPLIED_LABEL = "proof: supplied"; export const PROOF_SUFFICIENT_LABEL = "proof: sufficient"; export const NEEDS_REAL_BEHAVIOR_PROOF_LABEL = "triage: needs-real-behavior-proof"; export const MOCK_ONLY_PROOF_LABEL = "triage: mock-only-proof"; -export const MAINTAINER_AUTHOR_LABEL = "maintainer"; +export const MAINTAINER_TEAM_SLUG = "maintainer"; const privilegedAuthorAssociations = new Set(["OWNER", "MEMBER", "COLLABORATOR"]); @@ -95,7 +95,7 @@ function isAutomationUser(user = {}, fallbackLogin = "") { return user?.type === "Bot" || /\[bot\]$/i.test(login) || login.startsWith("app/"); } -export function isExternalPullRequest(pullRequest, labels) { +export function isExternalPullRequest(pullRequest) { if (!pullRequest) { return false; } @@ -105,21 +105,39 @@ export function isExternalPullRequest(pullRequest, labels) { const authorAssociation = String( pullRequest.author_association ?? pullRequest.authorAssociation ?? "", ).toUpperCase(); - if (privilegedAuthorAssociations.has(authorAssociation)) { - return false; - } - if (hasMaintainerAuthorLabel(labels ?? pullRequest.labels)) { - return false; - } - return true; + return !privilegedAuthorAssociations.has(authorAssociation); } export function hasProofOverride(labels) { return labelNames(labels).has(PROOF_OVERRIDE_LABEL); } -export function hasMaintainerAuthorLabel(labels) { - return labelNames(labels).has(MAINTAINER_AUTHOR_LABEL); +export async function isMaintainerTeamMember({ + token, + org, + login, + teamSlug = MAINTAINER_TEAM_SLUG, + fetch = globalThis.fetch, +} = {}) { + if (!token || !org || !login) { + return false; + } + const url = `https://api.github.com/orgs/${encodeURIComponent(org)}/teams/${encodeURIComponent(teamSlug)}/memberships/${encodeURIComponent(login)}`; + const response = await fetch(url, { + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + }); + if (response.status === 404) { + return false; + } + if (!response.ok) { + throw new Error(`Team membership lookup failed: ${response.status}`); + } + const body = await response.json(); + return body?.state === "active"; } export function extractRealBehaviorProofSection(body = "") { @@ -222,7 +240,7 @@ export function evaluateRealBehaviorProof({ pullRequest, labels } = {}) { if (hasProofOverride(currentLabels)) { return result("override", `Maintainer override label ${PROOF_OVERRIDE_LABEL} is present.`); } - if (!isExternalPullRequest(pullRequest, currentLabels)) { + if (!isExternalPullRequest(pullRequest)) { return result("skipped", "Maintainer, collaborator, or bot PRs do not require this gate."); } diff --git a/test/scripts/real-behavior-proof-policy.test.ts b/test/scripts/real-behavior-proof-policy.test.ts index 3165d688eb0..c81a8f55cf5 100644 --- a/test/scripts/real-behavior-proof-policy.test.ts +++ b/test/scripts/real-behavior-proof-policy.test.ts @@ -1,10 +1,11 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { MOCK_ONLY_PROOF_LABEL, NEEDS_REAL_BEHAVIOR_PROOF_LABEL, PROOF_OVERRIDE_LABEL, PROOF_SUPPLIED_LABEL, evaluateRealBehaviorProof, + isMaintainerTeamMember, labelsForRealBehaviorProof, } from "../../scripts/github/real-behavior-proof-policy.mjs"; @@ -173,13 +174,60 @@ describe("real-behavior-proof-policy", () => { }).status, ).toBe("override"); }); +}); - it("skips private maintainers identified by the maintainer label", () => { - const evaluation = evaluateRealBehaviorProof({ - pullRequest: externalPr("", { labels: [{ name: "maintainer" }] }), +describe("isMaintainerTeamMember", () => { + function jsonResponse(status: number, body: unknown = {}) { + return { + ok: status >= 200 && status < 300, + status, + json: () => Promise.resolve(body), + }; + } + + it("returns true for active members", async () => { + const fetch = vi.fn().mockResolvedValue(jsonResponse(200, { state: "active" })); + const result = await isMaintainerTeamMember({ + token: "tok", + org: "openclaw", + login: "private-maint", + fetch, }); - expect(evaluation.status).toBe("skipped"); - expect(labelsForRealBehaviorProof(evaluation)).toEqual([]); + expect(result).toBe(true); + expect(fetch).toHaveBeenCalledWith( + "https://api.github.com/orgs/openclaw/teams/maintainer/memberships/private-maint", + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: "Bearer tok", + Accept: "application/vnd.github+json", + }), + }), + ); + }); + + it("returns false for non-active membership states", async () => { + const fetch = vi.fn().mockResolvedValue(jsonResponse(200, { state: "pending" })); + expect(await isMaintainerTeamMember({ token: "t", org: "o", login: "u", fetch })).toBe(false); + }); + + it("returns false when GitHub returns 404", async () => { + const fetch = vi.fn().mockResolvedValue(jsonResponse(404)); + expect(await isMaintainerTeamMember({ token: "t", org: "o", login: "u", fetch })).toBe(false); + }); + + it("returns false when the token, org, or login is missing", async () => { + const fetch = vi.fn(); + expect(await isMaintainerTeamMember({ org: "o", login: "u", fetch })).toBe(false); + expect(await isMaintainerTeamMember({ token: "t", login: "u", fetch })).toBe(false); + expect(await isMaintainerTeamMember({ token: "t", org: "o", fetch })).toBe(false); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("throws on unexpected HTTP errors so the caller can warn and fall back", async () => { + const fetch = vi.fn().mockResolvedValue(jsonResponse(500)); + await expect( + isMaintainerTeamMember({ token: "t", org: "o", login: "u", fetch }), + ).rejects.toThrow(/500/); }); });