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 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
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'gh-issues-stats'
5
+
6
+ Github::Issues::App::Command.run
@@ -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