Commit 4a1539aa authored by Farid Neshat's avatar Farid Neshat

Refactor + add show + improvements

parent e981b0e9
......@@ -2,7 +2,7 @@ const fs = require('fs');
const path = require('path');
module.exports = function(robot, scripts) {
const scriptsPath = path.resolve(__dirname, 'src');
const scriptsPath = path.resolve(__dirname, 'src', 'scripts');
if (fs.existsSync(scriptsPath)) {
for (let script of fs.readdirSync(scriptsPath).sort()) {
if (scripts != null && !scripts.includes('*')) {
......
......@@ -110,6 +110,11 @@
"tweetnacl": "0.14.5"
}
},
"bluebird": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.5.0.tgz",
"integrity": "sha1-eRQg1/VR7qKJdFOop3ZT+WYG1nw="
},
"body-parser": {
"version": "1.13.3",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.13.3.tgz",
......@@ -871,9 +876,9 @@
}
},
"hubot-test-helper": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/hubot-test-helper/-/hubot-test-helper-1.6.0.tgz",
"integrity": "sha1-DnAvax4OvqhUZZ5ClXiyIoSrZAM=",
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/hubot-test-helper/-/hubot-test-helper-1.7.0.tgz",
"integrity": "sha1-vNRmOTmou40i84aQYhtVc91fB3w=",
"dev": true,
"requires": {
"hubot": "2.19.0"
......@@ -1748,19 +1753,19 @@
"ansi-regex": "2.1.1"
}
},
"sugar-core": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/sugar-core/-/sugar-core-2.0.4.tgz",
"integrity": "sha1-nbBzDmxH630oGEp5xKsYsreUbKA="
},
"sugar-date": {
"sugar": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/sugar-date/-/sugar-date-2.0.4.tgz",
"integrity": "sha1-aylydkh0RcJGSHpCOUgKPPq9VcI=",
"resolved": "https://registry.npmjs.org/sugar/-/sugar-2.0.4.tgz",
"integrity": "sha1-eab6TavFh/uFWlLucXbdBCv3V7A=",
"requires": {
"sugar-core": "2.0.4"
}
},
"sugar-core": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/sugar-core/-/sugar-core-2.0.4.tgz",
"integrity": "sha1-nbBzDmxH630oGEp5xKsYsreUbKA="
},
"supports-color": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz",
......
......@@ -18,12 +18,13 @@
"url": "https://github.com/hubot-scripts/hubot-bamboohr-timeoff/issues"
},
"dependencies": {
"bluebird": "^3.5.0",
"coffee-script": "^1.12.7",
"hubot-conversation": "^1.1.1",
"moment": "^2.18.1",
"moment-business-time": "^0.7.0",
"node-bamboohr": "^1.0.1",
"sugar-date": "^2.0.4",
"sugar": "^2.0.4",
"timestring": "^5.0.0"
},
"peerDependencies": {
......@@ -32,7 +33,7 @@
"devDependencies": {
"chai": "^4.1.0",
"hubot": "2.x",
"hubot-test-helper": "^1.3.0",
"hubot-test-helper": "^1.7.0",
"mocha": "^3.4.2",
"sinon": "^2.4.1",
"sinon-chai": "^2.7.0"
......
'use strict';
const bluebird = require('bluebird');
const Bamboohr = require('node-bamboohr');
module.exports = new Bamboohr({
apikey: process.env.HUBOT_BAMBOOHR_APIKEY,
subdomain: process.env.HUBOT_BAMBOOHR_DOMAIN,
});
bluebird.promisifyAll([Bamboohr, require('node-bamboohr/lib/employee')]);
'use strict';
module.exports = (regex, args) => {
// Throw away punctuations. This doesn't support non-english languages
args = args.replace(/([^a-z0-9])/ig, ' ').replace(/\s+/g, ' ');
let previousMatch = regex.exec(args);
if (!previousMatch) {
throw new Error("Couldn't parse any arguments. Specify the details with the following keywords: " +
regex.source.split('|').join(', '));
}
const result = {};
while (true) {
let nextMatch = regex.exec(args);
const currentArg = args.substring(previousMatch[0].length + previousMatch.index, nextMatch && nextMatch.index || args.length);
result[previousMatch[0]] = currentArg.trim();
if (!nextMatch) {
return result;
}
previousMatch = nextMatch;
}
};
......@@ -13,39 +13,23 @@
// Author:
// Farid Nouri Neshat <FaridN_SOAD@Yahoo.com>
'use strict';
const timestring = require('timestring');
const { Date } = require('sugar-date');
const Conversation = require('hubot-conversation');
const bamboohr = new (require('node-bamboohr'))({
apikey: process.env.HUBOT_BAMBOOHR_APIKEY,
subdomain: process.env.HUBOT_BAMBOOHR_DOMAIN,
});
const moment = require('moment');
require('moment-business-time');
const HOUR = 3600000;
const DAY = 24 * HOUR;
const WORKING_HOURS = 8;
const WORKING_DAY_START_HOUR = 9;
const WORKING_DAY_END_HOUR = 18;
const HUMAN_DATE_FORMAT = 'ddd MMM D';
const { Date } = require('sugar');
const Conversation = require('hubot-conversation');
const bamboohr = require('../bamboohr');
const types = require('../types');
const utils = require('../utils');
const parseArgs = require('../parse-args');
const DIALOG_TIMEOUT = 600000;
const WORKING_HOURS = 8;
let timeOffTypes;
bamboohr.timeOffTypes((err, result) => {
if (err) {
throw err;
}
timeOffTypes = result.timeOffTypes;
timeOffTypes.forEach(type => {
type.unit = type.units;
type.shortName = type.name.toLowerCase().replace('leave', '').trim();
});
});
module.exports = function(robot) {
module.exports = function (robot) {
const switchBoard = new Conversation(robot, ['user']);
robot.respond(/request\s+((\w+?)[\s-]+)?(time[- ]?off|leave|vacation)\s+(.+?)\s*$/i,
......@@ -55,7 +39,7 @@ module.exports = function(robot) {
let reason;
if (args.includes('because')) {
[args, reason] = args.split(/\s*because\s*/);
[args, reason] = args.split(/\s*because\s*/i);
}
......@@ -63,14 +47,20 @@ module.exports = function(robot) {
type = 'vacation';
}
type = getType(type, reason);
if (!type) {
if (/\bsick\b/i.test(reason)) {
type = 'sick';
} else if (/conf\b/i.test(reason)) {
type = 'conference';
}
}
type = types.getType(type, reason) || types.timeOffTypes[0];
let { start, amount, end } = normalizeArgs(parseArgs(args), type.unit);
let { start, amount, end } = normalizeArgs(parseRequestArgs(args), type.unit);
room = res.envelope.user.name;
robot.messageRoom(room, `So you want ${type.shortName} leave from ${
moment(start).format(HUMAN_DATE_FORMAT + ' YYYY')} to ${moment(end).format(HUMAN_DATE_FORMAT)} for ${
amount} working ${type.unit}?`);
const timeOffStr = utils.formatTimeOff({ type: type.name, start, amount, end, unit: type.unit});
res.reply(`So you want ${timeOffStr}?`);
const dialog = switchBoard.startDialog(res, DIALOG_TIMEOUT,
'You didn\'t answer. I don\'t have all day to wait for you. Next time answer in 60 seconds!');
......@@ -91,15 +81,9 @@ module.exports = function(robot) {
}];
}
bamboohr.employee(10).requestTimeOff(request, function (err, result) {
if (err) {
console.log(err,result)
msg.reply('I\'m sorry, something went wrong: ' + err.message);
return;
}
msg.reply('Everything worked out');
});
bamboohr.employee(10)
.requestTimeOffAsync(request)
.done(() => msg.reply('Everything worked out'), util.errorHandler(msg));
});
dialog.addChoice(/no/, (msg) => {
......@@ -122,57 +106,11 @@ I will try to guess the end date and the period, but in case if I was wrong, use
request sick leave from tomorrow till saturday for 20 hours because I got the flu.
The possible types are: ${timeOffTypes.map(type => type.shortName).join(', ')}
The possible types are: ${types.timeOffTypes.map(type => type.shortName).join(', ')}
`)
});
};
const parseArgs = module.exports.parseArgs = args => {
// Throw away punctuations. This doesn't support non-english languages
args = args.replace(/([^A-Za-z0-9])/g, ' ').replace(/\s+/g, ' ');
const regex = /\b(from|on|for|untill|till|to)\b/g;
let previousMatch = regex.exec(args);
if (!previousMatch) {
throw new Error("Couldn't parse any arguments. Specify the details with the following keywords: " +
regex.source().split('|').join(', '));
}
const result = {};
let currentDate;
while (true) {
let nextMatch = regex.exec(args);
const currentArg = args.substring(previousMatch[0].length + previousMatch.index, nextMatch && nextMatch.index || args.length);
if (previousMatch[0] === 'for') {
result.amount = timestring(currentArg, 'hours', {
hoursPerDay: 8,
daysPerWeek: 5,
});
} else {
// If keyword is not 'for', must be a date argument
const isStart = /from|on/.test(previousMatch[0]);
if (currentDate) {
const dateOption = { [isStart ? 'past': 'future'] : true };
currentDate = Date.get(currentDate, currentArg, dateOption);
} else {
currentDate = Date.create(currentArg, { future: true});
}
result[isStart ? 'start' : 'end'] = currentDate;
}
if (!nextMatch) {
if (result.end) {
result.end = Date.endOfDay(result.end);
}
return result;
}
previousMatch = nextMatch;
}
};
const normalizeArgs = module.exports.normalizeArgs = ({start, amount, end}, unit = 'hours') => {
if (!end && !amount) {
end = Date.endOfDay(Date.clone(start));
......@@ -203,13 +141,35 @@ const normalizeArgs = module.exports.normalizeArgs = ({start, amount, end}, unit
};
};
function getType(name, reason) {
if (!name) {
if (/\bsick\b/.test(reason)) {
name = 'sick';
} else if (/conf\b/.test(reason)) {
name = 'conference';
const parseRequestArgs = module.exports.parseArgs = args => {
args = parseArgs(/\b(from|on|for|untill|till|to)\b/ig, args);
let currentDate;
const result = {};
for (let key in args) {
if (key === 'for') {
result.amount = timestring(args[key], 'hours', {
hoursPerDay: 8,
daysPerWeek: 5,
});
} else {
// If keyword is not 'for', must be a date argument
const isStart = /from|on/.test(key);
if (currentDate) {
const dateOption = { [isStart ? 'past': 'future'] : true };
currentDate = Date.get(currentDate, args[key], dateOption);
} else {
currentDate = Date.create(args[key], { future: true});
}
result[isStart ? 'start' : 'end'] = currentDate;
}
}
return timeOffTypes.find(type => type.shortName === name) || timeOffTypes[0];
}
if (result.end) {
result.end = Date.endOfDay(result.end);
}
return result;
};
// Description
// A hubot scripts that adds the ability to make timeoff requests and manage them for BambooHR
//
// Configuration:
// HUBOT_BAMBOOHR_APIKEY, HUBOT_BAMBOOHR_DOMAIN
//
// Commands
// hubot timeoff help
// hubot request [<type>] timeoff|leave|vacation [from|on <date>] [for <period>] [[un]till|to <date>] [because <reason>]
//
// Notes:
//
// Author:
// Farid Nouri Neshat <FaridN_SOAD@Yahoo.com>
'use strict';
const Sugar = require('sugar');
const { Object, Date } = Sugar;
const bamboohr = require('../bamboohr');
const types = require('../types');
const utils = require('../utils');
const parseArgs = require('../parse-args');
const assert = require('assert');
module.exports = function(robot) {
robot.respond(/show *(.+?) *time-?offs?\s*(.*?)\s*$/i, res => {
let [, keywords, args] = res.match;
let { all, type, status, lastOnly, mineOnly, employeeSearch } = parseShowKeywords(keywords);
bamboohr.employeesAsync().then(employees => {
let query = ['Searching for'];
const options = {
action: 'approve',
};
const profile = res.message.user.profile;
const email = profile && profile.email;
if (mineOnly) {
if (!email) {
res.reply("You don't have an email associated with your user.");
return;
}
const employee = employees.find(employee => employee.fields.workEmail === email);
if (!employee) {
res.reply("I can't find your email in the BambooHR system.");
return;
}
query.push(email + '\'s');
options.employeeId = employee.id;
}
if (status) {
query.push(status);
if (status === 'pending') {
status = 'requested';
}
options.status = status;
}
if (type) {
query.push(type.name.toLowerCase());
options.type = type.id;
}
if (employeeSearch) {
const employee = employees.find(employee => {
return Object.find(employee.fields, new RegExp(Sugar.RegExp.escape(employeeSearch), 'i'));
});
if (!employee) {
res.reply("I can't find this person in the BambooHR system.");
return;
}
query.push(employee.fields.workEmail + '\'s');
options.employeeId = employee.id;
}
query.push('time-offs');
if (args) {
args = parseArgs(/\b(on|from|to|untill|till)\b/gi, args);
if (args.on) {
options.end = options.start = Date.create(args.on);
} else {
if (args.from) {
options.start = Date.create(args.from);
}
const end = args.to || args.untill || args.till;
if (end) {
if (options.start) {
options.end = Date.get(options.start, options.end, { future: true });
} else {
options.end = Date.create(end);
}
} else if (args.for) {
result.end = new Date(options.end + timestring(args[key], 'ms'));
}
}
if (args.on) {
query.push('on', utils.formatDate(options.start));
} else {
if (options.start) {
query.push('from', utils.formatDate(options.from));
}
if (options.end) {
query.push('to', utils.formatDate(options.from));
}
}
}
res.reply(query.join(' '));
return bamboohr.timeOffRequestsAsync(options).then(requests => {
if (lastOnly) {
requests = requests.slice(-1);
}
requests.forEach(request => {
let sentence = [];
if (!mineOnly) {
const employee = employees.find(employee => employee.id === request.employeeId);
let name;
if (employee) {
const { fields } = employee;
name = fields.preferredName ||
fields.firstName ||
fields.lastName ||
fields.displayName;
} else {
name = request.employeeName;
}
sentence.push(name, 'has');
}
sentence.push(/[aeiou]/.test(request.status[0]) ? 'an' : 'a');
let { status } = request;
if (status === 'requested') {
status = 'pending';
}
sentence.push(status);
sentence.push(utils.formatTimeOff(request));
res.reply(sentence.join(' '));
});
});
});
});
};
const statuses = ["approved", "denied", "superceded", "pending", "requested", "canceled"];
const parseShowKeywords = module.exports.parseShowKeywords = keywords => {
keywords = keywords.split(/\s+/);
let result = {
mineOnly: false,
lastOnly: false,
status: 'pending',
all: false
};
keywords.forEach(keyword => {
if (keyword === 'my') {
result.mineOnly = true;
} else if (keyword === 'last') {
result.lastOnly = true;
} else if (keyword === 'all') {
result.all = true;
} else if (statuses.includes(keyword)) {
result.status = keyword;
} else if (keyword.endsWith("\'s")) {
result.employeeSearch = keyword.slice(0, -2);
} else {
const t = types.getType(keyword);
if (t) {
result.type = t;
}
}
});
if (result.all) {
result.status = undefined;
}
return result;
};
// const room = res.envelope.user.name;
"use strict";
const bamboohr = require('./bamboohr');
let timeOffTypes;
exports.ready = bamboohr.timeOffTypesAsync().done(result => {
timeOffTypes = result.timeOffTypes;
exports.timeOffTypes = timeOffTypes;
timeOffTypes.forEach(type => {
type.unit = type.units;
type.shortName = type.name.toLowerCase().replace('leave', '').trim();
});
});
exports.getType = (name, reason) => {
return timeOffTypes.find(type => type.shortName === name);
};
'use strict';
const moment = require('moment');
const HUMAN_DATE_FORMAT = 'ddd MMM D';
exports.formatTimeOff = ({type, start, end, amount, unit, amountUnit}) => {
return `${type.toLowerCase()} from ${
exports.formatDate(start, true)} to ${exports.formatDate(end)} for ${
amount} working ${unit || amountUnit}`;
};
exports.formatDate = (date, withYear, format = HUMAN_DATE_FORMAT) =>
moment(date).format(format + (withYear ? ' YYYY' : ''));
exports.errorHandler = msg => err => msg.reply('I\'m sorry, something went wrong: ' + err.message || err);
'use strict';
const { expect } = require('chai');
const parseArgs = require('../src/parse-args');
describe('Parse args', function () {
it('one worded arguments', function () {
expect(parseArgs(/\b(from|on|to)\b/g, 'from today to tomorrow')).to.be.eql({
from: 'today',
to: 'tomorrow',
});
});
it('multiple worded arguments', function () {
expect(parseArgs(/\b(from|on|to)\b/g, 'from today 3pm to tomorrow 4pm')).to.be.eql({
from: 'today 3pm',
to: 'tomorrow 4pm',
});
});
});
// Because hubot-test-helper needs it.
'use strict';
require('coffee-script/register');
const Helper = require('hubot-test-helper');
const sinon = require('sinon');
const chai = require('chai');
chai.use(require('sinon-chai'));
const { expect } = chai;
const Bamboohr = require('node-bamboohr');
const BambooEmployee = require('node-bamboohr/lib/employee');
// Must be required before the script to stub everything properly
require('./stubs');
const stubs = [
sinon.stub(Bamboohr.prototype, 'timeOffTypes').yields(null, {
timeOffTypes: [{
id: '77',
name: 'Vacation',
units: 'hours',
}],
defaultHours: [
{ name: 'Saturday', amount: '0' },
{ name: 'Sunday', amount: '0' },
{ name: 'default', amount: '8' }
]
}),
sinon.stub(BambooEmployee.prototype, 'requestTimeOff').yields(),
];
const sctiptPath = '../src/bamboohr-timeoff.js';
const sctiptPath = '../src/scripts/request.js';
const script = require(sctiptPath);
const helper = new Helper(sctiptPath);
const roomName = '';
describe('bamboohr-timeoff', function() {
describe('Request', function() {
before(function () {
this.clock = sinon.useFakeTimers(Date.parse('1 July 2017'));
});
after(function () {
stubs.forEach(stub => stub.restore());
this.clock.restore();
});
beforeEach(function() {
this.room = helper.createRoom({
......@@ -45,33 +31,28 @@ describe('bamboohr-timeoff', function() {
afterEach(function() {
this.room.destroy();
});
it('from for', function() {
return this.room.user.say('bob', 'hubot request timeoff from 25 July 2017 for 2 days').then(() => {
expect(this.room.messages[1][1]).to.be.equal('So you want vacation leave from Tue Jul 25' +
' 2017 to Wed Jul 26 for 16 working hours?');
describe('requesting timeoff', function () {
it('from for', function() {
return this.room.user.say('bob', 'hubot request timeoff from 25 July 2017 for 2 days').then(() => {
expect(this.room.messages[1][1]).to.be.equal('@bob So you want vacation from Tue Jul 25' +
' 2017 to Wed Jul 26 for 16 working hours?');
});
});
});
it('from for because', function() {
return this.room.user.say('bob', 'hubot request timeoff from 25 July 2017 for 2 days because fuck').then(() => {
expect(this.room.messages[1][1]).to.be.equal('So you want vacation leave from Tue Jul 25' +
' 2017 to Wed Jul 26 for 16 working hours?');
it('from for because', function() {
return this.room.user.say('bob', 'hubot request timeoff from 25 July 2017 for 2 days because fuck').then(() => {
expect(this.room.messages[1][1]).to.be.equal('@bob So you want vacation from Tue Jul 25' +
' 2017 to Wed Jul 26 for 16 working hours?');
});
});
});
it('from for 8 hours because', function() {
return this.room.user.say('bob', 'hubot request timeoff from 25 July 2017 for 8 hours because fuck').then(() => {
expect(this.room.messages[1][1]).to.be.equal('So you want vacation leave from Tue Jul 25' +
' 2017 to Tue Jul 25 for 8 working hours?');
it('from for 8 hours because', function() {
return this.room.user.say('bob', 'hubot request timeoff from 25 July 2017 for 8 hours because fuck').then(() => {
expect(this.room.messages[1][1]).to.be.equal('@bob So you want vacation from Tue Jul 25' +
' 2017 to Tue Jul 25 for 8 working hours?');
});
});
});
describe('Parsing arguments', function () {
before(function () {
this.clock = sinon.useFakeTimers(Date.parse('1 July 2017'));
});
after(function () {
this.clock.restore();
});
it('for 1 day', function () {
expect(script.parseArgs('for 1 day')).to.be.eql({
amount: 8, // unit is in hours.
......
'use strict';
require('coffee-script/register');
const Helper = require('hubot-test-helper');
const sinon = require('sinon');
const chai = require('chai');
chai.use(require('sinon-chai'));
const { expect } = chai;
// Must be required before the script to stub everything properly
const stubs = require('./stubs');
const Bamboohr = require('node-bamboohr');
const sctiptPath = '../src/scripts/show.js';
const script = require(sctiptPath);
const helper = new Helper(sctiptPath);
describe('Show', function() {
before(function () {
this.clock = sinon.useFakeTimers(Date.parse('1 July 2017'));
});
after(function () {
this.clock.restore();
});
beforeEach(function() {
this.room = helper.createRoom({
name: 'bob',
});
});