michael 0.1.1 → 0.2.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.rubocop.yml +4 -1
- data/Gemfile.lock +6 -71
- data/exe/michael +2 -2
- data/lib/michael.rb +1 -0
- data/lib/michael/cli.rb +15 -1
- data/lib/michael/commands/auth.rb +11 -21
- data/lib/michael/commands/repos.rb +39 -2
- data/lib/michael/commands/repos/edit.rb +13 -9
- data/lib/michael/commands/repos/pull_requests.rb +38 -97
- data/lib/michael/constants.rb +7 -0
- data/lib/michael/models/pull_request.rb +139 -0
- data/lib/michael/models/repository.rb +45 -0
- data/lib/michael/models/review.rb +57 -0
- data/lib/michael/models/status.rb +43 -0
- data/lib/michael/models/user.rb +18 -0
- data/lib/michael/services/configuration.rb +44 -0
- data/lib/michael/{models/github/octokit_initializer.rb → services/github/initializer.rb} +9 -7
- data/lib/michael/services/github/pull_requests.rb +46 -0
- data/lib/michael/services/github/token.rb +41 -0
- data/lib/michael/services/github/users.rb +16 -0
- data/lib/michael/services/repositories.rb +34 -0
- data/lib/michael/version.rb +1 -1
- data/michael.gemspec +2 -18
- metadata +19 -245
- data/lib/michael/command.rb +0 -131
- data/lib/michael/models/configuration.rb +0 -69
- data/lib/michael/models/github/pr_wrapper.rb +0 -84
- data/lib/michael/models/github/pull_request.rb +0 -51
- data/lib/michael/models/github/review.rb +0 -41
- data/lib/michael/models/github/reviewer.rb +0 -17
- data/lib/michael/models/github/status.rb +0 -25
- data/lib/michael/models/github/team.rb +0 -17
- data/lib/michael/models/github/token_validator.rb +0 -30
- data/lib/michael/models/github/user.rb +0 -21
- data/lib/michael/models/guard.rb +0 -20
- data/lib/michael/models/pull_request_formatter.rb +0 -116
- data/lib/michael/models/repository_formatter.rb +0 -26
@@ -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,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 '
|
5
|
+
require_relative 'token'
|
6
6
|
|
7
7
|
module Michael
|
8
|
-
module
|
8
|
+
module Services
|
9
9
|
module Github
|
10
|
-
class
|
10
|
+
class Initializer < Token
|
11
11
|
attr_reader :octokit
|
12
12
|
|
13
|
-
def initialize
|
14
|
-
|
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
|