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