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.
@@ -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