|  | 
|  | 1 | +name: PR Assignee | 
|  | 2 | +on: | 
|  | 3 | +  # Important security note: Do NOT use `actions/checkout` | 
|  | 4 | +  # or any other method for checking out the pull request's source code. | 
|  | 5 | +  # This is because the pull request's source code is untrusted, but the | 
|  | 6 | +  # GITHUB_TOKEN has write permissions (because of the `on: pull_request_target` event). | 
|  | 7 | +  # | 
|  | 8 | +  # Quoting from the GitHub Docs: | 
|  | 9 | +  # > For workflows that are triggered by the pull_request_target event, the GITHUB_TOKEN is granted | 
|  | 10 | +  # > read/write repository permission unless the permissions key is specified and the workflow can access secrets, | 
|  | 11 | +  # > even when it is triggered from a fork. | 
|  | 12 | +  # > | 
|  | 13 | +  # > Although the workflow runs in the context of the base of the pull request, | 
|  | 14 | +  # > you should make sure that you do not check out, build, or run untrusted code from the pull request with this event. | 
|  | 15 | +  # | 
|  | 16 | +  # Source: https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows#pull_request_target | 
|  | 17 | +  # | 
|  | 18 | +  # See also: https://securitylab.github.com/resources/github-actions-preventing-pwn-requests/ | 
|  | 19 | +  pull_request_target: | 
|  | 20 | +    types: [opened, reopened, ready_for_review] | 
|  | 21 | + | 
|  | 22 | +# Permissions for the `GITHUB_TOKEN`: | 
|  | 23 | +permissions: | 
|  | 24 | +  pull-requests: write # Needed in order to assign a user as the PR assignee | 
|  | 25 | + | 
|  | 26 | +jobs: | 
|  | 27 | +  pr-assignee: | 
|  | 28 | +    runs-on: ubuntu-latest | 
|  | 29 | +    if: ${{ github.event.pull_request.draft != true }} | 
|  | 30 | +    steps: | 
|  | 31 | +      # Important security note: As discussed above, do NOT use `actions/checkout` | 
|  | 32 | +      # or any other method for checking out the pull request's source code. | 
|  | 33 | +      # This is because the pull request's source code is untrusted, but the | 
|  | 34 | +      # GITHUB_TOKEN has write permissions (because of the `on: pull_request_target` event). | 
|  | 35 | +      - name: Add Assignee | 
|  | 36 | +        # We pin all third-party actions to a full length commit SHA | 
|  | 37 | +        # https://docs.github.com/en/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions#using-third-party-actions | 
|  | 38 | +        uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 | 
|  | 39 | +        with: | 
|  | 40 | +          retries: 5 # retry GitHub API requests up to 5 times, with exponential backoff | 
|  | 41 | +          retry-exempt-status-codes: 404 | 
|  | 42 | +          # Don't retry 404 because we will hit a 404 when the PR author is a committer. | 
|  | 43 | +          # This 404 is normal and expected. | 
|  | 44 | +          # Do retry 400 and other 4xx errors because github sometimes (erroneously) | 
|  | 45 | +          # returns a 4xx error code due to server errors. | 
|  | 46 | +          script: | | 
|  | 47 | +            const oldPrAssignees = context.payload.pull_request.assignees | 
|  | 48 | +              .map(obj => obj.login) | 
|  | 49 | +            console.log('oldPrAssignees: ', oldPrAssignees); | 
|  | 50 | +            const prAuthor = context.payload.pull_request.user.login; | 
|  | 51 | +
 | 
|  | 52 | +            // Check if the PR is opened by a collaborator on the repo, aka someone with write (commit) permissions or higher. | 
|  | 53 | +            const relevantPerms = [ | 
|  | 54 | +              // 'triage', // Uncomment this line if you don't want PRs from triagers to get auto-assignees. | 
|  | 55 | +              'push', | 
|  | 56 | +              'maintain', | 
|  | 57 | +              'admin', | 
|  | 58 | +            ] | 
|  | 59 | +            const allCollaboratorsNestedPromises = relevantPerms.map( | 
|  | 60 | +              (perm) => github.paginate( | 
|  | 61 | +                // We use the `/repos/{owner}/{repo}/collaborators` endpoint to avoid needing org scope permissions: | 
|  | 62 | +                '/repos/{owner}/{repo}/collaborators', | 
|  | 63 | +                { | 
|  | 64 | +                  owner: context.repo.owner, | 
|  | 65 | +                  repo: context.repo.repo, | 
|  | 66 | +                  per_page: 100, | 
|  | 67 | +                  permission: perm, | 
|  | 68 | +                }, | 
|  | 69 | +                (response) => response.data.map((collaboratorInfo) => collaboratorInfo.login), | 
|  | 70 | +              ) | 
|  | 71 | +            ) | 
|  | 72 | +            const allCollaboratorsNested = await Promise.all(allCollaboratorsNestedPromises); | 
|  | 73 | +            const allCollaboratorsFlattened = allCollaboratorsNested.flat(); | 
|  | 74 | +
 | 
