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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +64 -0
- data/CODEOWNERS +1 -0
- data/CONTRIBUTING.md +123 -0
- data/CREDITS.md +20 -0
- data/Gemfile +7 -0
- data/Gemfile.lock +147 -0
- data/LICENSE +27 -0
- data/Makefile +7 -0
- data/README.md +66 -0
- data/Rakefile +15 -0
- data/exe/repofetch +60 -0
- data/lib/repofetch/DEFAULT_CONFIG +4 -0
- data/lib/repofetch/config.rb +40 -0
- data/lib/repofetch/env.rb +15 -0
- data/lib/repofetch/exceptions.rb +13 -0
- data/lib/repofetch/github/ASCII +20 -0
- data/lib/repofetch/github.rb +153 -0
- data/lib/repofetch/theme.rb +50 -0
- data/lib/repofetch/util.rb +11 -0
- data/lib/repofetch.rb +247 -0
- metadata +290 -0
@@ -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
|
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
|