Merge pull request #58 from bkeepers/pure-js-implemented

Use JS for .probots language
This commit is contained in:
Brandon Keepers
2016-11-18 13:48:09 -06:00
committed by GitHub
44 changed files with 613 additions and 1130 deletions

View File

@@ -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:
```

View File

@@ -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).

View File

@@ -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
```
---

View File

@@ -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());
```

View File

@@ -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')
};

View File

@@ -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})
);
};

View File

@@ -1,5 +0,0 @@
const updateIssue = require('./update-issue');
module.exports = function (context) {
return updateIssue(context, {state: 'closed'});
};

View File

@@ -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)})
);
};

View File

@@ -1,5 +0,0 @@
module.exports = function (context, ...labels) {
return context.github.issues.addLabels(
context.payload.toIssue({body: labels})
);
};

View File

@@ -1,3 +0,0 @@
module.exports = function (context) {
return context.github.issues.lock(context.payload.toIssue());
};

View File

@@ -1,5 +0,0 @@
const updateIssue = require('./update-issue');
module.exports = function (context) {
return updateIssue(context, {state: 'open'});
};

View File

@@ -1,6 +0,0 @@
module.exports = function (context, react) {
return context.github.reactions.createForIssue(
context.payload.toIssue({content: react})
);
};

View File

@@ -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}})
);
};

View File

@@ -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})
);
}));
};

View File

@@ -1,3 +0,0 @@
module.exports = function (context) {
return context.github.issues.unlock(context.payload.toIssue());
};

View File

@@ -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));
};

View File

@@ -1,5 +0,0 @@
module.exports = (context, name) => {
return name.reduce((object, attr) => {
return object ? object[attr] : null;
}, context.payload);
};

View File

@@ -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);
}));
}
};

View File

@@ -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);
};

View File

@@ -1,3 +0,0 @@
module.exports = {
labeled: require('./conditions/labeled')
};

View File

@@ -1,3 +0,0 @@
module.exports = (context, label) => {
return context.payload.label && context.payload.label.name === label;
};

View File

@@ -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);
});
}
};

View File

@@ -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);
}, []));
}
}

4
lib/evaluator.js Normal file
View File

@@ -0,0 +1,4 @@
class Evaluator {
}
module.exports = Evaluator;

View File

@@ -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;
}
}
};

View File

@@ -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]

160
lib/plugins/issues.js Normal file
View File

@@ -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
};

19
lib/sandbox.js Normal file
View File

@@ -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;

View File

@@ -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);
};
}
};

41
lib/workflow.js Normal file
View File

@@ -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;

View File

@@ -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",

View File

@@ -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']
});
});
});

View File

@@ -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!'
});
});
});

View File

@@ -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']
});
});
});

View File

@@ -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']}
});
});
});

View File

@@ -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'
});
});
});

View File

@@ -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);
});
});

View File

@@ -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();
});
});
});

View File

@@ -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);
});
});

View File

@@ -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();
});

View File

@@ -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;

View File

@@ -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",

View File

@@ -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'}]
}]);
});
});
});

227
test/plugins/issues.js Normal file
View File

@@ -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'
});
});
});
});