|  | 75 | +            // Skip BumpStdlibs.jl PRs | 
|  | 76 | +            allCollaboratorsFlattened.push('DilumAluthgeBot'); | 
|  | 77 | +            // Skip Dependabot PRs | 
|  | 78 | +            allCollaboratorsFlattened.push('dependabot'); | 
|  | 79 | +
 | 
|  | 80 | +            const isCollaborator = allCollaboratorsFlattened.includes(prAuthor); | 
|  | 81 | +
 | 
|  | 82 | +            console.log('prAuthor: ', prAuthor); | 
|  | 83 | +            console.log('isCollaborator: ', isCollaborator); | 
|  | 84 | +
 | 
|  | 85 | +            // Load the list of assignable reviewers from the JuliaLang/pr-assignment repo at: | 
|  | 86 | +            // https://github.com/JuliaLang/pr-assignment/blob/main/users.txt | 
|  | 87 | +            // | 
|  | 88 | +            // NOTE to JuliaLang committers: If you want to be assigned to new PRs, please add your | 
|  | 89 | +            // GitHub username to that file. | 
|  | 90 | +
 | 
|  | 91 | +            // Load file contents | 
|  | 92 | +            const { data: fileContentsObj } = await github.rest.repos.getContent({ | 
|  | 93 | +              owner: 'JuliaLang', | 
|  | 94 | +              repo: 'pr-assignment', | 
|  | 95 | +              path: 'users.txt', | 
|  | 96 | +              ref: 'main', | 
|  | 97 | +            }); | 
|  | 98 | +
 | 
|  | 99 | +            const fileContentsBufferObj = Buffer.from(fileContentsObj.content, "base64"); | 
|  | 100 | +            const fileContentsText = fileContentsBufferObj.toString("utf8"); | 
|  | 101 | +
 | 
