michael 0.1.1 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +4 -1
  3. data/Gemfile.lock +6 -71
  4. data/exe/michael +2 -2
  5. data/lib/michael.rb +1 -0
  6. data/lib/michael/cli.rb +15 -1
  7. data/lib/michael/commands/auth.rb +11 -21
  8. data/lib/michael/commands/repos.rb +39 -2
  9. data/lib/michael/commands/repos/edit.rb +13 -9
  10. data/lib/michael/commands/repos/pull_requests.rb +38 -97
  11. data/lib/michael/constants.rb +7 -0
  12. data/lib/michael/models/pull_request.rb +139 -0
  13. data/lib/michael/models/repository.rb +45 -0
  14. data/lib/michael/models/review.rb +57 -0
  15. data/lib/michael/models/status.rb +43 -0
  16. data/lib/michael/models/user.rb +18 -0
  17. data/lib/michael/services/configuration.rb +44 -0
  18. data/lib/michael/{models/github/octokit_initializer.rb → services/github/initializer.rb} +9 -7
  19. data/lib/michael/services/github/pull_requests.rb +46 -0
  20. data/lib/michael/services/github/token.rb +41 -0
  21. data/lib/michael/services/github/users.rb +16 -0
  22. data/lib/michael/services/repositories.rb +34 -0
  23. data/lib/michael/version.rb +1 -1
  24. data/michael.gemspec +2 -18
  25. metadata +19 -245
  26. data/lib/michael/command.rb +0 -131
  27. data/lib/michael/models/configuration.rb +0 -69
  28. data/lib/michael/models/github/pr_wrapper.rb +0 -84
  29. data/lib/michael/models/github/pull_request.rb +0 -51
  30. data/lib/michael/models/github/review.rb +0 -41
  31. data/lib/michael/models/github/reviewer.rb +0 -17
  32. data/lib/michael/models/github/status.rb +0 -25
  33. data/lib/michael/models/github/team.rb +0 -17
  34. data/lib/michael/models/github/token_validator.rb +0 -30
  35. data/lib/michael/models/github/user.rb +0 -21
  36. data/lib/michael/models/guard.rb +0 -20
  37. data/lib/michael/models/pull_request_formatter.rb +0 -116
  38. data/lib/michael/models/repository_formatter.rb +0 -26
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Michael
4
+ CONFIG_DIR_ABSOLUTE_PATH = "#{ENV['HOME']}/.config/kudrykv/michael"
5
+ CONFIG_FILENAME = 'config'
6
+ CONFIG_REPOS_FILENAME = 'repositories'
7
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pastel'
4
+ require 'ruby-duration'
5
+
6
+ module Michael
7
+ module Models
8
+ class PullRequest
9
+ attr_reader :pull_request
10
+ attr_accessor :statuses, :reviews, :comments
11
+
12
+ def initialize(pull_request)
13
+ @pull_request = pull_request
14
+ end
15
+
16
+ def number
17
+ pull_request[:number]
18
+ end
19
+
20
+ def title
21
+ pull_request[:title]
22
+ end
23
+
24
+ def author
25
+ pull_request[:user][:login]
26
+ end
27
+
28
+ def head_sha
29
+ pull_request[:head][:sha]
30
+ end
31
+
32
+ def labels
33
+ pull_request[:labels].map(&:name)
34
+ end
35
+
36
+ def approved?
37
+ return false if reviews.nil?
38
+
39
+ reviews.any? && reviews.all?(&:approved?)
40
+ end
41
+
42
+ def needs_review?
43
+ pull_request[:requested_reviewers].any? || pull_request[:requested_teams].any?
44
+ end
45
+
46
+ def author?(name)
47
+ author == name
48
+ end
49
+
50
+ def actionable?(name)
51
+ return false if author?(name)
52
+ return true if reviews.map(&:author).none?(name)
53
+
54
+ last_update_head?
55
+ end
56
+
57
+ def pretty_print
58
+ [
59
+ pastel.bold("\##{number}"),
60
+ statuses_in_dots,
61
+ reviews_in_dots,
62
+ title,
63
+ labels.empty? ? nil : pastel.bold.yellow("[#{labels.join(', ')}]"),
64
+ pastel.cyan(author),
65
+ pretty_last_update(Time.now, last_updated_at),
66
+ requested_changes,
67
+ commented
68
+ ].reject(&:nil?).join(' ')
69
+ end
70
+
71
+ private
72
+
73
+ def last_updated_at
74
+ updates = [pull_request[:updated_at]]
75
+ updates.concat(statuses.map(&:updated_at)) if !statuses.nil? && statuses.any?
76
+ updates.concat(reviews.map(&:submitted_at)) if !reviews.nil? && reviews.any?
77
+
78
+ updates.sort.pop
79
+ end
80
+
81
+ def statuses_in_dots
82
+ return nil if statuses.nil?
83
+ return '-' if statuses.empty?
84
+
85
+ statuses.map(&:dot).join
86
+ end
87
+
88
+ def reviews_in_dots
89
+ return nil if reviews.nil?
90
+ return '-' if !needs_review? && reviews.none?
91
+ return pastel.yellow('.') if reviews.empty?
92
+
93
+ reviews.map(&:dot).join
94
+ end
95
+
96
+ def requested_changes
97
+ rc = reviews.select(&:changes_requested?).map(&:author)
98
+ return nil if rc.empty?
99
+
100
+ '| ' + pastel.bold('Requested changes: ') + rc.map { |n| pastel.underscore(n) }.join(', ')
101
+ end
102
+
103
+ def commented
104
+ rc = reviews.select(&:commented?).map(&:author)
105
+ return nil if rc.empty?
106
+
107
+ '| Commented: ' + rc.join(', ')
108
+ end
109
+
110
+ def pretty_last_update(bigger, smaller)
111
+ duration = Duration.new(bigger-smaller)
112
+
113
+ wh = if duration.weeks.positive?
114
+ pastel.yellow.bold("#{duration.weeks} week(s)")
115
+ elsif duration.days.positive?
116
+ pastel.yellow("#{duration.days} day(s)")
117
+ elsif duration.hours.positive?
118
+ "#{duration.hours} hour(s)"
119
+ elsif duration.minutes.positive?
120
+ "#{duration.minutes} minute(s)"
121
+ else
122
+ 'seconds'
123
+ end
124
+
125
+ "last update #{wh} ago"
126
+ end
127
+
128
+ def last_update_head?
129
+ updated_at = pull_request[:updated_at]
130
+ reviewed_at = reviews.map(&:submitted_at).sort.pop
131
+ updated_at > reviewed_at
132
+ end
133
+
134
+ def pastel
135
+ @pastel ||= Pastel.new
136
+ end
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pastel'
4
+
5
+ module Michael
6
+ module Models
7
+ class Repository
8
+ attr_reader :org_name, :prs
9
+
10
+ def initialize(org_name, prs: nil)
11
+ @org_name = org_name
12
+ @prs = prs
13
+ end
14
+
15
+ def broken?
16
+ prs.nil?
17
+ end
18
+
19
+ def has_prs?
20
+ !broken? && prs.any?
21
+ end
22
+
23
+ def ==(other)
24
+ org_name == other.org_name &&
25
+ prs == other.prs
26
+ end
27
+
28
+ def pretty_print
29
+ return pastel.black.on_red(org_name) if broken?
30
+ return org_name if prs.none?
31
+
32
+ [
33
+ pastel.bold(org_name + ':'),
34
+ prs.map(&:pretty_print).join("\n")
35
+ ].join("\n")
36
+ end
37
+
38
+ private
39
+
40
+ def pastel
41
+ @pastel ||= Pastel.new
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pastel'
4
+
5
+ module Michael
6
+ module Models
7
+ class Review
8
+ def initialize(review)
9
+ @review = review
10
+ end
11
+
12
+ def author
13
+ review[:user][:login]
14
+ end
15
+
16
+ def submitted_at
17
+ review[:submitted_at]
18
+ end
19
+
20
+ def state
21
+ review[:state].to_sym
22
+ end
23
+
24
+ def approved?
25
+ state == :APPROVED
26
+ end
27
+
28
+ def changes_requested?
29
+ state == :CHANGES_REQUESTED
30
+ end
31
+
32
+ def commented?
33
+ state == :COMMENTED
34
+ end
35
+
36
+ def dot
37
+ dot_status[state]
38
+ end
39
+
40
+ private
41
+
42
+ attr_reader :review
43
+
44
+ def dot_status
45
+ @dot_status ||= {
46
+ APPROVED: pastel.green('^'),
47
+ CHANGES_REQUESTED: pastel.red('X'),
48
+ COMMENTED: '?'
49
+ }
50
+ end
51
+
52
+ def pastel
53
+ @pastel ||= Pastel.new
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pastel'
4
+
5
+ module Michael
6
+ module Models
7
+ class Status
8
+ def initialize(status, pastel_params: {})
9
+ @status = status
10
+ @pastel_params = pastel_params
11
+ end
12
+
13
+ def state
14
+ status[:state].to_sym
15
+ end
16
+
17
+ def updated_at
18
+ status[:updated_at]
19
+ end
20
+
21
+ def dot
22
+ dot_status[state]
23
+ end
24
+
25
+ private
26
+
27
+ attr_reader :status
28
+
29
+ def dot_status
30
+ @dot_status ||= {
31
+ success: pastel.green('+'),
32
+ pending: pastel.yellow('.'),
33
+ error: pastel.red('x'),
34
+ failure: pastel.red('x')
35
+ }
36
+ end
37
+
38
+ def pastel
39
+ @pastel ||= Pastel.new(@pastel_params)
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Michael
4
+ module Models
5
+ class User
6
+ def initialize(user)
7
+ @user = user
8
+ end
9
+
10
+ def username
11
+ user[:login]
12
+ end
13
+
14
+ private
15
+ attr_reader :user
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tty-config'
4
+
5
+ require_relative '../constants'
6
+
7
+ module Michael
8
+ module Services
9
+ class Configuration
10
+ def initialize(config)
11
+ raise Error, 'configuration is not initialized' if config.nil?
12
+
13
+ @config = config
14
+ end
15
+
16
+ def set(*keys, value: nil)
17
+ interact(:set, *keys, value: value)
18
+ end
19
+
20
+ def append(*values, to: nil)
21
+ interact(:append, *values, to: to)
22
+ end
23
+
24
+ def fetch(*keys, default: nil)
25
+ interact(:fetch, *keys, default: default)
26
+ end
27
+
28
+ def remove(*values, from: nil)
29
+ interact(:remove, *values, from: from)
30
+ end
31
+
32
+ private
33
+
34
+ def interact(symbol, *args)
35
+ config.read if config.exist?
36
+ resp = config.public_send(symbol, *args)
37
+ config.write(force: true)
38
+ resp
39
+ end
40
+
41
+ attr_reader :config
42
+ end
43
+ end
44
+ end
@@ -2,20 +2,22 @@
2
2
 
