michael 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,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'parallel'
4
+
5
+ require_relative '../../command'
6
+ require_relative '../../models/github/pull_request'
7
+ require_relative '../../models/guard'
8
+ require_relative '../../models/pull_request_formatter'
9
+ require_relative '../../models/repository_formatter'
10
+ require_relative '../../models/github/user'
11
+
12
+ module Michael
13
+ module Commands
14
+ class Repos
15
+ class PullRequests < Models::Guard
16
+ def initialize(options)
17
+ super()
18
+
19
+ @prs = Michael::Models::Github::PullRequest.new
20
+ @user = Michael::Models::Github::User.new
21
+ @repos = repos_config.fetch(:repos)
22
+ abort 'No repositories configured' if @repos.nil? || @repos.empty?
23
+
24
+ @options = options
25
+ end
26
+
27
+ def execute(out: $stdout)
28
+ list = get_repos_with_spinner(out)
29
+
30
+ print_good_prs(out, repos_with_prs(list))
31
+ print_repos_w_no_prs(out, repos_no_prs(list)) if options[:show_empty]
32
+ print_broken_repos(out, list.select { |item| item[:state] == :failed })
33
+ end
34
+
35
+ private
36
+
37
+ attr_reader :prs, :options, :repos, :user
38
+
39
+ def get_repos_with_spinner(out)
40
+ progress = 0
41
+ spin = spinner(
42
+ "[:spinner] :progress/#{repos.length} processing...",
43
+ output: out
44
+ )
45
+
46
+ spin.update(progress: 0)
47
+
48
+ result = Parallel.map(repos, in_threads: 5) do |repo|
49
+ progress += 1
50
+ spin.update(progress: progress)
51
+ spin.spin
52
+ out = prs.process_repo(repo)
53
+ spin.spin
54
+ out
55
+ end
56
+
57
+ spin.stop('done')
58
+
59
+ result
60
+ end
61
+
62
+ def repos_no_prs(list_all)
63
+ list_all
64
+ .select { |item| item[:state] == :success && item[:prs].empty? }
65
+ .map { |item| item[:repo] }
66
+ end
67
+
68
+ def repos_with_prs(list)
69
+ list = needs_review(list) if options[:needs_review]
70
+ list = skip_self(list) if options[:skip_self]
71
+ list = hide_approved_or_commented(list) if options[:hide_approved]
72
+ list = actionable(list) if options[:actionable]
73
+ list = select_repos_w_prs(list)
74
+
75
+ list.map { |item| Michael::Models::RepositoryFormatter.new(item[:repo], item[:prs]).pretty }
76
+ end
77
+
78
+ def skip_self(list)
79
+ list.each do |item|
80
+ item[:prs] = item[:prs].reject { |pr| pr.author == user.username }
81
+ end
82
+ end
83
+
84
+ def hide_approved_or_commented(list)
85
+ list.each do |item|
86
+ item[:prs] = item[:prs].reject do |pr|
87
+ pr.reviews.all? { |review| review.approved? || review.commented? }
88
+ end
89
+ end
90
+ end
91
+
92
+ def select_repos_w_prs(list)
93
+ list.select { |item| item[:state] == :success && item[:prs].any? }
94
+ end
95
+
96
+ def needs_review(list)
97
+ list.each do |item|
98
+ item[:prs] = item[:prs].reject do |pr|
99
+ pr.reviews.any?
100
+ end
101
+ end
102
+ end
103
+
104
+ def actionable(list)
105
+ list.each do |item|
106
+ item[:prs] = item[:prs].reject do |pr|
107
+ reviewed = pr.reviews.any? { |review| review.author == user.username }
108
+ new_changes = pr.last_update_head?
109
+
110
+ reviewed && !new_changes
111
+ end
112
+ end
113
+ end
114
+
115
+ def print_good_prs(out, list)
116
+ out.puts "\n" + list.join("\n\n")
117
+ end
118
+
119
+ def print_repos_w_no_prs(out, list)
120
+ out.puts "\nRepos with no opened PRs: #{list.join(', ')}" if list.any?
121
+ end
122
+
123
+ def print_broken_repos(out, list)
124
+ return if list.empty?
125
+
126
+ list = list.map { |repo| pastel.on_red.black(repo[:repo]) }
127
+ out.puts "\nBad repos: #{list.join(', ')}"
128
+ end
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'thor'
4
+
5
+ module Michael
6
+ module Commands
7
+ class Repos < Thor
8
+ namespace :repos
9
+
10
+ desc 'edit', 'Edit list of repos to follow'
11
+ method_option :help, aliases: '-h', type: :boolean,
12
+ desc: 'Display usage information'
13
+ def edit(*)
14
+ if options[:help]
15
+ invoke :help, ['edit']
16
+ else
17
+ require_relative 'repos/edit'
18
+ Michael::Commands::Repos::Edit.new(options).execute
19
+ end
20
+ end
21
+
22
+ desc 'prs', 'List open PRs'
23
+ method_option :help, aliases: '-h', type: :boolean,
24
+ desc: 'Display usage information'
25
+ method_option :show_empty, aliases: '-e', type: :boolean,
26
+ desc: 'List watched repos that have no open PRs'
27
+ method_option :skip_self, aliases: '-s', type: :boolean,
28
+ desc: 'Skip PRs created by the current user'
29
+ method_option :hide_approved, aliases: '-p', type: :boolean,
30
+ desc: 'Hide PRs that are approved and have'\
31
+ ' no requests for changes'
32
+ method_option :needs_review, aliases: '-n', type: :boolean,
33
+ desc: 'Show only ones which do not have any '\
34
+ 'reviews yet'
35
+ method_option :actionable, aliases: '-t', type: :boolean,
36
+ desc: 'List only actionable PRs. These are '\
37
+ 'PRs which you did not review yet, or ones which were updated after '\
38
+ 'your review'
39
+ def prs(*)
40
+ if options[:help]
41
+ invoke :help, ['prs']
42
+ else
43
+ require_relative 'repos/pull_requests'
44
+ Michael::Commands::Repos::PullRequests.new(options).execute
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+
5
+ require 'tty-config'
6
+
7
+ module Michael
8
+ module Models
9
+ class Configuration
10
+ CONFIG_ALLOWED_SYMBOLS = %i[set append fetch remove].freeze
11
+
12
+ def initialize(
13
+ config_dir: default_config_dir, config_name: default_filename
14
+ )
15
+ @config_dir = config_dir
16
+ @config_name = config_name
17
+ @config = create_config
18
+ end
19
+
20
+ def nuke
21
+ create_config.write(force: true)
22
+ self
23
+ end
24
+
25
+ def config_file_path
26
+ config_dir + '/' + config_name + '.yml'
27
+ end
28
+
29
+ def respond_to_missing?(symbol)
30
+ CONFIG_ALLOWED_SYMBOLS.include?(symbol)
31
+ end
32
+
33
+ def method_missing(symbol, *args)
34
+ return super unless CONFIG_ALLOWED_SYMBOLS.include?(symbol)
35
+
36
+ mkdir_once
37
+ config.read if config.exist?
38
+ resp = config.public_send(symbol, *args)
39
+ config.write(force: true)
40
+
41
+ resp
42
+ end
43
+
44
+ private
45
+
46
+ attr_reader :config_dir, :config_name, :config
47
+
48
+ def create_config
49
+ config = TTY::Config.new
50
+ config.filename = config_name
51
+ config.append_path(config_dir)
52
+
53
+ config
54
+ end
55
+
56
+ def default_config_dir
57
+ "#{ENV['HOME']}/.config/kudrykv/michael"
58
+ end
59
+
60
+ def default_filename
61
+ 'config'
62
+ end
63
+
64
+ def mkdir_once
65
+ @mkdir_once ||= FileUtils.mkdir_p(config_dir)
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'octokit'
4
+
5
+ require_relative '../configuration'
6
+
7
+ module Michael
8
+ module Models
9
+ module Github
10
+ class OctokitInitializer
11
+ attr_reader :octokit
12
+
13
+ def initialize
14
+ token = Configuration.new.fetch(:token)
15
+ @octokit = Octokit::Client.new(access_token: token)
16
+ @octokit.auto_paginate = true
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'reviewer'
4
+ require_relative 'team'
5
+
6
+ module Michael
7
+ module Models
8
+ module Github
9
+ class PRWrapper
10
+ attr_accessor :statuses, :reviews
11
+
12
+ def initialize(pull_request)
13
+ @pr = pull_request
14
+ @statuses = []
15
+ @reviews = []
16
+ end
17
+
18
+ def title
19
+ pr[:title]
20
+ end
21
+
22
+ def number
23
+ pr[:number]
24
+ end
25
+
26
+ def author
27
+ pr[:user][:login]
28
+ end
29
+
30
+ def head_sha
31
+ pr[:head][:sha]
32
+ end
33
+
34
+ def reviewed?
35
+ reviewers.any? || teams.any?
36
+ end
37
+
38
+ def reviewers
39
+ pr[:requested_reviewers].map { |reviewer| Reviewer.new(reviewer) }
40
+ end
41
+
42
+ def teams
43
+ pr[:requested_teams].map { |team| Team.new(team) }
44
+ end
45
+
46
+ def labels
47
+ pr[:labels].map { |label| label[:name] }
48
+ end
49
+
50
+ def requested_changes
51
+ reviews.select(&:changes_requested?).map(&:author)
52
+ end
53
+
54
+ def commented_on_pr
55
+ reviews
56
+ .reject { |review| author == review.author }
57
+ .select(&:commented?).map(&:author)
58
+ end
59
+
60
+ def last_update_head?
61
+ pr_last_update = pr[:updated_at]
62
+ review_last_update = pr.reviews&.map(&:submitted_at)&.sort&.pop
63
+
64
+ return false unless review_last_update
65
+
66
+ pr_last_update > review_last_update
67
+ end
68
+
69
+ def last_updated_at
70
+ updates = [pr[:updated_at]]
71
+
72
+ updates.concat pr.statuses.map(&:updated_at) unless pr.statuses.nil? || pr.statuses.any?
73
+ updates.concat pr.reviews.map(&:submitted_at) unless pr.reviews.nil? || pr.reviews.any?
74
+
75
+ updates.sort.pop
76
+ end
77
+
78
+ private
79
+
80
+ attr_reader :pr
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'octokit'
4
+
5
+ require_relative 'octokit_initializer'
6
+ require_relative 'pr_wrapper'
7
+ require_relative 'review'
8
+ require_relative 'status'
9
+
10
+ module Michael
11
+ module Models
12
+ module Github
13
+ class PullRequest < OctokitInitializer
14
+ def process_repo(org_repo, hsh = {})
15
+ list = search(org_repo, hsh)
16
+
17
+ return {repo: org_repo, state: :failed} unless list
18
+
19
+ {repo: org_repo, state: :success, prs: list}
20
+ end
21
+
22
+ private
23
+
24
+ def search(org_repo, state: 'open', with_statuses: true, with_reviews: true)
25
+ octokit.pull_requests(org_repo, state: state).map do |pr|
26
+ pr = PRWrapper.new(pr)
27
+ pr.statuses = statuses(org_repo, pr.head_sha) if with_statuses
28
+ pr.reviews = reviews(org_repo, pr.number) if with_reviews
29
+
30
+ pr
31
+ end
32
+ rescue Octokit::InvalidRepository
33
+ false
34
+ end
35
+
36
+ def statuses(org_repo, ref)
37
+ combined = octokit.combined_status(org_repo, ref)
38
+ combined[:statuses]&.map { |status| Status.new(status) }
39
+ end
40
+
41
+ def reviews(org_repo, pr_number)
42
+ octokit
43
+ .pull_request_reviews(org_repo, pr_number)
44
+ .map { |review| Review.new(review) }
45
+ .group_by(&:author).to_a
46
+ .map { |_author, reviews| reviews.sort_by(&:submitted_at).pop }
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Michael
4
+ module Models
5
+ module Github
6
+ class Review
7
+ def initialize(review)
8
+ @review = review
9
+ end
10
+
11
+ def author
12
+ review[:user][:login]
13
+ end
14
+
15
+ def state
16
+ review[:state].to_sym
17
+ end
18
+
19
+ def submitted_at
20
+ review[:submitted_at]
21
+ end
22
+
23
+ def approved?
24
+ state == :APPROVED
25
+ end
26
+
27
+ def changes_requested?
28
+ state == :CHANGES_REQUESTED
29
+ end
30
+
31
+ def commented?
32
+ state == :COMMENTED
33
+ end
34
+
35
+ private
36
+
37
+ attr_reader :review
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Michael
4
+ module Models
5
+ module Github
6
+ class Reviewer
7
+ def initialize(reviewer)
8
+ @reviewer = reviewer
9
+ end
10
+
11
+ private
12
+
13
+ attr_reader :reviewer
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Michael
4
+ module Models
5
+ module Github
6
+ class Status
7
+ def initialize(status)
8
+ @status = status
9
+ end
10
+
11
+ def state
12
+ status[:state].to_sym
13
+ end
14
+
15
+ def updated_at
16
+ status[:updated_at]
17
+ end
18
+
19
+ private
20
+
21
+ attr_reader :status
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Michael
4
+ module Models
5
+ module Github
6
+ class Team
7
+ def initialize(team)
8
+ @team = team
9
+ end
10
+
11
+ private
12
+
13
+ attr_reader :team
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'octokit'
4
+
5
+ require_relative '../configuration'
6
+
7
+ module Michael
8
+ module Models
9
+ module Github
10
+ class TokenValidator
11
+ class << self
12
+ def token_valid?(token)
13
+ scopes = Octokit::Client.new(access_token: token).scopes
14
+
15
+ return true if scopes.include?('repo')
16
+
17
+ puts 'Token should have `repo` scope'
18
+ false
19
+ rescue StandardError
20
+ false
21
+ end
22
+
23
+ def save_token(token)
24
+ Configuration.new.set(:token, value: token)
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'octokit_initializer'
4
+
5
+ module Michael
6
+ module Models
7
+ module Github
8
+ class User < OctokitInitializer
9
+ def username
10
+ me[:login]
11
+ end
12
+
13
+ private
14
+
15
+ def me
16
+ @me ||= octokit.user
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../command'
4
+ require_relative 'github/token_validator'
5
+
6
+ module Michael
7
+ module Models
8
+ class Guard < Michael::Command
9
+ attr_reader :config, :repos_config
10
+
11
+ def initialize
12
+ @config = Configuration.new
13
+ @repos_config = Configuration.new(config_name: 'repositories')
14
+
15
+ token_valid = Github::TokenValidator.token_valid?(config.fetch(:token))
16
+ abort 'Your token is invalid' unless token_valid
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pastel'
4
+ require 'ruby-duration'
5
+
6
+ module Michael
7
+ module Models
8
+ class PullRequestFormatter
9
+ class << self
10
+ def pretty(reponame, prs)
11
+ [
12
+ pastel.bold(reponame + ':'),
13
+ prs.map { |pr| PullRequestFormatter.new(pr).pretty }.join("\n")
14
+ ].join("\n")
15
+ end
16
+ end
17
+
18
+ def initialize(pr)
19
+ @pastel = Pastel.new
20
+ @pr = pr
21
+ end
22
+
23
+ def pretty
24
+ format(
25
+ '#%<number>s %<statuses>s %<reviews>s %<title>s %<variable>s',
26
+ number: pastel.bold(pr.number),
27
+ statuses: statuses_in_dots,
28
+ reviews: reviews_in_dots,
29
+ title: pr.title,
30
+ variable: variable_line
31
+ )
32
+ end
33
+
34
+ private
35
+
36
+ attr_reader :pastel, :pr
37
+
38
+ def statuses_in_dots
39
+ return '-' if pr.statuses.empty?
40
+
41
+ pr.statuses.map { |status| dot_status[status.state] }.join
42
+ end
43
+
44
+ def reviews_in_dots
45
+ return pastel.yellow('-') unless pr.reviewed? || pr.reviews.any?
46
+ return pastel.yellow('.') if pr.reviews.empty?
47
+
48
+ pr.reviews
49
+ .reject { |review| review.author == pr.author }
50
+ .map { |review| dot_status[review.state] }
51
+ .join
52
+ end
53
+
54
+ def variable_line
55
+ [
56
+ labels_stringified(pr.labels),
57
+ pastel.cyan(pr.author),
58
+ last_update,
59
+ who_req_changes_stringified,
60
+ who_commented_stringified,
61
+ ].reject(&:empty?).join(' ')
62
+ end
63
+
64
+ def labels_stringified(labels)
65
+ return '' if labels.empty?
66
+
67
+ pastel.bold.yellow("[#{labels.join(', ')}]")
68
+ end
69
+
70
+ def who_commented_stringified
71
+ names = pr.commented_on_pr
72
+ return '' if names.empty?
73
+
74
+ '| Commented: ' + names.join(', ')
75
+ end
76
+
77
+ def who_req_changes_stringified
78
+ names = pr.requested_changes
79
+ return '' if names.empty?
80
+
81
+ '| ' + pastel.bold('Requested changes: ') << names.map { |name| pastel.underscore(name) }.join(', ')
82
+ end
83
+
84
+ def last_update
85
+ time_diff = Duration.new(Time.now - pr.last_updated_at)
86
+ 'last update ' + last_update_when(time_diff) + ' ago'
87
+ end
88
+
89
+ def last_update_when(duration)
90
+ if duration.weeks.positive?
91
+ pastel.yellow.bold("#{duration.weeks} week(s)")
92
+ elsif duration.days.positive?
93
+ pastel.yellow("#{duration.days} day(s)")
94
+ elsif duration.hours.positive?
95
+ "#{duration.hours} hour(s)"
96
+ elsif duration.minutes.positive?
97
+ "#{duration.minutes} minute(s)"
98
+ else
99
+ 'seconds'
100
+ end
101
+ end
102
+
103
+ def dot_status
104
+ @dot_status ||= {
105
+ success: pastel.green('+'),
106
+ pending: pastel.yellow('.'),
107
+ error: pastel.red('x'),
108
+ failure: pastel.red('x'),
109
+ APPROVED: pastel.green('^'),
110
+ CHANGES_REQUESTED: pastel.red('X'),
111
+ COMMENTED: '?'
112
+ }
113
+ end
114
+ end
115
+ end
116
+ end