nya_pr 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,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NyaPr
4
+ module PullRequest
5
+ class CsvStore
6
+ HEADERS = %w[
7
+ repository
8
+ number
9
+ title
10
+ author
11
+ draft
12
+ created_at
13
+ updated_at
14
+ html_url
15
+ ].freeze
16
+
17
+ attr_reader :path
18
+
19
+ def initialize(path)
20
+ @path = path
21
+ end
22
+
23
+ def write(pull_requests)
24
+ CSV.open(path, 'w', write_headers: true, headers: HEADERS) do |csv|
25
+ pull_requests.each do |pull_request|
26
+ csv << HEADERS.map { |header| pull_request[header] }
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NyaPr
4
+ module PullRequest
5
+ class Finder
6
+ include NyaPr::Request
7
+
8
+ MAX_PULL_REQUESTS = 100
9
+ MAX_PER_PAGE = 100
10
+
11
+ attr_reader :client,
12
+ :repositories,
13
+ :limit,
14
+ :progress_enabled
15
+
16
+ def initialize(
17
+ client,
18
+ repositories,
19
+ limit: MAX_PULL_REQUESTS,
20
+ progress_enabled: true
21
+ )
22
+ @client = client
23
+ @repositories = repositories
24
+ @limit = limit
25
+ @progress_enabled = progress_enabled
26
+ end
27
+
28
+ def find
29
+ NyaPr.logger.info(
30
+ "Searching for up to #{limit} open pull requests " \
31
+ "in #{repositories.size} repositories"
32
+ )
33
+
34
+ progress_bar = build_progress
35
+ pull_requests = collect_pull_requests(progress_bar)
36
+
37
+ progress_bar.finish
38
+
39
+ NyaPr.logger.info(
40
+ "Finished searching for pull requests: found #{pull_requests.size}"
41
+ )
42
+
43
+ pull_requests
44
+ end
45
+
46
+ private
47
+
48
+ def collect_pull_requests(progress_bar)
49
+ pull_requests = []
50
+
51
+ repositories.each do |repository|
52
+ break if pull_requests.size >= limit
53
+
54
+ begin
55
+ next unless repository.fetch('has_pull_requests', true)
56
+
57
+ remaining = limit - pull_requests.size
58
+
59
+ pull_requests.concat(
60
+ pull_requests_for(repository, remaining)
61
+ )
62
+ ensure
63
+ progress_bar.advance
64
+ end
65
+ end
66
+
67
+ pull_requests
68
+ end
69
+
70
+ def pull_requests_for(repository, remaining)
71
+ NyaPr.logger.debug(
72
+ "Checking pull requests in #{repository.fetch('full_name')}"
73
+ )
74
+
75
+ pull_requests = []
76
+ page = 1
77
+
78
+ loop do
79
+ per_page = page_size(remaining, pull_requests.size)
80
+ batch = get_batch(repository, per_page, page)
81
+
82
+ break if batch.empty?
83
+
84
+ pull_requests.concat(
85
+ batch.map do |pull_request|
86
+ normalize(repository, pull_request)
87
+ end
88
+ )
89
+
90
+ break if batch.size < per_page
91
+ break if pull_requests.size >= remaining
92
+
93
+ page += 1
94
+ end
95
+
96
+ pull_requests
97
+ end
98
+
99
+ def page_size(remaining, collected)
100
+ [remaining - collected, MAX_PER_PAGE].min
101
+ end
102
+
103
+ def get_batch(repository, per_page, page)
104
+ get(
105
+ "/repos/#{repository.fetch('full_name')}/pulls",
106
+ state: 'open',
107
+ sort: 'updated',
108
+ direction: 'desc',
109
+ per_page: per_page,
110
+ page: page
111
+ )
112
+ end
113
+
114
+ def build_progress
115
+ Progress.new(
116
+ '🐾 Checking pull requests',
117
+ total: repositories.size,
118
+ enabled: progress_enabled && $stderr.tty?
119
+ )
120
+ end
121
+
122
+ def normalize(repository, pull_request)
123
+ {
124
+ 'repository' => repository.fetch('full_name'),
125
+ 'number' => pull_request.fetch('number'),
126
+ 'title' => pull_request.fetch('title'),
127
+ 'author' => pull_request.dig('user', 'login'),
128
+ 'draft' => pull_request.fetch('draft', false),
129
+ 'created_at' => pull_request['created_at'],
130
+ 'updated_at' => pull_request['updated_at'],
131
+ 'html_url' => pull_request.fetch('html_url')
132
+ }
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NyaPr
4
+ module PullRequest
5
+ class Printer
6
+ def print(pull_requests)
7
+ grouped_pull_requests(pull_requests).each do |repository, repository_pull_requests|
8
+ print_repository(repository, repository_pull_requests)
9
+ end
10
+ end
11
+
12
+ private
13
+
14
+ def grouped_pull_requests(pull_requests)
15
+ pull_requests.group_by do |pull_request|
16
+ pull_request['repository']
17
+ end
18
+ end
19
+
20
+ def print_repository(repository, pull_requests)
21
+ puts
22
+ puts repository
23
+ puts '-' * repository.length
24
+
25
+ pull_requests.each do |pull_request|
26
+ puts format_pull_request(pull_request)
27
+ puts
28
+ end
29
+ end
30
+
31
+ def format_pull_request(pull_request)
32
+ draft = pull_request['draft'] ? ' [DRAFT]' : ''
33
+
34
+ <<~OUTPUT.chomp
35
+ ##{pull_request['number']}#{draft} #{pull_request['title']}
36
+ Author: @#{pull_request['author']}
37
+ #{pull_request['html_url']}
38
+ OUTPUT
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NyaPr
4
+ module Repository
5
+ class Collector
6
+ attr_reader :client, :username, :owners, :store, :options
7
+
8
+ def initialize(client, username, owners, store, **options)
9
+ @client = client
10
+ @username = username
11
+ @owners = owners
12
+ @store = store
13
+ @options = options
14
+ end
15
+
16
+ def collect
17
+ return load_cached if use_cache?
18
+
19
+ discover
20
+ end
21
+
22
+ private
23
+
24
+ def load_cached
25
+ NyaPr.logger.info("Loading repositories from #{store.path}")
26
+
27
+ reject_archived(store.read).tap do |repositories|
28
+ NyaPr.logger.info(
29
+ "Loaded #{repositories.size} repositories from cache"
30
+ )
31
+ end
32
+ end
33
+
34
+ def discover
35
+ NyaPr.logger.info(
36
+ 'Repository cache unavailable or refresh requested'
37
+ )
38
+
39
+ repositories = Finder.
40
+ new(client, username).
41
+ repositories_for(owners)
42
+
43
+ repositories = filter_contributed(reject_archived(repositories))
44
+
45
+ store.write(repositories)
46
+
47
+ NyaPr.logger.info(
48
+ "Saved #{repositories.size} repositories to #{store.path}"
49
+ )
50
+
51
+ repositories
52
+ end
53
+
54
+ def filter_contributed(repositories)
55
+ own, external = repositories.partition do |repository|
56
+ repository_owner(repository).casecmp?(username)
57
+ end
58
+
59
+ own + Filter.new(client, username, progress_enabled: options[:progress]).contributed(external)
60
+ end
61
+
62
+ def reject_archived(repositories)
63
+ return repositories unless options[:skip_archived]
64
+
65
+ filtered = repositories.reject do |repository|
66
+ repository['archived']
67
+ end
68
+
69
+ NyaPr.logger.info(
70
+ "Skipped #{repositories.size - filtered.size} archived repositories"
71
+ )
72
+
73
+ filtered
74
+ end
75
+
76
+ def repository_owner(repository)
77
+ repository.dig('owner', 'login').to_s
78
+ end
79
+
80
+ def use_cache?
81
+ store.available? && !options[:refresh]
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NyaPr
4
+ module Repository
5
+ class CsvStore
6
+ HEADERS = %w[
7
+ full_name
8
+ html_url
9
+ visibility
10
+ private
11
+ archived
12
+ has_pull_requests
13
+ ].freeze
14
+
15
+ attr_reader :path
16
+
17
+ def initialize(path)
18
+ @path = path
19
+ end
20
+
21
+ def available?
22
+ return false unless File.file?(path)
23
+
24
+ CSV.open(path, headers: true) { |csv| !csv.first.nil? }
25
+ end
26
+
27
+ def read
28
+ CSV.read(path, headers: true).map do |row|
29
+ {
30
+ 'full_name' => row['full_name'],
31
+ 'html_url' => row['html_url'],
32
+ 'visibility' => row['visibility'],
33
+ 'private' => row['private'] == 'true',
34
+ 'archived' => row['archived'] == 'true',
35
+ 'has_pull_requests' => row['has_pull_requests'] != 'false'
36
+ }
37
+ end
38
+ end
39
+
40
+ def write(repositories)
41
+ CSV.open(path, 'w', write_headers: true, headers: HEADERS) do |csv|
42
+ repositories.each do |repository|
43
+ csv << HEADERS.map { |header| repository[header] }
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NyaPr
4
+ module Repository
5
+ class Filter
6
+ include NyaPr::Request
7
+
8
+ DEFAULT_WORKERS = 10
9
+
10
+ attr_reader :client, :username, :workers, :progress_enabled
11
+
12
+ def initialize(client, username, workers: DEFAULT_WORKERS, progress_enabled: true)
13
+ @client = client
14
+ @username = username
15
+ @workers = workers
16
+ @progress_enabled = progress_enabled
17
+ end
18
+
19
+ def contributed(repositories)
20
+ log_start(repositories)
21
+
22
+ progress_bar = build_progress(repositories)
23
+ result = process_repositories(repositories, progress_bar)
24
+
25
+ progress_bar.finish
26
+
27
+ log_result(result)
28
+ result
29
+ end
30
+
31
+ private
32
+
33
+ def log_start(repositories)
34
+ NyaPr.logger.info(
35
+ "Checking contributions in #{repositories.size} repositories " \
36
+ "using #{workers} workers"
37
+ )
38
+ end
39
+
40
+ def build_progress(repositories)
41
+ Progress.new(
42
+ '🐈 Checking repositories',
43
+ total: repositories.size,
44
+ enabled: progress_enabled && $stderr.tty?
45
+ )
46
+ end
47
+
48
+ def process_repositories(repositories, progress_bar)
49
+ queue = build_queue(repositories)
50
+ result = Array.new(repositories.size)
51
+
52
+ build_workers(queue, result, progress_bar).
53
+ each(&:value)
54
+
55
+ result.compact
56
+ end
57
+
58
+ def build_workers(queue, result, progress_bar)
59
+ Array.new(workers) do
60
+ Thread.new do
61
+ process_queue(queue, result, progress_bar)
62
+ end
63
+ end
64
+ end
65
+
66
+ def log_result(result)
67
+ NyaPr.logger.info(
68
+ "Finished contribution check: #{result.size} repositories matched"
69
+ )
70
+ end
71
+
72
+ def build_queue(repositories)
73
+ Queue.new.tap do |queue|
74
+ repositories.each_with_index do |repository, index|
75
+ queue << [index, repository]
76
+ end
77
+ end
78
+ end
79
+
80
+ def process_queue(queue, result, progress_bar)
81
+ loop do
82
+ index, repository = queue.pop(true)
83
+
84
+ if contributed_to?(repository)
85
+ result[index] = repository
86
+
87
+ NyaPr.logger.debug(
88
+ "Found contributions in #{repository.fetch('full_name')}"
89
+ )
90
+ end
91
+
92
+ progress_bar.advance
93
+ rescue ThreadError
94
+ break
95
+ end
96
+ end
97
+
98
+ def contributed_to?(repository)
99
+ commits = get(
100
+ "/repos/#{repository.fetch('full_name')}/commits",
101
+ author: username,
102
+ per_page: 1
103
+ )
104
+
105
+ commits.any?
106
+ rescue NyaPr::Error => e
107
+ raise unless e.status == 409
108
+
109
+ NyaPr.logger.debug(
110
+ "Skipping empty repository #{repository.fetch('full_name')}"
111
+ )
112
+
113
+ false
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NyaPr
4
+ module Repository
5
+ class Finder
6
+ include NyaPr::Request
7
+
8
+ attr_reader :client, :username
9
+
10
+ def initialize(client, username)
11
+ @client = client
12
+ @username = username
13
+ end
14
+
15
+ def repositories_for(owners)
16
+ owners = Array(owners)
17
+
18
+ NyaPr.logger.info(
19
+ "Starting repository collection for #{owners.size} owners"
20
+ )
21
+
22
+ repositories = owners.flat_map do |owner|
23
+ collect_repositories_for(owner)
24
+ end
25
+
26
+ NyaPr.logger.info(
27
+ "Finished repository collection: #{repositories.size} repositories found"
28
+ )
29
+
30
+ repositories
31
+ end
32
+
33
+ private
34
+
35
+ def collect_repositories_for(owner)
36
+ NyaPr.logger.info("Collecting repositories for #{owner}")
37
+
38
+ repositories_for_owner(owner).tap do |repositories|
39
+ NyaPr.logger.info(
40
+ "Collected #{repositories.size} repositories for #{owner}"
41
+ )
42
+ end
43
+ end
44
+
45
+ def repositories_for_owner(owner)
46
+ return own_repositories if owner.casecmp?(username)
47
+
48
+ type = owner_type(owner)
49
+
50
+ NyaPr.logger.debug("#{owner} is a GitHub #{type}")
51
+
52
+ case type
53
+ when 'Organization'
54
+ organization_repositories(owner)
55
+ when 'User'
56
+ user_repositories(owner)
57
+ else
58
+ raise NyaPr::Error, "Unsupported GitHub owner type: #{owner}"
59
+ end
60
+ end
61
+
62
+ def owner_type(owner)
63
+ get("/users/#{owner}").fetch('type')
64
+ end
65
+
66
+ def own_repositories
67
+ paginate(
68
+ '/user/repos',
69
+ affiliation: 'owner',
70
+ visibility: 'all'
71
+ )
72
+ end
73
+
74
+ def organization_repositories(owner)
75
+ paginate(
76
+ "/orgs/#{owner}/repos",
77
+ type: 'all'
78
+ )
79
+ end
80
+
81
+ def user_repositories(owner)
82
+ paginate(
83
+ "/users/#{owner}/repos",
84
+ type: 'owner'
85
+ )
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'oj'
4
+
5
+ module NyaPr
6
+ module Request
7
+ include NyaPr::Connection
8
+
9
+ MAX_PAGES = 1_000
10
+ PER_PAGE = 100
11
+
12
+ def get(path, params = {})
13
+ _response, body = do_get(path, params)
14
+
15
+ body
16
+ end
17
+
18
+ def paginate(path, params = {})
19
+ params = params.merge(per_page: params.fetch(:per_page, PER_PAGE))
20
+
21
+ page = 1
22
+ result = []
23
+
24
+ loop do
25
+ raise NyaPr::Error, 'Pagination limit exceeded' if page > MAX_PAGES
26
+
27
+ NyaPr.logger.debug("Fetching page #{page} for #{path}")
28
+ response, body = do_get(path, params.merge(page: page))
29
+
30
+ raise NyaPr::Error, 'Expected paginated response to be an array' unless body.is_a?(Array)
31
+
32
+ result.concat(body)
33
+
34
+ break if body.empty?
35
+ break unless next_page?(response)
36
+
37
+ page += 1
38
+ end
39
+
40
+ result
41
+ end
42
+
43
+ private
44
+
45
+ def do_get(path, params = {})
46
+ prepared_path = prepare(path)
47
+
48
+ NyaPr.logger.debug("GET #{prepared_path} #{params.inspect}")
49
+
50
+ response = connection(client).get(prepared_path, params)
51
+ body = parse_body(response)
52
+
53
+ NyaPr.logger.debug(
54
+ "GET #{prepared_path} -> HTTP #{response.status}"
55
+ )
56
+
57
+ raise_on_error!(response, body, prepared_path)
58
+
59
+ [response, body]
60
+ end
61
+
62
+ def parse_body(response)
63
+ return nil if response.body.nil? || response.body.empty?
64
+
65
+ Oj.load(response.body)
66
+ rescue Oj::ParseError
67
+ raise NyaPr::Error, "Failed to parse response: #{response.body}"
68
+ end
69
+
70
+ def raise_on_error!(response, body, path)
71
+ return if response.success?
72
+
73
+ message =
74
+ if body.is_a?(Hash)
75
+ body['message'] || body
76
+ else
77
+ body
78
+ end
79
+
80
+ raise NyaPr::Error.new(
81
+ "GET #{path}: GitHub API error #{response.status}: #{message}",
82
+ status: response.status
83
+ )
84
+ end
85
+
86
+ def next_page?(response)
87
+ response.headers['link']&.include?('rel="next"')
88
+ end
89
+
90
+ def prepare(path)
91
+ path.
92
+ delete_prefix('/').
93
+ gsub(%r{//}, '/').
94
+ gsub(%r{/+\z}, '')
95
+ end
96
+ end
97
+ end