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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +55 -0
- data/LICENSE +21 -0
- data/README.md +437 -0
- data/bin/gitfollow +15 -0
- data/lib/gitfollow/cli.rb +298 -0
- data/lib/gitfollow/client.rb +88 -0
- data/lib/gitfollow/notifier.rb +191 -0
- data/lib/gitfollow/storage.rb +186 -0
- data/lib/gitfollow/tracker.rb +237 -0
- data/lib/gitfollow/version.rb +5 -0
- data/lib/gitfollow.rb +12 -0
- metadata +255 -0
|
@@ -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
|