Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

A script to manage members of the org #114

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions lib/github-client.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
'use strict'

require('dotenv').load({ silent: true })
const GitHub = require('github')

const githubClient = new GitHub({
debug: require('debug')('github').enabled,
version: '3.0.0',
protocol: 'https',
host: 'api.github.com',
Expand All @@ -17,4 +19,30 @@ githubClient.authenticate({
token: process.env.GITHUB_TOKEN || 'invalid-placeholder-token'
})

// Gets all pages of a paged response
// example:
// github.orgs.getTeams({ org:'nodejs' })
// .then(github.allPages)
// .then(console.log)
githubClient.allPages = (res1, out = []) =>
githubClient.hasNextPage(res1)
? githubClient.getNextPage(res1)
.then((res2) => githubClient.allPages(res2, out.concat(res1)))
: out.concat(res1)

// Pages through responses until the predicate matches. Returns matched item.
// example:
// github.orgs.getTeams({ org:'nodejs' })
// .then(github.find(t => t.name === 'addon-api'))
// .then(console.log)
githubClient.find = (predicate) => {
const finder = (res) => {
var found = res.find(predicate)
if (typeof found !== 'undefined') return found

return githubClient.hasNextPage(res) && githubClient.getNextPage(res).then(finder)
}
return finder
}

module.exports = githubClient
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,11 @@
"bunyan-middleware": "^0.3.1",
"debug": "^2.2.0",
"dotenv": "^2.0.0",
"effd": "^1.4.8",
"express": "^4.13.4",
"github": "^7.0.1",
"glob": "^7.0.3",
"mentions-regex": "^2.0.3",
"lru-cache": "^4.0.1",
"travis-ci": "^2.1.0"
},
Expand Down
177 changes: 177 additions & 0 deletions scripts/manage-members.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
'use strict'

const P = require('effd')
const request = require('request')
const debug = require('debug')('manage-members')
const logger = require('../lib/logger.js')

var login
const github = require('../lib/github-client.js')
// store/cache info about the authenticated user
github.users.get({}).then((user) => {

This comment was marked as off-topic.

This comment was marked as off-topic.

This comment was marked as off-topic.

debug('authenticated as:', user.login)
login = user.login
})
.catch((err) => {
logger.error('authentication failed', err)
})

// we need this RegExp to have the global flag
const mentionsRegex = new RegExp(require('mentions-regex')().source, 'g')
const exclude = (a, b) => a.filter((x) => !b.find((y) => y === x))
const README = (file) => /^readme\.md$/i.test(file.filename || file) // array of strings or file objects

const ignoredErrors = new Set([
'Not Applicable',
'No members changed',
'Members section not found',
'Push is on non-default branch'
])
const logError = (err) => {
if (ignoredErrors.has(err && err.message)) {
return debug(err.message)
}
logger.error(err)

This comment was marked as off-topic.

}

module.exports = function (app) {
app.on('push', (e, owner, repo) => onPush(e, owner, repo).catch(logError))
app.on('pull_request.opened', (e, owner, repo) => onPullRequest(e, owner, repo).catch(logError))
app.on('pull_request.synchronize', (e, owner, repo) => onPullRequest(e, owner, repo).catch(logError))
}

function onPullRequest (event, owner, repo) {
const number = event.pull_request.number
debug('onPullRequest %s %s %s', owner, repo, number)

// ignore if not to the default branch
if (event.pull_request.base.ref !== event.repository.default_branch) {
return P.error('PR is to non-default branch')
}

return github.pullRequests.getFiles({ owner, repo, number, per_page: 100 })
.then(github.find(README))
.then((readme) => !readme ? P.error('README not modified') : readme.raw_url)
.then(download)
.then(parseTeamSection)
.then((team) => findChangedMembers(owner, team.name, team.mentions))
.then(createMessageBody)
.then((body) =>
github.issues.getComments({ owner, repo, number, per_page: 100 })
.then(github.find((comment) => comment.user.login === login))
.then((comment) =>
comment
? github.issues.editComment({ owner, repo, id: comment.id, body })
: github.issues.createComment({ owner, repo, number, body })
)
.then(() => body)
)
}

function onPush (event, org, repo) {
debug('onPush %s %s', org, repo)

// ignore if not to the default branch
if (event.ref !== 'refs/heads/' + event.repository.default_branch) {
return P.error('Push is on non-default branch')
}

// ignore if the readme wasn't changed
// missed edge case: a push directly to master (no PR, so no merge)
// and readme change was not in the head commit
const head = event.head_commit
const readme = head.modified.find(README) || head.added.find(README)
if (!readme) return P.error('README not modified')

return download(`https://github.com/${org}/${repo}/raw/${head.id}/${readme}`)
.then(parseTeamSection)
.then((team) => findChangedMembers(org, team.name, team.mentions))
.then(updateMembers)
}

function download (url) {
debug('downloading %s', url)
return P((Ø) => {
request(url, (err, res, body) =>
err
? Ø.error(err)
: res.statusCode !== 200
? Ø.error(`Download Failed: ${res.statusCode} ${res.statusMessage}`)
: Ø.done(body)
)
})
}

function updateMembers ({ id, added, removed }) {
debug('updateMembers %s %j %j', id, added, removed)

return P.all([].concat(
added.map((username) =>
github.orgs.addTeamMembership({ id, username }).catch(logError) // do not let errors stop processing
),
removed.map((username) =>
github.orgs.removeTeamMembership({ id, username }).catch(logError) // do not let errors stop processing
)))
.then(() => {
// don't return anything to the promise chain. nothing should need/want it.
})
}

function findChangedMembers (org, teamName, mentions) {
// get the id of the team listed in the README
return github.orgs.getTeams({ org, per_page: 100 })
.then(github.find((t) => t.name === teamName))
.then((team) =>
!team
? P.error('Team Not Found: ' + teamName)
// get the usernames of the team members
: github.orgs.getTeamMembers({ id: team.id, per_page: 100 })
.then(github.allPages)
.then((members) => members.map((member) => member.login.toLocaleLowerCase()))
.then((members) => ({
org,
id: team.id,
team: teamName,
added: exclude(mentions, members),
removed: exclude(members, mentions)
}))
)
.then((changes) =>
!changes.added.length && !changes.removed.length
? P.error('No members changed')
: changes
)
}

function createMessageBody ({org, team, added, removed}) {
var message = `This merge, if accepted, will cause changes to the @${org}/${team} team.\n`
if (added.length) message += `- Add: @${added.join(', @')}\n`
if (removed.length) message += `- Remove: @${removed.join(', @')}\n`
debug(message)
return message
}

function parseTeamSection (readmeContent) {
const start = readmeContent.search(/<!-- team:\w/)
const end = readmeContent.indexOf('<!-- team -->', start)
if (start === -1 || end === -1) return P.error('Members section not found')

const section = readmeContent.substring(start, end)
const mentions = section.match(mentionsRegex) || []
const result = {
name: readmeContent.match(/<!-- team:([^ ]+)/)[1],
mentions: mentions.map((mention) => mention.substr(2).toLocaleLowerCase())
}
debug('parseTeamSection %j', result)

return P(result)
}

// expose for tests
module.exports.download = download
module.exports.updateMembers = updateMembers
module.exports.findChangedMembers = findChangedMembers
module.exports.createMessageBody = createMessageBody
module.exports.parseTeamSection = parseTeamSection
module.exports.onPullRequest = onPullRequest
module.exports.onPush = onPush
17 changes: 17 additions & 0 deletions test/_fixtures/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Manage-Members Test README
This fixture is used for the manage-members tests

## Members
<!-- team:botsters here is our pretend team... -->

<small>html should be OK in here!</small>
Mr A (@a)
Mx B (@b)
@ c (not valid)
wm@gm.co (ignored)
and @e!

<!-- team -->

# License
MIT
2 changes: 2 additions & 0 deletions test/integration/node-labels-webhook.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ const testStubs = {
}
}

