Skip to content

Commit 8d158a9

Browse files
committed
feat: ping teams based on which files were changed
Using .github/CODEOWNERS, `github-bot` will ping the appropriate teams based on which files were changed in a Pull Request. This feature is inteded to work around GitHub's limitation which prevents teams without explicit write access from being added as reviewers (thus preventing the vast majority of teams in the org from being used on GitHub's CODEOWNERS feature). Ref: nodejs/node#33984
1 parent 0bb1390 commit 8d158a9

12 files changed

+869
-137
lines changed

lib/logger.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ const path = require('path')
44
const bunyan = require('bunyan')
55

66
const isRunningTests = process.env.npm_lifecycle_event === 'test'
7-
const stdoutLevel = isRunningTests ? 'FATAL' : 'INFO'
7+
const stdoutLevel = isRunningTests ? 'FATAL' : process.env.LOG_LEVEL || 'INFO'
88

99
const daysToKeepLogs = process.env.KEEP_LOGS || 10
1010
const logsDir = process.env.LOGS_DIR || ''

lib/node-owners.js

+31
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
'use static'
2+
3+
const { parse } = require('codeowners-utils')
4+
// NOTE(mmarchini): `codeowners-utils` doesn't respect ./ prefix,
5+
// so we use micromatch
6+
const micromatch = require('micromatch')
7+
8+
class Owners {
9+
constructor (ownersDefinitions) {
10+
this._ownersDefinitions = ownersDefinitions
11+
}
12+
13+
static fromFile (content) {
14+
return new Owners(parse(content))
15+
}
16+
17+
getOwnersForPaths (paths) {
18+
let ownersForPath = []
19+
for (const { pattern, owners } of this._ownersDefinitions) {
20+
if (micromatch(paths, pattern).length > 0) {
21+
ownersForPath = ownersForPath.concat(owners)
22+
}
23+
}
24+
// Remove duplicates before returning
25+
return ownersForPath.filter((v, i) => ownersForPath.indexOf(v) === i).sort()
26+
}
27+
}
28+
29+
module.exports = {
30+
Owners
31+
}

lib/node-repo.js

+109
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,13 @@
22

33
const LRU = require('lru-cache')
44
const retry = require('async').retry
5+
const Aigle = require('aigle')
6+
const request = require('request')
57

68
const githubClient = require('./github-client')
9+
const { createPrComment } = require('./github-comment')
710
const resolveLabels = require('./node-labels').resolveLabels
11+
const { Owners } = require('./node-owners')
812
const existingLabelsCache = new LRU({ max: 1, maxAge: 1000 * 60 * 60 })
913

1014
const fiveSeconds = 5 * 1000
@@ -185,10 +189,115 @@ function stringsInCommon (arr1, arr2) {
185189
return arr1.filter((str) => loweredArr2.indexOf(str.toLowerCase()) !== -1)
186190
}
187191

192+
async function deferredResolveOwnersThenPingPr (options) {
193+
const timeoutMillis = (options.timeoutInSec || 0) * 1000
194+
await sleep(timeoutMillis)
195+
return resolveOwnersThenPingPr(options)
196+
}
197+
198+
function getCodeOwnersUrl (owner, repo, defaultBranch) {
199+
const base = 'raw.githubusercontent.com'
200+
const filepath = '.github/CODEOWNERS'
201+
return `https://${base}/${owner}/${repo}/${defaultBranch}/${filepath}`
202+
}
203+
204+
async function getFiles ({ owner, repo, number, logger }) {
205+
try {
206+
const response = await githubClient.pullRequests.getFiles({
207+
owner,
208+
repo,
209+
number
210+
})
211+
return response.data.map(({ filename }) => filename)
212+
} catch (err) {
213+
logger.error(err, 'Error retrieving files from GitHub')
214+
throw err
215+
}
216+
}
217+
218+
async function getDefaultBranch ({ owner, repo, logger }) {
219+
try {
220+
const data = (await githubClient.repos.get({ owner, repo })).data || { }
221+
222+
if (!data['default_branch']) {
223+
logger.error(null, 'Couldn\' determine default branch')
224+
throw new Error('unknown default branch')
225+
}
226+
227+
return data.default_branch
228+
} catch (err) {
229+
logger.error(err, 'Error retrieving repository data')
230+
throw err
231+
}
232+
}
233+
234+
function getCodeOwnersFile (url, { logger }) {
235+
return new Promise((resolve, reject) => {
236+
request(url, (err, res, body) => {
237+
if (err || res.statusCode !== 200) {
238+
logger.error(err, 'Error retrieving OWNERS')
239+
return reject(err)
240+
}
241+
return resolve(body)
242+
})
243+
})
244+
}
245+
246+
async function resolveOwnersThenPingPr (options) {
247+
const { owner, repo } = options
248+
const times = options.retries || 5
249+
const interval = options.retryInterval || fiveSeconds
250+
const retry = fn => Aigle.retry({ times, interval }, fn)
251+
252+
options.logger.debug('getting file paths')
253+
options.number = options.prId
254+
const filepathsChanged = await retry(() => getFiles(options))
255+
256+
options.logger.debug('getting default branch')
257+
const defaultBranch = await retry(() => getDefaultBranch(options))
258+
259+
const url = getCodeOwnersUrl(owner, repo, defaultBranch)
260+
options.logger.debug(`Fetching OWNERS on ${url}`)
261+
262+
const file = await retry(() => getCodeOwnersFile(url, options))
263+
264+
options.logger.debug('parsing codeowners file')
265+
const owners = Owners.fromFile(file)
266+
const selectedOwners = owners.getOwnersForPaths(filepathsChanged)
267+
268+
options.logger.debug('pinging codeowners file')
269+
if (selectedOwners.length > 0) {
270+
await pingOwners(options, selectedOwners)
271+
}
272+
}
273+
274+
function getCommentForOwners (owners) {
275+
return `Review requested:\n\n${owners.map(i => `- [ ] ${i}`).join('\n')}`
276+
}
277+
278+
async function pingOwners (options, owners) {
279+
try {
280+
await createPrComment({
281+
owner: options.owner,
282+
repo: options.repo,
283+
number: options.prId,
284+
logger: options.logger
285+
}, getCommentForOwners(owners))
286+
} catch (err) {
287+
options.logger.error(err, 'Error while pinging owners')
288+
throw err
289+
}
290+
options.logger.debug('Pinged owners: ' + owners)
291+
}
292+
188293
exports.getBotPrLabels = getBotPrLabels
189294
exports.removeLabelFromPR = removeLabelFromPR
190295
exports.fetchExistingThenUpdatePr = fetchExistingThenUpdatePr
191296
exports.resolveLabelsThenUpdatePr = deferredResolveLabelsThenUpdatePr
297+
exports.resolveOwnersThenPingPr = deferredResolveOwnersThenPingPr
192298

193299
// exposed for testability
194300
exports._fetchExistingLabels = fetchExistingLabels
301+
exports._testExports = {
302+
pingOwners, getCodeOwnersFile, getCodeOwnersUrl, getDefaultBranch, getFiles, getCommentForOwners
303+
}

0 commit comments

Comments
 (0)