3
3
  require 'octokit'
4
4
 
5
- require_relative '../configuration'
5
+ require_relative 'token'
6
6
 
7
7
  module Michael
8
- module Models
8
+ module Services
9
9
  module Github
10
- class OctokitInitializer
10
+ class Initializer < Token
11
11
  attr_reader :octokit
12
12
 
13
- def initialize
14
- token = Configuration.new.fetch(:token)
13
+ def initialize(config)
14
+ super(config)
15
+
16
+ validate(token)
17
+
15
18
  @octokit = Octokit::Client.new(access_token: token)
16
- @octokit.auto_paginate = true
17
19
  end
18
20
  end
19
21
  end
20
22
  end
21
- end
23
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'initializer'
4
+ require_relative '../../models/pull_request'
5
+ require_relative '../../models/status'
6
+ require_relative '../../models/review'
7
+
8
+ module Michael
9
+ module Services
10
+ module Github
11
+ class PullRequests < Initializer
12
+ def search(org_repo, state: 'open')
13
+ octokit
14
+ .pull_requests(org_repo, state: state)
15
+ .map { |pr| process(org_repo, pr) }
16
+ rescue Octokit::InvalidRepository, Octokit::NotFound
17
+ nil
18
+ end
19
+
20
+ private
21
+
22
+ def process(org_repo, pull_request)
23
+ pr = Michael::Models::PullRequest.new(pull_request)
24
+
25
+ pr.statuses = statuses(org_repo, pr.head_sha)
26
+ pr.reviews = reviews(org_repo, pr.number)
27
+
28
+ pr
29
+ end
30
+
31
+ def statuses(org_repo, sha)
32
+ statuses = octokit.combined_status(org_repo, sha)[:statuses]
33
+ statuses.map { |s| Michael::Models::Status.new(s) }
34
+ end
35
+
36
+ def reviews(org_repo, pr_number)
37
+ octokit
38
+ .pull_request_reviews(org_repo, pr_number)
39
+ .map { |review| Michael::Models::Review.new(review) }
40
+ .group_by(&:author).to_a
41
+ .map { |_author, reviews| reviews.sort_by(&:submitted_at).pop }
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'octokit'
4
+
5
+ require_relative '../../../michael'
6
+
7
+ module Michael
8
+ module Services
9
+ module Github
10
+ class Token
11
+ def initialize(config)
12
+ raise Michael::Error, 'config is nil' if config.nil?
13
+
14
+ @config = config
15
+ end
16
+
17
+ def validate(token)
18
+ raise Michael::Error, 'access token must contain `repo` scope' unless scopes(token).include?('repo')
19
+ end
20
+
21
+ def store(token)
22
+ config.set(:token, value: token)
23
+ end
24
+
25
+ def token
26
+ config.fetch(:token)
27
+ end
28
+
29
+ private
30
+
31
+ attr_reader :config
32
+
33
+ def scopes(token)
34
+ Octokit::Client.new(access_token: token).scopes
35
+ rescue Octokit::Unauthorized
36
+ raise Michael::Error, 'invalid access token'
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end