repofetch 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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