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,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'fileutils'
5
+
6
+ module GitFollow
7
+ class Storage
8
+ DEFAULT_DATA_DIR = File.join(Dir.home, '.gitfollow')
9
+ SNAPSHOTS_FILE = 'snapshots.json'
10
+ HISTORY_FILE = 'history.json'
11
+
12
+ attr_reader :data_dir
13
+
14
+ def initialize(data_dir: DEFAULT_DATA_DIR)
15
+ @data_dir = data_dir
16
+ ensure_data_dir_exists
17
+ end
18
+
19
+ def save_snapshot(username:, followers:, following:)
20
+ snapshots = load_snapshots
21
+
22
+ snapshot = {
23
+ 'username' => username,
24
+ 'timestamp' => Time.now.utc.iso8601,
25
+ 'followers' => deep_stringify(followers),
26
+ 'following' => deep_stringify(following),
27
+ 'stats' => {
28
+ 'followers_count' => followers.size,
29
+ 'following_count' => following.size,
30
+ 'mutual_count' => calculate_mutual(followers, following).size
31
+ }
32
+ }
33
+
34
+ snapshots[username] ||= []
35
+ snapshots[username] << snapshot
36
+
37
+ write_json(snapshots_file_path, snapshots)
38
+ snapshot
39
+ end
40
+
41
+ def latest_snapshot(username)
42
+ snapshots = load_snapshots
43
+ snapshots[username]&.last
44
+ end
45
+
46
+ def all_snapshots(username)
47
+ snapshots = load_snapshots
48
+ snapshots[username] || []
49
+ end
50
+
51
+ def save_history_entry(username:, event_type:, user_data:)
52
+ history = load_history
53
+
54
+ entry = {
55
+ 'username' => username,
56
+ 'event_type' => event_type.to_s,
57
+ 'user' => deep_stringify(user_data),
58
+ 'timestamp' => Time.now.utc.iso8601
59
+ }
60
+
61
+ history[username] ||= []
62
+ history[username] << entry
63
+
64
+ write_json(history_file_path, history)
65
+ entry
66
+ end
67
+
68
+ def get_history(username, limit: nil)
69
+ history = load_history
70
+ entries = history[username] || []
71
+ limit ? entries.last(limit) : entries
72
+ end
73
+
74
+ def clear_data(username)
75
+ snapshots = load_snapshots
76
+ history = load_history
77
+
78
+ snapshots.delete(username)
79
+ history.delete(username)
80
+
81
+ write_json(snapshots_file_path, snapshots)
82
+ write_json(history_file_path, history)
83
+ true
84
+ end
85
+
86
+ def data_exists?(username)
87
+ snapshots = load_snapshots
88
+ !snapshots[username].nil? && !snapshots[username].empty?
89
+ end
90
+
91
+ def export(username:, format:, output_file:)
92
+ case format
93
+ when :json
94
+ export_json(username, output_file)
95
+ when :csv
96
+ export_csv(username, output_file)
97
+ else
98
+ raise ArgumentError, "Unsupported export format: #{format}"
99
+ end
100
+ end
101
+
102
+ private
103
+
104
+ # Convert all symbol keys to strings recursively
105
+ def deep_stringify(obj)
106
+ case obj
107
+ when Hash
108
+ obj.each_with_object({}) do |(key, value), hash|
109
+ hash[key.to_s] = deep_stringify(value)
110
+ end
111
+ when Array
112
+ obj.map { |item| deep_stringify(item) }
113
+ else
114
+ obj
115
+ end
116
+ end
117
+
118
+ def calculate_mutual(followers, following)
119
+ follower_logins = followers.map { |f| f['login'] || f[:login] }
120
+ following.select { |f| follower_logins.include?(f['login'] || f[:login]) }
121
+ end
122
+
123
+ def ensure_data_dir_exists
124
+ FileUtils.mkdir_p(@data_dir)
125
+ end
126
+
127
+ def snapshots_file_path
128
+ File.join(@data_dir, SNAPSHOTS_FILE)
129
+ end
130
+
131
+ def history_file_path
132
+ File.join(@data_dir, HISTORY_FILE)
133
+ end
134
+
135
+ def load_snapshots
136
+ load_json(snapshots_file_path)
137
+ end
138
+
139
+ def load_history
140
+ load_json(history_file_path)
141
+ end
142
+
143
+ def load_json(file_path)
144
+ return {} unless File.exist?(file_path)
145
+
146
+ JSON.parse(File.read(file_path))
147
+ rescue JSON::ParserError
148
+ {}
149
+ end
150
+
151
+ def write_json(file_path, data)
152
+ File.write(file_path, JSON.pretty_generate(data))
153
+ end
154
+
155
+ def export_json(username, output_file)
156
+ data = {
157
+ 'username' => username,
158
+ 'snapshots' => all_snapshots(username),
159
+ 'history' => get_history(username)
160
+ }
161
+ write_json(output_file, data)
162
+ output_file
163
+ end
164
+
165
+ def export_csv(username, output_file)
166
+ require 'csv'
167
+
168
+ history = get_history(username)
169
+
170
+ CSV.open(output_file, 'w') do |csv|
171
+ csv << ['Timestamp', 'Event Type', 'Username', 'User ID']
172
+
173
+ history.each do |entry|
174
+ csv << [
175
+ entry['timestamp'],
176
+ entry['event_type'],
177
+ entry['user']['login'],
178
+ entry['user']['id']
179
+ ]
180
+ end
181
+ end
182
+
183
+ output_file
184
+ end
185
+ end
186
+ end
@@ -0,0 +1,237 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GitFollow
4
+ class Tracker
5
+ attr_reader :client, :storage, :username
6
+
7
+ def initialize(client:, storage:)
8
+ @client = client
9
+ @storage = storage
10
+ @username = client.username
11
+ end
12
+
13
+ def initial_setup
14
+ followers = @client.fetch_followers
15
+ following = @client.fetch_following
16
+
17
+ @storage.save_snapshot(
18
+ username: @username,
19
+ followers: followers,
20
+ following: following
21
+ )
22
+ end
23
+
24
+ def check_changes
25
+ current_followers = @client.fetch_followers
26
+ current_following = @client.fetch_following
27
+
28
+ previous_snapshot = @storage.latest_snapshot(@username)
29
+
30
+ if previous_snapshot.nil?
31
+ return {
32
+ first_run: true,
33
+ message: 'No previous data found. Initializing...'
34
+ }
35
+ end
36
+
37
+ changes = detect_changes(previous_snapshot, current_followers, current_following)
38
+
39
+ # Save new snapshot
40
+ @storage.save_snapshot(
41
+ username: @username,
42
+ followers: current_followers,
43
+ following: current_following
44
+ )
45
+
46
+ save_history_entries(changes)
47
+
48
+ changes
49
+ end
50
+
51
+ def statistics
52
+ latest = @storage.latest_snapshot(@username)
53
+
54
+ return nil if latest.nil?
55
+
56
+ followers = latest['followers']
57
+ following = latest['following']
58
+ mutual = calculate_mutual(followers, following)
59
+
60
+ history = @storage.get_history(@username)
61
+ new_followers_count = history.count { |e| e['event_type'] == 'new_follower' }
62
+ unfollows_count = history.count { |e| e['event_type'] == 'unfollowed' }
63
+
64
+ {
65
+ followers_count: followers.size,
66
+ following_count: following.size,
67
+ mutual_count: mutual.size,
68
+ ratio: calculate_ratio(followers.size, following.size),
69
+ total_new_followers: new_followers_count,
70
+ total_unfollows: unfollows_count,
71
+ last_updated: latest['timestamp']
72
+ }
73
+ end
74
+
75
+ def generate_report(format: :text)
76
+ stats = statistics
77
+
78
+ return 'No data available. Run `gitfollow check` first.' if stats.nil?
79
+
80
+ history = @storage.get_history(@username, limit: 20)
81
+
82
+ case format
83
+ when :markdown
84
+ generate_markdown_report(stats, history)
85
+ when :text
86
+ generate_text_report(stats, history)
87
+ else
88
+ raise ArgumentError, "Unsupported format: #{format}"
89
+ end
90
+ end
91
+
92
+ def mutual_followers
93
+ latest = @storage.latest_snapshot(@username)
94
+ return [] if latest.nil?
95
+
96
+ calculate_mutual(latest['followers'], latest['following'])
97
+ end
98
+
99
+ def non_followers
100
+ latest = @storage.latest_snapshot(@username)
101
+ return [] if latest.nil?
102
+
103
+ following = latest['following']
104
+ follower_logins = latest['followers'].map { |f| f['login'] || f[:login] }
105
+
106
+ following.reject { |f| follower_logins.include?(f['login'] || f[:login]) }
107
+ end
108
+
109
+ private
110
+
111
+ def detect_changes(previous_snapshot, current_followers, _current_following)
112
+ previous_followers = previous_snapshot['followers']
113
+
114
+ # Extract IDs, handling both symbol and string keys
115
+ previous_follower_ids = previous_followers.map { |f| f['id'] || f[:id] }
116
+ current_follower_ids = current_followers.map { |f| f['id'] || f[:id] }
117
+
118
+ new_follower_ids = current_follower_ids - previous_follower_ids
119
+ unfollowed_ids = previous_follower_ids - current_follower_ids
120
+
121
+ # Find users by ID, handling both symbol and string keys
122
+ new_followers = current_followers.select { |f| new_follower_ids.include?(f['id'] || f[:id]) }
123
+ unfollowed = previous_followers.select { |f| unfollowed_ids.include?(f['id'] || f[:id]) }
124
+
125
+ {
126
+ new_followers: new_followers,
127
+ unfollowed: unfollowed,
128
+ has_changes: !new_followers.empty? || !unfollowed.empty?,
129
+ previous_count: previous_followers.size,
130
+ current_count: current_followers.size,
131
+ net_change: current_followers.size - previous_followers.size
132
+ }
133
+ end
134
+
135
+ def save_history_entries(changes)
136
+ changes[:new_followers].each do |user|
137
+ @storage.save_history_entry(
138
+ username: @username,
139
+ event_type: :new_follower,
140
+ user_data: user
141
+ )
142
+ end
143
+
144
+ changes[:unfollowed].each do |user|
145
+ @storage.save_history_entry(
146
+ username: @username,
147
+ event_type: :unfollowed,
148
+ user_data: user
149
+ )
150
+ end
151
+ end
152
+
153
+ def calculate_mutual(followers, following)
154
+ follower_logins = followers.map { |f| f['login'] || f[:login] }
155
+ following.select { |f| follower_logins.include?(f['login'] || f[:login]) }
156
+ end
157
+
158
+ def calculate_ratio(followers_count, following_count)
159
+ return 0.0 if following_count.zero?
160
+
161
+ (followers_count.to_f / following_count).round(2)
162
+ end
163
+
164
+ def generate_markdown_report(stats, history)
165
+ report = []
166
+ report << "# GitFollow Report for @#{@username}"
167
+ report << ''
168
+ report << "**Last Updated:** #{Time.parse(stats[:last_updated]).strftime('%Y-%m-%d %H:%M:%S UTC')}"
169
+ report << ''
170
+ report << '## Statistics'
171
+ report << ''
172
+ report << "- **Followers:** #{stats[:followers_count]}"
173
+ report << "- **Following:** #{stats[:following_count]}"
174
+ report << "- **Mutual:** #{stats[:mutual_count]}"
175
+ report << "- **Ratio:** #{stats[:ratio]}"
176
+ report << "- **Total New Followers:** #{stats[:total_new_followers]}"
177
+ report << "- **Total Unfollows:** #{stats[:total_unfollows]}"
178
+ report << ''
179
+ report << '## Recent Activity'
180
+ report << ''
181
+
182
+ if history.empty?
183
+ report << '_No recent activity_'
184
+ else
185
+ report << '| Timestamp | Event | User |'
186
+ report << '|-----------|-------|------|'
187
+
188
+ history.reverse.each do |entry|
189
+ timestamp = Time.parse(entry['timestamp']).strftime('%Y-%m-%d %H:%M')
190
+ event = entry['event_type'] == 'new_follower' ? '✅ New Follower' : '❌ Unfollowed'
191
+ user = "@#{entry['user']['login']}"
192
+
193
+ report << "| #{timestamp} | #{event} | #{user} |"
194
+ end
195
+ end
196
+
197
+ report << ''
198
+ report << '---'
199
+ report << '_Generated with [GitFollow](https://github.com/bulletdev/gitfollow)_'
200
+
201
+ report.join("\n")
202
+ end
203
+
204
+ def generate_text_report(stats, history)
205
+ report = []
206
+ report << "GitFollow Report for @#{@username}"
207
+ report << ('=' * 50)
208
+ report << ''
209
+ report << "Last Updated: #{Time.parse(stats[:last_updated]).strftime('%Y-%m-%d %H:%M:%S UTC')}"
210
+ report << ''
211
+ report << 'Statistics:'
212
+ report << " Followers: #{stats[:followers_count]}"
213
+ report << " Following: #{stats[:following_count]}"
214
+ report << " Mutual: #{stats[:mutual_count]}"
215
+ report << " Ratio: #{stats[:ratio]}"
216
+ report << " Total New: #{stats[:total_new_followers]}"
217
+ report << " Total Unfollows: #{stats[:total_unfollows]}"
218
+ report << ''
219
+ report << 'Recent Activity:'
220
+ report << ('-' * 50)
221
+
222
+ if history.empty?
223
+ report << ' No recent activity'
224
+ else
225
+ history.reverse.each do |entry|
226
+ timestamp = Time.parse(entry['timestamp']).strftime('%Y-%m-%d %H:%M')
227
+ event = entry['event_type'] == 'new_follower' ? '✅ NEW' : '❌ UNFOLLOW'
228
+ user = "@#{entry['user']['login']}"
229
+
230
+ report << " [#{timestamp}] #{event} #{user}"
231
+ end
232
+ end
233
+
234
+ report.join("\n")
235
+ end
236
+ end
237
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GitFollow
4
+ VERSION = '0.1.0'
5
+ end
data/lib/gitfollow.rb ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'gitfollow/version'
4
+ require_relative 'gitfollow/client'
5
+ require_relative 'gitfollow/storage'
6
+ require_relative 'gitfollow/tracker'
7
+ require_relative 'gitfollow/notifier'
8
+ require_relative 'gitfollow/cli'
9
+
10
+ # GitFollow - Track GitHub followers and unfollows
11
+ module GitFollow
12
+ end
metadata ADDED
@@ -0,0 +1,255 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: gitfollow
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Michael D. Bullet
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: colorize
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '1.1'
26
+ - !ruby/object:Gem::Dependency
27
+ name: csv
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '3.2'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '3.2'
40
+ - !ruby/object:Gem::Dependency
41
+ name: dotenv
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '3.0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '3.0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: octokit
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '8.0'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '8.0'
68
+ - !ruby/object:Gem::Dependency
69
+ name: thor
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '1.3'
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '1.3'
82
+ - !ruby/object:Gem::Dependency
83
+ name: tty-spinner
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '0.9'
89
+ type: :runtime
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '0.9'
96
+ - !ruby/object:Gem::Dependency
97
+ name: tty-table
98
+ requirement: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - "~>"
101
+ - !ruby/object:Gem::Version
102
+ version: '0.12'
103
+ type: :runtime
104
+ prerelease: false
105
+ version_requirements: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - "~>"
108
+ - !ruby/object:Gem::Version
109
+ version: '0.12'
110
+ - !ruby/object:Gem::Dependency
111
+ name: rake
112
+ requirement: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - "~>"
115
+ - !ruby/object:Gem::Version
116
+ version: '13.0'
117
+ type: :development
118
+ prerelease: false
119
+ version_requirements: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - "~>"
122
+ - !ruby/object:Gem::Version
123
+ version: '13.0'
124
+ - !ruby/object:Gem::Dependency
125
+ name: rspec
126
+ requirement: !ruby/object:Gem::Requirement
127
+ requirements:
128
+ - - "~>"
129
+ - !ruby/object:Gem::Version
130
+ version: '3.12'
131
+ type: :development
132
+ prerelease: false
133
+ version_requirements: !ruby/object:Gem::Requirement
134
+ requirements:
135
+ - - "~>"
136
+ - !ruby/object:Gem::Version
137
+ version: '3.12'
138
+ - !ruby/object:Gem::Dependency
139
+ name: rubocop
140
+ requirement: !ruby/object:Gem::Requirement
141
+ requirements:
142
+ - - "~>"
143
+ - !ruby/object:Gem::Version
144
+ version: '1.60'
145
+ type: :development
146
+ prerelease: false
147
+ version_requirements: !ruby/object:Gem::Requirement
148
+ requirements:
149
+ - - "~>"
150
+ - !ruby/object:Gem::Version
151
+ version: '1.60'
152
+ - !ruby/object:Gem::Dependency
153
+ name: rubocop-rspec
154
+ requirement: !ruby/object:Gem::Requirement
155
+ requirements:
156
+ - - "~>"
157
+ - !ruby/object:Gem::Version
158
+ version: '2.26'
159
+ type: :development
160
+ prerelease: false
161
+ version_requirements: !ruby/object:Gem::Requirement
162
+ requirements:
163
+ - - "~>"
164
+ - !ruby/object:Gem::Version
165
+ version: '2.26'
166
+ - !ruby/object:Gem::Dependency
167
+ name: simplecov
168
+ requirement: !ruby/object:Gem::Requirement
169
+ requirements:
170
+ - - "~>"
171
+ - !ruby/object:Gem::Version
172
+ version: '0.22'
173
+ type: :development
174
+ prerelease: false
175
+ version_requirements: !ruby/object:Gem::Requirement
176
+ requirements:
177
+ - - "~>"
178
+ - !ruby/object:Gem::Version
179
+ version: '0.22'
180
+ - !ruby/object:Gem::Dependency
181
+ name: vcr
182
+ requirement: !ruby/object:Gem::Requirement
183
+ requirements:
184
+ - - "~>"
185
+ - !ruby/object:Gem::Version
186
+ version: '6.2'
187
+ type: :development
188
+ prerelease: false
189
+ version_requirements: !ruby/object:Gem::Requirement
190
+ requirements:
191
+ - - "~>"
192
+ - !ruby/object:Gem::Version
193
+ version: '6.2'
194
+ - !ruby/object:Gem::Dependency
195
+ name: webmock
196
+ requirement: !ruby/object:Gem::Requirement
197
+ requirements:
198
+ - - "~>"
199
+ - !ruby/object:Gem::Version
200
+ version: '3.19'
201
+ type: :development
202
+ prerelease: false
203
+ version_requirements: !ruby/object:Gem::Requirement
204
+ requirements:
205
+ - - "~>"
206
+ - !ruby/object:Gem::Version
207
+ version: '3.19'
208
+ description: A CLI tool to monitor your GitHub followers, detect new followers and
209
+ unfollows, and generate detailed reports with automated notifications.
210
+ email:
211
+ - contato@michaelbullet.com
212
+ executables:
213
+ - gitfollow
214
+ extensions: []
215
+ extra_rdoc_files: []
216
+ files:
217
+ - CHANGELOG.md
218
+ - LICENSE
219
+ - README.md
220
+ - bin/gitfollow
221
+ - lib/gitfollow.rb
222
+ - lib/gitfollow/cli.rb
223
+ - lib/gitfollow/client.rb
224
+ - lib/gitfollow/notifier.rb
225
+ - lib/gitfollow/storage.rb
226
+ - lib/gitfollow/tracker.rb
227
+ - lib/gitfollow/version.rb
228
+ homepage: https://github.com/bulletdev/gitfollow
229
+ licenses:
230
+ - MIT
231
+ metadata:
232
+ homepage_uri: https://github.com/bulletdev/gitfollow
233
+ source_code_uri: https://github.com/bulletdev/gitfollow.git
234
+ changelog_uri: https://github.com/bulletdev/gitfollow/blob/main/CHANGELOG.md
235
+ bug_tracker_uri: https://github.com/bulletdev/gitfollow/issues
236
+ documentation_uri: https://github.com/bulletdev/gitfollow/blob/main/README.md
237
+ rubygems_mfa_required: 'true'
238
+ rdoc_options: []
239
+ require_paths:
240
+ - lib
241
+ required_ruby_version: !ruby/object:Gem::Requirement
242
+ requirements:
243
+ - - ">="
244
+ - !ruby/object:Gem::Version
245
+ version: 3.4.5
246
+ required_rubygems_version: !ruby/object:Gem::Requirement
247
+ requirements:
248
+ - - ">="
249
+ - !ruby/object:Gem::Version
250
+ version: '0'
251
+ requirements: []
252
+ rubygems_version: 3.6.9
253
+ specification_version: 4
254
+ summary: Track GitHub followers and unfollows with ease
255
+ test_files: []