kward 0.66.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/.yardopts +9 -0
- data/CHANGELOG.md +12 -0
- data/Gemfile +8 -0
- data/Gemfile.lock +90 -0
- data/LICENSE +21 -0
- data/README.md +101 -0
- data/Rakefile +20 -0
- data/doc/authentication.md +105 -0
- data/doc/code-search.md +56 -0
- data/doc/configuration.md +310 -0
- data/doc/extensibility.md +186 -0
- data/doc/getting-started.md +127 -0
- data/doc/memory.md +192 -0
- data/doc/plugins.md +223 -0
- data/doc/releasing.md +36 -0
- data/doc/rpc.md +635 -0
- data/doc/usage.md +179 -0
- data/doc/web-search.md +28 -0
- data/exe/kward +5 -0
- data/kward.gemspec +33 -0
- data/lib/kward/agent.rb +234 -0
- data/lib/kward/ansi.rb +276 -0
- data/lib/kward/auth/file.rb +11 -0
- data/lib/kward/auth/github_oauth.rb +222 -0
- data/lib/kward/auth/openai_oauth.rb +323 -0
- data/lib/kward/auth/openrouter_api_key.rb +40 -0
- data/lib/kward/cancellation.rb +54 -0
- data/lib/kward/cli.rb +2122 -0
- data/lib/kward/clipboard.rb +84 -0
- data/lib/kward/compactor.rb +998 -0
- data/lib/kward/config_files.rb +564 -0
- data/lib/kward/conversation.rb +148 -0
- data/lib/kward/events.rb +13 -0
- data/lib/kward/export_path.rb +28 -0
- data/lib/kward/image_attachments.rb +331 -0
- data/lib/kward/markdown_transcript.rb +72 -0
- data/lib/kward/memory/manager.rb +652 -0
- data/lib/kward/message_access.rb +42 -0
- data/lib/kward/model/chat_invocation.rb +23 -0
- data/lib/kward/model/client.rb +875 -0
- data/lib/kward/model/context_overflow.rb +55 -0
- data/lib/kward/model/context_usage.rb +104 -0
- data/lib/kward/model/model_info.rb +188 -0
- data/lib/kward/model/retry_message.rb +11 -0
- data/lib/kward/model/stream_parser.rb +205 -0
- data/lib/kward/pan/index.html.erb +143 -0
- data/lib/kward/pan/server.rb +397 -0
- data/lib/kward/plugin_registry.rb +327 -0
- data/lib/kward/private_file.rb +18 -0
- data/lib/kward/prompt_interface.rb +2437 -0
- data/lib/kward/prompts/commands.rb +50 -0
- data/lib/kward/prompts/templates.rb +60 -0
- data/lib/kward/prompts.rb +58 -0
- data/lib/kward/resources/avatar_kward_logo.rb +48 -0
- data/lib/kward/resources/pixel_logo.rb +230 -0
- data/lib/kward/rpc/auth_manager.rb +265 -0
- data/lib/kward/rpc/config_manager.rb +58 -0
- data/lib/kward/rpc/prompt_bridge.rb +104 -0
- data/lib/kward/rpc/redactor.rb +47 -0
- data/lib/kward/rpc/server.rb +639 -0
- data/lib/kward/rpc/session_manager.rb +1122 -0
- data/lib/kward/rpc/tool_event_normalizer.rb +68 -0
- data/lib/kward/rpc/tool_metadata.rb +80 -0
- data/lib/kward/rpc/transcript_normalizer.rb +307 -0
- data/lib/kward/rpc/transport.rb +58 -0
- data/lib/kward/session_diff.rb +125 -0
- data/lib/kward/session_store.rb +493 -0
- data/lib/kward/skills/registry.rb +76 -0
- data/lib/kward/starter_pack_installer.rb +110 -0
- data/lib/kward/steering.rb +56 -0
- data/lib/kward/telemetry/logger.rb +195 -0
- data/lib/kward/telemetry/stats.rb +466 -0
- data/lib/kward/tools/ask_user_question.rb +107 -0
- data/lib/kward/tools/base.rb +45 -0
- data/lib/kward/tools/code_search.rb +65 -0
- data/lib/kward/tools/edit_file.rb +41 -0
- data/lib/kward/tools/list_directory.rb +21 -0
- data/lib/kward/tools/read_file.rb +30 -0
- data/lib/kward/tools/read_skill.rb +27 -0
- data/lib/kward/tools/registry.rb +117 -0
- data/lib/kward/tools/run_shell_command.rb +28 -0
- data/lib/kward/tools/search/code.rb +445 -0
- data/lib/kward/tools/search/web.rb +747 -0
- data/lib/kward/tools/tool_call.rb +87 -0
- data/lib/kward/tools/web_search.rb +48 -0
- data/lib/kward/tools/write_file.rb +29 -0
- data/lib/kward/transcript_export.rb +40 -0
- data/lib/kward/version.rb +4 -0
- data/lib/kward/workspace.rb +377 -0
- data/lib/kward.rb +6 -0
- data/lib/main.rb +3 -0
- metadata +232 -0
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
require "cgi"
|
|
2
|
+
require "fileutils"
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "open3"
|
|
6
|
+
require "pathname"
|
|
7
|
+
require "uri"
|
|
8
|
+
require_relative "../../config_files"
|
|
9
|
+
|
|
10
|
+
module Kward
|
|
11
|
+
class CodeSearch
|
|
12
|
+
DEFAULT_MAX_RESULTS = 10
|
|
13
|
+
MAX_MAX_RESULTS = 50
|
|
14
|
+
DEFAULT_CONTEXT_LINES = 2
|
|
15
|
+
DEFAULT_LINE_COUNT = 200
|
|
16
|
+
MAX_LINE_COUNT = 400
|
|
17
|
+
MAX_FILE_BYTES = 512 * 1024
|
|
18
|
+
MAX_SCANNED_FILES = 5_000
|
|
19
|
+
MAX_OUTPUT_BYTES = 16 * 1024
|
|
20
|
+
HTTP_TIMEOUT_SECONDS = 10
|
|
21
|
+
ECOSYSTEMS = %w[rubygems npm pypi crates go].freeze
|
|
22
|
+
ACTIONS = %w[package_search github_search repo_clone repo_search repo_read list_cache refresh_cache clear_cache].freeze
|
|
23
|
+
|
|
24
|
+
def initialize(cache_root: nil, http_client: NetHttpClient.new, git_runner: GitRunner.new, max_output_bytes: MAX_OUTPUT_BYTES)
|
|
25
|
+
@cache_root = File.expand_path(cache_root || ConfigFiles.code_search_cache_dir)
|
|
26
|
+
@http_client = http_client
|
|
27
|
+
@git_runner = git_runner
|
|
28
|
+
@max_output_bytes = max_output_bytes
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def call(args)
|
|
32
|
+
action = value(args, "action").to_s
|
|
33
|
+
return "Error: action must be one of: #{ACTIONS.join(", ")}" unless ACTIONS.include?(action)
|
|
34
|
+
|
|
35
|
+
case action
|
|
36
|
+
when "package_search" then package_search(args)
|
|
37
|
+
when "github_search" then github_search_action(args)
|
|
38
|
+
when "repo_clone" then repo_clone(args)
|
|
39
|
+
when "repo_search" then repo_search(args)
|
|
40
|
+
when "repo_read" then repo_read(args)
|
|
41
|
+
when "list_cache" then list_cache
|
|
42
|
+
when "refresh_cache" then refresh_cache(args)
|
|
43
|
+
when "clear_cache" then clear_cache(args)
|
|
44
|
+
end
|
|
45
|
+
rescue StandardError => e
|
|
46
|
+
"Error: code_search failed: #{redact(e.message)}"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
class NetHttpClient
|
|
50
|
+
def get_json(url, headers: {})
|
|
51
|
+
JSON.parse(get_text(url, headers: headers.merge("Accept" => "application/json")))
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def get_text(url, headers: {})
|
|
55
|
+
uri = URI(url)
|
|
56
|
+
request = Net::HTTP::Get.new(uri)
|
|
57
|
+
request["User-Agent"] = "Kward code_search"
|
|
58
|
+
headers.each { |key, val| request[key] = val }
|
|
59
|
+
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: HTTP_TIMEOUT_SECONDS, read_timeout: HTTP_TIMEOUT_SECONDS) do |http|
|
|
60
|
+
http.request(request)
|
|
61
|
+
end
|
|
62
|
+
raise "HTTP #{response.code} from #{uri.host}" unless response.is_a?(Net::HTTPSuccess)
|
|
63
|
+
|
|
64
|
+
response.body
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
class GitRunner
|
|
69
|
+
def run(*args, chdir: nil)
|
|
70
|
+
command = ["git", *args]
|
|
71
|
+
stdout, stderr, status = chdir ? Open3.capture3(*command, chdir: chdir) : Open3.capture3(*command)
|
|
72
|
+
raise "git #{args.join(" ")} failed: #{stderr.strip.empty? ? stdout.strip : stderr.strip}" unless status.success?
|
|
73
|
+
|
|
74
|
+
stdout
|
|
75
|
+
rescue Errno::ENOENT
|
|
76
|
+
raise "git executable not found"
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def package_search(args)
|
|
83
|
+
ecosystem = normalize_ecosystem(value(args, "ecosystem"))
|
|
84
|
+
return "Error: ecosystem must be one of: #{ECOSYSTEMS.join(", ")}" unless ecosystem
|
|
85
|
+
|
|
86
|
+
package = value(args, "package") || value(args, "query")
|
|
87
|
+
return "Error: package is required" if package.to_s.strip.empty?
|
|
88
|
+
|
|
89
|
+
data = fetch_package(ecosystem, package.to_s.strip)
|
|
90
|
+
repo = normalize_github_url(data[:source_url])
|
|
91
|
+
fallback = nil
|
|
92
|
+
fallback = github_search(data[:name], ecosystem: ecosystem, max_results: bounded_max_results(value(args, "max_results"))).first unless repo
|
|
93
|
+
|
|
94
|
+
lines = ["# Package search", "- Ecosystem: #{ecosystem}", "- Package: #{data[:name]}"]
|
|
95
|
+
lines << "- Version: #{data[:version]}" if data[:version]
|
|
96
|
+
lines << "- Description: #{data[:description]}" if data[:description]
|
|
97
|
+
if repo
|
|
98
|
+
lines << "- Source: #{repo[:html_url]}"
|
|
99
|
+
elsif fallback
|
|
100
|
+
lines << "- Source fallback: #{fallback[:html_url]}"
|
|
101
|
+
lines << "- Note: registry metadata did not include a public GitHub source URL; returned a GitHub search result."
|
|
102
|
+
else
|
|
103
|
+
lines << "- Source: not found"
|
|
104
|
+
end
|
|
105
|
+
truncate(lines.join("\n"))
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def github_search_action(args)
|
|
109
|
+
query = value(args, "query") || value(args, "package")
|
|
110
|
+
return "Error: query is required" if query.to_s.strip.empty?
|
|
111
|
+
|
|
112
|
+
results = github_search(query.to_s.strip, ecosystem: normalize_ecosystem(value(args, "ecosystem")), max_results: bounded_max_results(value(args, "max_results")))
|
|
113
|
+
return "Error: no GitHub repositories found" if results.empty?
|
|
114
|
+
|
|
115
|
+
lines = ["# GitHub repository search"]
|
|
116
|
+
results.each_with_index do |repo, index|
|
|
117
|
+
lines << "#{index + 1}. #{repo[:full_name]} - #{repo[:html_url]}"
|
|
118
|
+
lines << " #{repo[:description]}" unless repo[:description].to_s.empty?
|
|
119
|
+
end
|
|
120
|
+
truncate(lines.join("\n"))
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def repo_clone(args)
|
|
124
|
+
repo = require_repo(args)
|
|
125
|
+
return repo if repo.is_a?(String)
|
|
126
|
+
|
|
127
|
+
path, created = ensure_repo(repo, refresh: false)
|
|
128
|
+
"# Repository cached\n- Repository: #{repo[:full_name]}\n- Cache path: #{path}\n- Status: #{created ? "cloned" : "already cached"}"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def refresh_cache(args)
|
|
132
|
+
repo = require_repo(args)
|
|
133
|
+
return repo if repo.is_a?(String)
|
|
134
|
+
|
|
135
|
+
path, = ensure_repo(repo, refresh: true)
|
|
136
|
+
"# Repository refreshed\n- Repository: #{repo[:full_name]}\n- Cache path: #{path}"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def repo_search(args)
|
|
140
|
+
repo = require_repo(args)
|
|
141
|
+
return repo if repo.is_a?(String)
|
|
142
|
+
query = value(args, "query").to_s
|
|
143
|
+
return "Error: query is required" if query.strip.empty?
|
|
144
|
+
|
|
145
|
+
path, = ensure_repo(repo, refresh: false)
|
|
146
|
+
max_results = bounded_max_results(value(args, "max_results"))
|
|
147
|
+
context = bounded_integer(value(args, "context_lines"), DEFAULT_CONTEXT_LINES, 0, 5)
|
|
148
|
+
matches = search_files(path, query, max_results: max_results, context_lines: context)
|
|
149
|
+
return "Error: no matches found in #{repo[:full_name]}" if matches.empty?
|
|
150
|
+
|
|
151
|
+
lines = ["# Code search", "- Repository: #{repo[:full_name]}", "- Query: #{query}", ""]
|
|
152
|
+
matches.each do |match|
|
|
153
|
+
lines << "## #{match[:path]}:#{match[:line]}"
|
|
154
|
+
lines << "```"
|
|
155
|
+
lines.concat(match[:snippet])
|
|
156
|
+
lines << "```"
|
|
157
|
+
end
|
|
158
|
+
truncate(lines.join("\n"))
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def repo_read(args)
|
|
162
|
+
repo = require_repo(args)
|
|
163
|
+
return repo if repo.is_a?(String)
|
|
164
|
+
file = value(args, "path") || value(args, "file")
|
|
165
|
+
return "Error: path is required" if file.to_s.empty?
|
|
166
|
+
|
|
167
|
+
root, = ensure_repo(repo, refresh: false)
|
|
168
|
+
target = safe_existing_path(root, file.to_s)
|
|
169
|
+
return "Error: path is not a file: #{file}" unless File.file?(target)
|
|
170
|
+
return "Error: file is too large: #{file}" if File.size(target) > MAX_FILE_BYTES
|
|
171
|
+
|
|
172
|
+
start_line = bounded_integer(value(args, "start_line"), 1, 1, 1_000_000)
|
|
173
|
+
line_count = bounded_integer(value(args, "line_count"), DEFAULT_LINE_COUNT, 1, MAX_LINE_COUNT)
|
|
174
|
+
lines = File.readlines(target, chomp: true)
|
|
175
|
+
selected = lines.drop(start_line - 1).first(line_count)
|
|
176
|
+
numbered = selected.each_with_index.map { |line, index| "#{start_line + index}: #{line}" }
|
|
177
|
+
truncate((["# Code read", "- Repository: #{repo[:full_name]}", "- Path: #{file}", ""] + numbered).join("\n"))
|
|
178
|
+
rescue ArgumentError => e
|
|
179
|
+
"Error: #{e.message}"
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def list_cache
|
|
183
|
+
FileUtils.mkdir_p(@cache_root, mode: 0o700)
|
|
184
|
+
entries = Dir.children(@cache_root).sort.select { |entry| File.directory?(File.join(@cache_root, entry, ".git")) }
|
|
185
|
+
return "# Code search cache\nNo cached repositories." if entries.empty?
|
|
186
|
+
|
|
187
|
+
lines = ["# Code search cache"]
|
|
188
|
+
entries.each do |entry|
|
|
189
|
+
lines << "- #{entry}"
|
|
190
|
+
end
|
|
191
|
+
lines.join("\n")
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def clear_cache(args)
|
|
195
|
+
repo = require_repo(args)
|
|
196
|
+
return repo if repo.is_a?(String)
|
|
197
|
+
|
|
198
|
+
path = cache_path(repo)
|
|
199
|
+
return "# Cache clear\n- Repository: #{repo[:full_name]}\n- Status: not cached" unless inside_cache?(path) && Dir.exist?(path)
|
|
200
|
+
|
|
201
|
+
FileUtils.rm_rf(path)
|
|
202
|
+
"# Cache clear\n- Repository: #{repo[:full_name]}\n- Status: removed"
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def fetch_package(ecosystem, package)
|
|
206
|
+
case ecosystem
|
|
207
|
+
when "rubygems"
|
|
208
|
+
json = @http_client.get_json("https://rubygems.org/api/v1/gems/#{escape_path(package)}.json")
|
|
209
|
+
{ name: json["name"] || package, version: json["version"], description: json["info"], source_url: json["source_code_uri"] || json["homepage_uri"] || json["bug_tracker_uri"] }
|
|
210
|
+
when "npm"
|
|
211
|
+
json = @http_client.get_json("https://registry.npmjs.org/#{escape_path(package)}")
|
|
212
|
+
latest = json.dig("dist-tags", "latest")
|
|
213
|
+
version = latest && json.dig("versions", latest)
|
|
214
|
+
repo = version&.dig("repository") || json["repository"]
|
|
215
|
+
{ name: json["name"] || package, version: latest, description: json["description"], source_url: repository_url(repo) || json["homepage"] }
|
|
216
|
+
when "pypi"
|
|
217
|
+
json = @http_client.get_json("https://pypi.org/pypi/#{escape_path(package)}/json")
|
|
218
|
+
info = json["info"] || {}
|
|
219
|
+
urls = info["project_urls"].is_a?(Hash) ? info["project_urls"].values : []
|
|
220
|
+
{ name: info["name"] || package, version: info["version"], description: info["summary"], source_url: ([info["project_url"], info["home_page"], *urls].find { |url| normalize_github_url(url) }) }
|
|
221
|
+
when "crates"
|
|
222
|
+
json = @http_client.get_json("https://crates.io/api/v1/crates/#{escape_path(package)}")
|
|
223
|
+
crate = json["crate"] || {}
|
|
224
|
+
{ name: crate["id"] || package, version: crate["max_version"], description: crate["description"], source_url: crate["repository"] || crate["homepage"] }
|
|
225
|
+
when "go"
|
|
226
|
+
html = @http_client.get_text("https://pkg.go.dev/#{escape_path(package)}?tab=doc")
|
|
227
|
+
{ name: package, source_url: extract_github_url(html.to_s) }
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def github_search(query, ecosystem: nil, max_results: DEFAULT_MAX_RESULTS)
|
|
232
|
+
search = [query, ecosystem, "source repository"].compact.join(" ")
|
|
233
|
+
url = "https://api.github.com/search/repositories?q=#{CGI.escape(search)}&per_page=#{max_results}"
|
|
234
|
+
json = @http_client.get_json(url, headers: github_headers)
|
|
235
|
+
Array(json["items"]).filter_map do |item|
|
|
236
|
+
full_name = item["full_name"].to_s
|
|
237
|
+
next if full_name.empty?
|
|
238
|
+
|
|
239
|
+
{ full_name: full_name, html_url: item["html_url"], clone_url: item["clone_url"] || "https://github.com/#{full_name}.git", description: item["description"] }
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def ensure_repo(repo, refresh:)
|
|
244
|
+
path = cache_path(repo)
|
|
245
|
+
FileUtils.mkdir_p(@cache_root, mode: 0o700)
|
|
246
|
+
if Dir.exist?(File.join(path, ".git"))
|
|
247
|
+
if refresh
|
|
248
|
+
@git_runner.run("fetch", "--depth", "1", "origin", chdir: path)
|
|
249
|
+
@git_runner.run("reset", "--hard", "FETCH_HEAD", chdir: path)
|
|
250
|
+
end
|
|
251
|
+
return [path, false]
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
FileUtils.rm_rf(path) if Dir.exist?(path)
|
|
255
|
+
@git_runner.run("clone", "--depth", "1", repo[:clone_url], path)
|
|
256
|
+
[path, true]
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def search_files(root, query, max_results:, context_lines:)
|
|
260
|
+
matches = []
|
|
261
|
+
base = File.realpath(root)
|
|
262
|
+
scanned = 0
|
|
263
|
+
Dir.glob(File.join(root, "**", "*"), File::FNM_DOTMATCH).sort.each do |path|
|
|
264
|
+
next if matches.length >= max_results || scanned >= MAX_SCANNED_FILES
|
|
265
|
+
next if skip_search_path?(base, path)
|
|
266
|
+
|
|
267
|
+
scanned += 1
|
|
268
|
+
match = first_file_match(path, query, context_lines)
|
|
269
|
+
next unless match
|
|
270
|
+
|
|
271
|
+
matches << {
|
|
272
|
+
path: relative_path(root, path),
|
|
273
|
+
line: match[:line],
|
|
274
|
+
snippet: match[:snippet]
|
|
275
|
+
}
|
|
276
|
+
end
|
|
277
|
+
matches
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def skip_search_path?(base, path)
|
|
281
|
+
return true unless File.file?(path)
|
|
282
|
+
return true if File.symlink?(path)
|
|
283
|
+
return true if path.include?("#{File::SEPARATOR}.git#{File::SEPARATOR}")
|
|
284
|
+
return true unless inside_root?(base, File.realpath(path))
|
|
285
|
+
return true if File.size(path) > MAX_FILE_BYTES
|
|
286
|
+
|
|
287
|
+
false
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def first_file_match(path, query, context_lines)
|
|
291
|
+
previous = []
|
|
292
|
+
line_number = 0
|
|
293
|
+
File.open(path) do |file|
|
|
294
|
+
while (line = file.gets&.chomp)
|
|
295
|
+
line_number += 1
|
|
296
|
+
if line.include?(query)
|
|
297
|
+
match_line = line_number
|
|
298
|
+
following = []
|
|
299
|
+
context_lines.times do
|
|
300
|
+
next_line = file.gets&.chomp
|
|
301
|
+
break unless next_line
|
|
302
|
+
|
|
303
|
+
line_number += 1
|
|
304
|
+
following << "#{line_number}: #{next_line}"
|
|
305
|
+
end
|
|
306
|
+
return { line: match_line, snippet: previous + ["#{match_line}: #{line}"] + following }
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
previous << "#{line_number}: #{line}"
|
|
310
|
+
previous.shift while previous.length > context_lines
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
nil
|
|
314
|
+
rescue ArgumentError, Encoding::InvalidByteSequenceError, Encoding::UndefinedConversionError
|
|
315
|
+
nil
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def require_repo(args)
|
|
319
|
+
input = value(args, "repo") || value(args, "repository") || value(args, "url")
|
|
320
|
+
repo = normalize_github_url(input)
|
|
321
|
+
return repo if repo
|
|
322
|
+
|
|
323
|
+
"Error: repo must be a GitHub repository URL or owner/name"
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def normalize_github_url(input)
|
|
327
|
+
text = input.to_s.strip
|
|
328
|
+
return nil if text.empty?
|
|
329
|
+
text = "https://github.com/#{text}" if text.match?(%r{\A[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+\z})
|
|
330
|
+
text = text.sub(%r{\Agit\+}, "")
|
|
331
|
+
text = text.sub(%r{\Agit@github\.com:}, "https://github.com/")
|
|
332
|
+
uri = URI(text)
|
|
333
|
+
return nil unless uri.host.to_s.downcase == "github.com"
|
|
334
|
+
|
|
335
|
+
parts = uri.path.sub(%r{\A/}, "").sub(%r{\.git\z}, "").split("/")
|
|
336
|
+
return nil unless parts.length >= 2
|
|
337
|
+
|
|
338
|
+
owner = parts[0]
|
|
339
|
+
name = parts[1]
|
|
340
|
+
return nil unless owner.match?(%r{\A[A-Za-z0-9_.-]+\z}) && name.match?(%r{\A[A-Za-z0-9_.-]+\z})
|
|
341
|
+
|
|
342
|
+
full_name = "#{owner}/#{name}"
|
|
343
|
+
{ full_name: full_name, html_url: "https://github.com/#{full_name}", clone_url: "https://github.com/#{full_name}.git" }
|
|
344
|
+
rescue URI::InvalidURIError
|
|
345
|
+
nil
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def cache_path(repo)
|
|
349
|
+
key = repo[:full_name].downcase.gsub(%r{[^a-z0-9_.-]+}, "__")
|
|
350
|
+
File.join(@cache_root, key)
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def safe_join(root, path)
|
|
354
|
+
raise ArgumentError, "path must be relative" if Pathname.new(path).absolute?
|
|
355
|
+
|
|
356
|
+
base = File.realpath(root)
|
|
357
|
+
target = File.expand_path(path, base)
|
|
358
|
+
raise ArgumentError, "path outside repository: #{path}" unless inside_root?(base, target)
|
|
359
|
+
|
|
360
|
+
target
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def safe_existing_path(root, path)
|
|
364
|
+
target = safe_join(root, path)
|
|
365
|
+
return target unless File.exist?(target)
|
|
366
|
+
|
|
367
|
+
real_target = File.realpath(target)
|
|
368
|
+
raise ArgumentError, "path outside repository: #{path}" unless inside_root?(File.realpath(root), real_target)
|
|
369
|
+
|
|
370
|
+
real_target
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
def inside_root?(root, path)
|
|
374
|
+
path == root || path.start_with?(root + File::SEPARATOR)
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
def inside_cache?(path)
|
|
378
|
+
expanded = File.expand_path(path)
|
|
379
|
+
expanded == @cache_root || expanded.start_with?(@cache_root + File::SEPARATOR)
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
def normalize_ecosystem(value)
|
|
383
|
+
text = value.to_s.downcase
|
|
384
|
+
text = "rubygems" if text == "ruby" || text == "gem"
|
|
385
|
+
text = "pypi" if text == "python"
|
|
386
|
+
text = "crates" if text == "rust" || text == "crates.io"
|
|
387
|
+
ECOSYSTEMS.include?(text) ? text : nil
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
def bounded_max_results(value)
|
|
391
|
+
bounded_integer(value, DEFAULT_MAX_RESULTS, 1, MAX_MAX_RESULTS)
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
def bounded_integer(value, default, min, max)
|
|
395
|
+
integer = value.to_s.empty? ? default : value.to_i
|
|
396
|
+
[[integer, min].max, max].min
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
def repository_url(value)
|
|
400
|
+
return value["url"] || value[:url] if value.is_a?(Hash)
|
|
401
|
+
|
|
402
|
+
value
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
def escape_path(value)
|
|
406
|
+
value.to_s.split("/").map { |part| CGI.escape(part) }.join("/")
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def extract_github_url(text)
|
|
410
|
+
text[%r{https://github\.com/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(?:\.git)?}]
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
def github_headers
|
|
414
|
+
token = ENV["GITHUB_TOKEN"] || ENV["GH_TOKEN"]
|
|
415
|
+
token.to_s.empty? ? {} : { "Authorization" => "Bearer #{token}" }
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
def value(args, key)
|
|
419
|
+
return args[key] if args.respond_to?(:key?) && args.key?(key)
|
|
420
|
+
return args[key.to_sym] if args.respond_to?(:key?) && args.key?(key.to_sym)
|
|
421
|
+
|
|
422
|
+
nil
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
def relative_path(root, path)
|
|
426
|
+
Pathname.new(path).relative_path_from(Pathname.new(root)).to_s
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
def truncate(text)
|
|
430
|
+
return text if text.bytesize <= @max_output_bytes
|
|
431
|
+
|
|
432
|
+
text.byteslice(0, @max_output_bytes).to_s.scrub + "\n... truncated ..."
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
def redact(message)
|
|
436
|
+
text = message.to_s
|
|
437
|
+
[ENV["GITHUB_TOKEN"], ENV["GH_TOKEN"]].each do |token|
|
|
438
|
+
text = text.gsub(token, "[REDACTED]") unless token.to_s.empty?
|
|
439
|
+
end
|
|
440
|
+
text = text.gsub(@cache_root, "[CACHE]")
|
|
441
|
+
text = text.gsub(Dir.home, "~") if Dir.respond_to?(:home)
|
|
442
|
+
truncate(text)
|
|
443
|
+
end
|
|
444
|
+
end
|
|
445
|
+
end
|