// only load the script being tested
process.env.SCRIPTS = './scripts/node-subsystem-label.js'
const app = proxyquire('../../app', testStubs)

const readFixture = require('../read-fixture')
Expand Down
2 changes: 2 additions & 0 deletions test/integration/ping.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
const tap = require('tap')
const request = require('request')

// only load the script being tested
process.env.SCRIPTS = './scripts/ping.js'
const app = require('../../app')

tap.test('GET /ping responds with status 200 / "pong"', (t) => {
Expand Down
2 changes: 2 additions & 0 deletions test/integration/push-jenkins-update.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ const url = require('url')
const nock = require('nock')
const supertest = require('supertest')

// only load the script being tested
process.env.SCRIPTS = './scripts/node-jenkins-status.js'
const app = require('../../app')

const readFixture = require('../read-fixture')
Expand Down
5 changes: 4 additions & 1 deletion test/read-fixture.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@

const fs = require('fs')
const path = require('path')
const raw = (filename) => fs.readFileSync(path.join(__dirname, '_fixtures', filename)).toString()

module.exports = function readFixture (fixtureName) {
const content = fs.readFileSync(path.join(__dirname, '_fixtures', fixtureName)).toString()
const content = raw(fixtureName)
return JSON.parse(content)
}

module.exports.raw = raw
Loading