diff --git a/.github/workflows/pr-governance.yml b/.github/workflows/pr-governance.yml deleted file mode 100644 index 41e522e..0000000 --- a/.github/workflows/pr-governance.yml +++ /dev/null @@ -1,200 +0,0 @@ -name: PR Governance Gate - -on: - pull_request: - types: - - opened - - reopened - - synchronize - - ready_for_review - - edited - pull_request_review: - types: - - submitted - - edited - - dismissed - pull_request_review_thread: - types: - - resolved - - unresolved - -permissions: - contents: read - pull-requests: read - -jobs: - governance: - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Enforce governance policy - uses: actions/github-script@v7 - with: - script: | - const fs = require("fs"); - const path = require("path"); - - const protectedBranches = new Set(["main", "master", "mh-main", "bh-main"]); - const branchPattern = /^(feature|bugfix|hotfix|chore)\/[A-Za-z0-9._-]+$/; - const owner = context.repo.owner; - const repo = context.repo.repo; - const pr = context.payload.pull_request; - - if (!pr) { - core.setFailed("No pull request payload found in event context."); - return; - } - - if (!protectedBranches.has(pr.base.ref)) { - core.info(`Skipping governance checks for non-protected base branch: ${pr.base.ref}`); - return; - } - - const failures = []; - const prAuthor = (pr.user?.login || "").toLowerCase(); - const isBotPr = pr.user?.type === "Bot" || (pr.user?.login || "").endsWith("[bot]"); - - if (!isBotPr && !branchPattern.test(pr.head.ref)) { - failures.push( - `Branch "${pr.head.ref}" does not match required naming: feature/, bugfix/, hotfix/, chore/.` - ); - } - - const codeownersPath = path.join(process.env.GITHUB_WORKSPACE || ".", ".github", "CODEOWNERS"); - let codeownerUsers = []; - if (!fs.existsSync(codeownersPath)) { - failures.push("Missing .github/CODEOWNERS file."); - } else { - const content = fs.readFileSync(codeownersPath, "utf8"); - codeownerUsers = Array.from( - new Set( - content - .split(/\r?\n/) - .map((line) => line.trim()) - .filter((line) => line && !line.startsWith("#")) - .flatMap((line) => line.split(/\s+/).slice(1)) - .filter((ownerToken) => ownerToken.startsWith("@")) - .map((ownerToken) => ownerToken.slice(1).toLowerCase()) - .filter((ownerToken) => !ownerToken.includes("/")) - ) - ); - if (codeownerUsers.length === 0) { - failures.push("No individual CODEOWNERS users found. Add at least one @username in .github/CODEOWNERS."); - } - } - - const reviews = await github.paginate(github.rest.pulls.listReviews, { - owner, - repo, - pull_number: pr.number, - per_page: 100 - }); - - const latestReviewByUser = new Map(); - for (const review of reviews) { - const login = review.user?.login?.toLowerCase(); - if (!login || !review.submitted_at) { - continue; - } - - const existing = latestReviewByUser.get(login); - if (!existing) { - latestReviewByUser.set(login, review); - continue; - } - - const reviewTime = new Date(review.submitted_at).getTime(); - const existingTime = new Date(existing.submitted_at).getTime(); - if (reviewTime >= existingTime) { - latestReviewByUser.set(login, review); - } - } - - const effectiveReviews = Array.from(latestReviewByUser.values()); - const approvals = effectiveReviews.filter((review) => review.state === "APPROVED"); - const peerApprovals = approvals.filter( - (review) => review.user?.login?.toLowerCase() !== prAuthor - ); - const selfApprovals = reviews.filter( - (review) => - review.state === "APPROVED" && - review.user?.login?.toLowerCase() === prAuthor - ); - - if (selfApprovals.length > 0) { - failures.push("PR author approval detected. Authors cannot approve their own pull request."); - } - - if (peerApprovals.length < 1) { - failures.push("At least one peer approval is required."); - } - - const latestCommitApprovals = peerApprovals.filter((review) => review.commit_id === pr.head.sha); - if (latestCommitApprovals.length < 1) { - failures.push("No peer approval found on the latest commit. Re-approval is required after new commits."); - } - - if (codeownerUsers.length > 0) { - const codeownerApprovals = peerApprovals.filter((review) => - codeownerUsers.includes(review.user?.login?.toLowerCase() || "") - ); - if (codeownerApprovals.length < 1) { - failures.push("At least one CODEOWNERS approval is required."); - } else { - const codeownerLatestCommitApprovals = codeownerApprovals.filter( - (review) => review.commit_id === pr.head.sha - ); - if (codeownerLatestCommitApprovals.length < 1) { - failures.push("No CODEOWNERS approval found on the latest commit."); - } - } - } - - let unresolvedThreads = 0; - let cursor = null; - do { - const data = await github.graphql( - ` - query($owner: String!, $repo: String!, $number: Int!, $cursor: String) { - repository(owner: $owner, name: $repo) { - pullRequest(number: $number) { - reviewThreads(first: 100, after: $cursor) { - nodes { - isResolved - } - pageInfo { - hasNextPage - endCursor - } - } - } - } - } - `, - { - owner, - repo, - number: pr.number, - cursor - } - ); - - const reviewThreads = data.repository.pullRequest.reviewThreads; - unresolvedThreads += reviewThreads.nodes.filter((thread) => !thread.isResolved).length; - cursor = reviewThreads.pageInfo.hasNextPage ? reviewThreads.pageInfo.endCursor : null; - } while (cursor); - - if (unresolvedThreads > 0) { - failures.push(`There are ${unresolvedThreads} unresolved review thread(s).`); - } - - if (failures.length > 0) { - core.setFailed( - `PR governance checks failed:\n- ${failures.join("\n- ")}` - ); - return; - } - - core.info("PR governance checks passed.");