tempest-time 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (78) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +10 -0
  3. data/.ruby-version +1 -0
  4. data/.travis.yml +5 -0
  5. data/Gemfile +6 -0
  6. data/Gemfile.lock +133 -0
  7. data/LICENSE +21 -0
  8. data/README.md +8 -0
  9. data/Rakefile +10 -0
  10. data/bin/console +14 -0
  11. data/bin/setup +8 -0
  12. data/bin/tempest +18 -0
  13. data/lib/tempest_time.rb +4 -0
  14. data/lib/tempest_time/api/authorization.rb +17 -0
  15. data/lib/tempest_time/api/jira_api/authorization.rb +27 -0
  16. data/lib/tempest_time/api/jira_api/models/issue.rb +28 -0
  17. data/lib/tempest_time/api/jira_api/request.rb +40 -0
  18. data/lib/tempest_time/api/jira_api/requests/get_issue.rb +29 -0
  19. data/lib/tempest_time/api/jira_api/requests/get_user_issues.rb +36 -0
  20. data/lib/tempest_time/api/jira_api/response.rb +5 -0
  21. data/lib/tempest_time/api/jira_api/responses/get_issue.rb +16 -0
  22. data/lib/tempest_time/api/jira_api/responses/get_user_issues.rb +14 -0
  23. data/lib/tempest_time/api/request.rb +87 -0
  24. data/lib/tempest_time/api/response.rb +34 -0
  25. data/lib/tempest_time/api/tempo_api/authorization.rb +23 -0
  26. data/lib/tempest_time/api/tempo_api/models/worklog.rb +36 -0
  27. data/lib/tempest_time/api/tempo_api/request.rb +35 -0
  28. data/lib/tempest_time/api/tempo_api/requests/create_worklog.rb +51 -0
  29. data/lib/tempest_time/api/tempo_api/requests/delete_worklog.rb +29 -0
  30. data/lib/tempest_time/api/tempo_api/requests/get_worklog.rb +29 -0
  31. data/lib/tempest_time/api/tempo_api/requests/list_worklogs.rb +41 -0
  32. data/lib/tempest_time/api/tempo_api/requests/report.rb +0 -0
  33. data/lib/tempest_time/api/tempo_api/requests/submit_timesheet.rb +49 -0
  34. data/lib/tempest_time/api/tempo_api/response.rb +5 -0
  35. data/lib/tempest_time/api/tempo_api/responses/create_worklog.rb +28 -0
  36. data/lib/tempest_time/api/tempo_api/responses/delete_worklog.rb +8 -0
  37. data/lib/tempest_time/api/tempo_api/responses/get_worklog.rb +22 -0
  38. data/lib/tempest_time/api/tempo_api/responses/list_worklogs.rb +40 -0
  39. data/lib/tempest_time/api/tempo_api/responses/submit_timesheet.rb +13 -0
  40. data/lib/tempest_time/cli.rb +92 -0
  41. data/lib/tempest_time/command.rb +60 -0
  42. data/lib/tempest_time/commands/config.rb +30 -0
  43. data/lib/tempest_time/commands/config/edit.rb +38 -0
  44. data/lib/tempest_time/commands/config/setup.rb +55 -0
  45. data/lib/tempest_time/commands/delete.rb +31 -0
  46. data/lib/tempest_time/commands/issues.rb +37 -0
  47. data/lib/tempest_time/commands/list.rb +32 -0
  48. data/lib/tempest_time/commands/report.rb +83 -0
  49. data/lib/tempest_time/commands/submit.rb +26 -0
  50. data/lib/tempest_time/commands/teams.rb +30 -0
  51. data/lib/tempest_time/commands/teams/add.rb +29 -0
  52. data/lib/tempest_time/commands/teams/delete.rb +32 -0
  53. data/lib/tempest_time/commands/teams/edit.rb +49 -0
  54. data/lib/tempest_time/commands/track.rb +70 -0
  55. data/lib/tempest_time/helpers/formatting_helper.rb +16 -0
  56. data/lib/tempest_time/helpers/time_helper.rb +63 -0
  57. data/lib/tempest_time/models/report.rb +58 -0
  58. data/lib/tempest_time/services/generate_report.rb +54 -0
  59. data/lib/tempest_time/setting.rb +47 -0
  60. data/lib/tempest_time/settings/authorization.rb +19 -0
  61. data/lib/tempest_time/settings/teams.rb +19 -0
  62. data/lib/tempest_time/templates/config/.gitkeep +1 -0
  63. data/lib/tempest_time/templates/config/auth/.gitkeep +1 -0
  64. data/lib/tempest_time/templates/config/setup/.gitkeep +1 -0
  65. data/lib/tempest_time/templates/config/teams/.gitkeep +1 -0
  66. data/lib/tempest_time/templates/delete/.gitkeep +1 -0
  67. data/lib/tempest_time/templates/issue/.gitkeep +1 -0
  68. data/lib/tempest_time/templates/issues/.gitkeep +1 -0
  69. data/lib/tempest_time/templates/list/.gitkeep +1 -0
  70. data/lib/tempest_time/templates/report/.gitkeep +1 -0
  71. data/lib/tempest_time/templates/setup/.gitkeep +1 -0
  72. data/lib/tempest_time/templates/submit/.gitkeep +1 -0
  73. data/lib/tempest_time/templates/teams/.gitkeep +1 -0
  74. data/lib/tempest_time/templates/track/.gitkeep +1 -0
  75. data/lib/tempest_time/templates/view/.gitkeep +1 -0
  76. data/lib/tempest_time/version.rb +3 -0
  77. data/tempest-time.gemspec +62 -0
  78. metadata +517 -0
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../command'
4
+ require_relative '../helpers/time_helper'
5
+ require_relative '../api/tempo_api/requests/list_worklogs'
6
+
7
+ module TempestTime
8
+ module Commands
9
+ class List < TempestTime::Command
10
+ include TempestTime::Helpers::TimeHelper
11
+
12
+ def initialize(date, options)
13
+ @date = date
14
+ @options = options
15
+ end
16
+
17
+ def execute(input: $stdin, output: $stdout)
18
+ dates = parsed_date_input(@date)
19
+ dates.each do |start_date|
20
+ request = TempoAPI::Requests::ListWorklogs.new(
21
+ start_date,
22
+ @options['end_date'],
23
+ @options[:user]
24
+ )
25
+ request.send_request
26
+ puts "\nHere are your logs for #{formatted_date_range(start_date, @options['end_date'])}:\n"
27
+ puts request.response_message
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../command'
4
+ require_relative '../settings/teams'
5
+ require_relative '../services/generate_report'
6
+ require_relative '../helpers/time_helper'
7
+
8
+ module TempestTime
9
+ module Commands
10
+ class Report < TempestTime::Command
11
+ include TempestTime::Helpers::TimeHelper
12
+
13
+ def initialize(users, options)
14
+ @users = users || []
15
+ @options = options
16
+ end
17
+
18
+ def execute(input: $stdin, output: $stdout)
19
+ team = @options[:team]
20
+ @users = prompt_for_input if @users.empty? && team.nil?
21
+ @users.push(TempestTime::Settings::Teams.members(team)) if team
22
+ abort('No users specified.') unless @users.any?
23
+ with_spinner('Generating report...') do |spinner|
24
+ table = render_table
25
+ spinner.stop(pastel.green('Your report is ready!'))
26
+ date_range = "#{formatted_date(report.start_date)}"\
27
+ ' to '\
28
+ "#{formatted_date(report.end_date)}"
29
+ prompt.say("\nReport for #{pastel.green(date_range)}")
30
+ puts table
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ def report
37
+ @report ||= TempestTime::Services::GenerateReport.new(
38
+ @users.flatten, @options[:week]
39
+ )
40
+ end
41
+
42
+ def prompt_for_input
43
+ type = prompt.select(
44
+ "Generate a report for a #{pastel.green('user')} or #{pastel.green('team')}?",
45
+ ['User', 'Team']
46
+ )
47
+ return [prompt.ask('Which user would you like to analyse?')] if type == 'User'
48
+ teams = TempestTime::Settings::Teams
49
+ abort('You have no teams yet! Go make one! (tempest teams add)') unless teams.keys.any?
50
+ team = prompt.select(
51
+ "Which #{pastel.green('team')} would you like to analyse?",
52
+ teams.keys
53
+ )
54
+ teams.members(team)
55
+ end
56
+
57
+ def table_headings
58
+ %w[User COMP% UTIL%] + report.projects
59
+ end
60
+
61
+ def render_table
62
+ t = table.new(
63
+ table_headings,
64
+ report.reports.map { |r| row(r) } + [row(report.aggregate)]
65
+ )
66
+
67
+ t.render(:ascii, padding: [0, 1])
68
+ end
69
+
70
+ def row(r)
71
+ row = [
72
+ r.user,
73
+ r.total_compliance_percentage.round(2),
74
+ r.utilization_percentage.round(2)
75
+ ]
76
+ report.projects.each do |project|
77
+ row.push(r.project_compliance_percentages.to_h.fetch(project, 0).round(2))
78
+ end
79
+ row
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../command'
4
+ require_relative '../api/tempo_api/requests/submit_timesheet'
5
+
6
+ module TempestTime
7
+ module Commands
8
+ class Submit < TempestTime::Command
9
+ def initialize(options)
10
+ @options = options
11
+ end
12
+
13
+ def execute(input: $stdin, output: $stdout)
14
+ # Command logic goes here ...
15
+ reviewer = prompt.ask('Who should review this timesheet? (username)')
16
+ message = "Submit this week's timesheet to " + pastel.green(reviewer) + '?'
17
+ abort unless prompt.yes?(message)
18
+ abort unless prompt.yes?('Are you sure? No edits can be made once submitted!')
19
+
20
+ with_success_fail_spinner("Submitting this week's timesheet...") do
21
+ TempoAPI::Requests::SubmitTimesheet.new(reviewer).send_request
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'thor'
4
+
5
+ module TempestTime
6
+ module Commands
7
+ class Teams < Thor
8
+
9
+ namespace :config
10
+
11
+ desc 'add', 'Add a team.'
12
+ def add(*)
13
+ require_relative 'teams/add'
14
+ TempestTime::Commands::Teams::Add.new(options).execute
15
+ end
16
+
17
+ desc 'edit', 'Edit a team.'
18
+ def edit(*)
19
+ require_relative 'teams/edit'
20
+ TempestTime::Commands::Teams::Edit.new(options).execute
21
+ end
22
+
23
+ desc 'delete', 'Edit a team.'
24
+ def delete(*)
25
+ require_relative 'teams/delete'
26
+ TempestTime::Commands::Teams::Delete.new(options).execute
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../command'
4
+ require_relative '../../settings/teams'
5
+
6
+ module TempestTime
7
+ module Commands
8
+ class Teams
9
+ class Add < TempestTime::Command
10
+ def initialize(options)
11
+ @options = options
12
+ end
13
+
14
+ def execute(input: $stdin, output: $stdout)
15
+ teams = TempestTime::Settings::Teams
16
+ message =
17
+ 'Please enter ' + pastel.green('the members') + " of this team. "\
18
+ '(Comma-separated, e.g. jkirk, jpicard, bsisko, kjaneway) '
19
+ members = prompt.ask(message) do |q|
20
+ q.convert ->(input) { input.split(/,\s*/) }
21
+ end
22
+ name = prompt.ask('Please enter ' + pastel.green('the name') + ' of your new team.')
23
+ teams.update(name, members)
24
+ prompt.say(pastel.green('Success!'))
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../command'
4
+ require_relative '../../settings/teams'
5
+
6
+ module TempestTime
7
+ module Commands
8
+ class Teams
9
+ class Delete < TempestTime::Command
10
+ def initialize(options)
11
+ @options = options
12
+ end
13
+
14
+ def execute(input: $stdin, output: $stdout)
15
+ teams = TempestTime::Settings::Teams
16
+ abort("There are no teams to delete!") unless teams.keys.any?
17
+ team = prompt.select(
18
+ "Which #{pastel.green('team')} would you like to delete?",
19
+ teams.keys
20
+ )
21
+ if prompt.yes?(pastel.red("Are you sure you want to delete #{team}?"))
22
+ teams.delete(team)
23
+ prompt.say("Successfully #{pastel.red("deleted #{team}!")}")
24
+ else
25
+ abort('Nothing was deleted.')
26
+ end
27
+ execute if prompt.yes?('Delete another team?')
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../command'
4
+ require_relative '../../settings/teams'
5
+
6
+ module TempestTime
7
+ module Commands
8
+ class Teams
9
+ class Edit < TempestTime::Command
10
+ def initialize(options)
11
+ @options = options
12
+ end
13
+
14
+ def execute(input: $stdin, output: $stdout)
15
+ teams = TempestTime::Settings::Teams
16
+ abort("There are no teams to edit!") unless teams.keys.any?
17
+ team = prompt.select(
18
+ "Which #{pastel.green('team')} would you like to edit?",
19
+ teams.keys
20
+ )
21
+
22
+ members = teams.members(team)
23
+ member = prompt.select(
24
+ "Which #{pastel.green('member')} would you like to edit?",
25
+ members + ['Add New Member']
26
+ )
27
+
28
+ replace = prompt.ask(
29
+ "Enter the #{pastel.green('new name')}. "\
30
+ "Leave blank to #{pastel.red('delete')}."
31
+ )
32
+
33
+ members.delete(member)
34
+
35
+ if replace.nil?
36
+ teams.update(team, members)
37
+ prompt.say("Deleted #{pastel.red(member)}!")
38
+ else
39
+ members.push(replace)
40
+ teams.update(team, members)
41
+ prompt.say("Added #{pastel.green(replace)}")
42
+ end
43
+
44
+ execute if prompt.yes?('Keep editing?')
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../command'
4
+ require_relative '../helpers/time_helper'
5
+
6
+ require_relative '../api/tempo_api/requests/create_worklog'
7
+ require_relative '../api/jira_api/requests/get_issue'
8
+
9
+ module TempestTime
10
+ module Commands
11
+ class Track < TempestTime::Command
12
+ include TempestTime::Helpers::TimeHelper
13
+
14
+ def initialize(time, tickets, options)
15
+ @time = time
16
+ @tickets = tickets
17
+ @options = options
18
+ end
19
+
20
+ def execute(input: $stdin, output: $stdout)
21
+ time = @options[:split] ? parsed_time(@time) / @tickets.count : parsed_time(@time)
22
+ tickets = @tickets.any? ? @tickets.map(&:upcase) : [automatic_ticket]
23
+
24
+ prompt_message = "Track #{formatted_time(time)}, "\
25
+ "#{billability(@options)}, "\
26
+ "to #{tickets.join(', ')}?"
27
+ abort unless prompt.yes?(prompt_message, convert: :bool)
28
+
29
+ tickets.each do |ticket|
30
+ track_time(time, @options.merge(ticket: ticket))
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ def track_time(time, options)
37
+ message = "Tracking #{formatted_time(time)} to #{options['ticket']}..."
38
+ with_success_fail_spinner(message) do
39
+ options['remaining'] = if options['remaining'].nil?
40
+ remaining_estimate(options['ticket'], time)
41
+ else
42
+ parsed_time(options['remaining'])
43
+ end
44
+ TempoAPI::Requests::CreateWorklog.new(time, options).send_request
45
+ end
46
+ end
47
+
48
+ def remaining_estimate(ticket, time)
49
+ request = JiraAPI::Requests::GetIssue.new(ticket)
50
+ request.send_request
51
+ if request.response.failure?
52
+ abort("There was an issue getting this Jira ticket.\n"\
53
+ 'Please check the ticket number and your credentials.')
54
+ end
55
+ remaining = request.response.issue.remaining_estimate || 0
56
+ remaining > time ? remaining - time : 0
57
+ end
58
+
59
+ def automatic_ticket
60
+ ticket = /[A-Z]+-\d+/.match(Git.open(Dir.pwd).current_branch)
61
+ abort('Ticket not found for this branch. Please specify.') unless ticket
62
+ ticket.to_s
63
+ end
64
+
65
+ def billability(options)
66
+ options['billable'] ? 'billed' : 'non-billed'
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,16 @@
1
+ module TempestTime
2
+ module Helpers
3
+ module FormattingHelper
4
+ def braced(text, length = text.length)
5
+ padding_amount = length - text.length
6
+ front_padding = ' ' * (padding_amount / 2.0).ceil
7
+ back_padding = ' ' * (padding_amount / 2.0).floor
8
+ '[' + front_padding + text + back_padding + ']'
9
+ end
10
+
11
+ def with_percent_sign(decimal)
12
+ "#{(decimal * 100).to_i}%"
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,63 @@
1
+ require 'date'
2
+
3
+ module TempestTime
4
+ module Helpers
5
+ module TimeHelper
6
+ def parsed_time(time)
7
+ # Returns seconds.
8
+ return time if time.is_a?(Integer)
9
+
10
+ if /^\d*\.{0,1}\d{1,2}h$/.match(time)
11
+ return (time.chomp('h').to_f * 60 * 60).to_i
12
+ elsif /^\d+m$/.match(time)
13
+ return time.chomp('m').to_i * 60
14
+ end
15
+
16
+ abort("Please provide time in the correct format. e.g. 0.5h, .5h, 30m")
17
+ end
18
+
19
+ def formatted_time(seconds)
20
+ seconds < 3600 ? "#{seconds / 60} minutes" : "#{(seconds / 3600.to_f).round(2)} hours"
21
+ end
22
+
23
+ def formatted_date_range(start_date, end_date)
24
+ return formatted_date(start_date) if end_date.nil? || end_date.empty?
25
+ "#{formatted_date(start_date)} - #{formatted_date(end_date)}"
26
+ end
27
+
28
+ def formatted_date(date)
29
+ "#{Date::DAYNAMES[date.wday]}, #{Date::MONTHNAMES[date.month]} #{date.day}"
30
+ end
31
+
32
+ def parsed_date_input(date_input)
33
+ case date_input
34
+ when 'today', nil
35
+ [Date.today]
36
+ when 'yesterday'
37
+ [Date.today.prev_day]
38
+ when 'week'
39
+ this_week
40
+ else
41
+ [Date.parse(date_input)]
42
+ end
43
+ end
44
+
45
+ def beginning_of_this_week
46
+ Date.today - Date.today.wday
47
+ end
48
+
49
+ def beginning_of_week(week_number)
50
+ this_week_number = (Date.today + 1).cweek # Add one so weeks begin on Sunday.
51
+ return false unless week_number <= this_week_number
52
+ days_in_the_past = (this_week_number - week_number) * 7
53
+ beginning_of_this_week - days_in_the_past
54
+ end
55
+
56
+ def this_week
57
+ (0..6).map do |n|
58
+ beginning_of_this_week + n
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,58 @@
1
+ require_relative '../helpers/formatting_helper'
2
+
3
+ module TempestTime
4
+ module Models
5
+ class Report
6
+ include TempestTime::Helpers::FormattingHelper
7
+ EXPECTED_SECONDS = (37.5 * 60 * 60).to_i.freeze
8
+ INTERNAL_PROJECT = 'BCIT'.freeze
9
+
10
+ attr_reader :user, :worklogs, :number_of_users
11
+
12
+ def initialize(user, worklogs, number_of_users = 1)
13
+ @user = user
14
+ @worklogs = worklogs
15
+ @number_of_users = number_of_users
16
+ end
17
+
18
+ def project_total_times
19
+ @project_total_times ||= project_worklogs.map do |project, worklogs|
20
+ [project, time_logged_seconds(worklogs)]
21
+ end.to_h
22
+ end
23
+
24
+ def compliance_percentage(time)
25
+ (time.to_f / (EXPECTED_SECONDS * number_of_users)).round(2)
26
+ end
27
+
28
+ def project_compliance_percentages
29
+ @project_compliance_percentages ||= project_total_times.map do |project, time|
30
+ [project, compliance_percentage(time)]
31
+ end.to_h.sort
32
+ end
33
+
34
+ def total_compliance_percentage
35
+ @total_compliance_percentage ||= compliance_percentage(time_logged_seconds(worklogs))
36
+ end
37
+
38
+ def utilization_percentage
39
+ @utilization_percentage ||= project_compliance_percentages.inject(0) do |memo, (project, percentage)|
40
+ memo += percentage unless project == INTERNAL_PROJECT
41
+ memo
42
+ end
43
+ end
44
+
45
+ def time_logged_seconds(logs)
46
+ logs.flat_map(&:seconds).reduce(:+)
47
+ end
48
+
49
+ def project_worklogs
50
+ @project_worklogs ||= worklogs.group_by(&:project)
51
+ end
52
+
53
+ def projects
54
+ @projects ||= project_worklogs.keys.sort
55
+ end
56
+ end
57
+ end
58
+ end