gitfollow 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,298 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'thor'
4
+ require 'dotenv/load'
5
+ require 'tty-spinner'
6
+
7
+ module GitFollow
8
+ class CLI < Thor
9
+ class_option :token, type: :string, desc: 'GitHub personal access token (or set OCTOCAT_TOKEN env var)'
10
+ class_option :data_dir, type: :string, desc: 'Directory to store data files'
11
+
12
+ def self.exit_on_failure?
13
+ true
14
+ end
15
+
16
+ desc 'init', 'Initialize GitFollow and create first snapshot'
17
+ method_option :username, type: :string, desc: 'GitHub username (optional, will be fetched from token)'
18
+ def init
19
+ setup_components
20
+
21
+ spinner = TTY::Spinner.new('[:spinner] Fetching initial data...', format: :dots)
22
+ spinner.auto_spin
23
+
24
+ begin
25
+ snapshot = @tracker.initial_setup
26
+
27
+ spinner.success('Done!')
28
+
29
+ puts "\nInitialization complete!"
30
+ puts "Username: @#{snapshot[:username]}"
31
+ puts "Followers: #{snapshot[:stats][:followers_count]}"
32
+ puts "Following: #{snapshot[:stats][:following_count]}"
33
+ puts "Mutual: #{snapshot[:stats][:mutual_count]}"
34
+ puts "\nRun 'gitfollow check' to detect changes."
35
+ rescue StandardError => e
36
+ spinner.error("Failed: #{e.message}")
37
+ exit 1
38
+ end
39
+ end
40
+
41
+ desc 'check', 'Check for follower changes'
42
+ method_option :table, type: :boolean, default: false, desc: 'Display output as a table'
43
+ method_option :json, type: :boolean, default: false, desc: 'Output changes in JSON format'
44
+ method_option :notify, type: :string, desc: 'Create GitHub issue in repo (format: owner/repo)'
45
+ method_option :quiet, type: :boolean, default: false, aliases: '-q', desc: 'Suppress output if no changes'
46
+ def check
47
+ setup_components
48
+
49
+ spinner = TTY::Spinner.new('[:spinner] Checking for changes...', format: :dots)
50
+ spinner.auto_spin
51
+
52
+ begin
53
+ changes = @tracker.check_changes
54
+
55
+ spinner.success('Done!')
56
+
57
+ if changes[:first_run]
58
+ puts "\n#{changes[:message]}"
59
+ puts 'Running initial setup...'
60
+ invoke :init
61
+ return
62
+ end
63
+
64
+ if changes[:has_changes]
65
+ display_changes(changes)
66
+ notify_if_requested(changes) if options[:notify]
67
+ else
68
+ puts "\nNo changes detected." unless options[:quiet]
69
+ end
70
+ rescue StandardError => e
71
+ spinner.error("Failed: #{e.message}")
72
+ exit 1
73
+ end
74
+ end
75
+
76
+ desc 'report', 'Generate a detailed report'
77
+ method_option :format, type: :string, default: 'text', enum: %w[text markdown], desc: 'Report format'
78
+ method_option :output, type: :string, aliases: '-o', desc: 'Save report to file'
79
+ def report
80
+ setup_components
81
+
82
+ begin
83
+ report_content = @tracker.generate_report(format: options[:format].to_sym)
84
+
85
+ if options[:output]
86
+ File.write(options[:output], report_content)
87
+ puts "Report saved to #{options[:output]}"
88
+ else
89
+ puts report_content
90
+ end
91
+ rescue StandardError => e
92
+ puts "Error generating report: #{e.message}"
93
+ exit 1
94
+ end
95
+ end
96
+
97
+ desc 'stats', 'Display follower statistics'
98
+ method_option :json, type: :boolean, default: false, desc: 'Output statistics in JSON format'
99
+ def stats
100
+ setup_components
101
+
102
+ begin
103
+ statistics = @tracker.statistics
104
+
105
+ if statistics.nil?
106
+ puts 'No data available. Run `gitfollow init` first.'
107
+ exit 1
108
+ end
109
+
110
+ if options[:json]
111
+ require 'json'
112
+ puts JSON.pretty_generate(statistics)
113
+ else
114
+ display_statistics(statistics)
115
+ end
116
+ rescue StandardError => e
117
+ puts "Error fetching statistics: #{e.message}"
118
+ exit 1
119
+ end
120
+ end
121
+
122
+ desc 'mutual', 'List mutual followers'
123
+ method_option :json, type: :boolean, default: false, desc: 'Output in JSON format'
124
+ def mutual
125
+ setup_components
126
+
127
+ begin
128
+ mutual_followers = @tracker.mutual_followers
129
+
130
+ if mutual_followers.empty?
131
+ puts 'No mutual followers found.'
132
+ return
133
+ end
134
+
135
+ if options[:json]
136
+ require 'json'
137
+ puts JSON.pretty_generate(mutual_followers)
138
+ else
139
+ puts "Mutual Followers (#{mutual_followers.size}):"
140
+ mutual_followers.each do |user|
141
+ puts " • @#{user[:login]}"
142
+ end
143
+ end
144
+ rescue StandardError => e
145
+ puts "Error fetching mutual followers: #{e.message}"
146
+ exit 1
147
+ end
148
+ end
149
+
150
+ desc 'non-followers', 'List users you follow who don\'t follow back'
151
+ method_option :json, type: :boolean, default: false, desc: 'Output in JSON format'
152
+ def non_followers
153
+ setup_components
154
+
155
+ begin
156
+ non_followers = @tracker.non_followers
157
+
158
+ if non_followers.empty?
159
+ puts 'All users you follow also follow you back!'
160
+ return
161
+ end
162
+
163
+ if options[:json]
164
+ require 'json'
165
+ puts JSON.pretty_generate(non_followers)
166
+ else
167
+ puts "Non-Followers (#{non_followers.size}):"
168
+ non_followers.each do |user|
169
+ puts " • @#{user[:login]}"
170
+ end
171
+ end
172
+ rescue StandardError => e
173
+ puts "Error fetching non-followers: #{e.message}"
174
+ exit 1
175
+ end
176
+ end
177
+
178
+ desc 'export FORMAT OUTPUT', 'Export data to file (formats: json, csv)'
179
+ def export(format, output_file)
180
+ setup_components
181
+
182
+ begin
183
+ format_sym = format.to_sym
184
+ exported_file = @storage.export(
185
+ username: @client.username,
186
+ format: format_sym,
187
+ output_file: output_file
188
+ )
189
+
190
+ puts "Data exported to #{exported_file}"
191
+ rescue StandardError => e
192
+ puts "Error exporting data: #{e.message}"
193
+ exit 1
194
+ end
195
+ end
196
+
197
+ desc 'clear', 'Clear all stored data'
198
+ method_option :force, type: :boolean, default: false, aliases: '-f', desc: 'Skip confirmation'
199
+ def clear
200
+ setup_components
201
+
202
+ unless options[:force]
203
+ print "Are you sure you want to clear all data for @#{@client.username}? (y/N): "
204
+ confirmation = $stdin.gets.chomp.downcase
205
+ return unless confirmation == 'y'
206
+ end
207
+
208
+ begin
209
+ @storage.clear_data(@client.username)
210
+ puts "All data cleared for @#{@client.username}"
211
+ rescue StandardError => e
212
+ puts "Error clearing data: #{e.message}"
213
+ exit 1
214
+ end
215
+ end
216
+
217
+ desc 'version', 'Display GitFollow version'
218
+ def version
219
+ puts "GitFollow version #{GitFollow::VERSION}"
220
+ end
221
+
222
+ private
223
+
224
+ def setup_components
225
+ token = options[:token] || ENV.fetch('OCTOCAT_TOKEN', nil)
226
+
227
+ if token.nil? || token.empty?
228
+ puts 'Error: GitHub token not provided.'
229
+ puts 'Set OCTOCAT_TOKEN environment variable or use --token option.'
230
+ exit 1
231
+ end
232
+
233
+ begin
234
+ @client = GitFollow::Client.new(token: token)
235
+
236
+ unless @client.valid_token?
237
+ puts 'Error: Invalid GitHub token.'
238
+ exit 1
239
+ end
240
+
241
+ data_dir = options[:data_dir] || GitFollow::Storage::DEFAULT_DATA_DIR
242
+ @storage = GitFollow::Storage.new(data_dir: data_dir)
243
+ @tracker = GitFollow::Tracker.new(client: @client, storage: @storage)
244
+ @notifier = GitFollow::Notifier.new(client: @client)
245
+ rescue GitFollow::Error => e
246
+ puts "Error: #{e.message}"
247
+ exit 1
248
+ end
249
+ end
250
+
251
+ def display_changes(changes)
252
+ if options[:json]
253
+ require 'json'
254
+ puts JSON.pretty_generate(changes)
255
+ elsif options[:table]
256
+ puts "\n#{@notifier.format_as_table(changes)}"
257
+ else
258
+ puts "\n#{@notifier.format_terminal_output(changes)}"
259
+ end
260
+ end
261
+
262
+ # Display statistics in a formatted way
263
+ def display_statistics(stats)
264
+ require 'tty-table'
265
+
266
+ puts "\nFollower Statistics for @#{@client.username}"
267
+ puts '=' * 50
268
+
269
+ stats_table = TTY::Table.new(
270
+ rows: [
271
+ ['Followers', stats[:followers_count]],
272
+ ['Following', stats[:following_count]],
273
+ ['Mutual', stats[:mutual_count]],
274
+ ['Ratio', stats[:ratio]],
275
+ ['Total New Followers', stats[:total_new_followers]],
276
+ ['Total Unfollows', stats[:total_unfollows]]
277
+ ]
278
+ )
279
+ puts stats_table.render(:unicode, padding: [0, 1])
280
+
281
+ puts "\nLast Updated: #{Time.parse(stats[:last_updated]).strftime('%Y-%m-%d %H:%M:%S UTC')}"
282
+ end
283
+
284
+ def notify_if_requested(changes)
285
+ return unless options[:notify]
286
+
287
+ spinner = TTY::Spinner.new('[:spinner] Creating GitHub issue...', format: :dots)
288
+ spinner.auto_spin
289
+
290
+ begin
291
+ issue = @notifier.notify_via_issue(repo: options[:notify], changes: changes)
292
+ spinner.success("Issue created: #{issue[:url]}")
293
+ rescue StandardError => e
294
+ spinner.error("Failed to create issue: #{e.message}")
295
+ end
296
+ end
297
+ end
298
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'octokit'
4
+
5
+ module GitFollow
6
+ class Client
7
+ attr_reader :username, :client
8
+
9
+ def initialize(token:, username: nil)
10
+ raise ArgumentError, 'GitHub token is required' if token.nil? || token.empty?
11
+
12
+ @client = Octokit::Client.new(access_token: token)
13
+ @client.auto_paginate = true
14
+ @username = username || fetch_username
15
+ end
16
+
17
+ def fetch_followers
18
+ followers = @client.followers(@username)
19
+ followers.map { |f| { login: f.login, id: f.id } }
20
+ rescue Octokit::Error => e
21
+ handle_error(e, 'fetching followers')
22
+ end
23
+
24
+ def fetch_following
25
+ following = @client.following(@username)
26
+ following.map { |f| { login: f.login, id: f.id } }
27
+ rescue Octokit::Error => e
28
+ handle_error(e, 'fetching following')
29
+ end
30
+
31
+ def rate_limit
32
+ limit = @client.rate_limit
33
+ {
34
+ remaining: limit.remaining,
35
+ limit: limit.limit,
36
+ resets_at: limit.resets_at
37
+ }
38
+ rescue Octokit::Error => e
39
+ handle_error(e, 'fetching rate limit')
40
+ end
41
+
42
+ def create_issue(repo:, title:, body:)
43
+ issue = @client.create_issue(repo, title, body)
44
+ {
45
+ number: issue.number,
46
+ url: issue.html_url,
47
+ title: issue.title
48
+ }
49
+ rescue Octokit::Error => e
50
+ handle_error(e, 'creating issue')
51
+ end
52
+
53
+ def valid_token?
54
+ @client.user
55
+ true
56
+ rescue Octokit::Unauthorized
57
+ false
58
+ end
59
+
60
+ private
61
+
62
+ def fetch_username
63
+ user = @client.user
64
+ user.login
65
+ rescue Octokit::Error => e
66
+ handle_error(e, 'fetching username')
67
+ end
68
+
69
+ def handle_error(error, action)
70
+ case error
71
+ when Octokit::Unauthorized
72
+ raise GitFollow::Error, "Authentication failed while #{action}. Please check your GitHub token."
73
+ when Octokit::NotFound
74
+ raise GitFollow::Error, "Resource not found while #{action}. Please check your username or repository."
75
+ when Octokit::TooManyRequests
76
+ reset_time = error.response_headers['X-RateLimit-Reset'].to_i
77
+ reset_at = Time.at(reset_time)
78
+ raise GitFollow::Error, "Rate limit exceeded while #{action}. Resets at #{reset_at}."
79
+ when Octokit::ServerError
80
+ raise GitFollow::Error, "GitHub server error while #{action}. Please try again later."
81
+ else
82
+ raise GitFollow::Error, "Error while #{action}: #{error.message}"
83
+ end
84
+ end
85
+ end
86
+
87
+ class Error < StandardError; end
88
+ end
@@ -0,0 +1,191 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GitFollow
4
+ class Notifier
5
+ attr_reader :client, :username
6
+
7
+ def initialize(client:)
8
+ @client = client
9
+ @username = client.username
10
+ end
11
+
12
+ def notify_via_issue(repo:, changes:)
13
+ title = generate_issue_title(changes)
14
+ body = generate_issue_body(changes)
15
+
16
+ @client.create_issue(repo: repo, title: title, body: body)
17
+ end
18
+
19
+ def format_terminal_output(changes, colorize: true)
20
+ return "No changes detected for @#{@username}" unless changes[:has_changes]
21
+
22
+ output = []
23
+
24
+ if colorize
25
+ require 'colorize'
26
+ output << "Changes detected for @#{@username}".green.bold
27
+ else
28
+ output << "Changes detected for @#{@username}"
29
+ end
30
+
31
+ output << ''
32
+
33
+ if changes[:new_followers].any?
34
+ output << format_new_followers(changes[:new_followers], colorize)
35
+ output << ''
36
+ end
37
+
38
+ if changes[:unfollowed].any?
39
+ output << format_unfollowed(changes[:unfollowed], colorize)
40
+ output << ''
41
+ end
42
+
43
+ output << "Net change: #{format_net_change(changes[:net_change], colorize)}"
44
+ output << "Previous: #{changes[:previous_count]} → Current: #{changes[:current_count]}"
45
+
46
+ output.join("\n")
47
+ end
48
+
49
+ def format_as_table(changes)
50
+ require 'tty-table'
51
+
52
+ return "No changes detected for @#{@username}" unless changes[:has_changes]
53
+
54
+ output = []
55
+
56
+ if changes[:new_followers].any?
57
+ new_followers_table = TTY::Table.new(
58
+ header: ['New Followers', 'GitHub ID'],
59
+ rows: changes[:new_followers].map { |u| ["@#{u[:login]}", u[:id]] }
60
+ )
61
+ output << new_followers_table.render(:unicode, padding: [0, 1])
62
+ end
63
+
64
+ if changes[:unfollowed].any?
65
+ unfollowed_table = TTY::Table.new(
66
+ header: ['Unfollowed', 'GitHub ID'],
67
+ rows: changes[:unfollowed].map { |u| ["@#{u[:login]}", u[:id]] }
68
+ )
69
+ output << unfollowed_table.render(:unicode, padding: [0, 1])
70
+ end
71
+
72
+ summary_table = TTY::Table.new(
73
+ rows: [
74
+ ['Previous Count', changes[:previous_count]],
75
+ ['Current Count', changes[:current_count]],
76
+ ['Net Change', format_net_change(changes[:net_change], false)]
77
+ ]
78
+ )
79
+ output << summary_table.render(:unicode, padding: [0, 1])
80
+
81
+ output.join("\n\n")
82
+ end
83
+
84
+ def summary(changes)
85
+ return "No changes for @#{@username}" unless changes[:has_changes]
86
+
87
+ parts = []
88
+
89
+ parts << "#{changes[:new_followers].size} new follower(s)" if changes[:new_followers].any?
90
+ parts << "#{changes[:unfollowed].size} unfollow(s)" if changes[:unfollowed].any?
91
+
92
+ "#{parts.join(', ')} for @#{@username}"
93
+ end
94
+
95
+ private
96
+
97
+ def generate_issue_title(changes)
98
+ parts = []
99
+
100
+ parts << "#{changes[:new_followers].size} new" if changes[:new_followers].any?
101
+ parts << "#{changes[:unfollowed].size} unfollowed" if changes[:unfollowed].any?
102
+
103
+ "GitFollow: #{parts.join(', ')} - #{Time.now.strftime('%Y-%m-%d')}"
104
+ end
105
+
106
+ def generate_issue_body(changes)
107
+ body = []
108
+ body << "# Follower Changes for @#{@username}"
109
+ body << ''
110
+ body << "**Date:** #{Time.now.strftime('%Y-%m-%d %H:%M:%S UTC')}"
111
+ body << ''
112
+
113
+ if changes[:new_followers].any?
114
+ body << "## ✅ New Followers (#{changes[:new_followers].size})"
115
+ body << ''
116
+ changes[:new_followers].each do |user|
117
+ body << "- [@#{user[:login]}](https://github.com/#{user[:login]})"
118
+ end
119
+ body << ''
120
+ end
121
+
122
+ if changes[:unfollowed].any?
123
+ body << "## ❌ Unfollowed (#{changes[:unfollowed].size})"
124
+ body << ''
125
+ changes[:unfollowed].each do |user|
126
+ body << "- [@#{user[:login]}](https://github.com/#{user[:login]})"
127
+ end
128
+ body << ''
129
+ end
130
+
131
+ body << '## Summary'
132
+ body << ''
133
+ body << "- **Previous Count:** #{changes[:previous_count]}"
134
+ body << "- **Current Count:** #{changes[:current_count]}"
135
+ body << "- **Net Change:** #{format_net_change(changes[:net_change], false)}"
136
+ body << ''
137
+ body << '---'
138
+ body << '_Automated notification from [GitFollow](https://github.com/bulletdev/gitfollow)_'
139
+
140
+ body.join("\n")
141
+ end
142
+
143
+ def format_new_followers(new_followers, colorize)
144
+ header = "✅ New Followers (#{new_followers.size}):"
145
+
146
+ if colorize
147
+ require 'colorize'
148
+ header = header.green
149
+ end
150
+
151
+ lines = [header]
152
+ new_followers.each do |user|
153
+ lines << " • @#{user[:login]}"
154
+ end
155
+
156
+ lines.join("\n")
157
+ end
158
+
159
+ def format_unfollowed(unfollowed, colorize)
160
+ header = "❌ Unfollowed (#{unfollowed.size}):"
161
+
162
+ if colorize
163
+ require 'colorize'
164
+ header = header.red
165
+ end
166
+
167
+ lines = [header]
168
+ unfollowed.each do |user|
169
+ lines << " • @#{user[:login]}"
170
+ end
171
+
172
+ lines.join("\n")
173
+ end
174
+
175
+ def format_net_change(net_change, colorize)
176
+ sign = net_change.positive? ? '+' : ''
177
+ value = "#{sign}#{net_change}"
178
+
179
+ return value unless colorize
180
+
181
+ require 'colorize'
182
+ if net_change.positive?
183
+ value.green
184
+ elsif net_change.negative?
185
+ value.red
186
+ else
187
+ value
188
+ end
189
+ end
190
+ end
191
+ end