tempest-time 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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