summaryrefslogtreecommitdiffstats
path: root/ci/github-script/reviewers.js
blob: 31ae58c70d40a9a0b83507382a8324a7f814dfb9 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
// @ts-nocheck
export async function handleReviewers({
  github,
  context,
  core,
  log,
  dry,
  pull_request,
  reviews,
  user_maintainers,
  team_maintainers,
  owners,
  getUser,
  getTeam,
}) {
  const pull_number = pull_request.number

  // Users that the PR has already reached, e.g. they've left a review or have been requested for one
  const users_reached = new Set([
    ...pull_request.requested_reviewers.map(({ login }) => login.toLowerCase()),
    ...reviews.map(({ user }) => user.login.toLowerCase()),
  ])
  log('reviewers - users_reached', Array.from(users_reached).join(', '))

  // Same for teams
  const teams_reached = new Set([
    ...pull_request.requested_teams.map(({ slug }) => slug.toLowerCase()),
    ...reviews.flatMap(({ onBehalfOf }) =>
      onBehalfOf.nodes.map(({ slug }) => slug.toLowerCase()),
    ),
  ])
  log('reviewers - teams_reached', Array.from(teams_reached).join(', '))

  // Early sanity check, before we start making any API requests. The list of maintainers
  // does not have duplicates so the only user to filter out from this list would be the
  // PR author. Therefore, we check for a limit of 15+1, where 15 is the limit we check
  // further down again.
  // This is to protect against huge treewides consuming all our API requests for no
  // reason.
  if (user_maintainers.length + team_maintainers.length > 16) {
    core.warning('Too many potential reviewers, skipping review requests.')
    // Return a boolean on whether the "needs: reviewers" label should be set.
    return users_reached.size === 0 && teams_reached.size === 0
  }

  // Users that should be reached
  var users_to_reach = new Set([
    ...(
      await Promise.all(
        user_maintainers.map(async (id) => {
          const user = await getUser(id)
          // User may have deleted their account
          return user?.login?.toLowerCase()
        }),
      )
    ).filter(Boolean),
    ...owners
      .filter((handle) => handle && !handle.includes('/'))
      .map((handle) => handle.toLowerCase()),
  ])
    // We can't request a review from the author.
    .difference(new Set([pull_request.user?.login.toLowerCase()]))

  // Filter users to repository collaborators. If they're not, they can't be requested
  // for review. In that case, they probably missed their invite to the maintainers team.
  users_to_reach = new Set(
    (
      await Promise.all(
        Array.from(users_to_reach, async (username) => {
          // TODO: Restructure this file to only do the collaborator check for those users
          // who were not already part of a team. Being a member of a team makes them
          // collaborators by definition.
          try {
            await github.rest.repos.checkCollaborator({
              ...context.repo,
              username,
            })
            return username
          } catch (e) {
            if (e.status !== 404) throw e
            core.warning(
              `PR #${pull_number}: User ${username} cannot be requested for review because they don't exist or are not a repository collaborator, ignoring. They probably missed the automated invite to the maintainers team (see <https://github.com/NixOS/nixpkgs/issues/234293>).`,
            )
          }
        }),
      )
    ).filter(Boolean),
  )
  log('reviewers - users_to_reach', Array.from(users_to_reach).join(', '))

  // Similar for teams
  var teams_to_reach = new Set([
    ...(
      await Promise.all(
        team_maintainers.map(async (id) => {
          const team = await getTeam(id)
          // Team may have been deleted
          return team?.slug?.toLowerCase()
        }),
      )
    ).filter(Boolean),
    ...owners
      .map((handle) => handle.split('/'))
      .filter(
        ([org, slug]) =>
          org.toLowerCase() === context.repo.owner.toLowerCase() && slug,
      )
      .map(([, slug]) => slug.toLowerCase()),
  ])
  teams_to_reach = new Set(
    (
      await Promise.all(
        Array.from(teams_to_reach, async (slug) => {
          try {
            await github.rest.teams.checkPermissionsForRepoInOrg({
              org: context.repo.owner,
              team_slug: slug,
              owner: context.repo.owner,
              repo: context.repo.repo,
            })
            return slug
          } catch (e) {
            if (e.status !== 404) throw e
            core.warning(
              `PR #${pull_number}: Team ${slug} cannot be requested for review because it doesn't exist or has no repository permissions, ignoring. Probably wasn't added to the nixpkgs-maintainers team (see https://github.com/NixOS/nixpkgs/tree/master/maintainers#maintainer-teams)`,
            )
          }
        }),
      )
    ).filter(Boolean),
  )
  log('reviewers - teams_to_reach', Array.from(teams_to_reach).join(', '))

  if (users_to_reach.size + teams_to_reach.size > 15) {
    core.warning(
      `Too many reviewers (users: ${Array.from(users_to_reach).join(', ')}, teams: ${Array.from(teams_to_reach).join(', ')}), skipping review requests.`,
    )
    // Return a boolean on whether the "needs: reviewers" label should be set.
    return users_reached.size === 0 && teams_reached.size === 0
  }

  // We don't want to rerequest reviews from people who already reviewed or were requested
  const users_not_yet_reached = Array.from(
    users_to_reach.difference(users_reached),
  )
  log('reviewers - users_not_yet_reached', users_not_yet_reached.join(', '))
  // We don't want to rerequest reviews from teams who already reviewed or were requested
  const teams_not_yet_reached = Array.from(
    teams_to_reach.difference(teams_reached),
  )
  log('reviewers - teams_not_yet_reached', teams_not_yet_reached.join(', '))

  if (
    users_not_yet_reached.length === 0 &&
    teams_not_yet_reached.length === 0
  ) {
    log('Has reviewer changes', 'false (skipped)')
  } else if (dry) {
    core.info(
      `Requesting user reviewers for #${pull_number}: ${users_not_yet_reached.join(', ')} (dry)`,
    )
    core.info(
      `Requesting team reviewers for #${pull_number}: ${teams_not_yet_reached.join(', ')} (dry)`,
    )
  } else {
    // We had tried the "request all reviewers at once" thing in the past, but it didn't work out:
    //   https://github.com/NixOS/nixpkgs/commit/034613f860fcd339bd2c20c8f6bc259a2f9dc034
    // If we're hitting API errors here again, we'll need to investigate - and possibly reverse
    // course.
    await github.rest.pulls.requestReviewers({
      ...context.repo,
      pull_number,
      reviewers: users_not_yet_reached,
      team_reviewers: teams_not_yet_reached,
    })
  }

  // Return a boolean on whether the "needs: reviewers" label should be set.
  return (
    users_not_yet_reached.length === 0 &&
    teams_not_yet_reached.length === 0 &&
    users_reached.size === 0 &&
    teams_reached.size === 0
  )
}