Skip to content

Commit

Permalink
feat: marketplace version
Browse files Browse the repository at this point in the history
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
gr2m committed Oct 14, 2018
1 parent f38f084 commit 3e1285f
Show file tree
Hide file tree
Showing 18 changed files with 619 additions and 57 deletions.
17 changes: 11 additions & 6 deletions index.js
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)
}
9 changes: 9 additions & 0 deletions lib/app-config.js
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'
}
}
19 changes: 19 additions & 0 deletions lib/common/has-status-change.js
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
}
16 changes: 16 additions & 0 deletions lib/common/match-terms.js
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
}
25 changes: 25 additions & 0 deletions lib/free/get-status.js
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
}
}
35 changes: 35 additions & 0 deletions lib/free/set-status.js
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))
}
36 changes: 36 additions & 0 deletions lib/get-plan.js
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
}
}
111 changes: 60 additions & 51 deletions lib/handle-pull-request-change.js
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)
}
}
}
51 changes: 51 additions & 0 deletions lib/handle-requested-action.js
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)
}
}
}
Loading

0 comments on commit 3e1285f

Please sign in to comment.