dialecto-rails 0.1.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/LICENSE +21 -0
- data/README.md +196 -0
- data/action.yml +83 -0
- data/exe/dialecto-rails +6 -0
- data/lib/dialecto-rails.rb +1 -0
- data/lib/dialecto_rails/config.rb +206 -0
- data/lib/dialecto_rails/errors.rb +21 -0
- data/lib/dialecto_rails/in_context/args.rb +37 -0
- data/lib/dialecto_rails/in_context/assets/overlay.js +2784 -0
- data/lib/dialecto_rails/in_context/backend_hook.rb +39 -0
- data/lib/dialecto_rails/in_context/csp.rb +72 -0
- data/lib/dialecto_rails/in_context/git_context.rb +103 -0
- data/lib/dialecto_rails/in_context/key_index.rb +128 -0
- data/lib/dialecto_rails/in_context/launch_token.rb +78 -0
- data/lib/dialecto_rails/in_context/live_middleware.rb +307 -0
- data/lib/dialecto_rails/in_context/live_runtime.rb +53 -0
- data/lib/dialecto_rails/in_context/live_store.rb +14 -0
- data/lib/dialecto_rails/in_context/marker.rb +106 -0
- data/lib/dialecto_rails/in_context/marking.rb +122 -0
- data/lib/dialecto_rails/in_context/middleware.rb +202 -0
- data/lib/dialecto_rails/in_context/overrides.rb +61 -0
- data/lib/dialecto_rails/in_context/railtie_stub.rb +76 -0
- data/lib/dialecto_rails/in_context/runtime.rb +52 -0
- data/lib/dialecto_rails/in_context/sanitizer.rb +19 -0
- data/lib/dialecto_rails/in_context/store.rb +41 -0
- data/lib/dialecto_rails/scan/catalogs.rb +46 -0
- data/lib/dialecto_rails/scan/cli.rb +339 -0
- data/lib/dialecto_rails/scan/client.rb +117 -0
- data/lib/dialecto_rails/scan/envelope.rb +100 -0
- data/lib/dialecto_rails/scan/git.rb +68 -0
- data/lib/dialecto_rails/scan/worktree.rb +41 -0
- data/lib/dialecto_rails/unmarked.rb +42 -0
- data/lib/dialecto_rails/usages/call_args.rb +78 -0
- data/lib/dialecto_rails/usages/catalog_keys.rb +67 -0
- data/lib/dialecto_rails/usages/check.rb +70 -0
- data/lib/dialecto_rails/usages/haml_extractor.rb +260 -0
- data/lib/dialecto_rails/usages/keys.rb +206 -0
- data/lib/dialecto_rails/usages/known_wrappers.rb +31 -0
- data/lib/dialecto_rails/usages/lazy_scope.rb +52 -0
- data/lib/dialecto_rails/usages/parse_check.rb +75 -0
- data/lib/dialecto_rails/usages/read_by_name.json +28 -0
- data/lib/dialecto_rails/usages/record.rb +91 -0
- data/lib/dialecto_rails/usages/ruby_visitor.rb +628 -0
- data/lib/dialecto_rails/usages/scanner.rb +241 -0
- data/lib/dialecto_rails/usages/summary.rb +191 -0
- data/lib/dialecto_rails/usages/templates.rb +45 -0
- data/lib/dialecto_rails/usages/wrappers.rb +336 -0
- data/lib/dialecto_rails/version.rb +3 -0
- data/lib/dialecto_rails.rb +14 -0
- metadata +119 -0
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "optparse"
|
|
3
|
+
require_relative "../errors"
|
|
4
|
+
require_relative "../config"
|
|
5
|
+
require_relative "git"
|
|
6
|
+
require_relative "worktree"
|
|
7
|
+
require_relative "catalogs"
|
|
8
|
+
require_relative "envelope"
|
|
9
|
+
require_relative "client"
|
|
10
|
+
|
|
11
|
+
module DialectoRails
|
|
12
|
+
module Scan
|
|
13
|
+
# `dialecto-rails scan | usages | check | doctor | version`.
|
|
14
|
+
# `run` returns the exit code: 0 ok, 1 findings, 2 can't start, 3 refused, 4 retryable.
|
|
15
|
+
class Cli
|
|
16
|
+
COMMANDS = %w[scan doctor version help].freeze
|
|
17
|
+
TOKEN_SHAPE = /\Adlk_[A-Za-z0-9_-]{43}\z/
|
|
18
|
+
|
|
19
|
+
def self.run(argv, env: ENV, out: $stdout, err: $stderr, root: Dir.pwd, client: nil, input: $stdin)
|
|
20
|
+
new(argv, env: env, out: out, err: err, root: root, client: client, input: input).run
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def initialize(argv, env:, out:, err:, root:, client: nil, input: $stdin)
|
|
24
|
+
@argv = argv.dup
|
|
25
|
+
@env = env
|
|
26
|
+
@out = out
|
|
27
|
+
@err = err
|
|
28
|
+
@in = input
|
|
29
|
+
@root = root
|
|
30
|
+
@client_factory = client || ->(config) { Client.new(config, log: ->(line) { @err.puts(line) }) }
|
|
31
|
+
@flags = {}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def run
|
|
35
|
+
command = @argv.shift || "help"
|
|
36
|
+
case command
|
|
37
|
+
when "scan" then scan
|
|
38
|
+
when "doctor" then doctor
|
|
39
|
+
when "usages" then usages
|
|
40
|
+
when "check" then check
|
|
41
|
+
when "parse-check" then parse_check
|
|
42
|
+
when "version", "--version" then @out.puts("dialecto-rails #{VERSION}") || 0
|
|
43
|
+
when "help", "--help", "-h" then help
|
|
44
|
+
else
|
|
45
|
+
@err.puts("Unknown command #{command.inspect}.")
|
|
46
|
+
help(@err)
|
|
47
|
+
2
|
|
48
|
+
end
|
|
49
|
+
rescue Error => e
|
|
50
|
+
@err.puts(e.message)
|
|
51
|
+
@err.puts(e.backtrace.first(8)) if @flags[:debug]
|
|
52
|
+
e.exit_code
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def help(io = @out)
|
|
58
|
+
io.puts <<~HELP
|
|
59
|
+
dialecto-rails #{VERSION}
|
|
60
|
+
|
|
61
|
+
dialecto-rails scan [--branch NAME] [--sha SHA] [--usages] [--require-usages] [--worktree] [--dry-run] [--debug]
|
|
62
|
+
dialecto-rails usages [--summary] [--sha SHA] [--worktree] [--out FILE]
|
|
63
|
+
dialecto-rails check [--sha SHA] [--worktree]
|
|
64
|
+
dialecto-rails parse-check [--root DIR] (relative paths on standard input)
|
|
65
|
+
dialecto-rails doctor
|
|
66
|
+
dialecto-rails version
|
|
67
|
+
|
|
68
|
+
Settings come from the environment: DIALECTO_SCAN_TOKEN, DIALECTO_PROJECT (the project's number),
|
|
69
|
+
DIALECTO_URL, DIALECTO_CATALOG_PATHS, DIALECTO_USAGES, DIALECTO_DEFAULT_BRANCH.
|
|
70
|
+
HELP
|
|
71
|
+
0
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def parse_flags
|
|
75
|
+
OptionParser.new do |opts|
|
|
76
|
+
opts.on("--branch NAME") { |value| @flags[:branch] = value }
|
|
77
|
+
opts.on("--sha SHA") { |value| @flags[:sha] = value }
|
|
78
|
+
opts.on("--usages") { @flags[:usages] = true }
|
|
79
|
+
opts.on("--require-usages") { @flags[:require_usages] = true }
|
|
80
|
+
opts.on("--worktree") { @flags[:worktree] = true }
|
|
81
|
+
opts.on("--root DIR") { |value| @flags[:root] = value }
|
|
82
|
+
opts.on("--summary") { @flags[:summary] = true }
|
|
83
|
+
opts.on("--out FILE") { |value| @flags[:out] = value }
|
|
84
|
+
opts.on("--dry-run") { @flags[:dry_run] = true }
|
|
85
|
+
opts.on("--debug") { @flags[:debug] = true }
|
|
86
|
+
end.parse!(@argv)
|
|
87
|
+
rescue OptionParser::ParseError => e
|
|
88
|
+
raise ConfigError, e.message
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def config
|
|
92
|
+
@config ||= Config.new({usages: (true if @flags[:usages])}.compact, @env)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
## scan
|
|
96
|
+
|
|
97
|
+
def scan
|
|
98
|
+
parse_flags
|
|
99
|
+
require_settings!
|
|
100
|
+
git = Git.new(@root)
|
|
101
|
+
raise ConfigError, "#{@root} isn't inside a git repository." unless git.repository?
|
|
102
|
+
|
|
103
|
+
sha = @flags[:sha] || git.head_sha
|
|
104
|
+
branch = @flags[:branch] || current_branch(git)
|
|
105
|
+
client = @client_factory.call(config)
|
|
106
|
+
|
|
107
|
+
unless @flags[:dry_run] || scanned_branch?(client, git, branch)
|
|
108
|
+
@out.puts("Dialecto doesn't scan the branch #{branch}; nothing sent.")
|
|
109
|
+
write_outputs("outcome" => "skipped")
|
|
110
|
+
return 0
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# `--worktree` reads the folder as it is, dirty files included: a testing escape hatch, never what a PR is made from.
|
|
114
|
+
reader = @flags[:worktree] ? Worktree.new(@root) : git
|
|
115
|
+
@err.puts("Reading the working tree, not commit #{sha[0, 8]}: a pull request from this scan would carry these bytes.") if @flags[:worktree]
|
|
116
|
+
usages = usage_scan(reader, sha)
|
|
117
|
+
envelope = Envelope.build(git: reader, catalogs: Catalogs.new(config.catalog_paths), sha: sha, branch: branch,
|
|
118
|
+
usages: usages)
|
|
119
|
+
|
|
120
|
+
if @flags[:dry_run]
|
|
121
|
+
counts = envelope.counts
|
|
122
|
+
@out.puts("Dry run: #{counts[:files]} catalog files at #{sha[0, 8]} on #{branch}" \
|
|
123
|
+
"#{counts[:records] ? ", #{counts[:records]} usage records" : ""}; nothing sent.")
|
|
124
|
+
return 0
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
report(client.post_scan(envelope.body))
|
|
128
|
+
write_outputs("files" => envelope.counts[:files], "records" => envelope.counts[:records])
|
|
129
|
+
0
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# GitHub Actions step outputs, when the runner offers the file. Values come partly from the
|
|
133
|
+
# server's answer, so only a plain word is written: a newline would add outputs of its own.
|
|
134
|
+
def write_outputs(values)
|
|
135
|
+
path = @env["GITHUB_OUTPUT"].to_s
|
|
136
|
+
return if path.empty?
|
|
137
|
+
|
|
138
|
+
File.open(path, "a") do |file|
|
|
139
|
+
values.each { |name, value| file.puts("#{name}=#{value}") if value.to_s.match?(/\A[\w.-]+\z/) }
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def require_settings!
|
|
144
|
+
raise ConfigError, "Set DIALECTO_SCAN_TOKEN to the project's scan token (Settings → Scan token)." unless config.scan_token
|
|
145
|
+
unless config.project&.match?(/\A\d+\z/)
|
|
146
|
+
raise ConfigError, "Set DIALECTO_PROJECT to the project's number, the <number> in its Dialecto " \
|
|
147
|
+
"URL /repos/<number>#{config.project ? " (got #{config.project.inspect})" : ""}."
|
|
148
|
+
end
|
|
149
|
+
problem = config.url_problem
|
|
150
|
+
raise ConfigError, problem if problem
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def current_branch(git)
|
|
154
|
+
head_ref = @env["GITHUB_HEAD_REF"].to_s
|
|
155
|
+
return head_ref unless head_ref.empty?
|
|
156
|
+
|
|
157
|
+
ref = @env["GITHUB_REF_NAME"].to_s
|
|
158
|
+
return ref if @env["GITHUB_REF_TYPE"] == "branch" && !ref.empty?
|
|
159
|
+
|
|
160
|
+
git.branch || raise(ConfigError, "HEAD is detached: pass --branch NAME.")
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# The default branch is always scanned; others when the scan mode is `all`, they are tracked, or they
|
|
164
|
+
# match the project's regex (the rule of the generated workflow and `Branches.scans_branch?`).
|
|
165
|
+
def scanned_branch?(client, git, branch)
|
|
166
|
+
default = config.default_branch || git.default_branch
|
|
167
|
+
return true if branch == default
|
|
168
|
+
|
|
169
|
+
settings = client.scan_config
|
|
170
|
+
return true if settings["scan_mode"] == "all"
|
|
171
|
+
return true if Array(settings["tracked_branches"]).include?(branch)
|
|
172
|
+
|
|
173
|
+
regex = settings["branch_regex"].to_s
|
|
174
|
+
return true if !regex.empty? && Regexp.new(regex).match?(branch)
|
|
175
|
+
|
|
176
|
+
# Without a known default branch a refusal here would skip the branch that matters most.
|
|
177
|
+
default.nil?
|
|
178
|
+
rescue RegexpError
|
|
179
|
+
false
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
## usages and check
|
|
183
|
+
|
|
184
|
+
# Runs the usage scan for these two commands, which need no token and send nothing.
|
|
185
|
+
# `--worktree` reads the folder as it is, which needs no git repository.
|
|
186
|
+
def local_scan
|
|
187
|
+
parse_flags
|
|
188
|
+
reader, sha = scan_source
|
|
189
|
+
|
|
190
|
+
begin
|
|
191
|
+
require_relative "../usages/scanner"
|
|
192
|
+
rescue LoadError => e
|
|
193
|
+
raise ConfigError, "The usage scan needs the prism gem (#{e.message.lines.first.to_s.strip}); gem install prism."
|
|
194
|
+
end
|
|
195
|
+
scanner = DialectoRails::Usages::Scanner.new(git: reader, sha: sha, config: config)
|
|
196
|
+
[scanner.call, scanner.catalog]
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def scan_source
|
|
200
|
+
return [Worktree.new(@root), @flags[:sha] || "worktree"] if @flags[:worktree]
|
|
201
|
+
|
|
202
|
+
git = Git.new(@root)
|
|
203
|
+
raise ConfigError, "#{@root} isn't inside a git repository." unless git.repository?
|
|
204
|
+
|
|
205
|
+
[git, @flags[:sha] || git.head_sha]
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
## parse-check
|
|
209
|
+
|
|
210
|
+
# Reads relative paths, one per line, from standard input and checks that each still parses with the gem's
|
|
211
|
+
# own readers (`--root DIR`, default the current folder). For the rename check; it sends nothing.
|
|
212
|
+
def parse_check
|
|
213
|
+
parse_flags
|
|
214
|
+
begin
|
|
215
|
+
require_relative "../usages/parse_check"
|
|
216
|
+
rescue LoadError => e
|
|
217
|
+
raise ConfigError, "The parse check needs the prism gem (#{e.message.lines.first.to_s.strip}); gem install prism."
|
|
218
|
+
end
|
|
219
|
+
root = @flags[:root] || @root
|
|
220
|
+
files = @in.each_line.map(&:chomp).reject(&:empty?).map { |path| [path, File.binread(File.join(root, path))] }
|
|
221
|
+
result = DialectoRails::Usages::ParseCheck.call(files)
|
|
222
|
+
result[:failures].each { |failure| @out.puts("FAIL #{failure.path}:#{failure.line} #{failure.message}") }
|
|
223
|
+
@out.puts("checked #{result[:checked]}, skipped #{result[:skipped]}, failed #{result[:failures].length}")
|
|
224
|
+
result[:failures].empty? ? 0 : 1
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def usages
|
|
228
|
+
require_relative "../usages/summary"
|
|
229
|
+
envelope, catalog = local_scan
|
|
230
|
+
@out.puts(DialectoRails::Usages::Summary.new(envelope, catalog.keys.to_a).render)
|
|
231
|
+
File.write(@flags[:out], JSON.pretty_generate(envelope)) if @flags[:out]
|
|
232
|
+
0
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def check
|
|
236
|
+
require_relative "../usages/check"
|
|
237
|
+
envelope, catalog = local_scan
|
|
238
|
+
check = DialectoRails::Usages::Check.new(envelope, catalog)
|
|
239
|
+
check.findings.each do |finding|
|
|
240
|
+
@out.puts("#{finding.warning ? "warning" : "error"} #{finding.file}:#{finding.line} #{finding.code}: #{finding.message}")
|
|
241
|
+
end
|
|
242
|
+
@out.puts("No findings.") if check.findings.empty?
|
|
243
|
+
check.failed? ? 1 : 0
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def usage_scan(git, sha)
|
|
247
|
+
return nil unless config.usages?
|
|
248
|
+
|
|
249
|
+
require_relative "../usages/scanner"
|
|
250
|
+
DialectoRails::Usages::Scanner.new(git: git, sha: sha, config: config).call
|
|
251
|
+
rescue LoadError
|
|
252
|
+
usages_unavailable("the usage scan isn't available in this build")
|
|
253
|
+
rescue Error
|
|
254
|
+
raise
|
|
255
|
+
rescue => e
|
|
256
|
+
usages_unavailable("the usage scan failed (#{e.class}: #{e.message})")
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def usages_unavailable(reason)
|
|
260
|
+
raise ConfigError, "#{reason}; usages are required (--require-usages)." if @flags[:require_usages] || config.require_usages?
|
|
261
|
+
|
|
262
|
+
@err.puts("Sending the catalogs without call sites: #{reason}. Renames stay unavailable until a scan carries them.")
|
|
263
|
+
nil
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def report(answer)
|
|
267
|
+
outcome = answer["outcome"]
|
|
268
|
+
write_outputs("scan-id" => answer["scan_id"], "outcome" => outcome)
|
|
269
|
+
if outcome == "idempotent"
|
|
270
|
+
@out.puts("Already scanned (scan #{answer["scan_id"]}); nothing changed.")
|
|
271
|
+
else
|
|
272
|
+
counts = answer["counts"] || {}
|
|
273
|
+
@out.puts("Scan #{answer["scan_id"]} ingested: " \
|
|
274
|
+
"#{counts.map { |name, value| "#{value} #{name}" }.join(", ")}.")
|
|
275
|
+
end
|
|
276
|
+
Array(answer["skipped"]).each do |file|
|
|
277
|
+
@out.puts(" skipped #{file["path"]}: #{file["message"] || file["reason"]}")
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
## doctor
|
|
282
|
+
|
|
283
|
+
def doctor
|
|
284
|
+
parse_flags
|
|
285
|
+
git = Git.new(@root)
|
|
286
|
+
checks = []
|
|
287
|
+
checks << ["Ruby #{RUBY_VERSION}", RUBY_VERSION >= "3.2", "Dialecto's Rails SDK needs Ruby 3.2 or newer."]
|
|
288
|
+
checks << ["git repository", git.repository?, "Run it inside the project's git checkout."]
|
|
289
|
+
remote = git.repository? ? git.remote_url : nil
|
|
290
|
+
checks << ["origin remote #{remote || "(none)"}", !remote.to_s.empty?, "Add an origin remote: `git remote add origin <url>`."]
|
|
291
|
+
checks << ["catalog files at HEAD", catalogs_present?(git), "No file matches #{config.catalog_paths.join(", ")}: set DIALECTO_CATALOG_PATHS."]
|
|
292
|
+
token = config.scan_token
|
|
293
|
+
checks << ["scan token set", !token.nil?, "Set DIALECTO_SCAN_TOKEN (Settings → Scan token)."]
|
|
294
|
+
checks << ["scan token shape", token.nil? || token.match?(TOKEN_SHAPE), "The token should look like dlk_ followed by 43 characters; copy it exactly as Dialecto showed it."]
|
|
295
|
+
checks << ["project #{config.project || "(unset)"}", !!config.project&.match?(/\A\d+\z/), "Set DIALECTO_PROJECT to the project's number."]
|
|
296
|
+
checks << ["url #{config.url}", config.url_problem.nil?, config.url_problem.to_s]
|
|
297
|
+
checks << parsers_check
|
|
298
|
+
checks << server_check(token) if token && config.project&.match?(/\A\d+\z/) && config.url_problem.nil?
|
|
299
|
+
|
|
300
|
+
checks.each { |label, ok, fix| @out.puts("#{ok ? "ok " : "FAIL"} #{label}#{ok ? "" : "\n #{fix}"}") }
|
|
301
|
+
worker_warning
|
|
302
|
+
checks.all? { |_label, ok, _fix| ok } ? 0 : 2
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# The in-context editor keeps its drafts and markers in memory, one copy per worker process.
|
|
306
|
+
def worker_warning
|
|
307
|
+
workers = @env["WEB_CONCURRENCY"].to_i
|
|
308
|
+
return unless workers > 1
|
|
309
|
+
|
|
310
|
+
@out.puts("warn WEB_CONCURRENCY=#{workers}\n The in-context editor holds its state per process; with several workers a draft may " \
|
|
311
|
+
"vanish on the next request. Run the development server with one worker (the Rails default).")
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def catalogs_present?(git)
|
|
315
|
+
return false unless git.repository?
|
|
316
|
+
|
|
317
|
+
catalogs = Catalogs.new(config.catalog_paths)
|
|
318
|
+
git.entries(git.head_sha).any? { |entry| catalogs.match?(entry.path) }
|
|
319
|
+
rescue Error
|
|
320
|
+
false
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def parsers_check
|
|
324
|
+
require "prism"
|
|
325
|
+
["prism #{Prism::VERSION}", true, ""]
|
|
326
|
+
rescue LoadError
|
|
327
|
+
["prism", false, "Install it: gem install prism."]
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def server_check(_token)
|
|
331
|
+
client = @client_factory.call(config)
|
|
332
|
+
client.scan_config
|
|
333
|
+
["Dialecto accepts the token", true, ""]
|
|
334
|
+
rescue RefusedError, RetryableError => e
|
|
335
|
+
["Dialecto accepts the token", false, e.message]
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "net/http"
|
|
3
|
+
require "openssl"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
module DialectoRails
|
|
7
|
+
module Scan
|
|
8
|
+
# Talks to a Dialecto server under the project's scan token (docs/ingestion-api.md). Expected failures
|
|
9
|
+
# are `RefusedError` (fix the pipeline) or `RetryableError` (wait and rerun); the token never appears
|
|
10
|
+
# in a message.
|
|
11
|
+
class Client
|
|
12
|
+
Response = Struct.new(:status, :body, keyword_init: true)
|
|
13
|
+
|
|
14
|
+
RETRY_DELAYS = [1, 4, 16].freeze
|
|
15
|
+
MAX_RETRY_AFTER = 60
|
|
16
|
+
NETWORK_ERRORS = [
|
|
17
|
+
Timeout::Error, Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EHOSTUNREACH, Errno::ENETUNREACH,
|
|
18
|
+
Errno::EPIPE, SocketError, EOFError, OpenSSL::SSL::SSLError
|
|
19
|
+
].freeze
|
|
20
|
+
|
|
21
|
+
def initialize(config, sleeper: Kernel.method(:sleep), jitter: -> { rand * 0.25 }, log: nil)
|
|
22
|
+
@config = config
|
|
23
|
+
@sleeper = sleeper
|
|
24
|
+
@jitter = jitter
|
|
25
|
+
@log = log
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# `{scan_mode, branch_regex, tracked_branches}`.
|
|
29
|
+
def scan_config
|
|
30
|
+
JSON.parse(request(Net::HTTP::Get, "scan-config", nil).body)
|
|
31
|
+
rescue JSON::ParserError
|
|
32
|
+
raise RetryableError, "Dialecto's scan-config answer isn't JSON; try again in a moment."
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The server's answer to a scan: `{"scan_id", "outcome", "counts"?, "skipped"}`, with the HTTP status.
|
|
36
|
+
def post_scan(body)
|
|
37
|
+
response = request(Net::HTTP::Post, "scans", body)
|
|
38
|
+
parsed = begin
|
|
39
|
+
JSON.parse(response.body)
|
|
40
|
+
rescue JSON::ParserError
|
|
41
|
+
{}
|
|
42
|
+
end
|
|
43
|
+
parsed.merge("status" => response.status)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def request(klass, tail, body)
|
|
49
|
+
uri = URI.parse("#{@config.url}/api/repos/#{@config.project}/#{tail}")
|
|
50
|
+
attempts = 0
|
|
51
|
+
loop do
|
|
52
|
+
result = attempt(klass, uri, body)
|
|
53
|
+
return result if result.is_a?(Response)
|
|
54
|
+
|
|
55
|
+
attempts += 1
|
|
56
|
+
delay = RETRY_DELAYS[attempts - 1]
|
|
57
|
+
raise RetryableError, result if delay.nil?
|
|
58
|
+
|
|
59
|
+
wait = [delay, @retry_after.to_i].max + @jitter.call
|
|
60
|
+
@retry_after = nil
|
|
61
|
+
@log&.call("Dialecto answered #{result}; retrying in #{wait.round(1)}s (#{attempts}/#{RETRY_DELAYS.length})")
|
|
62
|
+
@sleeper.call(wait)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# A Response when the server gave a final answer; otherwise a String saying why it can be retried.
|
|
67
|
+
def attempt(klass, uri, body)
|
|
68
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
69
|
+
http.use_ssl = uri.scheme == "https"
|
|
70
|
+
http.open_timeout = 10
|
|
71
|
+
http.read_timeout = 60
|
|
72
|
+
req = klass.new(uri.request_uri)
|
|
73
|
+
req["Authorization"] = "Bearer #{@config.scan_token}"
|
|
74
|
+
req["Accept"] = "application/json"
|
|
75
|
+
req["User-Agent"] = "dialecto-rails/#{VERSION}"
|
|
76
|
+
if body
|
|
77
|
+
req["Content-Type"] = "application/json"
|
|
78
|
+
req.body = body
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
res = http.start { |connection| connection.request(req) }
|
|
82
|
+
classify(res)
|
|
83
|
+
rescue *NETWORK_ERRORS => e
|
|
84
|
+
"a network error (#{e.class.name.split("::").last})"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def classify(res)
|
|
88
|
+
status = res.code.to_i
|
|
89
|
+
return Response.new(status: status, body: res.body.to_s) if status.between?(200, 299)
|
|
90
|
+
|
|
91
|
+
# A redirect (the apex domain answers 301) is not an ingest; following it would resend the token.
|
|
92
|
+
if status.between?(300, 399)
|
|
93
|
+
raise RefusedError, "HTTP #{status} to #{res["location"] || "(no Location)"}: check DIALECTO_URL"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
if status == 429 || status >= 500
|
|
97
|
+
retry_after = res["retry-after"].to_i
|
|
98
|
+
@retry_after = [retry_after, MAX_RETRY_AFTER].min if retry_after.positive?
|
|
99
|
+
return "HTTP #{status}"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
raise RefusedError, refusal(status, res.body.to_s)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def refusal(status, raw)
|
|
106
|
+
data = begin
|
|
107
|
+
JSON.parse(raw)
|
|
108
|
+
rescue JSON::ParserError
|
|
109
|
+
{}
|
|
110
|
+
end
|
|
111
|
+
message = data["message"] || data["error"] || "HTTP #{status}"
|
|
112
|
+
code = data["error"] && data["message"] ? " (#{data["error"]})" : ""
|
|
113
|
+
"#{message}#{code}"
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
require "digest"
|
|
2
|
+
require "json"
|
|
3
|
+
|
|
4
|
+
module DialectoRails
|
|
5
|
+
module Scan
|
|
6
|
+
# The scan request body (docs/ingestion-api.md): the committed catalog files verbatim, the commit
|
|
7
|
+
# and branch they were read at, and a checksum that makes a re-run a no-op.
|
|
8
|
+
class Envelope
|
|
9
|
+
MB = 1024 * 1024
|
|
10
|
+
MAX_FILES = 1_000
|
|
11
|
+
MAX_FILE_BYTES = 5 * MB
|
|
12
|
+
MAX_CATALOG_BYTES = 10 * MB
|
|
13
|
+
MAX_BODY_BYTES = 20 * MB
|
|
14
|
+
|
|
15
|
+
attr_reader :payload, :files
|
|
16
|
+
|
|
17
|
+
# `usages` is the usage scan's envelope (a Hash) or nil.
|
|
18
|
+
def self.build(git:, catalogs:, sha:, branch:, usages: nil)
|
|
19
|
+
new(git, catalogs, sha, branch, usages).tap(&:validate!)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def initialize(git, catalogs, sha, branch, usages)
|
|
23
|
+
@git = git
|
|
24
|
+
@sha = sha
|
|
25
|
+
@branch = branch
|
|
26
|
+
@usages = usages
|
|
27
|
+
@files = git.entries(sha).select { |entry| catalogs.match?(entry.path) }.sort_by(&:path)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def validate!
|
|
31
|
+
raise ConfigError, no_files_message if files.empty?
|
|
32
|
+
check_limits!
|
|
33
|
+
@payload = build_payload
|
|
34
|
+
body = JSON.generate(@payload)
|
|
35
|
+
return if body.bytesize <= MAX_BODY_BYTES
|
|
36
|
+
|
|
37
|
+
raise ConfigError, "The scan is #{format_mb(body.bytesize)} MB; Dialecto takes #{MAX_BODY_BYTES / MB} MB. " \
|
|
38
|
+
"Narrow DIALECTO_CATALOG_PATHS, or send fewer usage records."
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def body = JSON.generate(payload)
|
|
42
|
+
|
|
43
|
+
def counts = {files: files.length, records: @usages ? @usages["usages"].length : nil}
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def build_payload
|
|
48
|
+
templates = files.map do |entry|
|
|
49
|
+
content = @git.blob(entry.blob).force_encoding(Encoding::UTF_8)
|
|
50
|
+
unless content.valid_encoding?
|
|
51
|
+
raise ConfigError, "#{entry.path} isn't valid UTF-8, so it can't be sent as text. Dialecto " \
|
|
52
|
+
"reads UTF-8 catalogs: re-save the file as UTF-8 or leave it out of DIALECTO_CATALOG_PATHS."
|
|
53
|
+
end
|
|
54
|
+
{path: entry.path, content: content}
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
payload = {gitSha: @sha, gitBranch: @branch, checksum: checksum, templates: templates}
|
|
58
|
+
payload[:railsUsages] = @usages if @usages
|
|
59
|
+
payload
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def check_limits!
|
|
63
|
+
if files.length > MAX_FILES
|
|
64
|
+
raise ConfigError, "#{delimit(files.length)} catalog files match; Dialecto takes #{delimit(MAX_FILES)}. " \
|
|
65
|
+
"Narrow DIALECTO_CATALOG_PATHS (for example to the source locale and the languages you translate)."
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
big = files.find { |entry| entry.size > MAX_FILE_BYTES }
|
|
69
|
+
if big
|
|
70
|
+
raise ConfigError, "#{big.path} is #{format_mb(big.size)} MB; Dialecto takes #{MAX_FILE_BYTES / MB} MB a file."
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
total = files.sum(&:size)
|
|
74
|
+
return if total <= MAX_CATALOG_BYTES
|
|
75
|
+
|
|
76
|
+
raise ConfigError, "The catalogs are #{format_mb(total)} MB; Dialecto takes #{MAX_CATALOG_BYTES / MB} MB. " \
|
|
77
|
+
"Narrow DIALECTO_CATALOG_PATHS."
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# The commit, the branch and each sent file's blob id, plus the usage records, so a code-only change
|
|
81
|
+
# with unchanged catalogs is a new scan, a re-run is a no-op and a revert is a new scan.
|
|
82
|
+
def checksum
|
|
83
|
+
digest = Digest::SHA256.new
|
|
84
|
+
digest << "dialecto-rails/1\n#{@sha}\n#{@branch}\n"
|
|
85
|
+
files.each { |entry| digest << "#{@git.respond_to?(:blob_id) ? @git.blob_id(entry.blob) : entry.blob} #{entry.path}\n" }
|
|
86
|
+
digest << "usages:#{Digest::SHA256.hexdigest(JSON.generate(@usages))}\n" if @usages
|
|
87
|
+
digest.hexdigest
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def no_files_message
|
|
91
|
+
"No file at #{@sha[0, 8]} matches the catalog paths. Dialecto reads the files your project's " \
|
|
92
|
+
"settings name; set DIALECTO_CATALOG_PATHS to match them (default config/locales/**/*.yml)."
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def format_mb(bytes) = format("%.1f", bytes.to_f / MB)
|
|
96
|
+
|
|
97
|
+
def delimit(number) = number.to_s.reverse.scan(/\d{1,3}/).join(",").reverse
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
require "open3"
|
|
2
|
+
|
|
3
|
+
module DialectoRails
|
|
4
|
+
module Scan
|
|
5
|
+
# Reads a repository through `git`, with an argument array (no shell). The scanner reads the committed
|
|
6
|
+
# state at a sha, never the working tree, because that is what pull requests are made against.
|
|
7
|
+
class Git
|
|
8
|
+
Entry = Struct.new(:path, :blob, :size, keyword_init: true)
|
|
9
|
+
|
|
10
|
+
TIMEOUT = 30
|
|
11
|
+
|
|
12
|
+
def initialize(root = Dir.pwd)
|
|
13
|
+
@root = root
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def repository?
|
|
17
|
+
run("rev-parse", "--is-inside-work-tree", chomp: true) == "true"
|
|
18
|
+
rescue ConfigError
|
|
19
|
+
false
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def head_sha = run("rev-parse", "HEAD", chomp: true)
|
|
23
|
+
|
|
24
|
+
# The current branch name, or nil on a detached HEAD.
|
|
25
|
+
def branch
|
|
26
|
+
name = run("rev-parse", "--abbrev-ref", "HEAD", chomp: true)
|
|
27
|
+
name == "HEAD" ? nil : name
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The remote's default branch, or nil when `origin/HEAD` isn't set.
|
|
31
|
+
def default_branch
|
|
32
|
+
ref = run("symbolic-ref", "--short", "refs/remotes/origin/HEAD", chomp: true, allow_failure: true)
|
|
33
|
+
ref&.sub(%r{\Aorigin/}, "")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def remote_url = run("remote", "get-url", "origin", chomp: true, allow_failure: true)
|
|
37
|
+
|
|
38
|
+
# Every blob under `sha`: path, blob id and size. Symlinks and submodules are left out.
|
|
39
|
+
def entries(sha)
|
|
40
|
+
raw = run("ls-tree", "-r", "-z", "-l", sha)
|
|
41
|
+
raw.split("\0").filter_map do |line|
|
|
42
|
+
meta, path = line.split("\t", 2)
|
|
43
|
+
mode, type, blob, size = meta.split(" ")
|
|
44
|
+
next unless type == "blob" && mode != "120000" && path
|
|
45
|
+
|
|
46
|
+
Entry.new(path: path.dup.force_encoding(Encoding::UTF_8), blob: blob, size: size.to_i)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def blob(id) = run("cat-file", "blob", id)
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def run(*args, chomp: false, allow_failure: false)
|
|
55
|
+
out, err, status = Open3.capture3("git", "-C", @root, *args, binmode: true)
|
|
56
|
+
unless status.success?
|
|
57
|
+
return nil if allow_failure
|
|
58
|
+
|
|
59
|
+
raise ConfigError, "git #{args.first} failed: #{err.to_s.strip.lines.first.to_s.strip}"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
chomp ? out.strip : out
|
|
63
|
+
rescue Errno::ENOENT
|
|
64
|
+
raise ConfigError, "git isn't installed or isn't on the PATH"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
require "digest"
|
|
2
|
+
require "find"
|
|
3
|
+
require_relative "git"
|
|
4
|
+
|
|
5
|
+
module DialectoRails
|
|
6
|
+
module Scan
|
|
7
|
+
# The `Git` reading interface over a plain folder: for `scan --worktree` (a testing escape hatch that sends
|
|
8
|
+
# what is on disk, not what is committed), and for sparse, blobless clones (the corpus), where `git
|
|
9
|
+
# cat-file` would fetch every missing blob from the network. Blob ids are git's, so the scan checksum still
|
|
10
|
+
# follows the bytes.
|
|
11
|
+
class Worktree
|
|
12
|
+
Entry = Git::Entry
|
|
13
|
+
SKIPPED = %w[.git node_modules].freeze
|
|
14
|
+
|
|
15
|
+
def initialize(root)
|
|
16
|
+
@root = root
|
|
17
|
+
@paths = {}
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def entries(_sha = nil)
|
|
21
|
+
found = []
|
|
22
|
+
Find.find(@root) do |path|
|
|
23
|
+
if SKIPPED.include?(File.basename(path)) then Find.prune
|
|
24
|
+
elsif File.file?(path) && !File.symlink?(path)
|
|
25
|
+
size = File.size(path)
|
|
26
|
+
found << Entry.new(path: path.delete_prefix("#{@root}/"), blob: path, size: size)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
found.sort_by(&:path)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# The id is the file's path here; `blob_id` gives git's id for the bytes, for the checksum.
|
|
33
|
+
def blob(id) = File.binread(id)
|
|
34
|
+
|
|
35
|
+
def blob_id(id)
|
|
36
|
+
bytes = File.binread(id)
|
|
37
|
+
Digest::SHA1.hexdigest("blob #{bytes.bytesize}\0".b + bytes)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|