UNPKG

hubot-scripts

Version:

Allows you to opt in to a variety of scripts

520 lines (473 loc) 21.4 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)