-
-
Notifications
You must be signed in to change notification settings - Fork 127
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
BREAKING CHANGE: WIP now differentiates between a free and a pro plan. The latter now supports configuration of terms and locations to look at. The new version also uses checks instead of commit statuses
- Loading branch information
Showing
18 changed files
with
619 additions
and
57 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,16 +1,21 @@ | ||
module.exports = probotPlugin | ||
module.exports = wip | ||
|
||
const sendLogs = require('./lib/send-logs') | ||
const sendLogs = require('./lib/logs/send') | ||
const handlePullRequestChange = require('./lib/handle-pull-request-change') | ||
const handleRequestedAction = require('./lib/handle-requested-action') | ||
|
||
function probotPlugin (robot) { | ||
robot.on([ | ||
function wip (app) { | ||
// listen to all relevant pull request event actions | ||
app.on([ | ||
'pull_request.opened', | ||
'pull_request.edited', | ||
'pull_request.labeled', | ||
'pull_request.unlabeled', | ||
'pull_request.synchronize' | ||
], handlePullRequestChange) | ||
], handlePullRequestChange.bind(null, app)) | ||
|
||
sendLogs(robot) | ||
// listen to an overwrite request action from a check run | ||
app.on('check_run.requested_action', handleRequestedAction.bind(null, app)) | ||
|
||
sendLogs(app) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,9 @@ | ||
module.exports = appConfig | ||
|
||
function appConfig () { | ||
return { | ||
// the app name appears in the list of pull request checks. We make it | ||
// configurable so we can deploy multiple versions that can be used side-by-side | ||
name: process.env.APP_NAME || 'WIP' | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,19 @@ | ||
module.exports = hasStatusChange | ||
|
||
const getAppConfig = require('../app-config') | ||
|
||
async function hasStatusChange (newStatus, context) { | ||
const { name } = getAppConfig() | ||
const { data: { check_runs: checkRuns } } = await context.github.checks.listForRef(context.repo({ | ||
ref: context.payload.pull_request.head.sha, | ||
check_name: name | ||
})) | ||
|
||
if (checkRuns.length === 0) return true | ||
|
||
const [{ conclusion, output }] = checkRuns | ||
const isWip = conclusion !== 'success' | ||
const hasOverride = output && /override/.test(output.title) | ||
|
||
return isWip !== newStatus.wip || hasOverride !== newStatus.override | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,16 @@ | ||
module.exports = matchTerms | ||
|
||
function matchTerms (terms, text) { | ||
// \b word boundaries don’t work around emoji, e.g. | ||
// > /\b🚧/i.test('🚧') | ||
// < false | ||
// but | ||
// > /(^|[^\w])🚧/i.test('🚧') | ||
// < true | ||
// > /(^|[^\w])🚧/i.test('foo🚧') | ||
// < false | ||
// > /(^|[^\w])🚧/i.test('foo 🚧') | ||
// < true | ||
const matches = text.match(new RegExp(`(^|[^\\w])(${terms.join('|')})([^\\w]|$)`, 'i')) | ||
return matches ? matches[2] : null | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,25 @@ | ||
module.exports = getStatusFree | ||
|
||
const matchTerms = require('../common/match-terms') | ||
|
||
async function getStatusFree (context) { | ||
const title = context.payload.pull_request.title | ||
const match = matchTerms([ | ||
'wip', | ||
'work in progress', | ||
'🚧' | ||
], title) | ||
|
||
if (!match) { | ||
return { | ||
wip: false | ||
} | ||
} | ||
|
||
return { | ||
wip: true, | ||
location: 'title', | ||
text: title, | ||
match | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,35 @@ | ||
module.exports = setStatusFree | ||
|
||
const getAppConfig = require('../app-config') | ||
|
||
function setStatusFree (newStatus, context) { | ||
const pullRequest = context.payload.pull_request | ||
const { name } = getAppConfig() | ||
|
||
const checkOptions = { | ||
name: name, | ||
head_branch: '', // workaround for https://github.com/octokit/rest.js/issues/874 | ||
head_sha: pullRequest.head.sha, | ||
status: 'in_progress', | ||
output: { | ||
title: 'Work in progress', | ||
summary: `The title "${pullRequest.title}" contains "${newStatus.match}".`, | ||
// text: `By default, WIP only checks the pull request title for the terms "WIP", "Work in progress" and "🚧". | ||
// | ||
// You can configure both the terms and the location that the WIP app will look for by signing up for the pro plan: https://github.com/marketplace/wip. All revenue will be donated to [Rails Girls Summer of Code](https://railsgirlssummerofcode.org/).` | ||
text: `By default, WIP only checks the pull request title for the terms "WIP", "Work in progress" and "🚧". | ||
You can configure both the terms and the location that the WIP app will look for by signing up for the pro plan (coming soon, all revenue will be donated to [Rails Girls Summer of Code](https://railsgirlssummerofcode.org/)). Add your account to [pro-plan-for-free.js](https://github.com/wip/app/blob/marketplace-app/pro-plan-for-free.js) to enable the Pro Plan features for the time being.` | ||
} | ||
} | ||
|
||
if (!newStatus.wip) { | ||
checkOptions.status = 'completed' | ||
checkOptions.conclusion = 'success' | ||
checkOptions.completed_at = new Date() | ||
checkOptions.output.title = 'Ready for review' | ||
checkOptions.output.summary = 'No match found based on configuration' | ||
} | ||
|
||
return context.github.checks.create(context.repo(checkOptions)) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,36 @@ | ||
module.exports = getPlan | ||
|
||
// Find out if user as pro plan or not. The request to check the installation | ||
// for the current account (user account_id or organization) needs to be | ||
// authenticated as the app, not installation. If the app has no plan it means | ||
// that it wasn’t installed from the marketplace but from github.com/app/wip. | ||
// We treat it these as "FREE". | ||
// | ||
// The plan can be overwritten to "Pro" by adding an account name to pro-plan-for-free.js | ||
|
||
const PRO_PLAN_FOR_FREE = require('../pro-plan-for-free') | ||
|
||
async function getPlan (robot, owner) { | ||
if (PRO_PLAN_FOR_FREE.includes(owner.login)) { | ||
return 'pro' | ||
} | ||
|
||
const authenticatedAsApp = await robot.auth() | ||
try { | ||
const { | ||
data: { | ||
marketplace_purchase: { plan } | ||
} | ||
} = await authenticatedAsApp.apps.checkMarketplaceListingAccount({ | ||
account_id: owner.id | ||
}) | ||
|
||
return plan.price_model === 'FREE' ? 'free' : 'pro' | ||
} catch (error) { | ||
if (error.code === 404) { | ||
return 'free' | ||
} | ||
|
||
throw error | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,62 +1,71 @@ | ||
module.exports = handlePullRequestChange | ||
|
||
async function handlePullRequestChange (context) { | ||
const getStatusFree = require('./free/get-status') | ||
const setStatusFree = require('./free/set-status') | ||
|
||
const getStatusPro = require('./pro/get-status') | ||
const setStatusPro = require('./pro/set-status') | ||
|
||
const getLogChild = require('./logs/get-child') | ||
const getPlan = require('./get-plan') | ||
const hasStatusChange = require('./common/has-status-change') | ||
const legacyHandler = require('./legacy/handle-pull-request-change') | ||
const overwriteLegacyCommitStatus = require('./legacy/overwrite-commit-status') | ||
|
||
async function handlePullRequestChange (app, context) { | ||
const { action, pull_request: pr, repository: repo } = context.payload | ||
const currentStatus = await getCurrentStatus(context) | ||
const labelNames = pr.labels.map(label => label.name) | ||
const isWip = containsWIP(pr.title) || labelNames.some(containsWIP) || await commitsContainWIP(context) | ||
const newStatus = isWip ? 'pending' : 'success' | ||
const shortUrl = `${repo.full_name}#${pr.number}` | ||
const logStatus = isWip ? '⏳' : '✅' | ||
|
||
const hasChange = currentStatus !== newStatus | ||
const log = context.log.child({ | ||
name: 'wip', | ||
event: context.event.event, | ||
action, | ||
account: repo.owner.id, | ||
repo: repo.id, | ||
change: hasChange, | ||
wip: isWip | ||
}) | ||
|
||
// if status did not change then don’t call .createStatus. Quotas for mutations | ||
// are much more restrictive so we want to avoid them if possible | ||
if (!hasChange) { | ||
return log.info(`😐${logStatus} ${shortUrl}`) | ||
} | ||
|
||
try { | ||
await context.github.repos.createStatus(context.repo({ | ||
sha: pr.head.sha, | ||
state: newStatus, | ||
target_url: 'https://github.com/apps/wip', | ||
description: isWip ? 'work in progress' : 'ready for review', | ||
context: 'WIP' | ||
})) | ||
|
||
log.info(`💾${logStatus} ${shortUrl}`) | ||
} catch (err) { | ||
log.error(err) | ||
} | ||
} | ||
// 1. get new status based on marketplace plan | ||
const plan = await getPlan(app, repo.owner) | ||
const newStatus = plan === 'free' ? await getStatusFree(context) : await getStatusPro(context) | ||
const shortUrl = `${repo.full_name}#${pr.number}` | ||
|
||
async function getCurrentStatus (context) { | ||
const { data: { statuses } } = await context.github.repos.getCombinedStatusForRef(context.repo({ | ||
ref: context.payload.pull_request.head.sha | ||
})) | ||
// 2. if status did not change then don’t create a new check run. Quotas for | ||
// mutations are more restrictive so we want to avoid them if possible | ||
const hasChange = await hasStatusChange(newStatus, context) | ||
|
||
return (statuses.find(status => status.context === 'WIP') || {}).state | ||
} | ||
// Override commit status set in previous WIP app. Unfortunately, when setting | ||
// a pull request state using a check that has the same context name as a | ||
// commit status, one does not override the other, instead they are both listed. | ||
// That means that a previous "pending" commit status can block a pull pull_request | ||
// indefinitely, hence the override. See https://github.com/wip/app/issues/89#notes-on-update-to-marketplace-version | ||
const didLegacyOveride = await overwriteLegacyCommitStatus(context) | ||
|
||
async function commitsContainWIP (context) { | ||
const commits = await context.github.pullRequests.getCommits(context.repo({ | ||
number: context.payload.pull_request.number | ||
})) | ||
const log = getLogChild({ context, action, plan, newStatus, repo, hasChange, shortUrl, didLegacyOveride }) | ||
|
||
return commits.data.map(element => element.commit.message).some(containsWIP) | ||
} | ||
// if status did not change then don’t call .createStatus. Quotas for mutations | ||
// are much more restrictive so we want to avoid them if possible | ||
if (!hasChange) { | ||
return log.noUpdate() | ||
} | ||
|
||
// 3. Create check run | ||
if (plan === 'free') { | ||
await setStatusFree(newStatus, context) | ||
} else { | ||
await setStatusPro(newStatus, context) | ||
} | ||
|
||
function containsWIP (string) { | ||
return /\b(wip|do not merge|work in progress)\b/i.test(string) | ||
log.stateChanged() | ||
} catch (error) { | ||
try { | ||
// workaround for https://github.com/octokit/rest.js/issues/684 | ||
const parsed = JSON.parse(error.message) | ||
for (const key in parsed) { | ||
error[key] = parsed[key] | ||
} | ||
|
||
// Erro code 403 (Resource not accessible by integration) means that | ||
// the user did not yet accept the new permissions, so we handle it the | ||
// old school way | ||
if (error.code === 403) { | ||
return legacyHandler(context) | ||
} | ||
|
||
context.log.error(error) | ||
} catch (e) { | ||
context.log.error(error) | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,51 @@ | ||
module.exports = handleRequestedAction | ||
|
||
const getConfig = require('./app-config') | ||
|
||
// add or remove "@wip ready for review" to/from the pull request description | ||
// based on the requested action: "override" or "reset" | ||
async function handleRequestedAction (app, context) { | ||
const { action, repository: repo } = context.payload | ||
const [requestedAction, prNumber] = context.payload.requested_action.identifier.split(':') | ||
const shortUrl = `${repo.full_name}#${prNumber}` | ||
const log = context.log.child({ | ||
name: getConfig().name, | ||
event: context.event, | ||
action, | ||
requested: requestedAction, | ||
account: repo.owner.id, | ||
repo: repo.id, | ||
private: repo.private | ||
}) | ||
|
||
try { | ||
const { data: { body } } = await context.github.pullRequests.get(context.repo({ | ||
number: prNumber | ||
})) | ||
|
||
if (requestedAction === 'override') { | ||
await context.github.pullRequests.update(context.repo({ | ||
number: prNumber, | ||
body: body.trim() ? `${body.trim()}\n\n@wip ready for review` : '@wip ready for review' | ||
})) | ||
return log.info(`🙈 ${shortUrl}`) | ||
} | ||
|
||
await context.github.pullRequests.update(context.repo({ | ||
number: prNumber, | ||
body: body.replace(/\s*@wip ready for review[!,.]?\s*/, '') | ||
})) | ||
log.info(`🙉 ${shortUrl}`) | ||
} catch (error) { | ||
try { | ||
// workaround for https://github.com/octokit/rest.js/issues/684 | ||
const parsed = JSON.parse(error.message) | ||
for (const key in parsed) { | ||
error[key] = parsed[key] | ||
} | ||
context.log.error(error) | ||
} catch (e) { | ||
context.log.error(error) | ||
} | ||
} | ||
} |
Oops, something went wrong.