diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fb6766f..ccc06a6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,9 +17,9 @@ To test with a real GitHub repository, you'll need to create a test repository a - **Payload URL:** Use the full `*.ngrok.io` - **Secret:** `development` - **Which events would you like to trigger this webhook?:** Choose **Send me everything**. -0. Create a `.probot` in your repo with: +0. Create a `.probot.js` in your repo with: - on issues.opened then comment("Hello World! Your bot is working!"); + on("issues.opened").comment("Hello World! Your bot is working!"); 0. Open a new issue. Your bot should post a comment (you may need to refresh to see it). @@ -34,6 +34,8 @@ To test with a real GitHub repository, you'll need to create a test repository a ## Adding an action +_**TODO:** This is out of date and will be updated after plugin API is settled._ + [Actions](docs/configuration.md#then) are called when a webhook is delivered and any conditions are met. For example, this configuration in `.probot` calls two actions when a new issue is opened: ``` @@ -77,6 +79,8 @@ That's it! You now have everything you need to implement an action. If you're lo ## Adding a condition +_**TODO:** This is out of date and will be updated after plugin API is settled._ + [Conditions](docs/configuration.md#then) are called when a webhook is delivered and are used to determine if the actions should be called. For example, this configuration in `.probot` checks if an issue or pull request has a label before closing it: ``` diff --git a/README.md b/README.md index 21b56ab..fda331f 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,14 @@ _**Heads up!** The [demo integration](https://github.com/integration/probot-demo 0. Go to the **[demo integration](https://github.com/integration/probot-demo)**, click **Install**, and then select an organization. 0. Add @probot as a collaborator with write access on your repository. -0. Create a `.probot` file in your repository with the following contents. See [Configuration](docs/configuration.md) for more information on what behaviors can be built. +0. Create a `.probot.js` file in your repository with the following contents. See [Configuration](docs/configuration.md) for more information on what behaviors can be built. - on issues.opened - then comment("Hello @{{ sender.login }}. Thanks for inviting me to your project. Read more about [all the things I can help you with][config]. I can't wait to get started!\\n[config]: https://github.com/bkeepers/PRobot/blob/master/docs/configuration.md"); + on("issues.opened").comment(` + Hello @{{ sender.login }}. Thanks for inviting me to your project. + Read more about [all the things I can help you with][config]. I can't + wait to get started! + + [config]: https://github.com/bkeepers/PRobot/blob/master/docs/configuration.md + `); 0. Open a new issue. @probot should post a comment (you may need to refresh to see it). diff --git a/docs/configuration.md b/docs/configuration.md index 677b8c1..106f82c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,25 +1,25 @@ # Configuration -Behaviors are configured in a file called `.probot` in your repository. +Workflows are configured in a file called `.probot.js` in your repository. ``` -# Auto-respond to new issues and pull requests -on issues.opened or pull_request.opened -then comment("Thanks for your contribution! Expect a reply within 48 hours.") -and label(triage); +// Auto-respond to new issues and pull requests +on('issues.opened', 'pull_request.opened') + .comment('Thanks for your contribution! Expect a reply within 48 hours.') + .label('triage'); -# Auto-close new pull requests -on pull_request.opened -then comment("Sorry @{{ user.login }}, pull requests are not accepted on this repository.") -and close; +// Auto-close new pull requests +on('pull_request.opened') + .comment('Sorry @{{ user.login }}, pull requests are not accepted on this repository.') + .close(); ``` -## Behaviors +## Workflows -Behaviors are composed of: +Workflows are composed of: - [`on`](#on) - webhook events to listen to -- [`if`](#if) (optional) - conditions to determine if the actions should be performed. +- [`filter`](#filter) (optional) - conditions to determine if the actions should be performed. - [`then`](#then) - actions to take in response to the event ### `on` @@ -27,19 +27,19 @@ Behaviors are composed of: Specifies the type of GitHub [webhook event](https://developer.github.com/webhooks/#events) that this behavior applies to: ``` -on issues then… +on('issues') ``` You can also specify multiple events to trigger this behavior: ``` -on issues or pull_request then… +on('issues', 'pull_request') ``` Many events also have an `action` (e.g. `created` for the `issue` event), which can be referenced with dot notation: ``` -on issues.labeled or issues.unlabeled then… +on('issues.labeled', 'issues.unlabeled') ``` [Webhook events](https://developer.github.com/webhooks/#events) include: @@ -69,48 +69,20 @@ on issues.labeled or issues.unlabeled then… TODO: document actions -### `if` +### `filter` -Only preform the actions if theses conditions are met. Conditions can be [operations](#operations) or [functions](#functions) - -Attributes of the [webhook payload](https://developer.github.com/webhooks/#events) can be used in conditions using the `@` syntax. +Only preform the actions if the function returns `true`. The `event` is passed as an argument to the function and attributes of the [webhook payload](https://developer.github.com/webhooks/#events). ``` -if @issue.body contains "- [ ]" +.filter(event => event.payload.issue.body.includes("- [ ]")) ``` -#### Operations - -Operator | Description | Example ---------------------|-------------------------------------|----------------------------------- -`is` | equal | `@sender.login is "hubot"` -`is not` | not equal | `@sender.login is not "hubot"` -`contains` | string contains a substring | `@issue.body contains "- [ ]"` -`does not contain` | string does not contain a substring | `@issue.body does not contain "- [x]"` -`matches` | matches a regular expression | `@issue.title matches "^\[?WIP\]?"` -`does not match` | does not match a regular expression | `@issue.title does not match "v\d+\.\d+"` -`and` | logical and | `labeled(bug) and @sender.login is "hubot"` -`or` | logical or | `labeled(bug) or labeled(defect)` -`not` | negate a condition | `not labeled(bug)` - -#### Functions - -##### `labeled` - -Test if the label was added. - -``` -if labeled(bug) -``` - -### `then` - #### `comment` Comments can be posted in response to any event performed on an Issue or Pull Request. Comments use [mustache](https://mustache.github.io/) for templates and can use any data from the event payload. ``` -… then comment("Hey @{{ user.login }}, thanks for the contribution!"); +.comment("Hey @{{ user.login }}, thanks for the contribution!"); ``` #### `close` @@ -118,7 +90,7 @@ Comments can be posted in response to any event performed on an Issue or Pull Re Close an issue or pull request. ``` -… then close; +.close(); ``` #### `open` @@ -126,7 +98,7 @@ Close an issue or pull request. Reopen an issue or pull request. ``` -… then open; +.open(); ``` #### `lock` @@ -134,7 +106,7 @@ Reopen an issue or pull request. Lock conversation on an issue or pull request. ``` -… then lock; +.lock(); ``` #### `unlock` @@ -142,7 +114,7 @@ Lock conversation on an issue or pull request. Unlock conversation on an issue or pull request. ``` -… then unlock; +.unlock(); ``` #### `label` @@ -150,7 +122,7 @@ Unlock conversation on an issue or pull request. Add labels ``` -… then label(bug); +.label('bug'); ``` #### `unlabel` @@ -158,25 +130,25 @@ Add labels Add labels ``` -… then unlabel("needs-work") and label("waiting-for-review"); +.unlabel('needs-work').label('waiting-for-review'); ``` #### `assign` ``` -… then assign(hubot); +.assign('hubot'); ``` #### `unassign` ``` -… then unassign(defunkt); +.unassign('defunkt'); ``` #### `react` ``` -… then react(heart); # or +1, -1, laugh, confused, heart, hooray +.react('heart'); # or +1, -1, laugh, confused, heart, hooray ``` --- diff --git a/docs/examples.md b/docs/examples.md index 90498f2..9279a6f 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -6,75 +6,96 @@ Here are some examples of interesting things you can do by combining these compo ### Require use of issue template - # .github/MISSING_ISSUE_TEMPLATE_AUTOREPLY.md - # - # Hey @{{ sender.login }}, thanks for opening an issue. Unfortunately, you - # are missing information from the issue template. Please open a new issue with - # all the information from the template and it will make it easier for us to - # help you. +```js +// .github/MISSING_ISSUE_TEMPLATE_AUTOREPLY.md +// +// Hey @{{ sender.login }}, thanks for opening an issue. Unfortunately, you +// are missing information from the issue template. Please open a new issue with +// all the information from the template and it will make it easier for us to +// help you. - on issues.opened - if - payload.body !matches /### Prerequisites.*### Description.*### Steps to Reproduce.*### Versions/ - or payload.body matches /- [ ]/ - then - comment(from_file(".github/MISSING_ISSUE_TEMPLATE_AUTOREPLY.md") - and label("insufficient-info") - and close; +on('issues.opened') + .filter((event) => { + return !event.issue.body.match(/### Steps to Reproduce/) + || event.issue.body.includes('- [ ]') + }) + .comment.contents('.github/MISSING_ISSUE_TEMPLATE_AUTOREPLY.md') + .label('insufficient-info') + .close(); +``` ### Post welcome message for new contributors - on issues.opened or pull_request.opened - if first_time_contributor # plugins could implement conditions like this - then comment(file(".github/NEW_CONTRIBUTOR_TEMPLATE.md")); +```js +on('issues.opened', 'pull_request.opened') + .filter.firstTimeContributor() // plugins could implement conditions like this + .comment.contents('.github/NEW_CONTRIBUTOR_TEMPLATE.md'); +``` ### Auto-close new pull requests - on pull_request.opened - then comment("Sorry @{{ user.login }}, pull requests are not accepted on this repository.") - and close; +```js +on('pull_request.opened') + .comment('Sorry @{{ user.login }}, pull requests are not accepted on this repository.') + .close(); +``` ### Close issues with no body - on issues.opened - if @issue.body matches /^$/ - then comment("Hey @{{ user.login }}, you didn't include a description of the problem, so we're closing this issue."); +```js +on('issues.opened') + .filter((event) => event.issue.body.match(/^$/)) + .comment('Hey @{{ user.login }}, you didn't include a description of the problem, so we're closing this issue.'); +``` ### @mention watchers when label added - on *.labeled then - # TODO: figure out syntax for loading watchers from file - comment("Hey {{ mentions }}, you wanted to know when the `{{ payload.label.name }}` label was added."); +```js +on('*.labeled') + // TODO: figure out syntax for loading watchers from file + .comment('Hey {{ mentions }}, you wanted to know when the `{{ payload.label.name }}` label was added.'); +``` ### Assign a reviewer for new bugs - on pull_request.labeled - if labeled(bug) - then assign(random(file(OWNERS))); +```js +on('pull_request.labeled') + .filter((event) => event.labeled(bug)) + .assign(random(file(OWNERS))); +``` ### Perform actions based on content of comments - on issue_comment.opened - if @issue.body matches /^@probot assign @(\w+)$/ - then assign({{ matches[0] }}) +```js +on('issue_comment.opened') + .filter((event) => event.issue.body.match(/^@probot assign @(\w+)$/)) + .assign({{ matches[0] }}); - on issue_comment.opened - if @issue.body matches /^@probot label @(\w+)$/ - then label($1) +on('issue_comment.opened') + .filter((event) => event.issue.body.match(/^@probot label @(\w+)$/)) + .label($1); +``` ### Close stale issues and pull requests - on *.labeled - if labeled("needs-work") and state("open") - then delay(7 days) and close +```js +every('day') + .find.issues({state: 'open', label: 'needs-work'}) + .filter.lastActive(7, 'days') + .close(); +``` ### Tweet when a new release is created - on release.published - then tweet("Get it while it's hot! {{ repository.name }} {{ release.name }} was just released! {{ release.html_url }}") +```js +on('release.published') + .tweet("Get it while it's hot! {{ repository.name }} {{ release.name }} was just released! {{ release.html_url }}"); +``` ### Assign a reviewer issues or pull requests with a label - on issues.opened and pull_request.opened and issues.labeled and pull_request.labeled - if labeled(security) - then assign(random(members(security-first-responders)); +```js +on('issues.opened', 'pull_request.opened', 'issues.labeled', 'pull_request.labeled') + .filter.labeled('security') + .assign(team('security-first-responders').random()); +``` diff --git a/lib/actions.js b/lib/actions.js deleted file mode 100644 index 6444c45..0000000 --- a/lib/actions.js +++ /dev/null @@ -1,13 +0,0 @@ -module.exports = { - assign: require('./actions/assign'), - close: require('./actions/close'), - comment: require('./actions/comment'), - label: require('./actions/label'), - lock: require('./actions/lock'), - open: require('./actions/open'), - unassign: require('./actions/unassign'), - unlabel: require('./actions/unlabel'), - unlock: require('./actions/unlock'), - react: require('./actions/react'), - updateIssue: require('./actions/update-issue') -}; diff --git a/lib/actions/assign.js b/lib/actions/assign.js deleted file mode 100644 index 527a642..0000000 --- a/lib/actions/assign.js +++ /dev/null @@ -1,16 +0,0 @@ -// Assign one or more users to an issue or pull request. -// -// ``` -// # Assign a single user -// then assign(bkeepers); -// -// # Assign multiple users -// then assign(bkeepers, benbalter); -// ``` -// - -module.exports = function (context, ...logins) { - return context.github.issues.addAssigneesToIssue( - context.payload.toIssue({assignees: logins}) - ); -}; diff --git a/lib/actions/close.js b/lib/actions/close.js deleted file mode 100644 index 7f84f65..0000000 --- a/lib/actions/close.js +++ /dev/null @@ -1,5 +0,0 @@ -const updateIssue = require('./update-issue'); - -module.exports = function (context) { - return updateIssue(context, {state: 'closed'}); -}; diff --git a/lib/actions/comment.js b/lib/actions/comment.js deleted file mode 100644 index 67b4bf5..0000000 --- a/lib/actions/comment.js +++ /dev/null @@ -1,7 +0,0 @@ -const handlebars = require('handlebars'); - -module.exports = function (context, template) { - return context.github.issues.createComment( - context.payload.toIssue({body: handlebars.compile(template)(context.payload)}) - ); -}; diff --git a/lib/actions/label.js b/lib/actions/label.js deleted file mode 100644 index 753ebdf..0000000 --- a/lib/actions/label.js +++ /dev/null @@ -1,5 +0,0 @@ -module.exports = function (context, ...labels) { - return context.github.issues.addLabels( - context.payload.toIssue({body: labels}) - ); -}; diff --git a/lib/actions/lock.js b/lib/actions/lock.js deleted file mode 100644 index 8a5cbab..0000000 --- a/lib/actions/lock.js +++ /dev/null @@ -1,3 +0,0 @@ -module.exports = function (context) { - return context.github.issues.lock(context.payload.toIssue()); -}; diff --git a/lib/actions/open.js b/lib/actions/open.js deleted file mode 100644 index 27662cd..0000000 --- a/lib/actions/open.js +++ /dev/null @@ -1,5 +0,0 @@ -const updateIssue = require('./update-issue'); - -module.exports = function (context) { - return updateIssue(context, {state: 'open'}); -}; diff --git a/lib/actions/react.js b/lib/actions/react.js deleted file mode 100644 index 0fc27c5..0000000 --- a/lib/actions/react.js +++ /dev/null @@ -1,6 +0,0 @@ - -module.exports = function (context, react) { - return context.github.reactions.createForIssue( - context.payload.toIssue({content: react}) - ); -}; diff --git a/lib/actions/unassign.js b/lib/actions/unassign.js deleted file mode 100644 index 936e02d..0000000 --- a/lib/actions/unassign.js +++ /dev/null @@ -1,16 +0,0 @@ -// Unassign one or more users from an issue or pull request. -// -// ``` -// # Unassign a single user -// then unassign(bkeepers); -// -// # Unassign multiple users -// then unassign(bkeepers, benbalter); -// ``` -// - -module.exports = function (context, ...logins) { - return context.github.issues.removeAssigneesFromIssue( - context.payload.toIssue({body: {assignees: logins}}) - ); -}; diff --git a/lib/actions/unlabel.js b/lib/actions/unlabel.js deleted file mode 100644 index d4df621..0000000 --- a/lib/actions/unlabel.js +++ /dev/null @@ -1,9 +0,0 @@ -module.exports = (context, ...labels) => { - // FIXME: check that it has the label first, or handle expected error: - // {"message":"Label does not exist","documentation_url":"https://developer.github.com/v3/issues/labels/#remove-a-label-from-an-issue"} - return Promise.all(labels.map(label => { - return context.github.issues.removeLabel( - context.payload.toIssue({name: label}) - ); - })); -}; diff --git a/lib/actions/unlock.js b/lib/actions/unlock.js deleted file mode 100644 index 23fade2..0000000 --- a/lib/actions/unlock.js +++ /dev/null @@ -1,3 +0,0 @@ -module.exports = function (context) { - return context.github.issues.unlock(context.payload.toIssue()); -}; diff --git a/lib/actions/update-issue.js b/lib/actions/update-issue.js deleted file mode 100644 index be003cf..0000000 --- a/lib/actions/update-issue.js +++ /dev/null @@ -1,4 +0,0 @@ -// https://mikedeboer.github.io/node-github/#api-issues-edit -module.exports = function (context, attrs) { - return context.github.issues.edit(context.payload.toIssue(attrs)); -}; diff --git a/lib/attribute.js b/lib/attribute.js deleted file mode 100644 index 5c52884..0000000 --- a/lib/attribute.js +++ /dev/null @@ -1,5 +0,0 @@ -module.exports = (context, name) => { - return name.reduce((object, attr) => { - return object ? object[attr] : null; - }, context.payload); -}; diff --git a/lib/behavior.js b/lib/behavior.js deleted file mode 100644 index 84f1666..0000000 --- a/lib/behavior.js +++ /dev/null @@ -1,17 +0,0 @@ -module.exports = class Behavior { - constructor(events, conditions, actions) { - this.events = events; - this.conditions = conditions; - this.actions = actions; - } - - perform(context) { - if (this.conditions && !this.conditions(context)) { - return Promise.resolve(); - } - - return Promise.all(this.actions.map(action => { - return action(context); - })); - } -}; diff --git a/lib/binary-expression.js b/lib/binary-expression.js deleted file mode 100644 index e880120..0000000 --- a/lib/binary-expression.js +++ /dev/null @@ -1,24 +0,0 @@ -const operators = { - 'is': (left, right) => left === right, - 'is not': (left, right) => left !== right, - 'contains': (left, right) => left.includes(right), - 'does not contain': (left, right) => !left.includes(right), - 'matches': (left, right) => left.match(right), - 'does not match': (left, right) => !left.match(right), - 'or': (left, right) => left || right, - 'and': (left, right) => left && right -}; - -function resolve(value, context) { - if (typeof value === 'function') { - return value(context); - } else { - return value; - } -} - -module.exports = (context, left, operator, right) => { - const leftValue = resolve(left, context); - const rightValue = resolve(right, context); - return operators[operator](leftValue, rightValue); -}; diff --git a/lib/conditions.js b/lib/conditions.js deleted file mode 100644 index 70e5a21..0000000 --- a/lib/conditions.js +++ /dev/null @@ -1,3 +0,0 @@ -module.exports = { - labeled: require('./conditions/labeled') -}; diff --git a/lib/conditions/labeled.js b/lib/conditions/labeled.js deleted file mode 100644 index 618b031..0000000 --- a/lib/conditions/labeled.js +++ /dev/null @@ -1,3 +0,0 @@ -module.exports = (context, label) => { - return context.payload.label && context.payload.label.name === label; -}; diff --git a/lib/configuration.js b/lib/configuration.js index cbb3f3f..aecd74c 100644 --- a/lib/configuration.js +++ b/lib/configuration.js @@ -1,16 +1,16 @@ +const vm = require('vm'); const debug = require('debug')('PRobot'); -const Transformer = require('./transformer'); -const parser = require('./parser'); +const Sandbox = require('./sandbox'); module.exports = class Configuration { // Get bot config from target repository static load(github, repository) { - debug('Fetching .probot from %s', repository.full_name); + debug('Fetching .probot.js from %s', repository.full_name); const parts = repository.full_name.split('/'); return github.repos.getContent({ owner: parts[0], repo: parts[1], - path: '.probot' + path: '.probot.js' }).then(data => { const content = new Buffer(data.content, 'base64').toString(); debug('Configuration fetched', content); @@ -19,21 +19,19 @@ module.exports = class Configuration { } static parse(content) { - const transformer = new Transformer(parser.parse(content)); - return new Configuration(transformer.transform()); + const sandbox = new Sandbox(); + vm.createContext(sandbox.api); + vm.runInContext(content, sandbox.api); + return new Configuration(sandbox.workflows); } - constructor(behaviors) { - this.behaviors = behaviors; + constructor(workflows) { + this.workflows = workflows; } - // FIXME: this can be moved into Behavior as a condition - behaviorsFor(event) { - return this.behaviors.filter(behavior => { - return behavior.events.filter(e => { - return e.name === event.event && - (!e.action || e.action === event.payload.action); - }).length > 0; + workflowsFor(event) { + return this.workflows.filter(w => { + return w.matchesEvent(event) && w.filterFn(event); }); } }; diff --git a/lib/dispatcher.js b/lib/dispatcher.js index 63d9755..dc87d64 100644 --- a/lib/dispatcher.js +++ b/lib/dispatcher.js @@ -1,4 +1,5 @@ const Context = require('./context'); +const issues = require('./plugins/issues'); class Dispatcher { constructor(github, event) { @@ -8,13 +9,23 @@ class Dispatcher { call(config) { // Get behaviors for the event - const behaviors = config.behaviorsFor(this.event); const context = new Context(this.github, config, this.event); + const workflows = config.workflowsFor(this.event); + + // FIXME: have a better method to register evaluators + const evaluators = [ + issues.Evaluator + ]; // Handle all behaviors - return Promise.all(behaviors.map(behavior => { - return behavior.perform(context); - })); + return Promise.all(workflows.map(w => { + return evaluators.map(E => { + const evaluator = new E(); + return evaluator.evaluate(w, context); + }); + }).reduce((a, b) => { + return a.concat(b); + }, [])); } } diff --git a/lib/evaluator.js b/lib/evaluator.js new file mode 100644 index 0000000..b3c1023 --- /dev/null +++ b/lib/evaluator.js @@ -0,0 +1,4 @@ +class Evaluator { +} + +module.exports = Evaluator; diff --git a/lib/parser.js b/lib/parser.js deleted file mode 100644 index 295ec69..0000000 --- a/lib/parser.js +++ /dev/null @@ -1,21 +0,0 @@ -const fs = require('fs'); -const path = require('path'); -const PEG = require('pegjs'); -const PEGUtil = require('pegjs-util'); - -// FIXME: eventually this should be generated on release -const parser = PEG.generate(fs.readFileSync(path.join(__dirname, 'parser.pegjs'), 'utf8'), { - allowedStartRules: ['start', 'if'] -}); - -module.exports = { - parse: (content, options) => { - const result = PEGUtil.parse(parser, content, options); - - if (result.error) { - throw new Error('\n' + PEGUtil.errorMessage(result.error, true), null, result.error.line); - } else { - return result.ast; - } - } -}; diff --git a/lib/parser.pegjs b/lib/parser.pegjs deleted file mode 100644 index 60d8a89..0000000 --- a/lib/parser.pegjs +++ /dev/null @@ -1,160 +0,0 @@ -{ - var unroll = options.util.makeUnroll(location, options) - - // Helper Functions - - function binaryExpression(head, tail) { - return tail.reduce(function(result, element) { - return { - type: 'BinaryExpression', - operator: element[1], - left: result, - right: element[3] - }; - }, head); - } -} - -start = _* b:behavior* _* { return b; } - -behavior - = _* events:on _ conditions:if _ actions:then _* semicolon { - return { - type: 'behavior', - events: events, - conditions: conditions, - actions: actions - }; - } - / _* events:on _ actions:then _* semicolon { - return { - type: 'behavior', - events: events, - actions: actions - }; - } - -// on - -on = "on" _ head:event tail:(_ or _ event)* { return unroll(head, tail, 3); } - -event - = e:word dot a:word { return {type: 'event', name: e, action: a}; } - / e:word { return {type: 'event', name: e}; } - -// if - -if = "if" _ conditions:RelationalExpression { return conditions; } - -RelationalExpression - = head:LogicalOrExpression tail:(_ RelationalOperator _ LogicalOrExpression)* { - return binaryExpression(head, tail); - } -RelationalOperator - = "is not" - / "is" - / "does not contain" - / "contains" - / "does not match" - / "matches" - -LogicalOrExpression - = head:LogicalAndExpression tail:(_ or _ LogicalAndExpression)* { - return binaryExpression(head, tail); - } - -LogicalAndExpression - = head:UnaryExpression tail:(_ and _ UnaryExpression)* { - return binaryExpression(head, tail); - } - -UnaryExpression - = operand - / operator:UnaryOperator _ argument:UnaryExpression { - return { - type: 'UnaryExpression', - operator: operator, - argument: argument - } - } - -UnaryOperator = "not" - -operand = condition / attribute / string / boolean - -condition - = name:word "(" args:arguments ")" { - return {type: 'condition', name: name, args: args}; - } - -attribute = "@" head:word tail:(dot word)* { - return {type: 'attribute', name: unroll(head, tail, 1)}; -} - -// then - -then - = "then" _ head:action tail:(_ and _ action)* { - return unroll(head, tail, 3); - } - -action - = name:word "(" _* args:arguments _* ")" { - return {type: 'action', name: name, args: args}; - } - / name:word { return {type: 'action', name: name}; } - -arguments - = head:argument tail:(_* comma _* argument)* { return unroll(head, tail, 3); } - -argument = word / string - -boolean = true / false -true = "true" { return true; } -false = "false" { return false; } - -// Strings - -string "string" - = quotation_mark chars:char* quotation_mark { return chars.join(""); } - -char - = unescaped - / escape - sequence:( - '"' - / "\\" - / "/" - / "b" { return "\b"; } - / "f" { return "\f"; } - / "n" { return "\n"; } - / "r" { return "\r"; } - / "t" { return "\t"; } - / "u" digits:$(HEXDIG HEXDIG HEXDIG HEXDIG) { - return String.fromCharCode(parseInt(digits, 16)); - } - ) - { return sequence; } - -escape - = "\\" - -quotation_mark - = '"' - -unescaped - = [^\0-\x1F\x22\x5C] - -HEXDIG = [0-9a-f]i - -// Character Classes - -word = letters:[a-zA-Z0-9_]+ { return letters.join(''); } -dot = "." -comma = "," -semicolon = ";" -and = "and" -or = "or" -_ "whitespace" = ([ \t\n\r] / comment)+ -comment = "#" (!LineTerminator .)* -LineTerminator = [\n\r\u2028\u2029] diff --git a/lib/plugins/issues.js b/lib/plugins/issues.js new file mode 100644 index 0000000..8f88793 --- /dev/null +++ b/lib/plugins/issues.js @@ -0,0 +1,160 @@ +const handlebars = require('handlebars'); +const Evaluator = require('../evaluator'); + +const IssuePlugin = superclass => class extends superclass { + comment(content) { + this._setCommentData({content}); + return this; + } + + assign(...assignees) { + this._setCommentData({assignees}); + return this; + } + + unassign(...assignees) { + this._setCommentData({unassignees: assignees}); + return this; + } + + label(...labels) { + this._setCommentData({labels}); + return this; + } + + unlabel(...labels) { + this._setCommentData({unlabels: labels}); + return this; + } + + lock() { + this._setCommentData({lock: true}); + return this; + } + + unlock() { + this._setCommentData({unlock: true}); + return this; + } + + open() { + this._setCommentData({open: true}); + return this; + } + + close() { + this._setCommentData({close: true}); + return this; + } + + react(reaction) { + this._setCommentData({reaction}); + return this; + } + + _setCommentData(obj) { + if (this.issueActions === undefined) { + this.issueActions = {}; + } + Object.assign(this.issueActions, obj); + } +}; + +// This is the function that implements all of the actions configured above. +class IssueEvaluator extends Evaluator { + evaluate(workflow, context) { + const event = context.event; + + // Bail if no issue related actions + if (workflow.issueActions === undefined) { + return; + } + + // Need to guard to make sure we are only processing events we know what to + // do with. It might be nice to have it throw errors in a validation step + // so that people know when that they have actions that don't match their + // events. + if (event.issue !== undefined && event.pull_request !== undefined) { + return; + } + const promises = []; + + if (workflow.issueActions.content !== undefined) { + const template = handlebars.compile(workflow.issueActions.content)(context.payload); + + promises.push( + context.github.issues.createComment(context.payload.toIssue({body: template})) + ); + } + + if (workflow.issueActions.assignees !== undefined) { + const assignees = workflow.issueActions.assignees; + promises.push( + context.github.issues.addAssigneesToIssue(context.payload.toIssue({assignees})) + ); + } + + if (workflow.issueActions.unassignees !== undefined) { + const assignees = {assignees: workflow.issueActions.unassignees}; + promises.push( + context.github.issues.removeAssigneesFromIssue(context.payload.toIssue({body: assignees})) + ); + } + + if (workflow.issueActions.labels !== undefined) { + const labels = workflow.issueActions.labels; + promises.push( + context.github.issues.addLabels(context.payload.toIssue({body: labels})) + ); + } + + if (workflow.issueActions.unlabels !== undefined) { + const labels = workflow.issueActions.unlabels; + promises.push( + labels.map(label => { + return context.github.issues.removeLabel( + context.payload.toIssue({name: label}) + ); + }) + ); + } + + if (workflow.issueActions.lock !== undefined) { + promises.push( + context.github.issues.lock(context.payload.toIssue({})) + ); + } + + if (workflow.issueActions.unlock !== undefined) { + promises.push( + context.github.issues.unlock(context.payload.toIssue({})) + ); + } + + if (workflow.issueActions.open !== undefined) { + promises.push( + context.github.issues.edit(context.payload.toIssue({state: 'open'})) + ); + } + + if (workflow.issueActions.close !== undefined) { + promises.push( + context.github.issues.edit(context.payload.toIssue({state: 'closed'})) + ); + } + + if (workflow.issueActions.reaction !== undefined) { + const reaction = workflow.issueActions.reaction; + promises.push( + context.github.reactions.createForIssue(context.payload.toIssue({content: reaction})) + ); + } + + return promises; + } +} + +module.exports = { + Plugin: IssuePlugin, + Evaluator: IssueEvaluator +}; diff --git a/lib/sandbox.js b/lib/sandbox.js new file mode 100644 index 0000000..632b282 --- /dev/null +++ b/lib/sandbox.js @@ -0,0 +1,19 @@ +const Workflow = require('./workflow'); + +class Sandbox { + constructor() { + this.workflows = []; + + this.api = { + on: this.on.bind(this) + }; + } + + on(...events) { + const workflow = new Workflow(events); + this.workflows.push(workflow); + return workflow; + } +} + +module.exports = Sandbox; diff --git a/lib/transformer.js b/lib/transformer.js deleted file mode 100644 index 287e220..0000000 --- a/lib/transformer.js +++ /dev/null @@ -1,71 +0,0 @@ -const walk = require('tree-walk'); -const debug = require('debug')('PRobot'); -const Behavior = require('./behavior'); -const actions = require('./actions'); -const conditions = require('./conditions'); -const binaryExpression = require('./binary-expression'); -const attribute = require('./attribute'); - -module.exports = class Transformer { - constructor(tree) { - this.tree = tree; - } - - transform() { - return walk.reduce(this.tree, this.walk.bind(this)); - } - - walk(result, node) { - if (this[node.type]) { - return this[node.type](result, node); - } else if (node.type) { - throw new Error('Unknown node: ' + node.type); - } else { - return result || node; - } - } - - event(node) { - return node; - } - - attribute(node) { - return context => attribute(context, node.name); - } - - action(node) { - return context => { - if (!actions[node.name]) { - throw new Error('Unknown action: ' + node.name); - } - debug('action: %s', node.name, node.args); - return actions[node.name].apply(context, [context].concat(node.args)); - }; - } - - behavior(node) { - return new Behavior(node.events, node.conditions, node.actions); - } - - condition(node) { - return context => { - if (!conditions[node.name]) { - throw new Error('Unknown condition: ' + node.name); - } - debug('condition: %s', node.name, node.args); - return conditions[node.name].apply(context, [context].concat(node.args)); - }; - } - - BinaryExpression(node) { - return context => { - return binaryExpression(context, node.left, node.operator, node.right); - }; - } - - UnaryExpression(node) { - return context => { - return !node.argument(context); - }; - } -}; diff --git a/lib/workflow.js b/lib/workflow.js new file mode 100644 index 0000000..70ee131 --- /dev/null +++ b/lib/workflow.js @@ -0,0 +1,41 @@ +const issues = require('./plugins/issues'); + +class WorkflowCore { + constructor(events) { + this.events = events; + this.filterFn = () => true; + } + + filter(fn) { + this.filterFn = fn; + return this; + } + + matchesEvent(event) { + const eventWithAction = [event.event, event.payload.action]; + + return this.events.find(e => { + const parts = e.split('.'); + for (let i = 0; i < parts.length; i++) { + if (parts[i] !== eventWithAction[i]) { + return false; + } + } + return true; + }); + } +} + +// FIXME: issues +const plugins = [ + issues.Plugin +]; + +// Helper to combine an array of mixins into one class +function mix(superclass, ...mixins) { + return mixins.reduce((c, mixin) => mixin(c), superclass); +} + +class Workflow extends mix(WorkflowCore, ...plugins) {} + +module.exports = Workflow; diff --git a/package.json b/package.json index 298a9cb..d9739d1 100644 --- a/package.json +++ b/package.json @@ -14,11 +14,7 @@ "expect": "^1.20.2", "github": "^5.2.0", "github-webhook-handler": "^0.6.0", - "handlebars": "^4.0.5", - "jsdoc": "^3.4.2", - "pegjs": "^0.10.0", - "pegjs-util": "^1.4.0", - "tree-walk": "^0.4.0" + "handlebars": "^4.0.5" }, "devDependencies": { "mocha": "^3.0.2", diff --git a/test/actions/assign.js b/test/actions/assign.js deleted file mode 100644 index 6ce75f1..0000000 --- a/test/actions/assign.js +++ /dev/null @@ -1,36 +0,0 @@ -const expect = require('expect'); -const action = require('../../lib/actions/assign'); -const Context = require('../../lib/context'); -const payload = require('../fixtures/webhook/comment.created.json'); - -const createSpy = expect.createSpy; - -const github = { - issues: { - addAssigneesToIssue: createSpy() - } -}; - -const context = new Context(github, {}, {payload}); - -describe('action.assign', () => { - it('assigns a user', () => { - action(context, 'bkeepers'); - expect(github.issues.addAssigneesToIssue).toHaveBeenCalledWith({ - owner: 'bkeepers-inc', - repo: 'test', - number: 6, - assignees: ['bkeepers'] - }); - }); - - it('assigns multiple users', () => { - action(context, 'hello', 'world'); - expect(github.issues.addAssigneesToIssue).toHaveBeenCalledWith({ - owner: 'bkeepers-inc', - repo: 'test', - number: 6, - assignees: ['hello', 'world'] - }); - }); -}); diff --git a/test/actions/comment.js b/test/actions/comment.js deleted file mode 100644 index d42d1ae..0000000 --- a/test/actions/comment.js +++ /dev/null @@ -1,25 +0,0 @@ -const expect = require('expect'); -const action = require('../../lib/actions/comment'); -const Context = require('../../lib/context'); -const payload = require('../fixtures/webhook/comment.created.json'); - -const createSpy = expect.createSpy; - -const github = { - issues: { - createComment: createSpy() - } -}; - -describe('action.comment', () => { - it('creates a comment', () => { - const context = new Context(github, {}, {payload}); - action(context, 'Hello @{{ sender.login }}!'); - expect(github.issues.createComment).toHaveBeenCalledWith({ - owner: 'bkeepers-inc', - repo: 'test', - number: 6, - body: 'Hello @bkeepers!' - }); - }); -}); diff --git a/test/actions/label.js b/test/actions/label.js deleted file mode 100644 index 39bc472..0000000 --- a/test/actions/label.js +++ /dev/null @@ -1,35 +0,0 @@ -const expect = require('expect'); -const action = require('../../lib/actions/label'); -const Context = require('../../lib/context'); -const payload = require('../fixtures/webhook/comment.created.json'); - -const createSpy = expect.createSpy; - -const github = { - issues: { - addLabels: createSpy() - } -}; -const context = new Context(github, {}, {payload}); - -describe('action.label', () => { - it('adds a label', () => { - action(context, 'hello'); - expect(github.issues.addLabels).toHaveBeenCalledWith({ - owner: 'bkeepers-inc', - repo: 'test', - number: 6, - body: ['hello'] - }); - }); - - it('adds multiple labels', () => { - action(context, ['hello', 'world']); - expect(github.issues.addLabels).toHaveBeenCalledWith({ - owner: 'bkeepers-inc', - repo: 'test', - number: 6, - body: ['hello'] - }); - }); -}); diff --git a/test/actions/unassign.js b/test/actions/unassign.js deleted file mode 100644 index f4f351b..0000000 --- a/test/actions/unassign.js +++ /dev/null @@ -1,35 +0,0 @@ -const expect = require('expect'); -const action = require('../../lib/actions/unassign'); -const Context = require('../../lib/context'); -const payload = require('../fixtures/webhook/comment.created.json'); - -const createSpy = expect.createSpy; - -const github = { - issues: { - removeAssigneesFromIssue: createSpy() - } -}; -const context = new Context(github, {}, {payload}); - -describe('action.unassign', () => { - it('unassigns a user', () => { - action(context, 'bkeepers'); - expect(github.issues.removeAssigneesFromIssue).toHaveBeenCalledWith({ - owner: 'bkeepers-inc', - repo: 'test', - number: 6, - body: {assignees: ['bkeepers']} - }); - }); - - it('unassigns multiple users', () => { - action(context, 'hello', 'world'); - expect(github.issues.removeAssigneesFromIssue).toHaveBeenCalledWith({ - owner: 'bkeepers-inc', - repo: 'test', - number: 6, - body: {assignees: ['hello', 'world']} - }); - }); -}); diff --git a/test/actions/unlabel.js b/test/actions/unlabel.js deleted file mode 100644 index 4d21409..0000000 --- a/test/actions/unlabel.js +++ /dev/null @@ -1,42 +0,0 @@ -const expect = require('expect'); -const action = require('../../lib/actions/unlabel'); -const Context = require('../../lib/context'); -const payload = require('../fixtures/webhook/comment.created.json'); - -const createSpy = expect.createSpy; - -const github = { - issues: { - removeLabel: createSpy() - } -}; -const context = new Context(github, {}, {payload}); - -describe('action.unlabel', () => { - it('removes a single label', () => { - action(context, 'hello'); - expect(github.issues.removeLabel).toHaveBeenCalledWith({ - owner: 'bkeepers-inc', - repo: 'test', - number: 6, - name: 'hello' - }); - }); - - it('removes a multiple labels', () => { - action(context, 'hello', 'goodbye'); - expect(github.issues.removeLabel).toHaveBeenCalledWith({ - owner: 'bkeepers-inc', - repo: 'test', - number: 6, - name: 'hello' - }); - - expect(github.issues.removeLabel).toHaveBeenCalledWith({ - owner: 'bkeepers-inc', - repo: 'test', - number: 6, - name: 'goodbye' - }); - }); -}); diff --git a/test/attribute.js b/test/attribute.js deleted file mode 100644 index 4e741ac..0000000 --- a/test/attribute.js +++ /dev/null @@ -1,16 +0,0 @@ -const expect = require('expect'); -const attribute = require('../lib/attribute'); - -describe('attribute', () => { - it('fetches the attribute from the payload', () => { - const context = {payload: {foo: {bar: 'baz'}}}; - const value = attribute(context, ['foo', 'bar']); - expect(value).toEqual('baz'); - }); - - it('returns null for unknown attributes', () => { - const context = {payload: {}}; - const value = attribute(context, ['foo', 'unknown']); - expect(value).toEqual(null); - }); -}); diff --git a/test/conditions.js b/test/conditions.js deleted file mode 100644 index b985fd8..0000000 --- a/test/conditions.js +++ /dev/null @@ -1,105 +0,0 @@ -const expect = require('expect'); -const Transformer = require('../lib/transformer'); -const Context = require('../lib/context'); -const parser = require('../lib/parser'); -const payload = require('./fixtures/webhook/issues.labeled.json'); - -const context = new Context({}, {}, {payload}); - -function test(string) { - const ast = parser.parse(string, {startRule: 'if'}); - const condition = new Transformer(ast).transform(); - return typeof condition === 'function' ? condition(context) : condition; -} - -describe('conditions', () => { - describe('is', () => { - it('passes when operands are equal', () => { - expect(test(`if @sender.login is "bkeepers"`)).toBeTruthy(); - }); - - it('fails when operands are not equal', () => { - expect(test(`if @sender.login is "nobody"`)).toBeFalsy(); - }); - }); - - describe('is not', () => { - it('fails when operands are equal', () => { - expect(test('if @sender.login is not "bkeepers"')).toBeFalsy(); - }); - - it('passes when operands are not equal', () => { - expect(test('if @sender.login is not "nobody"')).toBeTruthy(); - }); - }); - - describe('contains', () => { - it('passes when operand contains substring', () => { - expect(test('if @issue.title contains "bug"')).toBeTruthy(); - }); - - it('fails when operand does not contain substring', () => { - expect(test('if @issue.title contains "nope"')).toBeFalsy(); - }); - }); - - describe('does not contain', () => { - it('fails when operand contains substring', () => { - expect(test('if @issue.title does not contain "bug"')).toBeFalsy(); - }); - - it('passes when operand does not contain substring', () => { - expect(test('if @issue.title does not contain "nope"')).toBeTruthy(); - }); - }); - - describe('matches', () => { - it('passes when operand matches regexp', () => { - expect(test('if @sender.login matches "ke+"')).toBeTruthy(); - }); - - it('fails when operand does not match regexp', () => { - expect(test('if @issue.title matches "nope"')).toBeFalsy(); - }); - }); - - describe('does not match', () => { - it('fails when operand matches regexp', () => { - expect(test('if @sender.login does not match "ke+"')).toBeFalsy(); - }); - - it('passes when operand does not match regexp', () => { - expect(test('if @issue.title does not match "nope"')).toBeTruthy(); - }); - }); - - describe('or', () => { - it('passes if either operand is truthy', () => { - expect(test('if labeled(bug) or labeled(feature)')).toBeTruthy(); - }); - - it('fails if neither operand is truthy', () => { - expect(test('if labeled(nope) or labeled(never)')).toBeFalsy(); - }); - }); - - describe('and', () => { - it('passes if both operands are truthy', () => { - expect(test('if labeled(bug) and @issue.title contains "bug"')).toBeTruthy(); - }); - - it('fails if one operand is not truthy', () => { - expect(test('if labeled(bug) and labeled(nope)')).toBeFalsy(); - }); - }); - - describe('not', () => { - it('passes if condition is not truthy', () => { - expect(test('if not labeled(something)')).toBeTruthy(); - }); - - it('fails if condition is truthy', () => { - expect(test('if not labeled(bug)')).toBeFalsy(); - }); - }); -}); diff --git a/test/configuration.js b/test/configuration.js index 27f9f09..875cf14 100644 --- a/test/configuration.js +++ b/test/configuration.js @@ -4,6 +4,16 @@ const config = require('./fixtures/content/probot.json'); const createSpy = expect.createSpy; +config.content = new Buffer(` + on("issues.opened") + .comment("Hello World!") + .assign("bkeepers") + .react("heart"); + + on("issues.closed") + .unassign("bkeepers"); +`).toString('base64'); + describe('Configuration', () => { describe('load', () => { let github; @@ -23,32 +33,32 @@ describe('Configuration', () => { expect(github.repos.getContent).toHaveBeenCalledWith({ owner: 'bkeepers', repo: 'test', - path: '.probot' + path: '.probot.js' }); - expect(config.behaviors.length).toEqual(2); + expect(config.workflows.length).toEqual(2); done(); }); }); }); - describe('behaviorsFor', () => { + describe('workflowsFor', () => { const config = Configuration.parse(` - on issues then label(active); - on issues.created then close; - on pull_request.labeled then lock; + on("issues").label("active"); + on("issues.created").close(); + on("pull_request.labeled").lock(); `); it('returns behaviors for event', () => { expect( - config.behaviorsFor({event: 'issues', payload: {}}).length + config.workflowsFor({event: 'issues', payload: {}}).length ).toEqual(1); }); it('returns behaviors for event and action', () => { expect( - config.behaviorsFor({event: 'issues', payload: {action: 'created'}}).length + config.workflowsFor({event: 'issues', payload: {action: 'created'}}).length ).toEqual(2); }); }); diff --git a/test/dispatcher.js b/test/dispatcher.js index 6f12e83..64f0860 100644 --- a/test/dispatcher.js +++ b/test/dispatcher.js @@ -22,7 +22,7 @@ describe('dispatch', () => { describe('reply to new issue with a comment', () => { it('posts a coment', () => { - const config = Configuration.parse('on issues then comment("Hello World!");'); + const config = Configuration.parse('on("issues").comment("Hello World!")'); return dispatcher.call(config).then(() => { expect(github.issues.createComment).toHaveBeenCalled(); }); @@ -31,7 +31,7 @@ describe('dispatch', () => { describe('reply to new issue with a comment', () => { it('calls the action', () => { - const config = Configuration.parse('on issues.created then comment("Hello World!");'); + const config = Configuration.parse('on("issues.created").comment("Hello World!")'); return dispatcher.call(config).then(() => { expect(github.issues.createComment).toHaveBeenCalled(); @@ -41,7 +41,7 @@ describe('dispatch', () => { describe('on an event with a different action', () => { it('does not perform behavior', () => { - const config = Configuration.parse('on issues.labeled then comment("Hello World!");'); + const config = Configuration.parse('on("issues.labeled").comment("Hello World!")'); return dispatcher.call(config).then(() => { expect(github.issues.createComment).toNotHaveBeenCalled(); @@ -49,7 +49,7 @@ describe('dispatch', () => { }); }); - describe('conditions', () => { + describe('filter', () => { beforeEach(() => { const labeled = require('./fixtures/webhook/issues.labeled.json'); @@ -58,20 +58,15 @@ describe('dispatch', () => { dispatcher = new Dispatcher(github, event); }); - it('fails for unknown conditions', () => { - const config = Configuration.parse(`on issues.labeled if failwhale(bug) then close;`); - expect(() => dispatcher.call(config)).toThrow(/unknown condition/i); - }); - it('calls action when condition matches', () => { - const config = Configuration.parse(`on issues.labeled if labeled(bug) then close;`); + const config = Configuration.parse('on("issues.labeled").filter((e) => e.payload.label.name == "bug").close()'); return dispatcher.call(config).then(() => { expect(github.issues.edit).toHaveBeenCalled(); }); }); it('does not call action when conditions do not match', () => { - const config = Configuration.parse(`on issues.labeled if labeled(foobar) then close;`); + const config = Configuration.parse('on("issues.labeled").filter((e) => e.payload.label.name == "foobar").close()'); return dispatcher.call(config).then(() => { expect(github.issues.edit).toNotHaveBeenCalled(); }); diff --git a/test/fixtures/behaviors b/test/fixtures/behaviors deleted file mode 100644 index 5c4e976..0000000 --- a/test/fixtures/behaviors +++ /dev/null @@ -1,29 +0,0 @@ -# Integration test - -on issues # ignore this -then close; - -on issues then assign(hubot, probot); - -on issues.assign -if labeled(foo) and labeled(bar) or labeled(baz) -then close and comment("nope!"); - -on issues.opened -if @issue.user.login matches "bot$" -then comment("No bots allowed!") and close; - -on issues.opened if @sender.login is "bkeepers" then close; -on issues.opened if @sender.login is not "bkeepers" then close; -on issues.opened if @issue.body contains "- [ ]" then label(wip); - -on issues.opened -if not labeled(bug) -then close; - -on issues.opened -if @issue.body does not match "### Prerequisites.*### Description.*### Steps to Reproduce.*### Versions" - or @issue.body.body contains "- [ ]" -then - label("insufficient-info") - and close; diff --git a/test/fixtures/content/probot.json b/test/fixtures/content/probot.json index 79dcd3d..4aca345 100644 --- a/test/fixtures/content/probot.json +++ b/test/fixtures/content/probot.json @@ -1,6 +1,6 @@ { - "name": ".probot", - "path": ".probot", + "name": ".probot.js", + "path": ".probot.js", "sha": "c0736435c3f35dc5702d5024ed00d399b99623af", "size": 127, "url": "https://api.github.com/repos/bkeepers-inc/test/contents/.probot?ref=master", @@ -8,7 +8,7 @@ "git_url": "https://api.github.com/repos/bkeepers-inc/test/git/blobs/c0736435c3f35dc5702d5024ed00d399b99623af", "download_url": "https://raw.githubusercontent.com/bkeepers-inc/test/master/.probot?token=AVvMVIL7kvcrgJYZEipG9eAgUPcGHBnxks5YBayLwA%3D%3D", "type": "file", - "content": "b24gaXNzdWVzLm9wZW5lZCB0aGVuIGNvbW1lbnQoIkhlbGxvIHdvcmxkISIp\nIGFuZCBhc3NpZ24oYmtlZXBlcnMpIGFuZCByZWFjdChoZWFydCk7Cm9uIGlz\nc3Vlcy5jbG9zZWQgdGhlbiB1bmFzc2lnbihia2VlcGVycyk7Cg==\n", + "content": "d29ya2Zsb3dzLnB1c2goCiAgb24oImlzc3Vlcy5vcGVuZWQiKQogICAgLmNv\nbW1lbnQoIkhlbGxvIFdvcmxkISIpCiAgICAuYXNzaWduKCJia2VlcGVycyIp\nCiAgICAucmVhY3QoImhlYXJ0IikKKQoKd29ya2Zsb3dzLnB1c2goCiAgb24o\nImlzc3Vlcy5jbG9zZWQiKQogICAgLnVuYXNzaWduKCJia2VlcGVycyIpCikK\n", "encoding": "base64", "_links": { "self": "https://api.github.com/repos/bkeepers-inc/test/contents/.probot?ref=master", diff --git a/test/parser.js b/test/parser.js deleted file mode 100644 index 14329b0..0000000 --- a/test/parser.js +++ /dev/null @@ -1,261 +0,0 @@ -const fs = require('fs'); -const expect = require('expect'); -const parser = require('../lib/parser'); - -describe('parser', () => { - it('successfully parses a bunch of examples', () => { - parser.parse(fs.readFileSync('./test/fixtures/behaviors').toString()); - }); - - it('reports syntax errors', () => { - expect(() => parser.parse('lolwut?').toThrow(/parse error/)); - }); - - it('parses multiple statements', () => { - expect(parser.parse(` - on issues.opened then close; - on pull_requests.open then assign(bkeepers); - `)).toEqual([ - { - type: 'behavior', - events: [{type: 'event', action: 'opened', name: 'issues'}], - actions: [{type: 'action', name: 'close'}] - }, - { - type: 'behavior', - events: [{type: 'event', action: 'open', name: 'pull_requests'}], - actions: [{type: 'action', name: 'assign', args: ['bkeepers']}] - } - ]); - }); - - it('parses a blank doc', () => { - expect(parser.parse('\n\n\n')).toEqual([]); - }); - - it('fails on junk', () => { - expect(() => parser.parse('onnope then nope;')).toThrow(); - expect(() => parser.parse('on nope thennope;')).toThrow(); - }); - - describe('on', () => { - it('parses an event', () => { - expect(parser.parse('on issues then close;')).toEqual([{ - type: 'behavior', - events: [{type: 'event', name: 'issues'}], - actions: [{type: 'action', name: 'close'}] - }]); - }); - - it('parses an event and action', () => { - expect(parser.parse('on issues.opened then close;')).toEqual([{ - type: 'behavior', - events: [{type: 'event', name: 'issues', action: 'opened'}], - actions: [{type: 'action', name: 'close'}] - }]); - }); - - it('parses multiple events', () => { - expect(parser.parse( - 'on issues.opened or pull_request.opened then close;' - )).toEqual([{ - type: 'behavior', - events: [ - {type: 'event', name: 'issues', action: 'opened'}, - {type: 'event', name: 'pull_request', action: 'opened'} - ], - actions: [{type: 'action', name: 'close'}] - }]); - }); - }); - - describe('if', () => { - function parse(string) { - return parser.parse(string, {startRule: 'if'}); - } - - it('parses simple conditionals', () => { - expect( - parser.parse('on issues if labeled(enhancement) then close;') - ).toEqual([{ - type: 'behavior', - events: [{type: 'event', name: 'issues'}], - conditions: {type: 'condition', name: 'labeled', args: ['enhancement']}, - actions: [{type: 'action', name: 'close'}] - }]); - }); - - it('parses logical or conditions', () => { - expect(parse('if labeled(enhancement) or labeled(design)')).toEqual({ - type: 'BinaryExpression', - operator: 'or', - left: {type: 'condition', name: 'labeled', args: ['enhancement']}, - right: {type: 'condition', name: 'labeled', args: ['design']} - }); - }); - - it('parses multiple logical conditions', () => { - expect( - parse('if labeled(enhancement) or labeled(design) or labeled(bug)') - ).toEqual({ - type: 'BinaryExpression', - operator: 'or', - left: { - type: 'BinaryExpression', - left: {type: 'condition', name: 'labeled', args: ['enhancement']}, - operator: 'or', - right: {type: 'condition', name: 'labeled', args: ['design']} - }, - right: {type: 'condition', name: 'labeled', args: ['bug']} - }); - }); - - it('parses logical and conditions', () => { - expect(parse('if labeled(enhancement) and labeled(design)')).toEqual({ - type: 'BinaryExpression', - operator: 'and', - left: {type: 'condition', name: 'labeled', args: ['enhancement']}, - right: {type: 'condition', name: 'labeled', args: ['design']} - }); - }); - - it('parses attributes, matches, and regexps', () => { - expect(parse('if @issue.user.login matches "bot$"')).toEqual({ - type: 'BinaryExpression', - operator: 'matches', - left: {type: 'attribute', name: ['issue', 'user', 'login']}, - right: 'bot$' - }); - }); - - describe('not', () => { - it('negates conditions', () => { - expect(parse('if not labeled(bug)')).toEqual({ - type: 'UnaryExpression', - operator: 'not', - argument: {type: 'condition', name: 'labeled', args: ['bug']} - }); - }); - }); - - describe('precedence', () => { - it('orders "and" over "or"', () => { - expect(parse('if true and false or true')).toEqual({ - type: 'BinaryExpression', - left: { - type: 'BinaryExpression', - left: true, - operator: 'and', - right: false - }, - operator: 'or', - right: true - }); - - expect(parse('if false or true and true')).toEqual({ - type: 'BinaryExpression', - left: false, - operator: 'or', - right: { - type: 'BinaryExpression', - left: true, - operator: 'and', - right: true - } - }); - - expect(parse('if true and true or false and false')).toEqual({ - type: 'BinaryExpression', - left: { - type: 'BinaryExpression', - left: true, - operator: 'and', - right: true - }, - operator: 'or', - right: { - type: 'BinaryExpression', - left: false, - operator: 'and', - right: false - } - }); - }); - }); - }); - - describe('then', () => { - it('parses multiple words', () => { - expect(parser.parse( - 'on issues then close and lock;' - )).toEqual([{ - type: 'behavior', - events: [{type: 'event', name: 'issues'}], - actions: [ - {type: 'action', name: 'close'}, - {type: 'action', name: 'lock'} - ] - }]); - }); - - it('parses string arguments', () => { - expect(parser.parse( - 'on issues then comment("Hello World!");' - )).toEqual([{ - type: 'behavior', - events: [{type: 'event', name: 'issues'}], - actions: [ - {type: 'action', name: 'comment', args: ['Hello World!']} - ] - }]); - }); - - it('parses word arguments', () => { - expect(parser.parse( - 'on issues then assign(bkeepers);' - )).toEqual([{ - type: 'behavior', - events: [{type: 'event', name: 'issues'}], - actions: [ - {type: 'action', name: 'assign', args: ['bkeepers']} - ] - }]); - }); - - it('parses multiple word arguments', () => { - expect(parser.parse( - 'on issues then assign(bkeepers, hubot);' - )).toEqual([{ - type: 'behavior', - events: [{type: 'event', name: 'issues'}], - actions: [ - {type: 'action', name: 'assign', args: ['bkeepers', 'hubot']} - ] - }]); - }); - }); - - describe('comments', () => { - it('ignores lines that start with comments', () => { - expect(parser.parse(` - # This could literally be anything. - on issues then close; - `)).toEqual( - [{type: 'behavior', - events: [{type: 'event', name: 'issues'}], - actions: [{type: 'action', name: 'close'}]}] - ); - }); - - it('ignores trailing comments on lines', () => { - expect(parser.parse(` - on issues # Ignore this - then close; - `)).toEqual([{ - type: 'behavior', - events: [{type: 'event', name: 'issues'}], - actions: [{type: 'action', name: 'close'}] - }]); - }); - }); -}); diff --git a/test/plugins/issues.js b/test/plugins/issues.js new file mode 100644 index 0000000..0637d96 --- /dev/null +++ b/test/plugins/issues.js @@ -0,0 +1,227 @@ +const expect = require('expect'); +const issues = require('../../lib/plugins/issues'); +const Workflow = require('../../lib/workflow'); +const Context = require('../../lib/context'); +const payload = require('../fixtures/webhook/comment.created.json'); + +const createSpy = expect.createSpy; + +const github = { + reactions: { + createForIssue: createSpy() + }, + issues: { + lock: createSpy(), + unlock: createSpy(), + edit: createSpy(), + addLabels: createSpy(), + createComment: createSpy(), + addAssigneesToIssue: createSpy(), + removeAssigneesFromIssue: createSpy(), + removeLabel: createSpy() + } +}; +const context = new Context(github, {}, {payload}); + +describe('issues plugin', () => { + before(() => { + this.w = new Workflow(); + this.evaluator = new issues.Evaluator(); + }); + + describe('locking', () => { + it('locks', () => { + this.w.lock(); + + Promise.all(this.evaluator.evaluate(this.w, context)); + expect(github.issues.lock).toHaveBeenCalledWith({ + owner: 'bkeepers-inc', + repo: 'test', + number: 6 + }); + }); + + it('unlocks', () => { + this.w.unlock(); + + Promise.all(this.evaluator.evaluate(this.w, context)); + expect(github.issues.unlock).toHaveBeenCalledWith({ + owner: 'bkeepers-inc', + repo: 'test', + number: 6 + }); + }); + }); + + describe('state', () => { + it('opens an issue', () => { + this.w.open(); + + Promise.all(this.evaluator.evaluate(this.w, context)); + expect(github.issues.edit).toHaveBeenCalledWith({ + owner: 'bkeepers-inc', + repo: 'test', + number: 6, + state: 'open' + }); + }); + it('closes an issue', () => { + this.w.close(); + + Promise.all(this.evaluator.evaluate(this.w, context)); + expect(github.issues.edit).toHaveBeenCalledWith({ + owner: 'bkeepers-inc', + repo: 'test', + number: 6, + state: 'closed' + }); + }); + }); + + describe('labels', () => { + it('adds a label', () => { + this.w.label('hello'); + + Promise.all(this.evaluator.evaluate(this.w, context)); + expect(github.issues.addLabels).toHaveBeenCalledWith({ + owner: 'bkeepers-inc', + repo: 'test', + number: 6, + body: ['hello'] + }); + }); + + it('adds multiple labels', () => { + this.w.label('hello', 'world'); + + Promise.all(this.evaluator.evaluate(this.w, context)); + expect(github.issues.addLabels).toHaveBeenCalledWith({ + owner: 'bkeepers-inc', + repo: 'test', + number: 6, + body: ['hello', 'world'] + }); + }); + + it('removes a single label', () => { + this.w.unlabel('hello'); + + Promise.all(this.evaluator.evaluate(this.w, context)); + expect(github.issues.removeLabel).toHaveBeenCalledWith({ + owner: 'bkeepers-inc', + repo: 'test', + number: 6, + name: 'hello' + }); + }); + + it('removes a multiple labels', () => { + this.w.unlabel('hello', 'goodbye'); + + Promise.all(this.evaluator.evaluate(this.w, context)); + expect(github.issues.removeLabel).toHaveBeenCalledWith({ + owner: 'bkeepers-inc', + repo: 'test', + number: 6, + name: 'hello' + }); + + expect(github.issues.removeLabel).toHaveBeenCalledWith({ + owner: 'bkeepers-inc', + repo: 'test', + number: 6, + name: 'goodbye' + }); + }); + }); + + describe('comments', () => { + it('creates a comment', () => { + this.w.comment('Hello world!'); + + Promise.all(this.evaluator.evaluate(this.w, context)); + expect(github.issues.createComment).toHaveBeenCalledWith({ + owner: 'bkeepers-inc', + repo: 'test', + number: 6, + body: 'Hello world!' + }); + }); + + it('evaluates templates with handlebars', () => { + this.w.comment('Hello @{{ sender.login }}!'); + + Promise.all(this.evaluator.evaluate(this.w, context)); + expect(github.issues.createComment).toHaveBeenCalledWith({ + owner: 'bkeepers-inc', + repo: 'test', + number: 6, + body: 'Hello @bkeepers!' + }); + }); + }); + + describe('assignment', () => { + it('assigns a user', () => { + this.w.assign('bkeepers'); + + Promise.all(this.evaluator.evaluate(this.w, context)); + expect(github.issues.addAssigneesToIssue).toHaveBeenCalledWith({ + owner: 'bkeepers-inc', + repo: 'test', + number: 6, + assignees: ['bkeepers'] + }); + }); + + it('assigns multiple users', () => { + this.w.assign('hello', 'world'); + + Promise.all(this.evaluator.evaluate(this.w, context)); + expect(github.issues.addAssigneesToIssue).toHaveBeenCalledWith({ + owner: 'bkeepers-inc', + repo: 'test', + number: 6, + assignees: ['hello', 'world'] + }); + }); + + it('unassigns a user', () => { + this.w.unassign('bkeepers'); + + Promise.all(this.evaluator.evaluate(this.w, context)); + expect(github.issues.removeAssigneesFromIssue).toHaveBeenCalledWith({ + owner: 'bkeepers-inc', + repo: 'test', + number: 6, + body: {assignees: ['bkeepers']} + }); + }); + + it('unassigns multiple users', () => { + this.w.unassign('hello', 'world'); + + Promise.all(this.evaluator.evaluate(this.w, context)); + expect(github.issues.removeAssigneesFromIssue).toHaveBeenCalledWith({ + owner: 'bkeepers-inc', + repo: 'test', + number: 6, + body: {assignees: ['hello', 'world']} + }); + }); + }); + + describe('reactions', () => { + it('react', () => { + this.w.react('heart'); + + Promise.all(this.evaluator.evaluate(this.w, context)); + expect(github.reactions.createForIssue).toHaveBeenCalledWith({ + owner: 'bkeepers-inc', + repo: 'test', + number: 6, + content: 'heart' + }); + }); + }); +});