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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 4c2e4b26d292367033205a9d9e60877b5e62835d3ff1125749fc7755c21605c6
|
|
4
|
+
data.tar.gz: 11126d3e881cc2f48c2eda51f7a64dd9c3df6a096cd64915fe75babd3f6e1742
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 4c824e6748a0c2eb65ffdf684dc6b0631fc051cf5613a8df2ff1b157c8692ef4b087832d5b1e5076f8b05b335c2c2820dceae6af2c139cfc0dbf6b501794bccb
|
|
7
|
+
data.tar.gz: 74d4fb1b10b3e36cccb03e6d046347866c1939dd78fdeb5b139002c04f4f97bc5017c96c1cd77813f2c38c545d94d88086561528412cee024f70f9916cf47cd8
|
data/bin/gh-issues-stats
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'zeitwerk'
|
|
4
|
+
|
|
5
|
+
loader = Zeitwerk::Loader.for_gem(warn_on_extra_files: false)
|
|
6
|
+
loader.inflector.inflect(
|
|
7
|
+
'gh-issues-stats' => 'Github',
|
|
8
|
+
'version' => 'VERSION'
|
|
9
|
+
)
|
|
10
|
+
loader.collapse("#{__dir__}/github/issues/mixins")
|
|
11
|
+
loader.collapse("#{__dir__}/github/issues/app/mixins")
|
|
12
|
+
loader.setup
|
|
13
|
+
|
|
14
|
+
##
|
|
15
|
+
# Namespace for Github modules
|
|
16
|
+
module Github; end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'clamp'
|
|
4
|
+
|
|
5
|
+
module Github
|
|
6
|
+
class Issues
|
|
7
|
+
module App
|
|
8
|
+
##
|
|
9
|
+
# Base command class
|
|
10
|
+
#
|
|
11
|
+
# Inherits from Clamp::Command and includes mixins and is inherited by
|
|
12
|
+
# the specific command classes
|
|
13
|
+
class Base < Clamp::Command
|
|
14
|
+
include Github::Issues::App::Chart
|
|
15
|
+
include Github::Issues::App::Exec
|
|
16
|
+
include Github::Issues::App::Legend
|
|
17
|
+
include Github::Issues::App::Options
|
|
18
|
+
include Github::Issues::App::Statistics
|
|
19
|
+
include Github::Issues::App::Table
|
|
20
|
+
|
|
21
|
+
option ['-m', '--man'], :flag, 'show manpage' do # rubocop:disable Metrics/BlockLength
|
|
22
|
+
manpage = <<~MANPAGE
|
|
23
|
+
Name:
|
|
24
|
+
gh-issues-stats - Analyse Github repository issues lifecycle.
|
|
25
|
+
|
|
26
|
+
#{help}
|
|
27
|
+
|
|
28
|
+
Description:
|
|
29
|
+
gh-issues-stats is a command-line tool that helps you analyze and
|
|
30
|
+
understand the issues lifecycle in any GitHub repository. It provides
|
|
31
|
+
detailed statistics, visualizations, and insights about issues including:
|
|
32
|
+
|
|
33
|
+
- Issue creation trends over time (yearly/monthly breakdowns)
|
|
34
|
+
- Closing time statistics (average and median)
|
|
35
|
+
- Label-based filtering and analysis
|
|
36
|
+
- Visual charts and tables for data representation
|
|
37
|
+
- Cached data for faster subsequent queries
|
|
38
|
+
|
|
39
|
+
Commands:
|
|
40
|
+
yearly REPOSITORY
|
|
41
|
+
Display yearly statistics for a repository.
|
|
42
|
+
|
|
43
|
+
monthly YEAR REPOSITORY
|
|
44
|
+
Display monthly statistics for a specific year.
|
|
45
|
+
|
|
46
|
+
labels REPOSITORY
|
|
47
|
+
Display all labels used in a repository.
|
|
48
|
+
|
|
49
|
+
Options:
|
|
50
|
+
--configuration-file FILE
|
|
51
|
+
Specify a configuration file (default: ~/.config/gh-issues-stats.json)
|
|
52
|
+
|
|
53
|
+
--cache-path PATH
|
|
54
|
+
Specify cache path (default: ~/.cache/gh-issues-stats)
|
|
55
|
+
|
|
56
|
+
--refresh INTERVAL
|
|
57
|
+
Set refresh interval (e.g., 30minutes, 2.5hours, 1day) (default: 24hours)
|
|
58
|
+
|
|
59
|
+
--label LABEL
|
|
60
|
+
Filter by specific label (can be used multiple times; prefix with '!' to exclude)
|
|
61
|
+
|
|
62
|
+
--[no-]finished
|
|
63
|
+
Show finished stats (default: false) (statistics about created and closed issues in the same period)
|
|
64
|
+
|
|
65
|
+
--[no-]legend
|
|
66
|
+
Toggle legend display (default: true)
|
|
67
|
+
|
|
68
|
+
--[no-]pager
|
|
69
|
+
Toggle output paging (default: false)
|
|
70
|
+
|
|
71
|
+
--[no-]chart
|
|
72
|
+
Show bar chart instead of table (default: false)
|
|
73
|
+
|
|
74
|
+
-v, --version
|
|
75
|
+
Show version information
|
|
76
|
+
|
|
77
|
+
-m, --man
|
|
78
|
+
Show this manual page
|
|
79
|
+
|
|
80
|
+
Examples:
|
|
81
|
+
Display yearly statistics for a repository:
|
|
82
|
+
$ gh-issues-stats yearly rails/rails
|
|
83
|
+
|
|
84
|
+
Display monthly statistics for a specific year:
|
|
85
|
+
$ gh-issues-stats monthly 2023 rails/rails
|
|
86
|
+
|
|
87
|
+
Filter issues by multiple labels:
|
|
88
|
+
$ gh-issues-stats yearly rails/rails --label bug --label '!enhancement'
|
|
89
|
+
|
|
90
|
+
Show monthly breakdown with chart visualization:
|
|
91
|
+
$ gh-issues-stats monthly 2023 rails/rails --chart
|
|
92
|
+
|
|
93
|
+
Use with custom refresh interval:
|
|
94
|
+
$ gh-issues-stats yearly rails/rails --refresh 2hours
|
|
95
|
+
|
|
96
|
+
List all labels in a repository:
|
|
97
|
+
$ gh-issues-stats labels rails/rails
|
|
98
|
+
|
|
99
|
+
Configuration:
|
|
100
|
+
For higher API rate limits, you can provide GitHub credentials through
|
|
101
|
+
a JSON configuration file. The configuration file will be loaded from
|
|
102
|
+
the path specified via --configuration-file (default: ~/.config/gh-issues-stats.json).
|
|
103
|
+
|
|
104
|
+
Caching:
|
|
105
|
+
Issue data is cached locally in the specified cache path --cache-path
|
|
106
|
+
(default: ~/.cache/gh-issues-stats) to improve performance on subsequent
|
|
107
|
+
queries. The cache is automatically refreshed based on the refresh interval
|
|
108
|
+
(default: 24 hours).
|
|
109
|
+
|
|
110
|
+
Author:
|
|
111
|
+
Tobias Schäfer <github@blackox.org>
|
|
112
|
+
|
|
113
|
+
Homepage:
|
|
114
|
+
https://github.com/tschaefer/gh-issues-stats
|
|
115
|
+
MANPAGE
|
|
116
|
+
TTY::Pager.page(manpage)
|
|
117
|
+
|
|
118
|
+
exit 0
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
option ['-v', '--version'], :flag, 'show version' do
|
|
122
|
+
puts "gh-issues-stats #{Github::Issues::VERSION}"
|
|
123
|
+
exit 0
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'tty-table'
|
|
5
|
+
|
|
6
|
+
module Github
|
|
7
|
+
class Issues
|
|
8
|
+
module App
|
|
9
|
+
##
|
|
10
|
+
# Command to list labels in a repository
|
|
11
|
+
class Labels < Base
|
|
12
|
+
TABLE_COLUMNS = 3
|
|
13
|
+
|
|
14
|
+
parameter 'REPOSITORY', 'the repository to analyze', required: true
|
|
15
|
+
option '--json', :flag, 'output in JSON format', default: false, attribute_name: :structured
|
|
16
|
+
|
|
17
|
+
def execute
|
|
18
|
+
run = exec_run(@repository, @cfgfile, @cachepath, @refresh)
|
|
19
|
+
labels = exec_load(run, :labels, [])
|
|
20
|
+
|
|
21
|
+
if structured?
|
|
22
|
+
puts JSON.pretty_generate(labels)
|
|
23
|
+
return
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
rows = []
|
|
27
|
+
labels.each_slice(TABLE_COLUMNS) do |slice|
|
|
28
|
+
(TABLE_COLUMNS - slice.size).times { slice << '' } if slice.size < TABLE_COLUMNS
|
|
29
|
+
rows << slice
|
|
30
|
+
end
|
|
31
|
+
table = TTY::Table.new(rows: rows)
|
|
32
|
+
puts table.render(border_class: TTY::Table::Border::Null, resize: true, multiline: true)
|
|
33
|
+
puts "\nTotal labels: #{labels.size}"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'hashie/mash'
|
|
4
|
+
require 'unicode_plot'
|
|
5
|
+
|
|
6
|
+
module Github
|
|
7
|
+
class Issues
|
|
8
|
+
module App
|
|
9
|
+
##
|
|
10
|
+
# Mixin to create charts for issues data
|
|
11
|
+
module Chart
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
##
|
|
15
|
+
# Create charts for the given issues data
|
|
16
|
+
#
|
|
17
|
+
# @param issues [Hashie::Mash] Issues data
|
|
18
|
+
#
|
|
19
|
+
# @return [String] Rendered charts
|
|
20
|
+
def chart_create(issues)
|
|
21
|
+
values = chart_determine_values(issues)
|
|
22
|
+
|
|
23
|
+
if %w[true 1].include?(ENV.fetch('NO_COLOR', 'false').upcase)
|
|
24
|
+
"#{chart_plot_created(values)}\n\n" \
|
|
25
|
+
"#{chart_plot_closed(values)}\n\n" \
|
|
26
|
+
"#{chart_plot_open(values)}\n\n" \
|
|
27
|
+
"#{chart_plot_created_closed_ratio(values)}"
|
|
28
|
+
else
|
|
29
|
+
chart_plot_created(values).render
|
|
30
|
+
chart_plot_closed(values).render
|
|
31
|
+
chart_plot_open(values).render
|
|
32
|
+
chart_plot_created_closed_ratio(values).render
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def chart_determine_values(issues)
|
|
37
|
+
values = Hashie::Mash.new(
|
|
38
|
+
{
|
|
39
|
+
labels: [],
|
|
40
|
+
created_counts: [],
|
|
41
|
+
closed_counts: [],
|
|
42
|
+
open_counts: [],
|
|
43
|
+
created_closed_ratios: []
|
|
44
|
+
}
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
issues.each do |period, data|
|
|
48
|
+
values.labels << period.to_s
|
|
49
|
+
values.created_counts << (data.created&.size || 0)
|
|
50
|
+
values.closed_counts << (data.closed&.size || 0)
|
|
51
|
+
values.open_counts << (data.open&.size || 0)
|
|
52
|
+
values.created_closed_ratios << data.stats.ratio.all.round(2)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
values
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
##
|
|
59
|
+
# Create the created issues barplot
|
|
60
|
+
#
|
|
61
|
+
# @return [UnicodePlot::Barplot] Created issues barplot
|
|
62
|
+
def chart_plot_created(data)
|
|
63
|
+
UnicodePlot.barplot(
|
|
64
|
+
data.labels,
|
|
65
|
+
data.created_counts,
|
|
66
|
+
title: 'Created',
|
|
67
|
+
color: :red,
|
|
68
|
+
width: 60
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
##
|
|
73
|
+
# Create the closed issues barplot
|
|
74
|
+
#
|
|
75
|
+
# @return [UnicodePlot::Barplot] Closed issues barplot
|
|
76
|
+
def chart_plot_closed(data)
|
|
77
|
+
UnicodePlot.barplot(
|
|
78
|
+
data.labels,
|
|
79
|
+
data.closed_counts,
|
|
80
|
+
title: 'Closed',
|
|
81
|
+
color: :green,
|
|
82
|
+
width: 60
|
|
83
|
+
)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
##
|
|
87
|
+
# Create the open issues barplot
|
|
88
|
+
#
|
|
89
|
+
# @return [UnicodePlot::Barplot] Open issues barplot
|
|
90
|
+
def chart_plot_open(data)
|
|
91
|
+
UnicodePlot.barplot(
|
|
92
|
+
data.labels,
|
|
93
|
+
data.open_counts,
|
|
94
|
+
title: 'Open',
|
|
95
|
+
color: :yellow,
|
|
96
|
+
width: 60
|
|
97
|
+
)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
##
|
|
101
|
+
# Create the created/closed ratio barplot
|
|
102
|
+
#
|
|
103
|
+
# @return [UnicodePlot::Barplot] Created/Closed ratio barplot
|
|
104
|
+
def chart_plot_created_closed_ratio(data)
|
|
105
|
+
UnicodePlot.barplot(
|
|
106
|
+
data.labels,
|
|
107
|
+
data.created_closed_ratios,
|
|
108
|
+
title: 'Created/Closed Ratio',
|
|
109
|
+
color: :blue,
|
|
110
|
+
width: 60
|
|
111
|
+
)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'pastel'
|
|
5
|
+
require 'tty-pager'
|
|
6
|
+
require 'tty-spinner'
|
|
7
|
+
|
|
8
|
+
module Github
|
|
9
|
+
class Issues
|
|
10
|
+
module App
|
|
11
|
+
##
|
|
12
|
+
# Mixin for execution methods
|
|
13
|
+
module Exec
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
##
|
|
17
|
+
# Bail out of execution with an error message
|
|
18
|
+
#
|
|
19
|
+
# @param message [String] Error message to display
|
|
20
|
+
def exec_bailout(message)
|
|
21
|
+
warn Pastel.new.red.bold(message)
|
|
22
|
+
exit 1
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
##
|
|
26
|
+
# Initialize and return the GitHub Issues instance
|
|
27
|
+
#
|
|
28
|
+
# @param repository [String] GitHub repository in 'owner/repo' format
|
|
29
|
+
# @param config_file [String, nil] Path to the configuration file
|
|
30
|
+
# @param refresh_interval [String, nil] Refresh interval for caching
|
|
31
|
+
#
|
|
32
|
+
# @return [Github::Issues] Initialized GitHub Issues instance
|
|
33
|
+
def exec_run(repository, config_file, cache_path, refresh_interval)
|
|
34
|
+
Github::Issues.new(
|
|
35
|
+
repository,
|
|
36
|
+
credentials: options_parse_configuration(config_file),
|
|
37
|
+
refresh: options_parse_refresh_interval(refresh_interval),
|
|
38
|
+
cache: options_parse_cache_path(cache_path)
|
|
39
|
+
)
|
|
40
|
+
rescue Octokit::NotFound
|
|
41
|
+
exec_bailout("Repository '#{repository}' not found.")
|
|
42
|
+
rescue StandardError => e
|
|
43
|
+
exec_bailout(e.message)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
##
|
|
47
|
+
# Execute the specified method on the GitHub Issues instance with a
|
|
48
|
+
# loading spinner
|
|
49
|
+
#
|
|
50
|
+
# @param run [Github::Issues] GitHub Issues instance
|
|
51
|
+
# @param method [Symbol] Method to call on the instance
|
|
52
|
+
# @param args [Array] Arguments to pass to the method
|
|
53
|
+
#
|
|
54
|
+
# @return [Object] Result of the method call
|
|
55
|
+
def exec_load(run, method, args)
|
|
56
|
+
results = nil
|
|
57
|
+
spinner = TTY::Spinner.new(':spinner Fetching data ...', format: :dots, clear: true)
|
|
58
|
+
spinner.run("Done.\n") do
|
|
59
|
+
results = run.send(method, *args)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
results
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
##
|
|
66
|
+
# Generate and display the output based on the specified format
|
|
67
|
+
#
|
|
68
|
+
# @param issues [Hashie::Mash] List of issues to display
|
|
69
|
+
# @param format [String] Output format ('table', 'chart', 'json')
|
|
70
|
+
# @param extra [Hash, nil] Extra information for the legend
|
|
71
|
+
#
|
|
72
|
+
# @return [void]
|
|
73
|
+
def exec_output(issues, format, extra: nil)
|
|
74
|
+
content = case format
|
|
75
|
+
when 'table' then table_create(issues, finished?)
|
|
76
|
+
when 'chart' then chart_create(issues)
|
|
77
|
+
when 'json' then return puts JSON.pretty_generate(issues)
|
|
78
|
+
else exec_bailout("Unsupported output format: #{format}")
|
|
79
|
+
end
|
|
80
|
+
legend = legend_create(issues, legend?, extra:)
|
|
81
|
+
|
|
82
|
+
TTY::Pager.new(enabled: pager?).page("#{content}#{legend}")
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Github
|
|
4
|
+
class Issues
|
|
5
|
+
module App
|
|
6
|
+
##
|
|
7
|
+
# Mixin for legend creation
|
|
8
|
+
module Legend
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
##
|
|
12
|
+
# Create a legend for the issues report
|
|
13
|
+
#
|
|
14
|
+
# @param issues [Array<Hash>] Issues data
|
|
15
|
+
# @param extra [String, nil] Extra information to include in the legend
|
|
16
|
+
#
|
|
17
|
+
# @return [String] Legend string
|
|
18
|
+
def legend_create(issues, legend, extra: nil)
|
|
19
|
+
return "\n" unless legend
|
|
20
|
+
|
|
21
|
+
created = 0
|
|
22
|
+
closed = 0
|
|
23
|
+
open = 0
|
|
24
|
+
issues.each do |group|
|
|
25
|
+
data = group.last
|
|
26
|
+
|
|
27
|
+
created += data[:created].size
|
|
28
|
+
closed += data[:closed].size
|
|
29
|
+
open += data[:created].size - data[:closed].size
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
legend = "\n\n#{created} created. #{closed} closed. #{open} open."
|
|
33
|
+
legend = "#{legend}\n#{extra}" if extra
|
|
34
|
+
|
|
35
|
+
"#{legend}\n"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Github
|
|
6
|
+
class Issues
|
|
7
|
+
module App
|
|
8
|
+
##
|
|
9
|
+
# Mixin for option parsing methods
|
|
10
|
+
module Options
|
|
11
|
+
DEFAULT_CONFIG_FILE = File.join(Dir.home, '.config/gh-issues-stats.json').freeze
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
##
|
|
16
|
+
# Parse refresh interval option and transforms strings like
|
|
17
|
+
# '30minutes', '2hours', '1day' into seconds
|
|
18
|
+
#
|
|
19
|
+
# @param interval [String, nil] Refresh interval string
|
|
20
|
+
#
|
|
21
|
+
# @return [Float] Refresh interval in seconds
|
|
22
|
+
def options_parse_refresh_interval(interval)
|
|
23
|
+
interval ||= '24hours'
|
|
24
|
+
match = interval.match(/^(\d+(?:\.\d+)?)(seconds?|minutes?|hours?|days?)$/)
|
|
25
|
+
|
|
26
|
+
unless match
|
|
27
|
+
raise "Invalid refresh interval format: '#{interval}'. " \
|
|
28
|
+
"Use format like '30minutes', '2hours', '1day'"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
value = match[1].to_f
|
|
32
|
+
unit = match[2]
|
|
33
|
+
|
|
34
|
+
case unit
|
|
35
|
+
when /^seconds?$/
|
|
36
|
+
value
|
|
37
|
+
when /^minutes?$/
|
|
38
|
+
value * 60
|
|
39
|
+
when /^hours?$/
|
|
40
|
+
value * 60 * 60
|
|
41
|
+
when /^days?$/
|
|
42
|
+
value * 60 * 60 * 24
|
|
43
|
+
else
|
|
44
|
+
raise "Unknown time unit: #{unit}"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
##
|
|
49
|
+
# Parse configuration file option and loads JSON configuration from the
|
|
50
|
+
# specified file or default location
|
|
51
|
+
#
|
|
52
|
+
# @param file [String, nil] Path to configuration file
|
|
53
|
+
#
|
|
54
|
+
# @return [Hash] Configuration options
|
|
55
|
+
def options_parse_configuration(file)
|
|
56
|
+
file ||= DEFAULT_CONFIG_FILE
|
|
57
|
+
File.exist?(file) ? JSON.load_file(file).transform_keys(&:to_sym) : {}
|
|
58
|
+
rescue JSON::ParserError
|
|
59
|
+
raise "Failed to parse configuration file '#{file}'"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
##
|
|
63
|
+
# Parse cache path option and returns the specified path or
|
|
64
|
+
# a default cache path
|
|
65
|
+
#
|
|
66
|
+
# @param path [String, nil] Path to cache directory
|
|
67
|
+
#
|
|
68
|
+
# @return [String] Cache directory path
|
|
69
|
+
def options_parse_cache_path(path)
|
|
70
|
+
path || File.join(Dir.home, '.cache', 'gh-issues-stats')
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'hashie'
|
|
4
|
+
|
|
5
|
+
module Github
|
|
6
|
+
class Issues
|
|
7
|
+
module App
|
|
8
|
+
##
|
|
9
|
+
# Mixin for statistics methods
|
|
10
|
+
module Statistics
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
##
|
|
14
|
+
# Convert stats closing time from seconds to days
|
|
15
|
+
#
|
|
16
|
+
# @param stats [Hashie::Mash] Statistics data
|
|
17
|
+
#
|
|
18
|
+
# @return [Hashie::Mash] Statistics data with time in days
|
|
19
|
+
def stats_as_days(stats)
|
|
20
|
+
Hashie::Mash.new(
|
|
21
|
+
{
|
|
22
|
+
all_avg: stats_seconds_to_days(stats.close_time.all.average),
|
|
23
|
+
all_median: stats_seconds_to_days(stats.close_time.all.median),
|
|
24
|
+
finished_avg: stats_seconds_to_days(stats.close_time.finished.average),
|
|
25
|
+
finished_median: stats_seconds_to_days(stats.close_time.finished.median)
|
|
26
|
+
}
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
##
|
|
31
|
+
# Convert seconds to days
|
|
32
|
+
#
|
|
33
|
+
# @param seconds [Integer] Time in seconds
|
|
34
|
+
#
|
|
35
|
+
# @return [Integer] Time in days
|
|
36
|
+
def stats_seconds_to_days(seconds)
|
|
37
|
+
(seconds / 60 / 60 / 24).round
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'tty-table'
|
|
4
|
+
|
|
5
|
+
module Github
|
|
6
|
+
class Issues
|
|
7
|
+
module App
|
|
8
|
+
##
|
|
9
|
+
# Mixin for table rendering
|
|
10
|
+
module Table
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
##
|
|
14
|
+
# Renders the table for the issues statistics
|
|
15
|
+
#
|
|
16
|
+
# @param issues [Hash::Mash] Issues statistics data
|
|
17
|
+
# @param finished [Boolean] Whether to include finished issues statistics
|
|
18
|
+
#
|
|
19
|
+
# @return [String] Rendered table as a string
|
|
20
|
+
def table_create(issues, finished)
|
|
21
|
+
header = table_header_create(finished)
|
|
22
|
+
|
|
23
|
+
rows = issues.map do |year, data|
|
|
24
|
+
table_row_create(year, data, finished)
|
|
25
|
+
end
|
|
26
|
+
table = TTY::Table.new(header, rows)
|
|
27
|
+
|
|
28
|
+
table.render(multiline: true, width: 2**16) do |renderer|
|
|
29
|
+
renderer.border do
|
|
30
|
+
mid '─'
|
|
31
|
+
mid_mid '─'
|
|
32
|
+
center ' '
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
##
|
|
38
|
+
# Creates the table header
|
|
39
|
+
#
|
|
40
|
+
# @param finished [Boolean] Whether to include finished issues statistics
|
|
41
|
+
#
|
|
42
|
+
# @return [Array<String>] Table header
|
|
43
|
+
def table_header_create(finished)
|
|
44
|
+
period = self.class.name.split('::').last.sub('Command', '')
|
|
45
|
+
header = [
|
|
46
|
+
period
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
header += %w[
|
|
50
|
+
Created
|
|
51
|
+
Closed
|
|
52
|
+
Open
|
|
53
|
+
Created-Closed-Ratio
|
|
54
|
+
Closed-Avg
|
|
55
|
+
Closed-Median
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
return header unless finished
|
|
59
|
+
|
|
60
|
+
header + %w[
|
|
61
|
+
Finished
|
|
62
|
+
Finished-Ratio
|
|
63
|
+
Finished-Avg
|
|
64
|
+
Finished-Median
|
|
65
|
+
]
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
##
|
|
69
|
+
# Creates a table row for a given period and data
|
|
70
|
+
#
|
|
71
|
+
# @param period [String] Period (year, month as integer)
|
|
72
|
+
# @param data [Hash::Mash] Period data
|
|
73
|
+
# @param finished [Boolean] Whether to include finished issues
|
|
74
|
+
#
|
|
75
|
+
# @return [Array] Table row
|
|
76
|
+
def table_row_create(period, data, finished)
|
|
77
|
+
stats = stats_as_days(data.stats)
|
|
78
|
+
all = [
|
|
79
|
+
period,
|
|
80
|
+
data.created&.size || 0,
|
|
81
|
+
data.closed&.size || 0,
|
|
82
|
+
data.open&.size || 0,
|
|
83
|
+
data.stats.ratio.all.round(2),
|
|
84
|
+
stats.all_avg,
|
|
85
|
+
stats.all_median
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
all += table_row_finished_create(data) if finished
|
|
89
|
+
|
|
90
|
+
all
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
##
|
|
94
|
+
# Creates the finished issues statistics for a table row
|
|
95
|
+
# @param data [Hash::Mash] Period data
|
|
96
|
+
#
|
|
97
|
+
# @return [Array] Finished issues statistics
|
|
98
|
+
def table_row_finished_create(data)
|
|
99
|
+
[
|
|
100
|
+
data.finished&.size || 0,
|
|
101
|
+
data.stats.ratio.finished.round(2),
|
|
102
|
+
stats_seconds_to_days(data.stats.close_time.finished.average),
|
|
103
|
+
stats_seconds_to_days(data.stats.close_time.finished.median)
|
|
104
|
+
]
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|