repofetch 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,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'action_view'
4
+ require 'octokit'
5
+ require 'optparse'
6
+ require 'repofetch'
7
+
8
+ class Repofetch
9
+ # Adds support for GitHub repositories.
10
+ class Github < Repofetch::Plugin
11
+ include ActionView::Helpers::NumberHelper
12
+
13
+ HTTP_REMOTE_REGEX = %r{https?://github\.com/(?<owner>[\w.\-]+)/(?<repository>[\w.\-]+)}.freeze
14
+ SSH_REMOTE_REGEX = %r{git@github\.com:(?<owner>[\w.\-]+)/(?<repository>[\w.\-]+)}.freeze
15
+ ASCII = File.read(File.expand_path('github/ASCII', __dir__))
16
+
17
+ attr_reader :owner, :repository
18
+
19
+ # Initializes the GitHub plugin.
20
+ def initialize(owner, repository)
21
+ super
22
+
23
+ @owner = owner
24
+ @repository = repository
25
+ @client = Octokit::Client.new(access_token: ENV.fetch('GITHUB_TOKEN', nil))
26
+ end
27
+
28
+ def repo_id
29
+ "#{@owner}/#{@repository}"
30
+ end
31
+
32
+ def stats
33
+ [url, stargazers, subscribers, forks, created, updated, size, issues, pull_requests]
34
+ end
35
+
36
+ # Detects that the repository is a GitHub repository.
37
+ def self.matches_repo?(git)
38
+ default_remote = Repofetch.default_remote(git)
39
+ url = default_remote&.url
40
+ matches_remote?(url)
41
+ end
42
+
43
+ # Detects that the remote URL is for a GitHub repository.
44
+ def self.matches_remote?(remote)
45
+ HTTP_REMOTE_REGEX.match?(remote) || SSH_REMOTE_REGEX.match?(remote)
46
+ end
47
+
48
+ # Gets the owner and repository from a GitHub local repository.
49
+ def self.repo_identifiers(git)
50
+ default_remote = Repofetch.default_remote(git)
51
+ url = default_remote&.url
52
+ remote_identifiers(url)
53
+ end
54
+
55
+ # Gets the owner and repository from a GitHub remote URL.
56
+ #
57
+ # Returns nil if there is no match.
58
+ def self.remote_identifiers(remote)
59
+ match = HTTP_REMOTE_REGEX.match(remote)
60
+ match = SSH_REMOTE_REGEX.match(remote) if match.nil?
61
+ raise "Remote #{remote.inspect} doesn't look like a GitHub remote" if match.nil?
62
+
63
+ [match[:owner], match[:repository].delete_suffix('.git')]
64
+ end
65
+
66
+ # Creates an instance from a +Git::Base+ instance.
67
+ def self.from_git(git, args, _config)
68
+ # TODO: Raise a better exception than ArgumentError
69
+ raise ArgumentError, 'Explicitly activate this plugin to CLI arguments' unless args.empty?
70
+
71
+ owner, repository = repo_identifiers(git)
72
+
73
+ new(owner, repository)
74
+ end
75
+
76
+ # Creates an instance from CLI args and configuration.
77
+ def self.from_args(args, _config)
78
+ parser = OptionParser.new do |opts|
79
+ opts.banner = 'Usage: <plugin activation> -- [options] OWNER/REPOSITORY'
80
+ opts.separator ''
81
+ opts.separator 'This plugin can use the GITHUB_TOKEN environment variable increase rate limits'
82
+ end
83
+ parser.parse(args)
84
+ split = args[0]&.split('/')
85
+
86
+ # TODO: Raise a better exception than ArgumentError
87
+ raise ArgumentError, parser.to_s unless split&.length == 2
88
+
89
+ new(*split)
90
+ end
91
+
92
+ def header
93
+ "#{theme.format(:bold, "#{owner}/#{repository}")} @ #{theme.format(:bold, 'GitHub')}"
94
+ end
95
+
96
+ def ascii
97
+ ASCII
98
+ end
99
+
100
+ protected
101
+
102
+ def repo_stats
103
+ @repo_stats = @client.repository(repo_id) if @repo_stats.nil?
104
+ @repo_stats
105
+ end
106
+
107
+ def url
108
+ Repofetch::Stat.new('URL', repo_stats['clone_url'], emoji: '🌐', theme: theme)
109
+ end
110
+
111
+ def stargazers
112
+ Repofetch::Stat.new('stargazers', repo_stats['stargazers_count'], emoji: '⭐', theme: theme)
113
+ end
114
+
115
+ def subscribers
116
+ Repofetch::Stat.new('subscribers', repo_stats['subscribers_count'], emoji: '👀', theme: theme)
117
+ end
118
+
119
+ def forks
120
+ Repofetch::Stat.new('forks', repo_stats['forks_count'], emoji: '🔱', theme: theme)
121
+ end
122
+
123
+ def created
124
+ Repofetch::TimespanStat.new('created', repo_stats['created_at'], emoji: '🐣', theme: theme)
125
+ end
126
+
127
+ def updated
128
+ Repofetch::TimespanStat.new('updated', repo_stats['updated_at'], emoji: '📤', theme: theme)
129
+ end
130
+
131
+ def size
132
+ byte_size = number_to_human_size(
133
+ (repo_stats['size'] || 0) * 1024,
134
+ precision: 2,
135
+ significant: false,
136
+ strip_insignificant_zeros: false
137
+ )
138
+ Repofetch::Stat.new('size', byte_size, emoji: '💽', theme: theme)
139
+ end
140
+
141
+ def issues
142
+ @issue_search = @client.search_issues("repo:#{repo_id} is:issue", per_page: 1, page: 0) if @issue_search.nil?
143
+ Repofetch::Stat.new('issues', @issue_search['total_count'], emoji: '❗', theme: theme)
144
+ end
145
+
146
+ def pull_requests
147
+ @pr_search = @client.search_issues("repo:#{repo_id} is:pr", per_page: 1, page: 0) if @pr_search.nil?
148
+ Repofetch::Stat.new('pull requests', @pr_search['total_count'], emoji: '🔀', theme: theme)
149
+ end
150
+ end
151
+ end
152
+
153
+ Repofetch::Github.register
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Repofetch
4
+ # Provides a theme for styling output.
5
+ class Theme
6
+ DEFAULT_STYLES = {
7
+ black: 30,
8
+ red: 31,
9
+ green: 32,
10
+ yellow: 33,
11
+ blue: 34,
12
+ magenta: 35,
13
+ cyan: 36,
14
+ white: 37,
15
+ on_black: 40,
16
+ on_red: 41,
17
+ on_green: 42,
18
+ on_yellow: 43,
19
+ on_blue: 44,
20
+ on_magenta: 45,
21
+ on_cyan: 46,
22
+ on_white: 47,
23
+ bold: 1,
24
+ underline: 4,
25
+ reset: 0,
26
+ default: 0
27
+ }.freeze
28
+
29
+ attr_reader :styles
30
+
31
+ def initialize(styles = {})
32
+ @styles = DEFAULT_STYLES.merge(styles)
33
+ end
34
+
35
+ # Gets the ANSI escape sequence for a style.
36
+ def style(name)
37
+ "\e[#{styles[name]}m"
38
+ end
39
+
40
+ # Formats a string with ANSI escape sequences.
41
+ def format(name, text)
42
+ "#{style(name)}#{text}#{style(:reset)}"
43
+ end
44
+
45
+ # Returns a Hash with ANSI escape sequences for each style.
46
+ def to_h
47
+ styles.transform_values { |value| "\e[#{value}m" }
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Repofetch
4
+ # Provides uncategorized utilities.
5
+ class Util
6
+ # Cleans a string with style parameters (e.g. "%{green}OK" -> "OK")
7
+ def self.clean_s(str)
8
+ str.gsub(/%{[\w\d]+?}/, '')
9
+ end
10
+ end
11
+ end
data/lib/repofetch.rb ADDED
@@ -0,0 +1,247 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'action_view'
4
+ require 'git'
5
+ require 'repofetch/config'
6
+ require 'repofetch/env'
7
+ require 'repofetch/exceptions'
8
+ require 'repofetch/theme'
9
+ require 'repofetch/util'
10
+
11
+ # Main class for repofetch
12
+ class Repofetch
13
+ MAX_ASCII_WIDTH = 40
14
+ MAX_ASCII_HEIGHT = 20
15
+ DEFAULT_THEME = Theme.new.freeze
16
+ @plugins = []
17
+
18
+ class << self
19
+ attr_reader :plugins
20
+ end
21
+
22
+ # Registers a plugin.
23
+ #
24
+ # @param [Plugin] plugin The plugin to register
25
+ def self.register_plugin(plugin)
26
+ @plugins << plugin
27
+ end
28
+
29
+ # Replaces an existing plugin. If the existing plugin does not exist,
30
+ # then it registers the plugin instead.
31
+ #
32
+ # @param [Plugin] old The plugin to be replaced
33
+ # @param [Plugin] new The new plugin
34
+ def self.replace_or_register_plugin(old, new)
35
+ index = @plugins.find_index(old)
36
+ if index.nil?
37
+ register_plugin(new)
38
+ else
39
+ @plugins[index] = new
40
+ @plugins
41
+ end
42
+ end
43
+
44
+ # Returns the plugin that should be used.
45
+ # Raises a +Repofetch::NoPluginsError+ if no plugins are found.
46
+ # Raises a +Repofetch::TooManyPluginsError+ if more than one plugin is found.
47
+ #
48
+ # @param [String] git An instance of +Git::Base+
49
+ # @param [Array<String>] args The arguments passed to the program.
50
+ # @param [Repofetch::Config] config The configuration to use.
51
+ #
52
+ # @returns [Plugin] A plugin to use.
53
+ def self.get_plugin(git, args, config)
54
+ available_plugins = @plugins.filter do |plugin_class|
55
+ plugin_class.matches_repo?(git)
56
+ rescue NoMethodError
57
+ warn "#{plugin_class} Does not implement +matches_repo?+"
58
+ false
59
+ end
60
+ raise NoPluginsError if available_plugins.empty?
61
+
62
+ raise TooManyPluginsError if available_plugins.length > 1
63
+
64
+ available_plugins[0].from_git(git, args, config)
65
+ end
66
+
67
+ # Gets the name of the default remote to use.
68
+ #
69
+ # Will try to pick "origin", but if that is not found then it will
70
+ # pick the first one found, or nil if there aren't any available.
71
+ #
72
+ # @param [String] path The path to the repository.
73
+ #
74
+ # @returns [Git::Remote]
75
+ def self.default_remote(git)
76
+ remotes = git.remotes
77
+ found_remote = remotes.find { |remote| remote.name == 'origin' }
78
+ found_remote = remotes[0] if found_remote.nil?
79
+ found_remote
80
+ end
81
+
82
+ # Just wrapper around +default_remote+ since this is likely the most common
83
+ # use case (and it's easier than referencing the +Git::Remote+ docs to ensure
84
+ # correct usage in each plugin).
85
+ #
86
+ # @param [String] path The path to the repository.
87
+ #
88
+ # @return [String]
89
+ def self.default_remote_url(path)
90
+ default_remote(path)&.url
91
+ end
92
+
93
+ # Base class for plugins.
94
+ class Plugin
95
+ # Plugin intializer arguments should come from the +from_git+ or +from_args+
96
+ # class methods.
97
+ def initialize(*) end
98
+
99
+ # Registers this plugin class for repofetch.
100
+ def self.register
101
+ Repofetch.register_plugin(self)
102
+ end
103
+
104
+ # Tries to replace another plugin. An example use case might be if this plugin
105
+ # extends another registered plugin.
106
+ #
107
+ # @param [Plugin] old The plugin to replace
108
+ def self.replace_or_register(old)
109
+ Repofetch.replace_or_register_plugin(old, self)
110
+ end
111
+
112
+ # Detects that this plugin should be used. Should be overridden by subclasses.
113
+ #
114
+ # An example implementation is checking if +Repofetch.default_remote_url+ matches
115
+ # a regular expression.
116
+ #
117
+ # @param [Git::Base] _git The Git repository object
118
+ def self.matches_repo?(_git)
119
+ raise NoMethodError, 'matches_repo? must be overridden by the plugin subclass'
120
+ end
121
+
122
+ # This should use a git instance and call +Plugin.new+.
123
+ #
124
+ # @param [Git::Base] _git The Git repository object to use when calling +Plugin.new+.
125
+ # @param [Array] _args The arguments to process.
126
+ # @param [Config] _config The configuration loaded by the CLI.
127
+ #
128
+ # @returns [Plugin]
129
+ def self.from_git(_git, _args, _config)
130
+ raise NoMethodError, 'from_git must be overridden by the plugin subclass'
131
+ end
132
+
133
+ # This will receive an array of strings (e.g. +ARGV+) and call +Plugin.new+.
134
+ #
135
+ # @param [Array] _args The arguments to process.
136
+ # @param [Config] _config The configuration loaded by the CLI.
137
+ #
138
+ # @returns [Plugin]
139
+ def self.from_args(_args, _config)
140
+ raise NoMethodError, 'from_args must be overridden by the plugin subclass'
141
+ end
142
+
143
+ # Gets the plugin's theme. Override to use a theme besides the default.
144
+ def theme
145
+ Repofetch::DEFAULT_THEME
146
+ end
147
+
148
+ # The ASCII to be printed alongside the stats.
149
+ #
150
+ # This should be overridden by the plugin subclass.
151
+ # Should be within the bounds 40x20 (width x height).
152
+ def ascii
153
+ raise NoMethodError, 'ascii must be overridden by the plugin subclass'
154
+ end
155
+
156
+ # The header to show for the plugin.
157
+ #
158
+ # This should be overridden by the plugin subclass.
159
+ # For example, "foo/bar @ GitHub".
160
+ def header
161
+ raise NoMethodError, 'header must be overridden by the plugin subclass'
162
+ end
163
+
164
+ # Creates the separator that appears underneath the header
165
+ def separator
166
+ '-' * Repofetch::Util.clean_s(header).length
167
+ end
168
+
169
+ def to_s
170
+ zipped_lines.map do |ascii_line, stat_line|
171
+ cleaned_ascii = Repofetch::Util.clean_s(ascii_line)
172
+ styled_ascii = (ascii_line % theme.to_h) + theme.style(:reset)
173
+ aligned_stat_line = "#{' ' * (MAX_ASCII_WIDTH + 5)}#{stat_line}"
174
+ "#{styled_ascii}#{aligned_stat_line.slice(cleaned_ascii.length..)}\n"
175
+ end.join
176
+ end
177
+
178
+ # An array of stats that will be displayed to the right of the ASCII art.
179
+ #
180
+ # @returns [Array<Stat>]
181
+ def stats
182
+ []
183
+ end
184
+
185
+ # Makes an array of stat lines, including the header and separator.
186
+ def stat_lines
187
+ [header, separator, *stats.map(&:to_s)]
188
+ end
189
+
190
+ # Zips ASCII lines with stat lines.
191
+ #
192
+ # If there are more of one than the other, than the zip will be padded with empty strings.
193
+ def zipped_lines
194
+ ascii_lines = ascii.lines.map(&:chomp)
195
+ if ascii_lines.length > stat_lines.length
196
+ ascii_lines.zip(stat_lines)
197
+ else
198
+ stat_lines.zip(ascii_lines).map(&:reverse)
199
+ end.map { |ascii, stat| [ascii.to_s, stat.to_s] }
200
+ end
201
+ end
202
+
203
+ # Base class for stats.
204
+ class Stat
205
+ attr_reader :label, :value, :emoji
206
+
207
+ # Creates a stat
208
+ #
209
+ # @param [String] label The label of the stat
210
+ # @param value The value of the stat
211
+ # @param [String] emoji An optional emoji for the stat
212
+ def initialize(label, value, emoji: nil, theme: nil)
213
+ @label = label
214
+ @value = value
215
+ @emoji = emoji
216
+ @theme = theme
217
+ end
218
+
219
+ def to_s
220
+ "#{@emoji || ''}#{@theme.nil? ? @label : @theme.format(:bold, @label)}: #{format_value}"
221
+ end
222
+
223
+ # Formats the value
224
+ #
225
+ # This simply converts the value to a string, but can be overridden but
226
+ # subclasses to affect +to_s+.
227
+ def format_value
228
+ @value.to_s
229
+ end
230
+ end
231
+
232
+ # Timespan stat for "x units ago" stats.
233
+ class TimespanStat < Stat
234
+ include ActionView::Helpers::DateHelper
235
+
236
+ # Formats the value as "x units ago".
237
+ def format_value(now = nil)
238
+ now = Time.now if now.nil?
239
+ "#{distance_of_time_in_words(@value, now)} ago"
240
+ end
241
+ end
242
+
243
+ def self.clear_plugins
244
+ @plugins = []
245
+ end
246
+ private_class_method :clear_plugins
247
+ end