Skip to content
f680e28 Sep 16, 2013
@Quintus @robertfwest @ruanwz
520 lines (473 sloc) 20.9 KB
# Description:
# Allows Hubot to interact with Harvest's (http://harvestapp.com) time-tracking
# service.
#
# Dependencies:
# None
# Configuration:
# HUBOT_HARVEST_SUBDOMAIN
#
# Commands:
#
# hubot remember my harvest account <email> with password <password> - Make hubot remember your Harvest credentials
# hubot forget my harvest account - Make hubot forget your Harvest credentials again
# hubot start harvest - Restart the last timer.
# hubot start harvest at <project>/<task>: <notes> - Start a Harvest timer at a given project-task combination
# hubot stop harvest [at project/task] - Stop the most recent Harvest timer or the one for the given project-task combination.
# hubot daily harvest [of <user>] [at yyyy-mm-dd] - Show a user's Harvest timers for today (or yours, if noone is specified) or a specific day
# hubot list harvest tasks [of <user>] - Show the Harvest project-task combinations available to a user (or you, if noone is specified)
# hubot is harvest down/up - Check if the Harvest API is reachable.
#
# Notes:
# All commands and command arguments are case-insenitive. If you work
# on a project "FooBar", hubot will unterstand "foobar" as well. This
# is also true for abbreviations, so if you don't have similary named
# projects, "foob" will do as expected.
#
# Some examples:
# > hubot remember my harvest account joe@example.org with password doe
# > hubot list harvest tasks
# > hubot start harvest at myproject/important-task: Some notes go here.
# > hubot start harvest at myp/imp: Some notes go here.
# > hubot daily harvest of nickofotheruser
#
# Full command descriptions:
#
# hubot remember my harvest account <email> with password <password>
# Saves your Harvest credentials to allow Hubot to track
# time for you.
#
# hubot forget my harvest account
# Deletes your account credentials from Hubt's memory.
#
# hubot start harvest
# Examines the list of timers for today and creates a new timer with
# the same properties as the most recent one.
#
# hubot start harvest at <project>/<task>: <notes>
# Starts a timer for a task at a project (both of which may
# be abbreviated, Hubot will ask you if your input is
# ambigious). An existing timer (if any) will be stopped.
#
# hubot stop harvest [at <project>/<task>]
# Stops the timer for a task, if any. If no project is given,
# stops the first active timer it can find. The project and
# task arguments may be abbreviated as with start.
#
# hubot daily harvest [of <user>] [on yyyy-mm-dd]
# Hubot responds with your/a specific user's entries
# for the given date; if no date is given, assumes today.
# If user is ommitted, you are assumed; if both the user and
# the date are ommited, your entries for today will be displayed.
#
# hubot list harvest tasks [of <user>]
# Gives you a list of all project/task combinations available
# to you or a specific user. You can use these for the start command.
#
# Note on HUBOT_HARVEST_SUBDOMAIN:
# This is the subdomain you access the Harvest service with, e.g.
# if you have the Harvest URL http://yourcompany.harvestapp.com
# you should set this to "yourcompany" (without the quotes).
#
# Author:
# Quintus @ Asquera
#
http = require("http")
unless process.env.HUBOT_HARVEST_SUBDOMAIN
console.log "Please set HUBOT_HARVEST_SUBDOMAIN in the environment to use the harvest plugin script."
# Checks if we have the information necessary for making requests
# for a user. If we don't, reply accordingly and return null. Otherwise,
# return the user object.
# If `test_user` is supplied, checks the credentials for the user
# with that name, otherwise the sender of `msg` is checked.
check_user = (robot, msg, test_user = null) ->
# Detect the user; if none is passed, assume the sender.
user = null
if test_user
user = robot.brain.userForName(test_user)
unless user
msg.reply "#{msg.match[2]}? Whoʼs that?"
return null
else
user = msg.message.user
# Check if we know the detected user's credentials.
unless user.harvest_account
if user == msg.message.user
msg.reply "You have to tell me your Harvest credentials first."
else
msg.reply "I didnʼt crack #{user.name}ʼs Harvest credentials yet, but Iʼm working on it… Sorry for the inconvenience."
return null
return user
# Issues an empty GET request to harvest to test whether the service is
# available at the moment. The callback gets passed an exception object
# describing the connection error; if everything is fine it gets passed
# null.
check_harvest_down = (callback) ->
opts =
headers:
"Content-Type": "application/json"
"Accept": "application/json"
method: "GET"
host: "#{process.env.HUBOT_HARVEST_SUBDOMAIN}.harvestapp.com"
port: 80
path: "/account/who_am_i"
req = http.request opts, (response) ->
callback null
req.on "error", (error) ->
callback error
req.setTimeout 5000, ->
req.destroy() # Cancel the request
callback "Connection timeout"
req.end()
### Definitions for hubot ###
module.exports = (robot) ->
# Periodically check the Harvest service for availability
cb = ->
check_harvest_down (error) ->
if (error)
robot.send "broadcast", "Harvest appears to be down; exact error is: #{error}"
setInterval(cb, 600000) # 10 Minutes in milliseconds
# Check if Harvest is available.
robot.respond /is harvest (down|up)/i, (msg) ->
check_harvest_down (error) ->
if error
msg.reply("Harvest is down; exact error: #{error}")
else
msg.reply("Harvest is up.")
# Provide facility for saving the account credentials.
robot.respond /remember my harvest account (.+) with password (.+)/i, (msg) ->
account = new HarvestAccount msg.match[1], msg.match[2]
harvest = new HarvestService(account)
# If the credentials are valid, remember them, otherwise
# tell the user they are wrong.
try
harvest.test msg, (valid) ->
if valid
msg.message.user.harvest_account = account
msg.reply "Thanks, Iʼll remember your credentials. Have fun with Harvest."
else
msg.reply "Uh-oh – I just tested your credentials, but they appear to be wrong. Please specify the correct ones."
catch error
msg.reply "Unable to test credentials: fatal error: #{error}"
# Allows a user to delete his credentials.
robot.respond /forget my harvest account/i, (msg) ->
msg.message.user.harvest_account = null
msg.reply "Okay, I erased your credentials from my memory."
# Retrieve your or a specific user's timesheet for today.
robot.respond /daily harvest( of (\w+))?( on (\d{4})-(\d{2})-(\d{2}))?/i, (msg) ->
unless user = check_user(robot, msg, msg.match[2])
return
harvest = new HarvestService(user.harvest_account)
if msg.match[3]
target_date = new Date(parseInt(msg.match[4]), parseInt(msg.match[5] - 1), parseInt(msg.match[6])) # Month starts at 0
try
if target_date
harvest.daily_at msg, target_date, (status, body) ->
if 200 <= status <= 299
if body.day_entries.length == 0
msg.reply "#{user.name} has no entries on #{target_date}."
else
msg.reply "#{user.name}'s entries on #{target_date}:"
for entry in body.day_entries
if entry.ended_at == ""
msg.reply "#{entry.project} (#{entry.client}) → #{entry.task} <#{entry.notes}> [running since #{entry.started_at} (#{entry.hours}h)]"
else
msg.reply "#{entry.project} (#{entry.client}) → #{entry.task} <#{entry.notes}> [#{entry.started_at}#{entry.ended_at} (#{entry.hours}h)]"
else
msg.reply "Failed to retrieve entry information: request failed with status #{status}."
else
harvest.daily msg, (status, body) ->
if 200 <= status <= 299
msg.reply "Your entries for today, #{user.name}:"
for entry in body.day_entries
if entry.ended_at == ""
msg.reply "#{entry.project} (#{entry.client}) → #{entry.task} <#{entry.notes}> [running since #{entry.started_at} (#{entry.hours}h)]"
else
msg.reply "#{entry.project} (#{entry.client}) → #{entry.task} <#{entry.notes}> [#{entry.started_at}#{entry.ended_at} (#{entry.hours}h)]"
else
msg.reply "Failed to retrieve entry information: request failed with status #{status}."
catch error
msg.reply("Failed to retrieve entry information: fatal error: #{error}")
# List all project/task combinations that are available to a user.
robot.respond /list harvest tasks( of (.+))?/i, (msg) ->
unless user = check_user(robot, msg, msg.match[2])
return
harvest = new HarvestService(user.harvest_account)
try
harvest.daily msg, (status, body) ->
if 200 <= status <= 299
msg.reply "The following project/task combinations are available for you, #{user.name}:"
for project in body.projects
msg.reply "• Project #{project.name}"
for task in project.tasks
msg.reply "#{task.name} (#{if task.billable then 'billable' else 'non-billable'})"
else
msg.reply "Failed to retrieve project/task list: request failed with status #{status}."
catch error
msg.reply "Failed to retrieve project/task list: fatal error: #{error}"
# Kick off a new timer, stopping the previously running one, if any.
robot.respond /start harvest at (.+)\/(.+): (.*)/i, (msg) ->
unless user = check_user(robot, msg)
return
harvest = new HarvestService(user.harvest_account)
project = msg.match[1]
task = msg.match[2]
notes = msg.match[3]
try
harvest.start msg, project, task, notes, (status, body) ->
if 200 <= status <= 299
if body.hours_for_previously_running_timer?
msg.reply "Previously running timer stopped at #{body.hours_for_previously_running_timer}h."
msg.reply "OK, I started tracking you on #{body.project}/#{body.task}."
else
msg.reply "Failed to start timer: request failed with status #{status}."
catch error
msg.reply "Failed to start timer: fatal error: #{error}"
robot.respond /start harvest$/i, (msg) ->
unless user = check_user(robot, msg)
return
harvest = new HarvestService(user.harvest_account)
try
harvest.restart msg, (status, body) ->
if 200 <= status <= 299
if body.hours_for_previously_running_timer?
msg.reply "Previously running timer stopped at #{body.hours_for_previously_running_timer}h."
msg.reply "OK, I started tracking you on #{body.project}/#{body.task}."
else
msg.reply "Failed to start timer: request failed with status #{status}."
catch error
msg.reply "Failed to start timer: fatal error: #{error}"
# Stops the timer running for a project/task combination,
# if any. If no combination is given, stops the first
# active timer available.
robot.respond /stop harvest( at (.+)\/(.+))?/i, (msg) ->
unless user = check_user(robot, msg)
return
harvest = new HarvestService(user.harvest_account)
if msg.match[1]
project = msg.match[2]
task = msg.match[3]
try
harvest.stop msg, project, task, (status, body) ->
if 200 <= status <= 299
msg.reply "Timer stopped (#{body.hours}h)."
else
msg.reply "Failed to stop timer: request failed with status #{status}."
msg.reply body
catch error
msg.reply("Failed to stop timer: fatal error: #{error}")
else
try
harvest.stop_first msg, (status, body) ->
if 200 <= status <= 299
msg.reply "Timer stopped (#{body.hours}h)."
else
msg.reply "Failed to stop timer: request failed with status #{status}."
catch error
msg.reply("Failed to stop timer: fatal error: #{error}")
# Class encapsulating a user's Harvest credentials; safe to store
# in Hubot's Redis brain (no methods, this is a data-only construct).
class HarvestAccount
# Create a new harvest account. Pass in the account's email and the
# password used to access harvest. These credentials are the same you
# use for logging into Harvest's web service.
constructor: (email, password) ->
@email = email
@password = password
# This class represents a user's connection to the Harvest API;
# it is bound to a specific account and cannot be stored permanently
# in Hubot's (Redis) brain.
#
# The API calls are asynchronous, i.e. the methods executing
# the request immediately return. To process the response,
# you have to attach a callback to the method call, which
# unless documtened otherwise will receive two arguments,
# the first being the response's status code, the second
# one is the response's body as a JavaScript object created
# via `JSON.parse`.
class HarvestService
# Creates a new connection to the Harvest API for the given
# account.
constructor: (account) ->
@base_url = "https://#{process.env.HUBOT_HARVEST_SUBDOMAIN}.harvestapp.com"
@account = account
# Tests whether the account credentials are valid.
# If so, the callback gets passed `true`, otherwise
# it gets passed `false`.
test: (msg, callback) ->
this.request(msg).path("account/who_am_i").get() (err, res, body) ->
if 200 <= res.statusCode <= 299
callback true
else
callback false
# Issues /daily to the Harvest API.
daily: (msg, callback) ->
this.request(msg).path("/daily").get() (err, res, body) ->
if 200 <= res.statusCode <= 299
callback res.statusCode, JSON.parse(body)
else
callback res.statusCode, null
# Issues /daily/<dayofyear>/<year> to the Harvest API.
daily_at: (msg, date, callback) ->
this.request(msg).path("/daily/#{this.day_of_year(date)}/#{date.getFullYear()}").get() (err, res, body) ->
if 200 <= res.statusCode <= 299
callback res.statusCode, JSON.parse(body)
else
callback res.statusCode, null
restart: (msg, callback) ->
this.daily msg, (status, body) =>
if 200 <= status <= 299
if body.day_entries.length == 0
msg.reply "No last entry to restart, sorry."
else
last_entry = body.day_entries.pop()
data =
notes: last_entry.notes
project_id: last_entry.project_id
task_id: last_entry.task_id
this.request(msg).path("/daily/add").post(JSON.stringify(data)) (err, res, body) ->
if 200 <= res.statusCode <= 299
callback res.statusCode, JSON.parse(body)
else
callback res.statusCode, null
else
callback status, null
# Issues /daily/add to the Harvest API to add a new timer
# starting from now.
start: (msg, target_project, target_task, notes, callback) ->
this.find_project_and_task msg, target_project, target_task, (project, task) =>
# OK, task and project found. Start the tracker.
data =
notes: notes
project_id: project.id
task_id: task.id
this.request(msg).path("/daily/add").post(JSON.stringify(data)) (err, res, body) ->
if 200 <= res.statusCode <= 299
callback res.statusCode, JSON.parse(body)
else
callback res.statusCode, null
# Issues /daily/timer/<id> to the Harvest API to stop
# the timer running at `entry.id`. If that timer isn't
# running, replys accordingly, otherwise calls the callback
# when the operation has finished.
stop_entry: (msg, entry, callback) ->
if entry.timer_started_at?
this.request(msg).path("/daily/timer/#{entry.id}").get() (err, res, body) ->
if 200 <= res.statusCode <= 299
callback res.statusCode, JSON.parse(body)
else
callback res.statusCode, null
else
msg.reply "This timer is not running."
# Issues /daily/timer/<id> to the Harvest API to stop
# the timer running at <id>, which is determined by
# looking up the current day_entry for the given
# project/task combination. If no entry is found (i.e.
# no timer has been started for this combination today),
# replies with an error message and doesn't executes the
# callback.
stop: (msg, target_project, target_task, callback) ->
this.find_day_entry msg, target_project, target_task, (entry) =>
this.stop_entry msg, entry, (status, body) -> callback status, body
# Issues /daily/timer/<id> to the Harvest API to stop
# the timer running at <id>, which is the first active
# timer it can find in today's timesheet, then calls the
# callback. If no active timer is found, replies accordingly
# and doesn't execute the callback.
stop_first: (msg, callback) ->
this.daily msg, (status, body) =>
found_entry = null
for entry in body.day_entries
if entry.timer_started_at?
found_entry = entry
break
if found_entry?
this.stop_entry msg, found_entry, (status, body) -> callback status, body
else
msg.reply "Currently there is no timer running."
# (internal method)
# Assembles the basic parts of a request to the Harvest
# API, i.e. the Content-Type/Accept and authorization
# headers. The returned HTTPClient object can (and should)
# be customized further by calling path() and other methods
# on it.
request: (msg) ->
req = msg.http(@base_url).headers
"Content-Type": "application/json"
"Accept": "application/json"
.auth(@account.email, @account.password)
return req
# (internal method)
# Searches through all projects available to the sender of
# `msg` for a project whose name inclues `target_project`.
# If exactly one is found, query all tasks available for the
# sender in this projects, and if exactly one is found,
# execute the callback with the project object as the first
# and the task object as the second argument. If more or
# less than one project or task are found to match the query,
# reply accordingly to the user (the callback never gets
# executed in this case).
find_project_and_task: (msg, target_project, target_task, callback) ->
this.daily msg, (status, body) ->
# Search through all possible projects for the matching ones
projects = []
for project in body.projects
if project.name.toLowerCase().indexOf(target_project.toLowerCase()) != -1
projects.push(project)
# Ask the user if the project name is ambivalent
if projects.length == 0
msg.reply "Sorry, no matching projects found."
return
else if projects.length > 1
msg.reply "I found the following #{projects.length} projects for your query, please be more precise:"
for project in projects
msg.reply "#{project.name}"
return
# Repeat the same process for the tasks
tasks = []
for task in projects[0].tasks
if task.name.toLowerCase().indexOf(target_task.toLowerCase()) != -1
tasks.push(task)
if tasks.length == 0
msg.reply "Sorry, no matching tasks found."
else if tasks.length > 1
msg.reply "I found the following #{tasks.length} tasks for your query, please be more pricese:"
for task in tasks
msg.reply "#{task.name}"
return
# Execute the callback with the results
callback projects[0], tasks[0]
# (internal method)
# Searches through all entries made for today and tries
# to find a running timer for the given project/task
# combination.
# If it is found, the respective entry object is passed to
# the callback, otherwise an error message is replied and
# the callback doesn't get executed.
find_day_entry: (msg, target_project, target_task, callback) ->
this.find_project_and_task msg, target_project, target_task, (project, task) =>
this.daily msg, (status, body) ->
# For some unknown reason, the daily entry IDs are strings
# instead of numbers, causing the comparison below to fail.
# So, convert our target stuff to strings as well.
project_id = "#{project.id}"
task_id = "#{task.id}"
# Iterate through all available entries for today
# and try to find the requested ID.
found_entry = null
for entry in body.day_entries
if entry.project_id == project_id and entry.task_id == task_id and entry.timer_started_at?
found_entry = entry
break
# None found
unless found_entry?
msg.reply "I couldnʼt find a running timer in todayʼs timesheet for the combination #{target_project}/#{target_task}."
return
# Execute the callback with the result
callback found_entry
# Takes a Date object and figures out which day in its
# year it represents and returns that one. Leap years
# are honoured.
day_of_year: (date) ->
start = new Date(date.getFullYear(), 0, 0)
return Math.ceil((date - start) / 86400000)
Something went wrong with that request. Please try again.