gh-issues-stats 0.4.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.
- checksums.yaml +7 -0
- data/bin/gh-issues-stats +6 -0
- data/lib/gh-issues-stats.rb +16 -0
- data/lib/github/issues/app/base.rb +128 -0
- data/lib/github/issues/app/labels.rb +38 -0
- data/lib/github/issues/app/mixins/chart.rb +116 -0
- data/lib/github/issues/app/mixins/exec.rb +87 -0
- data/lib/github/issues/app/mixins/legend.rb +40 -0
- data/lib/github/issues/app/mixins/options.rb +75 -0
- data/lib/github/issues/app/mixins/statistics.rb +42 -0
- data/lib/github/issues/app/mixins/table.rb +109 -0
- data/lib/github/issues/app/month.rb +31 -0
- data/lib/github/issues/app/year.rb +32 -0
- data/lib/github/issues/app.rb +21 -0
- data/lib/github/issues/database.rb +133 -0
- data/lib/github/issues/mixins/database/marshal.rb +37 -0
- data/lib/github/issues/mixins/database/schema.rb +38 -0
- data/lib/github/issues/mixins/database/store.rb +100 -0
- data/lib/github/issues/mixins/fetch.rb +126 -0
- data/lib/github/issues/mixins/group.rb +105 -0
- data/lib/github/issues/mixins/statistics.rb +77 -0
- data/lib/github/issues/version.rb +7 -0
- data/lib/github/issues.rb +200 -0
- metadata +222 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Github
|
|
4
|
+
class Issues
|
|
5
|
+
module App
|
|
6
|
+
##
|
|
7
|
+
# Command to show issues created per month in a given year
|
|
8
|
+
class Month < Base
|
|
9
|
+
parameter 'YEAR', 'the year to filter by', required: true do |value|
|
|
10
|
+
Integer(value)
|
|
11
|
+
end
|
|
12
|
+
parameter 'REPOSITORY', 'the repository to analyze', required: true
|
|
13
|
+
option '--format', 'FORMAT', 'specify output format (table, chart, json).', default: 'table'
|
|
14
|
+
option '--label', 'LABEL', 'filter by label', multivalued: true
|
|
15
|
+
option '--[no-]finished', :flag, 'show finished stats.', default: false
|
|
16
|
+
option '--[no-]legend', :flag, 'do not print a legend.', default: true
|
|
17
|
+
option '--[no-]pager', :flag, 'do not pipe output into a pager.', default: false
|
|
18
|
+
|
|
19
|
+
def execute
|
|
20
|
+
labels = @label_list || []
|
|
21
|
+
run = exec_run(@repository, @cfgfile, @cachepath, @refresh)
|
|
22
|
+
issues = exec_load(run, :per_month_filtered_by_labels, [year, labels])
|
|
23
|
+
|
|
24
|
+
return exec_bailout('No issues found.') if issues.nil? || issues.empty?
|
|
25
|
+
|
|
26
|
+
exec_output(issues, format)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Github
|
|
4
|
+
class Issues
|
|
5
|
+
module App
|
|
6
|
+
##
|
|
7
|
+
# Command to show issue stats per year
|
|
8
|
+
class Year < Base
|
|
9
|
+
parameter 'REPOSITORY', 'the repository to analyze', required: true
|
|
10
|
+
option '--format', 'FORMAT', 'specify output format (table, chart, json).', default: 'table'
|
|
11
|
+
option '--label', 'LABEL', 'filter by label', multivalued: true
|
|
12
|
+
option '--[no-]finished', :flag, 'show finished stats.', default: false
|
|
13
|
+
option '--[no-]legend', :flag, 'do not print a legend.', default: true
|
|
14
|
+
option '--[no-]pager', :flag, 'do not pipe output into a pager.', default: false
|
|
15
|
+
|
|
16
|
+
def execute
|
|
17
|
+
labels = @label_list || []
|
|
18
|
+
run = exec_run(@repository, @cfgfile, @cachepath, @refresh)
|
|
19
|
+
issues = exec_load(run, :per_year_filtered_by_labels, [labels])
|
|
20
|
+
|
|
21
|
+
return exec_bailout('No issues found.') if issues.nil? || issues.empty?
|
|
22
|
+
|
|
23
|
+
average = stats_seconds_to_days(run.all_average_closing_time)
|
|
24
|
+
median = stats_seconds_to_days(run.all_median_closing_time)
|
|
25
|
+
extra = "#{average} days average closing time. #{median} days median closing time."
|
|
26
|
+
|
|
27
|
+
exec_output(issues, format, extra:)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'tty-table'
|
|
4
|
+
|
|
5
|
+
module Github
|
|
6
|
+
class Issues
|
|
7
|
+
module App
|
|
8
|
+
##
|
|
9
|
+
# Main command class
|
|
10
|
+
class Command < Base
|
|
11
|
+
option '--configuration-file', 'FILE', 'configuration file', attribute_name: :cfgfile
|
|
12
|
+
option '--cache-path', 'PATH', 'cache path', attribute_name: :cachepath
|
|
13
|
+
option '--refresh', 'INTERVAL', 'refresh interval (e.g., 30minutes, 2.5hours, 1day)'
|
|
14
|
+
|
|
15
|
+
subcommand 'yearly', 'Show issues per year.', Year
|
|
16
|
+
subcommand 'monthly', 'Show issues per month.', Month
|
|
17
|
+
subcommand 'labels', 'List all labels.', Labels
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'sqlite3'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'time'
|
|
6
|
+
|
|
7
|
+
module Github
|
|
8
|
+
class Issues
|
|
9
|
+
##
|
|
10
|
+
# Database class manages the SQLite database for storing GitHub issues
|
|
11
|
+
class Database
|
|
12
|
+
include Marshal
|
|
13
|
+
include Schema
|
|
14
|
+
include Store
|
|
15
|
+
|
|
16
|
+
##
|
|
17
|
+
# Database connection handle
|
|
18
|
+
attr_reader :connection
|
|
19
|
+
|
|
20
|
+
##
|
|
21
|
+
# Path to the database file
|
|
22
|
+
attr_reader :path
|
|
23
|
+
|
|
24
|
+
##
|
|
25
|
+
# Initialize the Database instance
|
|
26
|
+
#
|
|
27
|
+
# @param path [String] Path to the SQLite database file
|
|
28
|
+
#
|
|
29
|
+
# @return [Github::Issues::Database] Initialized Database instance
|
|
30
|
+
def initialize(path)
|
|
31
|
+
@path = path
|
|
32
|
+
@connection = SQLite3::Database.new(@path)
|
|
33
|
+
@connection.results_as_hash = true
|
|
34
|
+
|
|
35
|
+
schema_create(connection)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
##
|
|
39
|
+
# Close the database connection
|
|
40
|
+
def close
|
|
41
|
+
connection.close
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
##
|
|
45
|
+
# Store a list of issues and update the last fetch timestamp
|
|
46
|
+
#
|
|
47
|
+
# @param issues [Array<Sawyer::Ressource>] List of issues to store
|
|
48
|
+
# @param timestamp [Time] Timestamp of the fetch operation
|
|
49
|
+
#
|
|
50
|
+
# @return [void]
|
|
51
|
+
def store_issues(issues, timestamp)
|
|
52
|
+
connection.transaction do
|
|
53
|
+
issues.each do |issue|
|
|
54
|
+
store_issue(connection, issue)
|
|
55
|
+
end
|
|
56
|
+
store_metadata(connection, 'last_fetch', timestamp.utc.iso8601)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
##
|
|
61
|
+
# Retrieve all stored issues from the database
|
|
62
|
+
#
|
|
63
|
+
# @return [Array<Issues>] List of stored issues
|
|
64
|
+
def all_issues
|
|
65
|
+
rows = connection.execute('SELECT * FROM issues ORDER BY created_at DESC')
|
|
66
|
+
rows.map { |row| marshal_issue(row) }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
##
|
|
70
|
+
# Retrieve all unique labels from the stored issues
|
|
71
|
+
#
|
|
72
|
+
# @return [Array<String>] List of unique labels
|
|
73
|
+
def all_labels
|
|
74
|
+
rows = connection.execute('SELECT labels FROM issues')
|
|
75
|
+
label_set = Set.new
|
|
76
|
+
rows.each do |row|
|
|
77
|
+
labels = JSON.parse(row['labels'] || '[]')
|
|
78
|
+
labels.each do |label|
|
|
79
|
+
label_set.add(label)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
label_set.to_a
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
##
|
|
86
|
+
# Count the total number of stored issues
|
|
87
|
+
#
|
|
88
|
+
# @return [Integer] Total number of issues
|
|
89
|
+
def count_issues
|
|
90
|
+
connection.get_first_value('SELECT COUNT(*) FROM issues')
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
##
|
|
94
|
+
# Determine if the database needs to be updated based on the age of the
|
|
95
|
+
# last fetch timestamp
|
|
96
|
+
#
|
|
97
|
+
# @param max_age_seconds [Integer] Maximum age in seconds before the database is considered stale
|
|
98
|
+
#
|
|
99
|
+
# @return [Boolean] True if the database needs to be updated, false
|
|
100
|
+
# otherwise
|
|
101
|
+
def needs_update?(max_age_seconds = 86_400)
|
|
102
|
+
timestamp = cache_timestamp
|
|
103
|
+
return true unless timestamp
|
|
104
|
+
|
|
105
|
+
(Time.now - timestamp) >= max_age_seconds
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
##
|
|
109
|
+
# Retrieve a metadata value by key
|
|
110
|
+
#
|
|
111
|
+
# @param key [String] Metadata key
|
|
112
|
+
#
|
|
113
|
+
# @return [String, nil] Metadata value or nil if not found
|
|
114
|
+
def metadata(key)
|
|
115
|
+
row = connection.get_first_row('SELECT value FROM metadata WHERE key = ?', key)
|
|
116
|
+
return nil unless row
|
|
117
|
+
|
|
118
|
+
row['value']
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
##
|
|
122
|
+
# Retrieve the timestamp of the last fetch operation
|
|
123
|
+
#
|
|
124
|
+
# @return [Time, nil] Timestamp of the last fetch or nil if not found
|
|
125
|
+
def cache_timestamp
|
|
126
|
+
timestamp_str = metadata('last_fetch')
|
|
127
|
+
return nil unless timestamp_str
|
|
128
|
+
|
|
129
|
+
Time.parse(timestamp_str)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'hashie'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'time'
|
|
6
|
+
|
|
7
|
+
module Github
|
|
8
|
+
class Issues
|
|
9
|
+
class Database
|
|
10
|
+
##
|
|
11
|
+
# Mixin to marshal issue data from database format to Hashie::Mash
|
|
12
|
+
module Marshal
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
##
|
|
16
|
+
# Marshal issue data from database format to Hashie::Mash
|
|
17
|
+
#
|
|
18
|
+
# @param issue [Hash] Issue data from database
|
|
19
|
+
#
|
|
20
|
+
# @return [Hashie::Mash] Marshaled issue data
|
|
21
|
+
def marshal_issue(issue)
|
|
22
|
+
Hashie::Mash.new(
|
|
23
|
+
{
|
|
24
|
+
id: issue['id'],
|
|
25
|
+
created_at: Time.parse(issue['created_at']),
|
|
26
|
+
closed_at: issue['closed_at'] ? Time.parse(issue['closed_at']) : nil,
|
|
27
|
+
number: issue['number'],
|
|
28
|
+
url: issue['url'],
|
|
29
|
+
labels: JSON.parse(issue['labels'] || '[]'),
|
|
30
|
+
state: issue['state']
|
|
31
|
+
}
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Github
|
|
4
|
+
class Issues
|
|
5
|
+
class Database
|
|
6
|
+
##
|
|
7
|
+
# Mixin for creating database schema
|
|
8
|
+
module Schema
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
##
|
|
12
|
+
# Creates the database schema for issues and metadata tables
|
|
13
|
+
#
|
|
14
|
+
# @param connection [SQLite3::Database] Database connection
|
|
15
|
+
def schema_create(connection)
|
|
16
|
+
connection.execute <<-SQL
|
|
17
|
+
CREATE TABLE IF NOT EXISTS issues (
|
|
18
|
+
id INTEGER PRIMARY KEY,
|
|
19
|
+
created_at TEXT NOT NULL,
|
|
20
|
+
closed_at TEXT,
|
|
21
|
+
number INTEGER UNIQUE NOT NULL,
|
|
22
|
+
url TEXT NOT NULL,
|
|
23
|
+
labels TEXT,
|
|
24
|
+
state TEXT NOT NULL
|
|
25
|
+
)
|
|
26
|
+
SQL
|
|
27
|
+
|
|
28
|
+
connection.execute <<-SQL
|
|
29
|
+
CREATE TABLE IF NOT EXISTS metadata (
|
|
30
|
+
key TEXT PRIMARY KEY,
|
|
31
|
+
value TEXT NOT NULL
|
|
32
|
+
)
|
|
33
|
+
SQL
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Github
|
|
4
|
+
class Issues
|
|
5
|
+
class Database
|
|
6
|
+
##
|
|
7
|
+
# Mixin for storing issues and metadata in the database
|
|
8
|
+
module Store
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
##
|
|
12
|
+
# Store an issue in the database
|
|
13
|
+
#
|
|
14
|
+
# @param connection [SQLite3::Database] Database connection
|
|
15
|
+
# @param issue [Sawyer::Ressource] Issue to store
|
|
16
|
+
#
|
|
17
|
+
# @return [void]
|
|
18
|
+
def store_issue(connection, issue)
|
|
19
|
+
if store_issue_exist?(connection, issue.number)
|
|
20
|
+
store_update_issue(connection, issue)
|
|
21
|
+
else
|
|
22
|
+
store_insert_issue(connection, issue)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
##
|
|
27
|
+
# Store metadata in the database
|
|
28
|
+
#
|
|
29
|
+
# @param connection [SQLite3::Database] Database connection
|
|
30
|
+
# @param key [String] Metadata key
|
|
31
|
+
# @param value [String] Metadata value
|
|
32
|
+
#
|
|
33
|
+
# @return [void]
|
|
34
|
+
def store_metadata(connection, key, value)
|
|
35
|
+
connection.execute(
|
|
36
|
+
'INSERT OR REPLACE INTO metadata (key, value) VALUES (?, ?)',
|
|
37
|
+
[key, value]
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
##
|
|
42
|
+
# Check if an issue exists in the database
|
|
43
|
+
#
|
|
44
|
+
# @param connection [SQLite3::Database] Database connection
|
|
45
|
+
# @param issue_number [Integer] Issue number
|
|
46
|
+
#
|
|
47
|
+
# @return [Boolean] True if the issue exists, false otherwise
|
|
48
|
+
def store_issue_exist?(connection, issue_number)
|
|
49
|
+
existing = connection.get_first_row(
|
|
50
|
+
'SELECT id FROM issues WHERE number = ?',
|
|
51
|
+
issue_number
|
|
52
|
+
)
|
|
53
|
+
!existing.nil?
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
##
|
|
57
|
+
# Update an existing issue in the database
|
|
58
|
+
#
|
|
59
|
+
# @param connection [SQLite3::Database] Database connection
|
|
60
|
+
# @param issue [Github::Issue] Issue to update
|
|
61
|
+
#
|
|
62
|
+
# @return [void]
|
|
63
|
+
def store_update_issue(connection, issue)
|
|
64
|
+
connection.execute(
|
|
65
|
+
'UPDATE issues SET (created_at, closed_at, url, labels, state) = (?, ?, ?, ?, ?) WHERE number = ?',
|
|
66
|
+
[
|
|
67
|
+
issue.created_at.utc.iso8601,
|
|
68
|
+
issue.closed_at.nil? ? nil : issue.closed_at.utc.iso8601,
|
|
69
|
+
issue.html_url,
|
|
70
|
+
issue.labels.map(&:name).to_json,
|
|
71
|
+
issue.state,
|
|
72
|
+
issue.number
|
|
73
|
+
]
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
##
|
|
78
|
+
# Insert a new issue into the database
|
|
79
|
+
#
|
|
80
|
+
# @param connection [SQLite3::Database] Database connection
|
|
81
|
+
# @param issue [Github::Issue] Issue to insert
|
|
82
|
+
#
|
|
83
|
+
# @return [void]
|
|
84
|
+
def store_insert_issue(connection, issue)
|
|
85
|
+
connection.execute(
|
|
86
|
+
'INSERT INTO issues (created_at, closed_at, url, labels, state, number) VALUES (?, ?, ?, ?, ?, ?)',
|
|
87
|
+
[
|
|
88
|
+
issue.created_at.utc.iso8601,
|
|
89
|
+
issue.closed_at.nil? ? nil : issue.closed_at.utc.iso8601,
|
|
90
|
+
issue.html_url,
|
|
91
|
+
issue.labels.map(&:name).to_json,
|
|
92
|
+
issue.state,
|
|
93
|
+
issue.number
|
|
94
|
+
]
|
|
95
|
+
)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'octokit'
|
|
4
|
+
require 'time'
|
|
5
|
+
|
|
6
|
+
module Github
|
|
7
|
+
class Issues
|
|
8
|
+
##
|
|
9
|
+
# Mixin for fetching issues from GitHub and the database
|
|
10
|
+
module Fetch
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
##
|
|
14
|
+
# Ensure the database is up-to-date with issues from GitHub, if the
|
|
15
|
+
# refresh interval has passed or if there are no issues in the database,
|
|
16
|
+
# fetch issues from GitHub and update the database
|
|
17
|
+
#
|
|
18
|
+
# @param database [Database] Issues database
|
|
19
|
+
# @param refresh [Integer] Refresh interval in seconds
|
|
20
|
+
# @param credentials [Hash] GitHub API credentials
|
|
21
|
+
# @param repository [String] GitHub repository (e.g., 'owner/repo')
|
|
22
|
+
#
|
|
23
|
+
# @return [void]
|
|
24
|
+
def ensure_issues_loaded(database, refresh, credentials, repository)
|
|
25
|
+
return unless database.needs_update?(refresh) || database.count_issues.zero?
|
|
26
|
+
|
|
27
|
+
fetch_and_update_issues(database, credentials, repository)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
##
|
|
31
|
+
# Fetch issues from GitHub and update the database
|
|
32
|
+
#
|
|
33
|
+
# @param database [Database] Issues database
|
|
34
|
+
# @param credentials [Hash] GitHub API credentials
|
|
35
|
+
# @param repository [String] GitHub repository (e.g., 'owner/repo')
|
|
36
|
+
#
|
|
37
|
+
# @return [void]
|
|
38
|
+
def fetch_and_update_issues(database, credentials, repository)
|
|
39
|
+
cached_timestamp = database.cache_timestamp
|
|
40
|
+
fetch_timestamp = Time.now
|
|
41
|
+
|
|
42
|
+
if cached_timestamp
|
|
43
|
+
new_issues = fetch_issues_from_github(
|
|
44
|
+
credentials,
|
|
45
|
+
repository,
|
|
46
|
+
since: cached_timestamp
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
database.store_issues(new_issues, fetch_timestamp) unless new_issues.empty?
|
|
50
|
+
else
|
|
51
|
+
all_issues = fetch_issues_from_github(credentials, repository)
|
|
52
|
+
database.store_issues(all_issues, fetch_timestamp)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
##
|
|
57
|
+
# Fetch issues from GitHub
|
|
58
|
+
#
|
|
59
|
+
# @param credentials [Hash] GitHub API credentials
|
|
60
|
+
# @param repository [String] GitHub repository (e.g., 'owner/repo')
|
|
61
|
+
# @param since [Time, nil] Optional timestamp to fetch issues updated since this time
|
|
62
|
+
#
|
|
63
|
+
# @return [Array<Sawyer::Resource>] The fetched issues
|
|
64
|
+
def fetch_issues_from_github(credentials, repository, since: nil)
|
|
65
|
+
octokit = Octokit::Client.new(credentials)
|
|
66
|
+
octokit.auto_paginate = true
|
|
67
|
+
|
|
68
|
+
options = {
|
|
69
|
+
state: 'all',
|
|
70
|
+
sort: 'created',
|
|
71
|
+
direction: 'desc'
|
|
72
|
+
}
|
|
73
|
+
options[:since] = since.utc.iso8601 if since
|
|
74
|
+
|
|
75
|
+
octokit.issues(repository, options)
|
|
76
|
+
.reject { |issue| issue.pull_request || issue.draft }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
##
|
|
80
|
+
# Check if a GitHub repository exists
|
|
81
|
+
#
|
|
82
|
+
# @param credentials [Hash] GitHub API credentials
|
|
83
|
+
# @param repository [String] GitHub repository (e.g., 'owner/repo')
|
|
84
|
+
#
|
|
85
|
+
# @return [void]
|
|
86
|
+
def github_repository_exist!(credentials, repository)
|
|
87
|
+
octokit = Octokit::Client.new(credentials)
|
|
88
|
+
octokit.repository(repository)
|
|
89
|
+
|
|
90
|
+
nil
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
##
|
|
94
|
+
# Fetch issues from the database applying the given labels filter
|
|
95
|
+
#
|
|
96
|
+
# @param database [Database] Issues database
|
|
97
|
+
# @param labels [Array<String>, nil] Labels to filter issues by
|
|
98
|
+
#
|
|
99
|
+
# @return [Array<Hashie::Mash>] Filtered issues
|
|
100
|
+
def fetch_issues_from_database(database, labels: nil)
|
|
101
|
+
return database.all_issues if labels.nil? || labels.empty?
|
|
102
|
+
|
|
103
|
+
include_labels, exclude_labels = group_include_exclude_labels(labels)
|
|
104
|
+
|
|
105
|
+
database.all_issues.select do |issue|
|
|
106
|
+
has_includes = include_labels.all? { |label| issue.labels.include?(label) }
|
|
107
|
+
has_excludes = exclude_labels.any? { |label| issue.labels.include?(label) }
|
|
108
|
+
has_includes && !has_excludes
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
##
|
|
113
|
+
# Separating include and exclude labels
|
|
114
|
+
#
|
|
115
|
+
# @param labels [Array<String>] Labels to filter issues by
|
|
116
|
+
#
|
|
117
|
+
# @return [Array<Array<String>, Array<String>>] Include and exclude labels
|
|
118
|
+
def group_include_exclude_labels(labels)
|
|
119
|
+
include_labels = labels.reject { |label| label.start_with?('!') }
|
|
120
|
+
exclude_labels = labels.select { |label| label.start_with?('!') }.map { |label| label[1..] }
|
|
121
|
+
|
|
122
|
+
[include_labels, exclude_labels]
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Github
|
|
4
|
+
class Issues
|
|
5
|
+
##
|
|
6
|
+
# Mixin for grouping issues by time periods
|
|
7
|
+
module Group
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
##
|
|
11
|
+
# Groups issues by a given period (:year, :month) and returns a hash with
|
|
12
|
+
# keys as period values and values as hashes containing arrays of
|
|
13
|
+
# created, closed, finished, and open issues
|
|
14
|
+
#
|
|
15
|
+
# @param list [Array<Hashie::Mash>] List of issues to group
|
|
16
|
+
# @param period [Symbol] Period to group by (:year or :month)
|
|
17
|
+
#
|
|
18
|
+
# @return [Hashie::Mash]
|
|
19
|
+
def group_by_period(list, period: :year)
|
|
20
|
+
created = list.group_by { |issue| issue.created_at.send(period) }
|
|
21
|
+
created = group_compact(created)
|
|
22
|
+
|
|
23
|
+
closed = list.group_by { |issue| issue.closed_at&.send(period) }
|
|
24
|
+
closed = group_compact(closed)
|
|
25
|
+
|
|
26
|
+
finished = list.select do |issue|
|
|
27
|
+
issue.state == 'closed' && issue.created_at.send(period) == issue.closed_at.send(period)
|
|
28
|
+
end
|
|
29
|
+
finished = finished.group_by { |issue| issue.created_at.send(period) }
|
|
30
|
+
finished = group_compact(finished)
|
|
31
|
+
|
|
32
|
+
open = group_open_issues(list, period)
|
|
33
|
+
open = group_compact(open)
|
|
34
|
+
|
|
35
|
+
group_merge(created, closed, finished, open)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
##
|
|
39
|
+
# Group compaction helper removes nil keys and ensures no nil values in
|
|
40
|
+
# the group hash
|
|
41
|
+
#
|
|
42
|
+
# @param group [Hashie::Mash] Group of issues
|
|
43
|
+
#
|
|
44
|
+
# @return [Hashie::Mash]
|
|
45
|
+
def group_compact(group)
|
|
46
|
+
group = group.reject { |k, _| k.nil? }
|
|
47
|
+
group.each { |k, v| group[k] = [] if v.nil? }
|
|
48
|
+
|
|
49
|
+
group
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
##
|
|
53
|
+
# Groups open issues by period - issues that are still open at the end of the period
|
|
54
|
+
#
|
|
55
|
+
# @param list [Array<Hashie::Mash>] List of issues
|
|
56
|
+
# @param period [Symbol] Period to group by (:year or :month)
|
|
57
|
+
#
|
|
58
|
+
# @return [Hash] Group of open issues by period
|
|
59
|
+
def group_open_issues(list, period)
|
|
60
|
+
open_by_period = {}
|
|
61
|
+
|
|
62
|
+
list.each do |issue|
|
|
63
|
+
created_period = issue.created_at.send(period)
|
|
64
|
+
|
|
65
|
+
if issue.state == 'open'
|
|
66
|
+
open_by_period[created_period] ||= []
|
|
67
|
+
open_by_period[created_period] << issue
|
|
68
|
+
elsif issue.closed_at
|
|
69
|
+
closed_period = issue.closed_at.send(period)
|
|
70
|
+
if created_period != closed_period
|
|
71
|
+
open_by_period[created_period] ||= []
|
|
72
|
+
open_by_period[created_period] << issue
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
open_by_period
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
##
|
|
81
|
+
# Merges created, closed, finished, and open issue groups into a single hash
|
|
82
|
+
# ensuring all keys are present in the final hash
|
|
83
|
+
#
|
|
84
|
+
# @param created [Hashie::Mash] Group of created issues
|
|
85
|
+
# @param closed [Hashie::Mash] Group of closed issues
|
|
86
|
+
# @param finished [Hashie::Mash] Group of finished issues
|
|
87
|
+
# @param open [Hashie::Mash] Group of open issues
|
|
88
|
+
#
|
|
89
|
+
# @return [Hashie::Mash] Merged group of issues
|
|
90
|
+
def group_merge(created, closed, finished, open)
|
|
91
|
+
keys = created.keys | closed.keys | finished.keys | open.keys
|
|
92
|
+
keys.sort!.reverse!
|
|
93
|
+
|
|
94
|
+
keys.each_with_object({}) do |key, hash|
|
|
95
|
+
hash[key] = {
|
|
96
|
+
created: created[key] || [],
|
|
97
|
+
closed: closed[key] || [],
|
|
98
|
+
finished: finished[key] || [],
|
|
99
|
+
open: open[key] || []
|
|
100
|
+
}
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|