gitloggl 0.1.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.
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitloggl
4
+ module Commands
5
+ class Sync < Gitloggl::Command
6
+ attr_accessor :date_from, :date_to
7
+ attr_accessor :toggl_bar, :gitlab_bar
8
+
9
+ def execute
10
+ self.date_from, self.date_to = select_dates
11
+
12
+ setup_toggl_hooks
13
+ setup_gitlab_hooks
14
+
15
+ toggl.run
16
+
17
+ print_tables
18
+ end
19
+
20
+ private
21
+
22
+ def print_tables
23
+ if updated.any?
24
+ puts pastel.green('Updated tracks')
25
+ puts(updated_table)
26
+ end
27
+
28
+ if rejected.any?
29
+ puts pastel.yellow('Rejected tracks')
30
+ puts(rejected_table)
31
+ end
32
+ end
33
+
34
+ def setup_toggl_hooks
35
+ toggl.on_first_page do |response|
36
+ self.toggl_bar = bars.register("toggl [:bar] :percent", total: response.total_pages)
37
+ self.gitlab_bar = bars.register("gitlab [:bar] :percent", total: response.total_count)
38
+ end
39
+
40
+ toggl.on_each_page do
41
+ toggl_bar.advance
42
+ end
43
+
44
+ toggl.on_each_page do |response|
45
+ issues = response.body.fetch('data').map do |row|
46
+ Gitlab::Issue.new(
47
+ spent_ms: Integer(row.fetch('dur')),
48
+ description: row.fetch('description')
49
+ )
50
+ end
51
+
52
+ gitlab.batch(issues)
53
+ end
54
+ end
55
+
56
+ def setup_gitlab_hooks
57
+ gitlab.on_completed do
58
+ gitlab_bar.advance
59
+ end
60
+
61
+ gitlab.on_skipped do
62
+ gitlab_bar.advance
63
+ end
64
+
65
+ gitlab.on_rejected do |issue, reason|
66
+ rejected.push([issue, reason])
67
+ end
68
+
69
+ gitlab.on_updated do |issue, diff|
70
+ updated.push([issue, diff])
71
+ end
72
+ end
73
+
74
+ def rejected
75
+ @rejected ||= []
76
+ end
77
+
78
+ def updated
79
+ @updated ||= []
80
+ end
81
+
82
+ def rejected_table
83
+ table = TTY::Table.new header: %w[Track Reason]
84
+
85
+ rejected.uniq { |(i)| i.description }.each do |(issue, reason)|
86
+ table << [issue.description, reason]
87
+ end
88
+
89
+ table.render :unicode, padding: 1
90
+ end
91
+
92
+ def updated_table
93
+ table = TTY::Table.new header: %w[Track Diff]
94
+
95
+ updated.each do |(issue, diff)|
96
+ table << [issue.description, diff]
97
+ end
98
+
99
+ table.render :unicode, padding: 1
100
+ end
101
+
102
+ def bars
103
+ @bars ||= TTY::ProgressBar::Multi.new("sync in progress #{date_from} - #{date_to} [:bar] :percent")
104
+ end
105
+
106
+ def toggl
107
+ @toggl ||= Toggle::Cli.new do |c|
108
+ c.verbose = verbose?
109
+ c.workspace_id = config.fetch(Const::TOGGL_WORKSPACE_ID)
110
+ c.token = config.fetch(Const::TOGGL_TOKEN)
111
+ c.date_from = date_from
112
+ c.date_to = date_to
113
+ end
114
+ end
115
+
116
+ def gitlab
117
+ @gitlab ||= Gitlab::Cli.new do |c|
118
+ c.verbose = verbose?
119
+ c.url = config.fetch(Const::GITLAB_URL)
120
+ c.token = config.fetch(Const::GITLAB_TOKEN)
121
+ end
122
+ end
123
+
124
+ def select_dates
125
+ prompt.select('Select period for sync. Tracks will be added to gitlab if diff will be detected') do |menu|
126
+ menu.choice 'Past 3 weeks', [3.weeks.ago.to_date, Date.current]
127
+ menu.choice 'Past 2 weeks', [2.weeks.ago.to_date, Date.current]
128
+ menu.choice 'Past 7 days', [7.days.ago.to_date, Date.current]
129
+ end
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitloggl
4
+ module Commands
5
+ class TogglCfg < Gitloggl::Command
6
+ def execute(*)
7
+ render_config_config
8
+
9
+ prompt.select('') do |menu|
10
+ menu.enum ')'
11
+ menu_back(menu)
12
+ menu.choice 'Change', -> { change }
13
+ end
14
+ end
15
+
16
+ private
17
+
18
+ def change
19
+ workspace_id = ask_workspace_id
20
+ token = ask_token
21
+
22
+ return unless prompt.yes? <<~ECHO
23
+ Change settings? WorkspaceID: #{pastel.green(workspace_id)}, token: #{pastel.green(token)}
24
+ ECHO
25
+
26
+ config.set(Const::TOGGL_WORKSPACE_ID, value: workspace_id)
27
+ config.set(Const::TOGGL_TOKEN, value: token)
28
+
29
+ config.write(force: true)
30
+ end
31
+
32
+ def ask_workspace_id
33
+ prompt.ask('Enter workspaceID:') do |q|
34
+ q.required true
35
+ q.validate /\A.+\Z/
36
+ end
37
+ end
38
+
39
+ def ask_token
40
+ prompt.ask('Enter token:') do |q|
41
+ q.required true
42
+ q.validate /\A.+\Z/
43
+ end
44
+ end
45
+
46
+ def render_config_config
47
+ table = TTY::Table.new header: %w[WorkspaceID Token]
48
+ table << [config.fetch(Const::TOGGL_WORKSPACE_ID), config.fetch(Const::TOGGL_TOKEN)]
49
+ puts table.render :unicode, padding: 1
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,39 @@
1
+ module Gitloggl
2
+ class Connection
3
+ USER_AGENT = 'Gitloggl bot'
4
+
5
+ attr_reader :url, :headers, :verbose
6
+
7
+ delegate :get, :post, :in_parallel, to: :transport
8
+
9
+ def initialize(url, headers: {}, verbose: false)
10
+ @url = url
11
+ @headers = headers
12
+ @verbose = verbose
13
+
14
+ yield(self) if block_given?
15
+ end
16
+
17
+ def transport
18
+ @transport ||= Faraday.new(url: url, headers: default_headers.merge(headers)) do |conn|
19
+ conn.use Faraday::Request::UrlEncoded
20
+ conn.use Faraday::Response::RaiseError
21
+ conn.response :json
22
+ conn.use Faraday::Response::Logger, self, bodies: true if verbose
23
+ conn.adapter :typhoeus
24
+ end
25
+ end
26
+
27
+ def default_headers
28
+ @default_headers ||= {
29
+ 'User-Agent' => USER_AGENT
30
+ }
31
+ end
32
+
33
+ %i[debug info warn error fatal].each do |name|
34
+ define_method name do |label, &block|
35
+ puts("[#{label}] #{block.call}")
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,8 @@
1
+ module Gitloggl
2
+ module Const
3
+ TOGGL_WORKSPACE_ID = :toggl_workspace_id
4
+ TOGGL_TOKEN = :toggl_token
5
+ GITLAB_URL = :gitlab_url
6
+ GITLAB_TOKEN = :gitlab_token
7
+ end
8
+ end
@@ -0,0 +1,86 @@
1
+ module Gitloggl
2
+ module Gitlab
3
+ class Cli
4
+ include Hooks
5
+ include Hooks::InstanceHooks
6
+
7
+ define_hook :on_rejected, scope: ->(*) { nil }
8
+ define_hook :on_updated, scope: ->(*) { nil }
9
+ define_hook :on_completed, scope: ->(*) { nil }
10
+ define_hook :on_skipped, scope: ->(*) { nil }
11
+
12
+ attr_accessor :token, :url, :verbose
13
+
14
+ def initialize(params = {})
15
+ params.each { |k, v| public_send("#{k}=", v) }
16
+ yield(self) if block_given?
17
+ end
18
+
19
+ def batch(issues)
20
+ stack = Middleware::Builder.new do |b|
21
+ b.use Stack::Filter, where: ->(issue) do
22
+ next true if issue.path.present?
23
+
24
+ rejected(issue, %{cant recognize gitlab issue-path})
25
+
26
+ false
27
+ end
28
+
29
+ b.use Stack::GroupAgg, callback: ->(issues) do
30
+ issues.each { skipped }
31
+ end
32
+
33
+ b.use Stack::DetectProject
34
+
35
+ b.use Stack::Filter, where: -> (issue) do
36
+ next true if issue.path.project_id.present?
37
+
38
+ rejected(issue, %{cant recognize gitlab projectID for issue})
39
+
40
+ false
41
+ end
42
+
43
+ b.use Stack::LoadNotes
44
+
45
+ b.use Stack::UpdateSpent, callback: ->(issue, diff_sec) do
46
+ updated(issue, %{added +#{ChronicDuration.output(diff_sec, keep_zero: true)} })
47
+ end
48
+ end
49
+
50
+ env = OpenStruct.new(cli: self, issues: issues)
51
+
52
+ stack.call(env)
53
+ end
54
+
55
+ def rejected(issue, reason)
56
+ run_hook(:on_completed, issue)
57
+ run_hook(:on_rejected, issue, reason)
58
+ end
59
+
60
+ def updated(issue, diff)
61
+ run_hook(:on_completed, issue)
62
+ run_hook(:on_updated, issue, diff)
63
+ end
64
+
65
+ def skipped
66
+ run_hook(:on_skipped)
67
+ end
68
+
69
+ def connection
70
+ @connection ||= Connection.new(url, headers: { 'PRIVATE-TOKEN' => token }, verbose: verbose).tap do |c|
71
+ c.transport.basic_auth(token, 'api_token')
72
+ end
73
+ end
74
+
75
+ # @return [Array<JSON>]
76
+ def projects
77
+ @projects ||= connection.get("/api/v4/projects").body
78
+ end
79
+
80
+ # @return [Integer]
81
+ def current_user_id
82
+ @current_user_id ||= connection.get("/api/v4/user").body.fetch('id')
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,25 @@
1
+ module Gitloggl
2
+ module Gitlab
3
+ class Issue < Struct.new(:description, :project_id, :spent_ms, keyword_init: true)
4
+ ISSUE_PATH_RE = /([[[:alnum:]]_-]*)#(\d+)/ # "poslogic-partner#471"
5
+
6
+ attr_accessor :notes
7
+
8
+ def spent_sec
9
+ spent_ms.fdiv(1000).round
10
+ end
11
+
12
+ def path
13
+ return nil unless description =~ ISSUE_PATH_RE
14
+ return nil unless $1.present? && $2.present?
15
+
16
+ @path ||= OpenStruct.new(project_path: $1, issue_id: $2)
17
+ end
18
+
19
+ def +(issue)
20
+ self.spent_ms = spent_ms + issue.spent_ms
21
+ self
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitloggl
4
+ module Gitlab
5
+ module Stack
6
+ class Abstract
7
+ attr_reader :app, :env, :options
8
+
9
+ def self.opt(*names)
10
+ names.each do |name|
11
+ define_method "#{name}!" do
12
+ options.fetch(name)
13
+ end
14
+ end
15
+ end
16
+
17
+ def initialize(app, options = {})
18
+ @app = app
19
+ @options = options
20
+ end
21
+
22
+ def call(env)
23
+ @env = env
24
+
25
+ before_call
26
+
27
+ around_call do
28
+ @app.call(env)
29
+ end
30
+
31
+ after_call
32
+ end
33
+
34
+ protected
35
+
36
+ def before_call
37
+ #:stub:
38
+ end
39
+
40
+ def around_call
41
+ yield
42
+ end
43
+
44
+ def after_call
45
+ #:stub:
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitloggl
4
+ module Gitlab
5
+ module Stack
6
+ class DetectProject < Abstract
7
+ def before_call
8
+ env.issues.each do |issue|
9
+ env.cli.projects.find do |row|
10
+ next unless row.fetch('path_with_namespace') =~ %r[#{issue.path.project_path}]
11
+
12
+ issue.path.project_id = row.fetch('id')
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitloggl
4
+ module Gitlab
5
+ module Stack
6
+ class Filter < Abstract
7
+ opt :where
8
+
9
+ def before_call
10
+ env.issues.select!(&where!)
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitloggl
4
+ module Gitlab
5
+ module Stack
6
+ class GroupAgg < Abstract
7
+ opt :callback
8
+
9
+ def before_call
10
+ env.issues = env.issues.group_by(&:path).each_with_object([]) do |(_, group), object|
11
+ object.push(group.inject { |a, b| a + b })
12
+
13
+ next unless group.many?
14
+
15
+ callback!.call(group[1..-1])
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitloggl
4
+ module Gitlab
5
+ module Stack
6
+ class LoadNotes < Abstract
7
+ def before_call
8
+ result = {}
9
+
10
+ env.cli.connection.in_parallel do
11
+ issue_paths.each do |path|
12
+ response = env.cli.connection.get("/api/v4/projects/#{path.project_id}/issues/#{path.issue_id}/notes")
13
+ result[path] = response
14
+ end
15
+ end
16
+
17
+ env.issues.each do |issue|
18
+ issue.notes = result[issue.path]&.body || []
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+
25
+ def issue_paths
26
+ env.issues.map(&:path).uniq.compact
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitloggl
4
+ module Gitlab
5
+ module Stack
6
+ class UpdateSpent < Abstract
7
+ GITLAB_SPENT_NOTE_RE = /added (.+) of time spent/
8
+
9
+ opt :callback
10
+
11
+ def before_call
12
+ queue = env.issues.each_with_object([]) do |issue, object|
13
+ diff_sec = issue.spent_sec - spent_sec(issue.notes)
14
+ diff_sec = diff_sec > 0 ? diff_sec : 0
15
+
16
+ callback!.call(issue, diff_sec)
17
+
18
+ next if diff_sec.zero?
19
+
20
+ object << [
21
+ "/api/v4/projects/#{issue.path.project_id}/issues/#{issue.path.issue_id}/add_spent_time",
22
+ duration: "#{diff_sec}sec"
23
+ ]
24
+ end
25
+
26
+ env.cli.connection.in_parallel do
27
+ queue.each { |argv| env.cli.connection.post(*argv) }
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ # @return [Integer]
34
+ def spent_sec(notes)
35
+ notes.inject(0) do |memo, row|
36
+ next memo unless row.fetch('author').fetch('id') == env.cli.current_user_id
37
+ next memo unless row.fetch('body') =~ GITLAB_SPENT_NOTE_RE
38
+
39
+ memo + ChronicDuration.parse($1).to_i
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1 @@
1
+ #
@@ -0,0 +1,60 @@
1
+ module Gitloggl
2
+ module Toggle
3
+ class Cli
4
+ include Hooks
5
+ include Hooks::InstanceHooks
6
+
7
+ define_hook :on_first_page, scope: ->(*) { nil }
8
+ define_hook :on_each_page, scope: ->(*) { nil }
9
+
10
+ attr_accessor :token, :workspace_id, :date_from, :date_to, :verbose
11
+
12
+ def initialize(params = {})
13
+ params.each { |k, v| public_send("#{k}=", v) }
14
+ yield(self) if block_given?
15
+ end
16
+
17
+ def run(page: 1)
18
+ body = request(page)
19
+
20
+ total_count = body.fetch('total_count')
21
+ per_page = body.fetch('per_page')
22
+ total_pages = total_count.fdiv(per_page).ceil
23
+
24
+ result = OpenStruct.new(
25
+ total_count: total_count,
26
+ per_page: per_page,
27
+ total_pages: total_pages,
28
+ page: page,
29
+ body: body
30
+ )
31
+
32
+ run_hook(:on_first_page, result) if page == 1
33
+ run_hook(:on_each_page, result)
34
+
35
+ run(page: page.next) if body['data'].length >= per_page
36
+ end
37
+
38
+ private
39
+
40
+ # @return [JSON]
41
+ def request(page)
42
+ response = connection.get('/reports/api/v2/details', {
43
+ workspace_id: workspace_id,
44
+ since: date_from,
45
+ until: date_to,
46
+ user_agent: Connection::USER_AGENT,
47
+ page: page
48
+ })
49
+
50
+ response.body
51
+ end
52
+
53
+ def connection
54
+ @connection ||= Connection.new('https://toggl.com/reports/api/v2/details', verbose: verbose).tap do |c|
55
+ c.transport.basic_auth(token, 'api_token')
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,3 @@
1
+ module Gitloggl
2
+ VERSION = '0.1.0'
3
+ end
data/lib/gitloggl.rb ADDED
@@ -0,0 +1,58 @@
1
+ require 'tty-table'
2
+ require 'tty-progressbar'
3
+ require 'ostruct'
4
+ require 'faraday'
5
+ require 'faraday_middleware'
6
+ require 'typhoeus'
7
+ require 'json'
8
+ require 'chronic_duration'
9
+ require 'middleware'
10
+ require 'hooks'
11
+ require 'active_support/core_ext/object/blank'
12
+ require 'active_support/core_ext/enumerable'
13
+ require 'active_support/dependencies/autoload'
14
+ require 'active_support/core_ext/module/delegation'
15
+ require 'active_support/duration'
16
+ require 'active_support/core_ext/date'
17
+ require 'active_support/core_ext/numeric/time'
18
+ require 'gitloggl/const'
19
+ require 'gitloggl/version'
20
+ require 'gitloggl/cli'
21
+ require 'gitloggl/command'
22
+ require 'gitloggl/connection'
23
+ require 'gitloggl/commands/main'
24
+
25
+ module Gitloggl
26
+ module Commands
27
+ extend ActiveSupport::Autoload
28
+
29
+ autoload :Main
30
+ autoload :TogglCfg
31
+ autoload :GitlabCfg
32
+ autoload :Sync
33
+ end
34
+
35
+ module Toggle
36
+ extend ActiveSupport::Autoload
37
+
38
+ autoload :Cli
39
+ end
40
+
41
+ module Gitlab
42
+ extend ActiveSupport::Autoload
43
+
44
+ autoload :Issue
45
+ autoload :Cli
46
+
47
+ module Stack
48
+ extend ActiveSupport::Autoload
49
+
50
+ autoload :Abstract
51
+ autoload :Filter
52
+ autoload :GroupAgg
53
+ autoload :DetectProject
54
+ autoload :LoadNotes
55
+ autoload :UpdateSpent
56
+ end
57
+ end
58
+ end