githuh 0.3.0 → 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 +4 -4
- data/.envrc +4 -0
- data/.github/workflows/ruby.yml +8 -19
- data/.gitignore +3 -0
- data/.rubocop.yml +5 -2
- data/.rubocop_todo.yml +65 -71
- data/.secrets.baseline +127 -0
- data/CHANGELOG.md +39 -0
- data/Gemfile +13 -1
- data/Gemfile.lock +261 -116
- data/{LICENSE → LICENSE.txt} +1 -1
- data/README.adoc +2 -2
- data/Rakefile +13 -6
- data/docs/img/badge.svg +21 -0
- data/docs/img/coverage.png +0 -0
- data/docs/img/coverage.svg +2 -2
- data/githuh.gemspec +7 -12
- data/justfile +57 -0
- data/lefthook.yml +35 -0
- data/lib/githuh/cli/commands/base.rb +19 -17
- data/lib/githuh/cli/commands/issue/export.rb +19 -18
- data/lib/githuh/cli/commands/repo/list.rb +136 -27
- data/lib/githuh/cli/commands/user/info.rb +2 -2
- data/lib/githuh/cli/launcher.rb +10 -14
- data/lib/githuh/env_loader.rb +34 -0
- data/lib/githuh/llm/anthropic.rb +29 -0
- data/lib/githuh/llm/base.rb +69 -0
- data/lib/githuh/llm/openai.rb +26 -0
- data/lib/githuh/llm.rb +31 -0
- data/lib/githuh/version.rb +1 -1
- data/lib/githuh.rb +10 -8
- metadata +39 -115
- data/issues.json +0 -3168
|
@@ -34,19 +34,18 @@ module Githuh
|
|
|
34
34
|
per_page: DEFAULT_PAGE_SIZE,
|
|
35
35
|
verbose: false,
|
|
36
36
|
info: true)
|
|
37
|
-
|
|
38
37
|
self.context = Githuh
|
|
39
38
|
self.verbose = verbose
|
|
40
39
|
self.info = info
|
|
41
40
|
self.token = api_token || determine_github_token
|
|
42
|
-
self.per_page = per_page.to_i
|
|
41
|
+
self.per_page = per_page.to_i
|
|
43
42
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
43
|
+
return unless info
|
|
44
|
+
|
|
45
|
+
begin
|
|
46
|
+
print_userinfo
|
|
47
|
+
rescue StandardError
|
|
48
|
+
nil
|
|
50
49
|
end
|
|
51
50
|
end
|
|
52
51
|
|
|
@@ -56,12 +55,12 @@ module Githuh
|
|
|
56
55
|
|
|
57
56
|
protected
|
|
58
57
|
|
|
59
|
-
def puts(*
|
|
60
|
-
stdout.puts(*
|
|
58
|
+
def puts(*)
|
|
59
|
+
stdout.puts(*)
|
|
61
60
|
end
|
|
62
61
|
|
|
63
|
-
def warn(*
|
|
64
|
-
stderr.puts(*
|
|
62
|
+
def warn(*)
|
|
63
|
+
stderr.puts(*)
|
|
65
64
|
end
|
|
66
65
|
|
|
67
66
|
def user_info
|
|
@@ -93,7 +92,7 @@ module Githuh
|
|
|
93
92
|
end
|
|
94
93
|
|
|
95
94
|
def determine_github_token
|
|
96
|
-
@github_token ||=
|
|
95
|
+
@github_token ||= ENV['GITHUB_TOKEN'] || `git config --global --get user.token`.chomp
|
|
97
96
|
|
|
98
97
|
return @github_token unless @github_token.empty?
|
|
99
98
|
|
|
@@ -108,15 +107,18 @@ module Githuh
|
|
|
108
107
|
def print_userinfo
|
|
109
108
|
duration = DateTime.now - DateTime.parse(user_info[:created_at].to_s)
|
|
110
109
|
years = (duration / 365).to_i
|
|
111
|
-
months = ((duration - years * 365) / 30).to_i
|
|
112
|
-
days = (duration - years * 365 - months * 30).to_i
|
|
110
|
+
months = ((duration - (years * 365)) / 30).to_i
|
|
111
|
+
days = (duration - (years * 365) - (months * 30)).to_i
|
|
113
112
|
|
|
114
113
|
lines = []
|
|
115
|
-
lines << sprintf(" Github API Token: %s", h("#{token[0..9]}#{'.' * 20}#{token[-11
|
|
114
|
+
lines << sprintf(" Github API Token: %s", h("#{token[0..9]}#{'.' * 20}#{token[-11..]}"))
|
|
116
115
|
lines << sprintf(" Current User: %s", h(user_info.login))
|
|
117
116
|
lines << sprintf(" Public Repos: %s", h(user_info.public_repos.to_s))
|
|
118
117
|
lines << sprintf(" Followers: %s", h(user_info.followers.to_s))
|
|
119
|
-
lines << sprintf(
|
|
118
|
+
lines << sprintf(
|
|
119
|
+
" Member For: %s",
|
|
120
|
+
h("%<years>d years, %<months>d months, %<days>d days" % { years:, months:, days: })
|
|
121
|
+
)
|
|
120
122
|
|
|
121
123
|
self.box = TTY::Box.frame(*lines,
|
|
122
124
|
padding: 0,
|
|
@@ -6,9 +6,9 @@ require "bundler/setup"
|
|
|
6
6
|
require "dry/cli"
|
|
7
7
|
require "json"
|
|
8
8
|
require "tty/progressbar"
|
|
9
|
-
require "csv"
|
|
10
9
|
require "active_support/inflector"
|
|
11
10
|
require "yaml"
|
|
11
|
+
require "csv"
|
|
12
12
|
|
|
13
13
|
require_relative "../base"
|
|
14
14
|
|
|
@@ -19,7 +19,7 @@ module Githuh
|
|
|
19
19
|
class Export < Base
|
|
20
20
|
FORMATS = {
|
|
21
21
|
json: "json",
|
|
22
|
-
csv:
|
|
22
|
+
csv: "csv",
|
|
23
23
|
}.freeze
|
|
24
24
|
|
|
25
25
|
DEFAULT_FORMAT = :csv
|
|
@@ -27,16 +27,16 @@ module Githuh
|
|
|
27
27
|
|
|
28
28
|
attr_accessor :filename, :file, :output, :repo, :issues, :format, :record_count, :mapping
|
|
29
29
|
|
|
30
|
-
desc "Export Repo issues into a CSV or JSON format\n" \
|
|
31
|
-
"
|
|
30
|
+
desc "Export Repo issues into a CSV or JSON format\n " \
|
|
31
|
+
"Default output file is " + DEFAULT_OUTPUT_FORMAT.bold.yellow
|
|
32
32
|
|
|
33
33
|
argument :repo, type: :string, required: true, desc: 'Name of the repo, eg "rails/rails"'
|
|
34
|
-
option :file, required: false, desc: "Output file, overrides "
|
|
34
|
+
option :file, required: false, desc: "Output file, overrides #{DEFAULT_OUTPUT_FORMAT}"
|
|
35
35
|
option :format, values: FORMATS.keys.map(&:to_s), default: DEFAULT_FORMAT.to_s, required: false, desc: "Output format"
|
|
36
36
|
option :mapping, type: :string, require: false, desc: "YAML file with label to estimates mapping"
|
|
37
37
|
|
|
38
|
-
def call(repo: nil, file: nil, format: nil, mapping: nil, **
|
|
39
|
-
super(**
|
|
38
|
+
def call(repo: nil, file: nil, format: nil, mapping: nil, **)
|
|
39
|
+
super(**)
|
|
40
40
|
|
|
41
41
|
self.record_count = 0
|
|
42
42
|
self.repo = repo
|
|
@@ -46,9 +46,10 @@ module Githuh
|
|
|
46
46
|
|
|
47
47
|
self.mapping = {}
|
|
48
48
|
if mapping && ::File.exist?(mapping)
|
|
49
|
-
self.mapping = ::YAML.
|
|
49
|
+
self.mapping = ::YAML.safe_load_file(mapping)['label-to-estimates'] || {}
|
|
50
50
|
end
|
|
51
51
|
|
|
52
|
+
Export.send(:remove_const, :LabelEstimates) if Export.const_defined?(:LabelEstimates)
|
|
52
53
|
Export.const_set(:LabelEstimates, self.mapping)
|
|
53
54
|
|
|
54
55
|
self.issues = []
|
|
@@ -139,14 +140,14 @@ module Githuh
|
|
|
139
140
|
CSV_HEADER.each do |column|
|
|
140
141
|
method = column.downcase.underscore.to_sym
|
|
141
142
|
value = if CSV_MAP[column]
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
143
|
+
CSV_MAP[column][client, issue]
|
|
144
|
+
else
|
|
145
|
+
begin
|
|
146
|
+
issue.to_h[method]
|
|
147
|
+
rescue StandardError
|
|
148
|
+
nil
|
|
149
|
+
end
|
|
150
|
+
end
|
|
150
151
|
value = value.strip if value.is_a?(String)
|
|
151
152
|
row << value
|
|
152
153
|
end
|
|
@@ -166,7 +167,7 @@ module Githuh
|
|
|
166
167
|
def print_conclusion
|
|
167
168
|
puts
|
|
168
169
|
puts TTY::Box.info("Success: written a total of #{record_count} records to #{filename}",
|
|
169
|
-
|
|
170
|
+
width: ui_width, padding: 1)
|
|
170
171
|
puts
|
|
171
172
|
end
|
|
172
173
|
|
|
@@ -175,7 +176,7 @@ module Githuh
|
|
|
175
176
|
puts TTY::Box.info("Format : #{self.format}\n" \
|
|
176
177
|
"File : #{filename}\n" \
|
|
177
178
|
"Repo : #{repo}\n",
|
|
178
|
-
|
|
179
|
+
width: ui_width, padding: 1)
|
|
179
180
|
puts
|
|
180
181
|
end
|
|
181
182
|
|
|
@@ -4,10 +4,12 @@
|
|
|
4
4
|
# vim: ft=ruby
|
|
5
5
|
require 'bundler/setup'
|
|
6
6
|
require 'dry/cli'
|
|
7
|
+
require 'base64'
|
|
7
8
|
require 'json'
|
|
8
9
|
require 'tty/progressbar'
|
|
9
10
|
|
|
10
11
|
require_relative '../base'
|
|
12
|
+
require_relative '../../../llm'
|
|
11
13
|
|
|
12
14
|
module Githuh
|
|
13
15
|
module CLI
|
|
@@ -23,18 +25,42 @@ module Githuh
|
|
|
23
25
|
DEFAULT_OUTPUT_FORMAT = "<username>.repositories.<format>"
|
|
24
26
|
FORK_OPTIONS = %w(exclude include only).freeze
|
|
25
27
|
|
|
26
|
-
attr_accessor :filename, :file, :output, :repos, :format,
|
|
28
|
+
attr_accessor :filename, :file, :output, :repos, :format,
|
|
29
|
+
:forks, :private, :record_count, :llm_adapter
|
|
27
30
|
|
|
28
|
-
desc "List owned repositories and render the output in markdown or JSON\n" \
|
|
29
|
-
"
|
|
31
|
+
desc "List owned repositories and render the output in markdown or JSON\n " \
|
|
32
|
+
"Default output file is " + DEFAULT_OUTPUT_FORMAT.bold.yellow
|
|
30
33
|
|
|
31
|
-
option :file,
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
option :private, type: :boolean, default: nil, required: false, desc: 'If specified, returns only private repos for true, public for false'
|
|
34
|
+
option :file,
|
|
35
|
+
required: false,
|
|
36
|
+
desc: "Output file, overrides #{DEFAULT_OUTPUT_FORMAT}"
|
|
35
37
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
+
option :format,
|
|
39
|
+
values: FORMATS.keys,
|
|
40
|
+
default: DEFAULT_FORMAT.to_s,
|
|
41
|
+
required: false,
|
|
42
|
+
desc: 'Output format'
|
|
43
|
+
|
|
44
|
+
option :forks, type: :string,
|
|
45
|
+
values: FORK_OPTIONS,
|
|
46
|
+
default: FORK_OPTIONS.first,
|
|
47
|
+
required: false,
|
|
48
|
+
desc: 'Include or exclude forks'
|
|
49
|
+
|
|
50
|
+
option :private,
|
|
51
|
+
type: :boolean,
|
|
52
|
+
default: nil,
|
|
53
|
+
required: false,
|
|
54
|
+
desc: 'If specified, returns only private repos for true, public for false'
|
|
55
|
+
|
|
56
|
+
option :llm,
|
|
57
|
+
type: :boolean,
|
|
58
|
+
default: false,
|
|
59
|
+
required: false,
|
|
60
|
+
desc: 'Use LLM (ANTHROPIC_API_KEY or OPENAI_API_KEY) to summarize README'
|
|
61
|
+
|
|
62
|
+
def call(file: nil, format: nil, forks: nil, private: nil, llm: false, **)
|
|
63
|
+
super(**)
|
|
38
64
|
|
|
39
65
|
self.record_count = 0
|
|
40
66
|
self.forks = forks
|
|
@@ -42,6 +68,7 @@ module Githuh
|
|
|
42
68
|
self.repos = []
|
|
43
69
|
self.output = StringIO.new
|
|
44
70
|
self.format = (format || DEFAULT_FORMAT).to_sym
|
|
71
|
+
self.llm_adapter = build_llm_adapter if llm
|
|
45
72
|
|
|
46
73
|
self.filename = file || "#{user_info.login}.repositories.#{FORMATS[self.format]}"
|
|
47
74
|
self.file = File.open(filename, 'w')
|
|
@@ -100,55 +127,137 @@ module Githuh
|
|
|
100
127
|
def bar_size
|
|
101
128
|
return 1 if client&.last_response.nil?
|
|
102
129
|
|
|
103
|
-
client&.last_response
|
|
130
|
+
client&.last_response&.rels&.[](:last)&.href&.match(/page=(\d+).*$/)&.[](1)&.to_i # rubocop:disable Style/SafeNavigationChainLength
|
|
104
131
|
end
|
|
105
132
|
|
|
106
133
|
def render_as_markdown(repositories)
|
|
107
134
|
output.puts "### #{client.user.name}'s Repos\n"
|
|
135
|
+
|
|
136
|
+
llm_bar = build_llm_progress_bar(repositories.size) if llm_adapter
|
|
137
|
+
|
|
108
138
|
repositories.each_with_index do |repo, index|
|
|
109
139
|
output.puts repo_as_markdown(index, repo)
|
|
140
|
+
llm_bar&.advance
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
if llm_bar
|
|
144
|
+
llm_bar.finish
|
|
145
|
+
puts
|
|
110
146
|
end
|
|
147
|
+
|
|
111
148
|
output.string
|
|
112
149
|
end
|
|
113
150
|
|
|
151
|
+
def build_llm_progress_bar(total)
|
|
152
|
+
return unless info || verbose
|
|
153
|
+
|
|
154
|
+
color = llm_adapter.class.const_defined?(:BAR_COLOR) ? llm_adapter.class::BAR_COLOR : :cyan
|
|
155
|
+
provider = llm_adapter.class.name.split('::').last
|
|
156
|
+
|
|
157
|
+
puts
|
|
158
|
+
puts " • Summarizing #{total} READMEs with #{provider}…".send(color)
|
|
159
|
+
TTY::ProgressBar.new("[:bar]",
|
|
160
|
+
title: 'LLM Summaries',
|
|
161
|
+
total: total,
|
|
162
|
+
width: ui_width - 2,
|
|
163
|
+
head: '',
|
|
164
|
+
complete: '▉'.send(color))
|
|
165
|
+
end
|
|
166
|
+
|
|
114
167
|
def render_as_json(repositories)
|
|
115
168
|
JSON.pretty_generate(repositories.map(&:to_hash))
|
|
116
169
|
end
|
|
117
170
|
|
|
118
171
|
def repo_as_markdown(index, repo)
|
|
172
|
+
description = describe(repo)
|
|
173
|
+
|
|
119
174
|
<<~REPO
|
|
120
175
|
|
|
121
176
|
### #{index + 1}. [#{repo.name}](#{repo.url}) (#{repo.stargazers_count} ★)
|
|
122
177
|
|
|
123
|
-
#{
|
|
124
|
-
#{
|
|
178
|
+
#{"**#{repo.language}**. " if repo.language}
|
|
179
|
+
#{"Distributed under the **#{repo.license.name}** license." if repo.license}
|
|
125
180
|
|
|
126
|
-
#{
|
|
181
|
+
#{description}
|
|
127
182
|
|
|
128
183
|
REPO
|
|
129
184
|
end
|
|
130
185
|
|
|
186
|
+
def describe(repo)
|
|
187
|
+
return repo.description unless llm_adapter
|
|
188
|
+
|
|
189
|
+
readme = fetch_readme(repo)
|
|
190
|
+
return repo.description if readme.nil? || readme.empty?
|
|
191
|
+
|
|
192
|
+
llm_adapter.summarize(readme)
|
|
193
|
+
rescue StandardError => e
|
|
194
|
+
warn "LLM summary failed for #{repo_full_name(repo)}: #{e.message}" if verbose
|
|
195
|
+
repo.description
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def fetch_readme(repo)
|
|
199
|
+
readme = client.readme(repo_full_name(repo))
|
|
200
|
+
return nil unless readme
|
|
201
|
+
|
|
202
|
+
encoded = readme.respond_to?(:content) ? readme.content : readme[:content]
|
|
203
|
+
return nil if encoded.nil? || encoded.to_s.empty?
|
|
204
|
+
|
|
205
|
+
Base64.decode64(encoded).force_encoding('UTF-8')
|
|
206
|
+
rescue StandardError => e
|
|
207
|
+
warn "README fetch failed for #{repo_full_name(repo)}: #{e.message}" if verbose
|
|
208
|
+
nil
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def repo_full_name(repo)
|
|
212
|
+
repo.respond_to?(:full_name) && repo.full_name ? repo.full_name : repo.name
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def build_llm_adapter
|
|
216
|
+
adapter = Githuh::LLM.build
|
|
217
|
+
raise Githuh::LLM::Error, '--llm was specified but neither ANTHROPIC_API_KEY nor OPENAI_API_KEY is set' unless adapter
|
|
218
|
+
|
|
219
|
+
announce_llm(adapter) if info
|
|
220
|
+
adapter
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def announce_llm(adapter)
|
|
224
|
+
provider = adapter.class.name.split('::').last
|
|
225
|
+
model = adapter.class.const_defined?(:MODEL) ? adapter.class::MODEL : 'n/a'
|
|
226
|
+
|
|
227
|
+
puts
|
|
228
|
+
puts TTY::Box.info(
|
|
229
|
+
"LLM summaries: ENABLED\n" \
|
|
230
|
+
"Provider : #{provider}\n" \
|
|
231
|
+
"Model : #{model}\n" \
|
|
232
|
+
"\n" \
|
|
233
|
+
"For each repository, the README will be fetched and summarized\n" \
|
|
234
|
+
"into a 5-6 sentence description before writing to the output file.",
|
|
235
|
+
width: ui_width, padding: 1
|
|
236
|
+
)
|
|
237
|
+
puts
|
|
238
|
+
end
|
|
239
|
+
|
|
131
240
|
private
|
|
132
241
|
|
|
133
242
|
def filter_result!(result)
|
|
134
243
|
result.reject! do |r|
|
|
135
244
|
fork_reject = case forks
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
245
|
+
when 'exclude'
|
|
246
|
+
r.fork
|
|
247
|
+
when 'only'
|
|
248
|
+
!r.fork
|
|
249
|
+
when 'include'
|
|
250
|
+
false
|
|
251
|
+
end
|
|
143
252
|
|
|
144
253
|
private_reject = case private
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
254
|
+
when true
|
|
255
|
+
!r.private
|
|
256
|
+
when false
|
|
257
|
+
r.private
|
|
258
|
+
when nil
|
|
259
|
+
false
|
|
260
|
+
end
|
|
152
261
|
|
|
153
262
|
fork_reject || private_reject
|
|
154
263
|
end
|
data/lib/githuh/cli/launcher.rb
CHANGED
|
@@ -13,12 +13,10 @@ module Githuh
|
|
|
13
13
|
class Launcher
|
|
14
14
|
attr_accessor :argv, :stdin, :stdout, :stderr, :kernel, :command
|
|
15
15
|
|
|
16
|
-
def initialize(argv, stdin =
|
|
17
|
-
if ::Githuh.launcher
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
Githuh.launcher = self
|
|
21
|
-
end
|
|
16
|
+
def initialize(argv, stdin = $stdin, stdout = $stdout, stderr = $stderr, kernel = nil)
|
|
17
|
+
raise(ArgumentError, "Another instance of CLI Launcher was detected, aborting.") if ::Githuh.launcher
|
|
18
|
+
|
|
19
|
+
Githuh.launcher = self
|
|
22
20
|
|
|
23
21
|
self.argv = argv
|
|
24
22
|
self.stdin = stdin
|
|
@@ -28,7 +26,7 @@ module Githuh
|
|
|
28
26
|
end
|
|
29
27
|
|
|
30
28
|
def execute!
|
|
31
|
-
if argv.empty? ||
|
|
29
|
+
if argv.empty? || !!%w(--help -h).intersect?(argv)
|
|
32
30
|
stdout.puts BANNER
|
|
33
31
|
Githuh.configure_kernel_behavior! help: true
|
|
34
32
|
else
|
|
@@ -39,17 +37,15 @@ module Githuh
|
|
|
39
37
|
self.command = ::Dry::CLI.new(::Githuh::CLI::Commands)
|
|
40
38
|
command.call(arguments: argv, out: stdout, err: stderr)
|
|
41
39
|
rescue StandardError => e
|
|
42
|
-
lines = [e.message.gsub(
|
|
43
|
-
if e.backtrace &&
|
|
40
|
+
lines = [e.message.gsub("\n", ', ')]
|
|
41
|
+
if e.backtrace && !!ARGV.intersect?(%w[-v --verbose])
|
|
44
42
|
lines << ''
|
|
45
43
|
lines.concat(e.backtrace)
|
|
46
44
|
end
|
|
47
45
|
|
|
48
46
|
box = TTY::Box.frame(*lines,
|
|
49
|
-
**BOX_OPTIONS.
|
|
50
|
-
|
|
51
|
-
title: { top_center: "┤ #{e.class.name} ├" },
|
|
52
|
-
))
|
|
47
|
+
**BOX_OPTIONS, width: TTY::Screen.width,
|
|
48
|
+
title: { top_center: "┤ #{e.class.name} ├" })
|
|
53
49
|
stderr.puts
|
|
54
50
|
stderr.print box
|
|
55
51
|
ensure
|
|
@@ -62,7 +58,7 @@ module Githuh
|
|
|
62
58
|
end
|
|
63
59
|
end
|
|
64
60
|
|
|
65
|
-
BANNER = <<~BANNER
|
|
61
|
+
BANNER = <<~BANNER.freeze
|
|
66
62
|
|
|
67
63
|
#{'Githuh CLI'.bold.yellow} #{::Githuh::VERSION.bold.green} — API client for Github.com.
|
|
68
64
|
#{'© 2020 Konstantin Gredeskoul, All rights reserved. MIT License.'.cyan}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Githuh
|
|
4
|
+
module EnvLoader
|
|
5
|
+
# Load a simple KEY=VALUE .env file from the current working directory
|
|
6
|
+
# and/or HOME, without clobbering already-set ENV entries.
|
|
7
|
+
def self.load!
|
|
8
|
+
[File.join(Dir.pwd, '.env'), File.join(Dir.home, '.env')].uniq.each do |path|
|
|
9
|
+
next unless File.file?(path) && File.readable?(path)
|
|
10
|
+
|
|
11
|
+
File.foreach(path) do |raw|
|
|
12
|
+
line = raw.strip
|
|
13
|
+
next if line.empty? || line.start_with?('#')
|
|
14
|
+
|
|
15
|
+
key, value = line.split('=', 2)
|
|
16
|
+
next if key.nil? || value.nil?
|
|
17
|
+
|
|
18
|
+
key = key.strip
|
|
19
|
+
value = strip_quotes(value.strip)
|
|
20
|
+
ENV[key] ||= value
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.strip_quotes(value)
|
|
26
|
+
if (value.start_with?('"') && value.end_with?('"')) ||
|
|
27
|
+
(value.start_with?("'") && value.end_with?("'"))
|
|
28
|
+
value[1..-2]
|
|
29
|
+
else
|
|
30
|
+
value
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
|
|
5
|
+
module Githuh
|
|
6
|
+
module LLM
|
|
7
|
+
class Anthropic < Base
|
|
8
|
+
ENDPOINT = URI('https://api.anthropic.com/v1/messages').freeze
|
|
9
|
+
MODEL = 'claude-haiku-4-5-20251001'
|
|
10
|
+
MAX_TOKENS = 800
|
|
11
|
+
BAR_COLOR = :yellow
|
|
12
|
+
|
|
13
|
+
def summarize(readme)
|
|
14
|
+
headers = {
|
|
15
|
+
'x-api-key' => api_key,
|
|
16
|
+
'anthropic-version' => '2023-06-01'
|
|
17
|
+
}
|
|
18
|
+
body = {
|
|
19
|
+
model: MODEL,
|
|
20
|
+
max_tokens: MAX_TOKENS,
|
|
21
|
+
messages: [{ role: 'user', content: prompt_for(readme) }]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
payload = parse!(post_json(ENDPOINT, headers, body))
|
|
25
|
+
payload.dig('content', 0, 'text').to_s.strip
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'uri'
|
|
6
|
+
|
|
7
|
+
module Githuh
|
|
8
|
+
module LLM
|
|
9
|
+
class Base
|
|
10
|
+
# Hard cap to keep prompts cheap and within model context.
|
|
11
|
+
README_CHAR_LIMIT = 12_000
|
|
12
|
+
REQUEST_TIMEOUT = 30
|
|
13
|
+
|
|
14
|
+
PROMPT = <<~PROMPT
|
|
15
|
+
You are summarizing a GitHub repository for a public directory page.
|
|
16
|
+
Below is the README. Write a user-friendly description of 5–6 sentences.
|
|
17
|
+
Focus on: what the project *is*, what problem it *solves*, who would
|
|
18
|
+
use it, and any notable technical approach or feature. Keep it flowing
|
|
19
|
+
prose — no bullet points, no headings, no quotes, no markdown syntax.
|
|
20
|
+
Do NOT include installation instructions, badges, license mentions,
|
|
21
|
+
or author credits. Return ONLY the description prose — no preamble.
|
|
22
|
+
|
|
23
|
+
README:
|
|
24
|
+
---
|
|
25
|
+
%<readme>s
|
|
26
|
+
---
|
|
27
|
+
PROMPT
|
|
28
|
+
|
|
29
|
+
attr_reader :api_key, :name
|
|
30
|
+
|
|
31
|
+
def initialize(api_key:)
|
|
32
|
+
@api_key = api_key
|
|
33
|
+
@name = self.class.name.split('::').last.downcase
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @param readme [String] raw README markdown
|
|
37
|
+
# @return [String] a 2-4 sentence description
|
|
38
|
+
def summarize(readme)
|
|
39
|
+
raise NotImplementedError
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
protected
|
|
43
|
+
|
|
44
|
+
def prompt_for(readme)
|
|
45
|
+
format(PROMPT, readme: readme.to_s[0, README_CHAR_LIMIT])
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def post_json(uri, headers, body)
|
|
49
|
+
req = Net::HTTP::Post.new(uri)
|
|
50
|
+
headers.each { |k, v| req[k] = v }
|
|
51
|
+
req['Content-Type'] = 'application/json'
|
|
52
|
+
req.body = JSON.dump(body)
|
|
53
|
+
|
|
54
|
+
Net::HTTP.start(uri.hostname, uri.port,
|
|
55
|
+
use_ssl: uri.scheme == 'https',
|
|
56
|
+
read_timeout: REQUEST_TIMEOUT,
|
|
57
|
+
open_timeout: REQUEST_TIMEOUT) do |http|
|
|
58
|
+
http.request(req)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def parse!(response)
|
|
63
|
+
raise Error, "#{name} API #{response.code}: #{response.body}" unless response.is_a?(Net::HTTPSuccess)
|
|
64
|
+
|
|
65
|
+
JSON.parse(response.body)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
|
|
5
|
+
module Githuh
|
|
6
|
+
module LLM
|
|
7
|
+
class OpenAI < Base
|
|
8
|
+
ENDPOINT = URI('https://api.openai.com/v1/chat/completions').freeze
|
|
9
|
+
MODEL = 'gpt-4o-mini'
|
|
10
|
+
MAX_TOKENS = 800
|
|
11
|
+
BAR_COLOR = :green
|
|
12
|
+
|
|
13
|
+
def summarize(readme)
|
|
14
|
+
headers = { 'Authorization' => "Bearer #{api_key}" }
|
|
15
|
+
body = {
|
|
16
|
+
model: MODEL,
|
|
17
|
+
max_tokens: MAX_TOKENS,
|
|
18
|
+
messages: [{ role: 'user', content: prompt_for(readme) }]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
payload = parse!(post_json(ENDPOINT, headers, body))
|
|
22
|
+
payload.dig('choices', 0, 'message', 'content').to_s.strip
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
data/lib/githuh/llm.rb
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Githuh
|
|
4
|
+
module LLM
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Returns an adapter instance based on available env vars.
|
|
8
|
+
# Priority: ANTHROPIC_API_KEY > OPENAI_API_KEY.
|
|
9
|
+
# Returns nil if no key is set (caller decides how to handle).
|
|
10
|
+
def self.build
|
|
11
|
+
if (key = env('ANTHROPIC_API_KEY'))
|
|
12
|
+
Anthropic.new(api_key: key)
|
|
13
|
+
elsif (key = env('OPENAI_API_KEY'))
|
|
14
|
+
OpenAI.new(api_key: key)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.available?
|
|
19
|
+
!build.nil?
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.env(name)
|
|
23
|
+
value = ENV.fetch(name, nil)
|
|
24
|
+
value && !value.strip.empty? ? value.strip : nil
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
require_relative 'llm/base'
|
|
30
|
+
require_relative 'llm/anthropic'
|
|
31
|
+
require_relative 'llm/openai'
|
data/lib/githuh/version.rb
CHANGED