|  | 102 | +            // Find lines that match the following regex, and extract the usernames: | 
|  | 103 | +            const regex = /^@([a-zA-Z0-9\-]+)(\s*?)?(#[\S]*?)?$/; | 
|  | 104 | +            const assigneeCandidates = fileContentsText | 
|  | 105 | +              .split('\n') | 
|  | 106 | +              .map(line => line.trim()) | 
|  | 107 | +              .map(line => line.match(regex)) | 
|  | 108 | +              .filter(match => match !== null) | 
|  | 109 | +              .map(match => match[1]); | 
|  | 110 | +
 | 
|  | 111 | +            console.log('assigneeCandidates: ', assigneeCandidates); | 
|  | 112 | +            if (assigneeCandidates.length < 1) { | 
|  | 113 | +              const msg = 'ERROR: Could not find any assigneeCandidates'; | 
|  | 114 | +              console.error(msg); | 
|  | 115 | +              throw new Error(msg); | 
|  | 116 | +            } | 
|  | 117 | +
 | 
|  | 118 | +            if (oldPrAssignees.length >= 1) { | 
|  | 119 | +              console.log('Skipping this PR, because it already has at least one assignee'); | 
|  | 120 | +              return; | 
|  | 121 | +            } | 
|  | 122 | +
 | 
|  | 123 | +
 | 
|  | 124 | +            const RUNNER_DEBUG_original = process.env.RUNNER_DEBUG; | 
|  | 125 | +            console.log('RUNNER_DEBUG_original: ', RUNNER_DEBUG_original); | 
|  | 126 | +            if (RUNNER_DEBUG_original === undefined) { | 
|  | 127 | +              var thisIsActionsRunnerDebugMode = false; | 
|  | 128 | +            } else { | 
|  | 129 | +              const RUNNER_DEBUG_trimmed = RUNNER_DEBUG_original.trim().toLowerCase() | 
|  | 130 | +              if (RUNNER_DEBUG_trimmed.length < 1) { | 
|  | 131 | +                var thisIsActionsRunnerDebugMode = false; | 
|  | 132 | +              } else { | 
|  | 133 | +                var thisIsActionsRunnerDebugMode = (RUNNER_DEBUG_trimmed == 'true') || (RUNNER_DEBUG_trimmed == '1'); | 
|  | 134 | +              } | 
|  | 135 | +            } | 
|  | 136 | +            console.log('thisIsActionsRunnerDebugMode: ', thisIsActionsRunnerDebugMode); | 
|  | 137 | +
 | 
|  | 138 | +            if (isCollaborator == true) { | 
|  | 139 | +
 | 
|  | 140 | +              if (thisIsActionsRunnerDebugMode) { | 
|  | 141 | +                // The PR author is a committer | 
|  | 142 | +                // But thisIsActionsRunnerDebugMode is true, so we proceed to still run the rest of the script | 
|  | 143 | +                console.log('PR is authored by JuliaLang committer, but thisIsActionsRunnerDebugMode is true, so we will still run the rest of the script: ', prAuthor); | 
|  | 144 | +              } else { | 
|  | 145 | +                // The PR author is a committer, so we skip assigning them | 
|  | 146 | +                console.log('Skipping PR authored by JuliaLang committer: ', prAuthor); | 
|  | 147 | +                console.log('Note: If you want to run the full script (even though the PR author is a committer), simply re-run this job with Actions debug logging enabled'); | 
|  | 148 | +                return; | 
|  | 149 | +              } | 
|  | 150 | +            } | 
|  | 151 | +
 | 
|  | 152 | +            var weDidEncounterError = false; | 
|  | 153 | +
 | 
|  | 154 | +            // Assign random committer | 
|  | 155 | +            const selectedAssignee = assigneeCandidates[Math.floor(Math.random()*assigneeCandidates.length)] | 
|  | 156 | +            console.log('selectedAssignee: ', selectedAssignee); | 
|  | 157 | +            console.log(`Attempting to assign @${selectedAssignee} to this PR...`); | 
|  | 158 | +            await github.rest.issues.addAssignees({ | 
|  | 159 | +              owner: context.repo.owner, | 
|  | 160 | +              repo: context.repo.repo, | 
|  | 161 | +              issue_number: context.payload.pull_request.number, | 
|  | 162 | +              assignees: selectedAssignee, | 
|  | 163 | +            }); | 
|  | 164 | +
 | 
|  | 165 | +            // The following is commented out because the label only makes sense in the presence of a larger state machine | 
|  | 166 | +            // // Add the "pr review" label | 
|  | 167 | +            // const prReviewLabel = 'status: waiting for PR reviewer'; | 
|  | 168 | +            // console.log('Attempting to add prReviewLabel to this PR...'); | 
|  | 169 | +            // await github.rest.issues.addLabels({ | 
|  | 170 | +            //   owner: context.repo.owner, | 
|  | 171 | +            //   repo: context.repo.repo, | 
|  | 172 | +            //   issue_number: context.payload.pull_request.number, | 
|  | 173 | +            //   labels: [prReviewLabel], | 
|  | 174 | +            // }); | 
|  | 175 | +
 | 
|  | 176 | +            // Now get the updated PR info, and see if we were successful: | 
|  | 177 | +            const updatedPrData = await github.rest.pulls.get({ | 
|  | 178 | +              owner: context.repo.owner, | 
|  | 179 | +              repo: context.repo.repo, | 
|  | 180 | +              pull_number: context.payload.pull_request.number, | 
|  | 181 | +            }); | 
|  | 182 | +            const newPrAssignees = updatedPrData | 
|  | 183 | +              .data | 
|  | 184 | +              .assignees | 
|  | 185 | +              .map(element => element.login) | 
|  | 186 | +            console.log('newPrAssignees: ', newPrAssignees); | 
|  | 187 | +            if (newPrAssignees.includes(selectedAssignee)) { | 
|  | 188 | +              console.log(`Successfully assigned @${selectedAssignee}`); | 
|  | 189 | +            } else { | 
|  | 190 | +              weDidEncounterError = true; | 
|  | 191 | +              console.log(`ERROR: Failed to assign @${selectedAssignee}`); | 
|  | 192 | +            } | 
|  | 193 | +            // const newPrLabels = updatedPrData | 
|  | 194 | +            //   .data | 
|  | 195 | +            //   .labels | 
|  | 196 | +            //   .map(element => element.name) | 
|  | 197 | +            // console.log('newPrLabels: ', newPrLabels); | 
|  | 198 | +            // if (newPrLabels.includes(prReviewLabel)) { | 
|  | 199 | +            //   console.log('Successfully added prReviewLabel'); | 
|  | 200 | +            // } else { | 
|  | 201 | +            //   weDidEncounterError = true; | 
|  | 202 | +            //   console.log('ERROR: Failed to add add prReviewLabel'); | 
|  | 203 | +            // } | 
|  | 204 | +
 | 
|  | 205 | +            // Exit with error if any problems were encountered earlier | 
|  | 206 | +            if (weDidEncounterError) { | 
|  | 207 | +              const msg = 'ERROR: Encountered at least one problem while running the script'; | 
|  | 208 | +              console.error(msg); | 
|  | 209 | +              throw new Error(msg); | 
|  | 210 | +            } | 
0 commit comments