From f908d281d7420ab31041fbdd408e2c707d406edd Mon Sep 17 00:00:00 2001 From: Nikita Menkovich Date: Thu, 6 Mar 2025 12:03:54 +0100 Subject: [PATCH] Update PR workflow script to account for non-member ok-to-test labels (#3142) --- .github/workflows/pr-github-actions.yaml | 132 ++++++++++++++++++++++- .github/workflows/pr.yaml | 86 +++++++++++---- 2 files changed, 194 insertions(+), 24 deletions(-) diff --git a/.github/workflows/pr-github-actions.yaml b/.github/workflows/pr-github-actions.yaml index c4d91126008..9c935e3f307 100644 --- a/.github/workflows/pr-github-actions.yaml +++ b/.github/workflows/pr-github-actions.yaml @@ -43,6 +43,11 @@ on: - "yes" - "no" +permissions: + contents: read + pull-requests: write + issues: write + defaults: run: shell: bash @@ -68,8 +73,125 @@ jobs: - name: set env id: set-env run: echo + check-running-allowed: + runs-on: ubuntu-latest + outputs: + result: ${{ steps.check-ownership-membership.outputs.result }} + steps: + - name: Check if running tests is allowed + id: check-ownership-membership + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GH_PERSONAL_ACCESS_TOKEN }} + script: | + async function isOrgMember(owner, username) { + core.info(`Checking membership for user: ${username}`); + try { + const { data: membership } = await github.rest.orgs.getMembershipForUser({ + org: owner, + username + }); + if (membership?.state === 'active') { + core.info(`${username} is confirmed as an org member`); + return true; + } else { + core.info(`${username} is not an active org member (state: ${membership?.state})`); + return false; + } + } catch (error) { + // Often the call fails if the user isn't in the org or it's private + core.error(`Error checking membership for user ${username}: ${error.message}`); + return false; + } + } + + const { owner, repo } = context.repo; + const prNumber = context.payload.pull_request.number; + const prAuthor = context.payload.pull_request.user.login; + + core.info(`Starting membership check for PR #${prNumber} by @${prAuthor}`); + + const authorIsOrgMember = await isOrgMember(owner, prAuthor); + if (authorIsOrgMember) { + core.info(`User @${prAuthor} is org member => authorized`); + return true; + } + + core.info(`User @${prAuthor} is NOT an org member; checking for 'ok-to-test' label`); + + let events; + try { + const resp = await github.rest.issues.listEvents({ + owner, + repo, + issue_number: prNumber + }); + events = resp.data; + } catch (error) { + core.error(`Error fetching issue events: ${error.message}`); + return false; + } + + // Find last 'labeled' event for 'ok-to-test' + const labeledOkToTest = events + .filter(e => e.event === 'labeled' && e.label?.name === 'ok-to-test') + .pop(); + if (!labeledOkToTest) { + core.info("No 'ok-to-test' label found on this PR"); + return false; + } + + core.info(`Found 'ok-to-test' label event by @${labeledOkToTest.actor.login}, verifying if they are an org member...`); + + const labelerLogin = labeledOkToTest.actor.login; + const labelerIsOrgMember = await isOrgMember(owner, labelerLogin); + if (!labelerIsOrgMember) { + core.info(`User @${labelerLogin} who labeled 'ok-to-test' is not an org member => not authorized`); + return false; + } + + core.info(`'ok-to-test' label added by an org member => authorized`); + return true + - name: comment-if-waiting-on-ok + if: steps.check-ownership-membership.outputs.result == 'false' && + github.event.action == 'opened' + uses: actions/github-script@v7 + with: + script: | + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: 'Hi! Thank you for contributing!\nThe tests on this PR will run after a maintainer adds an `ok-to-test` label to this PR manually. Thank you for your patience!' + }); + - name: cleanup-labels + uses: actions/github-script@v7 + with: + script: | + let labelsToRemove = ['ok-to-test', 'recheck']; + const prNumber = context.payload.pull_request.number; + const prLabels = new Set(context.payload.pull_request.labels.map(l => l.name)); + for await (const label of labelsToRemove.filter(l => prLabels.has(l))) { + core.info(`remove label=${label} for pr=${prNumber}`); + try { + const result = await github.rest.issues.removeLabel({ + ...context.repo, + issue_number: prNumber, + name: label + }); + } catch(error) { + // ignore the 404 error that arises + // when the label did not exist for the + // organization member + if (error.status && error.status != 404) { + throw error; + } + } + } shell: + needs: [set-env, check-running-allowed] + if: needs.check-running-allowed.outputs.result == 'true' runs-on: ubuntu-latest steps: @@ -117,6 +239,8 @@ jobs: shfmt_flags: "-i 4 -ci -kp -bn -sr" python: + needs: [set-env, check-running-allowed] + if: needs.check-running-allowed.outputs.result == 'true' runs-on: ubuntu-latest steps: @@ -159,6 +283,8 @@ jobs: reporter: ${{ steps.reporter.outputs.value }} workflows: + needs: [set-env, check-running-allowed] + if: needs.check-running-allowed.outputs.result == 'true' runs-on: ubuntu-latest steps: - name: checkout @@ -196,15 +322,17 @@ jobs: body: '${{ steps.lint.outputs.WORKFLOW_LINT }}' }) nbs-github-actions: + needs: [set-env, check-running-allowed] + if: needs.check-running-allowed.outputs.result == 'true' name: Launch scripts on test-data uses: ./.github/workflows/github_actions_scripts.yaml secrets: inherit create-and-delete-vm: + needs: [set-env, check-running-allowed] + if: needs.check-running-allowed.outputs.result == 'true' name: Create and delete VM uses: ./.github/workflows/create_and_delete_vm.yaml - needs: - - set-env with: nebius: ${{ needs.set-env.outputs.nebius }} allow_downgrade: ${{ needs.set-env.outputs.allow_downgrade }} diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 8362f39a8fd..6041442d12d 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -14,9 +14,16 @@ on: - 'synchronize' - 'reopened' - 'labeled' + +permissions: + contents: read + pull-requests: write + issues: write + concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number }} cancel-in-progress: true + jobs: check-running-allowed: runs-on: ubuntu-latest @@ -29,40 +36,75 @@ jobs: with: github-token: ${{ secrets.GH_PERSONAL_ACCESS_TOKEN }} script: | - // How to interpret membership status code: - // https://docs.github.com/en/rest/orgs/members?apiVersion=2022-11-28#check-organization-membership-for-a-user - const userLogin = context.payload.pull_request.user.login; - - const isOrgMember = async function () { + async function isOrgMember(owner, username) { + core.info(`Checking membership for user: ${username}`); try { - const response = await github.rest.orgs.checkMembershipForUser({ - org: context.payload.organization.login, - username: userLogin, + const { data: membership } = await github.rest.orgs.getMembershipForUser({ + org: owner, + username }); - return response.status == 204; - } catch (error) { - if (error.status && error.status == 404) { + if (membership?.state === 'active') { + core.info(`${username} is confirmed as an org member`); + return true; + } else { + core.info(`${username} is not an active org member (state: ${membership?.state})`); return false; } - throw error; + } catch (error) { + // Often the call fails if the user isn't in the org or it's private + core.error(`Error checking membership for user ${username}: ${error.message}`); + return false; } } - if (context.payload.repository.owner.login == userLogin) { - console.log('User is repo owner') + const { owner, repo } = context.repo; + const prNumber = context.payload.pull_request.number; + const prAuthor = context.payload.pull_request.user.login; + + core.info(`Starting membership check for PR #${prNumber} by @${prAuthor}`); + + const authorIsOrgMember = await isOrgMember(owner, prAuthor); + if (authorIsOrgMember) { + core.info(`User @${prAuthor} is org member => authorized`); return true; } - if (await isOrgMember()) { - console.log('User is member') - return true; + core.info(`User @${prAuthor} is NOT an org member; checking for 'ok-to-test' label`); + + let events; + try { + const resp = await github.rest.issues.listEvents({ + owner, + repo, + issue_number: prNumber + }); + events = resp.data; + } catch (error) { + core.error(`Error fetching issue events: ${error.message}`); + return false; + } + + // Find last 'labeled' event for 'ok-to-test' + const labeledOkToTest = events + .filter(e => e.event === 'labeled' && e.label?.name === 'ok-to-test') + .pop(); + + if (!labeledOkToTest) { + core.info("No 'ok-to-test' label found on this PR"); + return false; + } + + core.info(`Found 'ok-to-test' label event by @${labeledOkToTest.actor.login}, verifying if they are an org member...`); + + const labelerLogin = labeledOkToTest.actor.login; + const labelerIsOrgMember = await isOrgMember(owner, labelerLogin); + if (!labelerIsOrgMember) { + core.info(`User @${labelerLogin} who labeled 'ok-to-test' is not an org member => not authorized`); + return false; } - const labels = context.payload.pull_request.labels; - const okToTestLabel = labels.find( - label => label.name == 'ok-to-test' - ); - return okToTestLabel !== undefined; + core.info(`'ok-to-test' label added by an org member => authorized`); + return true - name: comment-if-waiting-on-ok if: steps.check-ownership-membership.outputs.result == 'false' && github.event.action == 'opened'