duel 0.1.0 → 0.1.1
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/AGENT_PROMPT.md +20 -16
- data/CHANGELOG.md +82 -1
- data/README.md +39 -24
- data/docs/ci.md +159 -35
- data/docs/configuration.md +66 -4
- data/docs/how-it-works.md +4 -2
- data/docs/json.md +5 -2
- data/docs/troubleshooting.md +6 -5
- data/lib/duel/baseline.rb +31 -3
- data/lib/duel/cli.rb +109 -40
- data/lib/duel/config.rb +37 -3
- data/lib/duel/init.rb +10 -2
- data/lib/duel/json_report.rb +11 -9
- data/lib/duel/runner.rb +1 -1
- data/lib/duel/summary.rb +145 -73
- data/lib/duel/templates/duel.rb.erb +7 -2
- data/lib/duel/templates/perf.yml.erb +89 -22
- data/lib/duel/version.rb +1 -1
- data/sig/duel/baseline.rbs +16 -1
- data/sig/duel/cli.rbs +26 -5
- data/sig/duel/config.rbs +16 -1
- data/sig/duel/init.rbs +6 -0
- data/sig/duel/json_report.rbs +7 -5
- data/sig/duel/runner.rbs +6 -3
- data/sig/duel/summary.rbs +47 -17
- metadata +1 -1
data/docs/json.md
CHANGED
|
@@ -47,6 +47,7 @@ A run that found one regression, abridged to two benchmarks:
|
|
|
47
47
|
"status": "failed",
|
|
48
48
|
"exit": 1,
|
|
49
49
|
"message": null,
|
|
50
|
+
"comment": "post",
|
|
50
51
|
"settings": {
|
|
51
52
|
"confidence": 0.99,
|
|
52
53
|
"limit": 1.02,
|
|
@@ -189,7 +190,8 @@ A run with nothing to compare has the same keys, with nothing compared:
|
|
|
189
190
|
"duel_version": "0.1.0",
|
|
190
191
|
"status": "nothing_to_compare",
|
|
191
192
|
"exit": 0,
|
|
192
|
-
"message": "duel: lib, ext unchanged since the baseline, so there's nothing to compare (--force to anyway)",
|
|
193
|
+
"message": "duel: lib, ext, duel, .duel.rb unchanged since the baseline, so there's nothing to compare (--force to anyway)",
|
|
194
|
+
"comment": "change",
|
|
193
195
|
"settings": {"confidence": 0.99, "limit": 1.02, "filter": "", "jobs": 4, "processes": 3, "timeout": 60.0, "warm": null, "force": false},
|
|
194
196
|
"seconds": null,
|
|
195
197
|
"baseline": null,
|
|
@@ -217,6 +219,7 @@ jq -r '.benchmarks[] | select(.verdict == "regressed") | .name' tmp/duel.json
|
|
|
217
219
|
| `status` | `"passed"`, `"failed"`, `"nothing_to_compare"`, or `"error"` (Duel couldn't compare). |
|
|
218
220
|
| `exit` | The exit status of `duel`: `0` for passed or nothing to compare, `1` for failed, `2` for an error. |
|
|
219
221
|
| `message` | What Duel said, for nothing to compare or an error, such as `"duel: no release tag (v*) before HEAD to compare against"`. Otherwise `null`. |
|
|
222
|
+
| `comment` | Whether the workflow that `duel init` writes comments on a pull request with the report, as [`c.comment`](configuration.md#choose-when-to-comment-on-a-pull-request) (or `--comment`) decides for this run: `"post"` a new comment, `"change"`, which posts one only if the workflow commented on the pull request before and its last comment's status differs from this run's, or `"none"`. See [Comment on a pull request](ci.md#comment-on-a-pull-request). |
|
|
220
223
|
| `settings` | The settings that the run used (see below). |
|
|
221
224
|
| `seconds` | How long the comparison took. `null` without a comparison. |
|
|
222
225
|
| `baseline`, `candidate` | The two versions (see below). `null` without a comparison. |
|
|
@@ -247,7 +250,7 @@ was slower across the board and confirmed (`"confirmed": true`).
|
|
|
247
250
|
|---|---|
|
|
248
251
|
| `ref` | The baseline's git ref: the one `--base` named, the release tag for `--base last-release`, or the main branch, such as `"origin/main"`, by default, where the baseline is the commit where `HEAD` left that branch. `null` for a baseline directory. |
|
|
249
252
|
| `commit` | The full hash of the commit compared. `null` for a baseline directory, or for a candidate outside a git repository. |
|
|
250
|
-
| `uncommitted_changes` | The candidate only: whether what `c.watch
|
|
253
|
+
| `uncommitted_changes` | The candidate only: whether what Duel watches (`c.watch`, the benchmarks, and the rest that a change must touch to be compared: see [Configure Duel](configuration.md)) has changes that aren't committed. `null` outside a git repository. |
|
|
251
254
|
| `root` | The tree that was compared, as an absolute path. |
|
|
252
255
|
| `ruby` | The version's Ruby, as its `RUBY_DESCRIPTION`. |
|
|
253
256
|
|
data/docs/troubleshooting.md
CHANGED
|
@@ -5,11 +5,12 @@ comparison doesn't go as you expect, and what to do about it.
|
|
|
5
5
|
|
|
6
6
|
## Duel says there's nothing to compare
|
|
7
7
|
|
|
8
|
-
If nothing under `lib/` or `ext
|
|
9
|
-
nothing to compare, and Duel passes.
|
|
10
|
-
|
|
11
|
-
that your benchmarks run. To change
|
|
12
|
-
compared, set `c.watch`. See
|
|
8
|
+
If nothing under `lib/` or `ext/`, your benchmarks, or `.duel.rb`
|
|
9
|
+
changed since the baseline, there's nothing to compare, and Duel passes.
|
|
10
|
+
To compare anyway, use `--force`. With nothing changed, Duel compares
|
|
11
|
+
your code with itself, which checks that your benchmarks run. To change
|
|
12
|
+
what a change must touch to be compared, set `c.watch`. See
|
|
13
|
+
[Choose what a change must touch to be compared](configuration.md#choose-what-a-change-must-touch-to-be-compared).
|
|
13
14
|
|
|
14
15
|
Against `--base last-release`, Duel also reports that there's nothing to
|
|
15
16
|
compare against until you tag your first release. Releases are tagged
|
data/lib/duel/baseline.rb
CHANGED
|
@@ -4,6 +4,7 @@ require "digest"
|
|
|
4
4
|
require "fileutils"
|
|
5
5
|
require "open3"
|
|
6
6
|
require_relative "../duel"
|
|
7
|
+
require_relative "rubies"
|
|
7
8
|
|
|
8
9
|
module Duel
|
|
9
10
|
# The version to compare against: a directory (Settings#base_dir), or a
|
|
@@ -39,17 +40,30 @@ module Duel
|
|
|
39
40
|
end
|
|
40
41
|
end
|
|
41
42
|
|
|
42
|
-
# Whether nothing
|
|
43
|
-
#
|
|
43
|
+
# Whether nothing #watched changed since the baseline (only for a git
|
|
44
|
+
# baseline compared with the working tree).
|
|
44
45
|
def unchanged?
|
|
45
46
|
return false if @settings.base_dir || @settings.candidate_dir
|
|
46
47
|
|
|
47
48
|
# (There's a ref: CLI#run checks first.)
|
|
48
49
|
commit = ref #: String
|
|
49
|
-
_, status = Open3.capture2e("git", "-C", @config.root, "diff", "--quiet", commit, "--",
|
|
50
|
+
_, status = Open3.capture2e("git", "-C", @config.root, "diff", "--quiet", commit, "--", *watched)
|
|
50
51
|
status.success?
|
|
51
52
|
end
|
|
52
53
|
|
|
54
|
+
# What a change must touch to be compared, from the root: the code
|
|
55
|
+
# (Config#watch), and what decides how it's measured. Each version
|
|
56
|
+
# runs the candidate's benchmarks and configuration, so a change to
|
|
57
|
+
# only those compares the code with itself, which checks them. They
|
|
58
|
+
# are the benchmarks' directory (the glob's, up to its first
|
|
59
|
+
# wildcard), the configuration's file, and with each version on its
|
|
60
|
+
# own Ruby, the version files at the root.
|
|
61
|
+
def watched
|
|
62
|
+
measured = [benchmarks, config_file]
|
|
63
|
+
measured.concat(version_files) if (@settings.ruby || @config.ruby) == :version_file
|
|
64
|
+
(@config.watch + measured).uniq
|
|
65
|
+
end
|
|
66
|
+
|
|
53
67
|
# The baseline's tree, checked out and built if it's a commit.
|
|
54
68
|
def root
|
|
55
69
|
base_dir = @settings.base_dir
|
|
@@ -129,6 +143,20 @@ module Duel
|
|
|
129
143
|
|
|
130
144
|
private
|
|
131
145
|
|
|
146
|
+
# The benchmarks' directory (see #watched), or for benchmarks at the
|
|
147
|
+
# root, their glob, as git matches one.
|
|
148
|
+
def benchmarks
|
|
149
|
+
glob = @config.benchmarks
|
|
150
|
+
dir = glob.split("/").take_while { |part| !part.match?(/[*?\[{]/) }.join("/")
|
|
151
|
+
dir.empty? ? ":(glob)#{glob}" : dir
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# The configuration's file, from the root (which is its directory).
|
|
155
|
+
def config_file = File.basename(@settings.config_path || ".duel.rb")
|
|
156
|
+
|
|
157
|
+
# The version files at the root (see Rubies).
|
|
158
|
+
def version_files = Rubies::VERSION_FILES.select { |name| File.exist?(File.join(@config.root, name)) }
|
|
159
|
+
|
|
132
160
|
# Where HEAD left the main branch: their merge base, which there isn't
|
|
133
161
|
# when their histories are unrelated, or in a shallow clone (CI's,
|
|
134
162
|
# fetched without the history), where git fails saying nothing.
|
data/lib/duel/cli.rb
CHANGED
|
@@ -16,9 +16,11 @@ module Duel
|
|
|
16
16
|
# rake task or a CI expression can't pass as an option can be set in
|
|
17
17
|
# the environment (DUEL_BASE, DUEL_FILTER, DUEL_FORCE), and options win.
|
|
18
18
|
# On GitHub Actions (GITHUB_STEP_SUMMARY), it adds its report to the
|
|
19
|
-
# job's summary, in Markdown (see Summary)
|
|
20
|
-
#
|
|
21
|
-
#
|
|
19
|
+
# job's summary, in Markdown (see Summary), and tells the step's
|
|
20
|
+
# outputs (GITHUB_OUTPUT) what to do about the comment on a pull
|
|
21
|
+
# request (see .comment). --markdown writes the Markdown to a file, and
|
|
22
|
+
# --json the report as JSON (see JSONReport), whatever the run's
|
|
23
|
+
# outcome, but not for a command line that isn't valid.
|
|
22
24
|
# @api private
|
|
23
25
|
class CLI
|
|
24
26
|
USAGE = "Usage: duel [options]\n duel init [options]\n\n" \
|
|
@@ -27,6 +29,18 @@ module Duel
|
|
|
27
29
|
# Where GitHub Actions has a step add to its job's summary.
|
|
28
30
|
STEP_SUMMARY = "GITHUB_STEP_SUMMARY"
|
|
29
31
|
|
|
32
|
+
# Where GitHub Actions has a step set its outputs.
|
|
33
|
+
STEP_OUTPUT = "GITHUB_OUTPUT"
|
|
34
|
+
|
|
35
|
+
# The statuses (as the JSON report has them) that the workflow
|
|
36
|
+
# comments on, by when it does (Config#comment, but :never, which
|
|
37
|
+
# comments on none).
|
|
38
|
+
COMMENTED = {always: %w[passed failed nothing_to_compare error], results: %w[passed failed error], failures: %w[failed error]}.freeze #: Hash[Config::comment, Array[String]]
|
|
39
|
+
|
|
40
|
+
# A run's status (as the JSON report has it) as the comment's marker
|
|
41
|
+
# has it: passed, failed (or couldn't compare), or neutral.
|
|
42
|
+
MARKED = {"passed" => "passed", "failed" => "failed", "error" => "failed", "nothing_to_compare" => "neutral"}.freeze
|
|
43
|
+
|
|
30
44
|
# The variables that set options, to their values, and the one that
|
|
31
45
|
# sets a flag by being set at all.
|
|
32
46
|
ENV_OPTIONS = {"DUEL_BASE" => "--base", "DUEL_FILTER" => "--filter"}.freeze
|
|
@@ -45,11 +59,11 @@ module Duel
|
|
|
45
59
|
return Init.new(@argv.drop(1), out: @out, err: @err).run if @argv.first == "init"
|
|
46
60
|
|
|
47
61
|
settings = @settings = catch(:version) { parse } or return 0
|
|
48
|
-
config = settings.config_path ? Duel.load_config(settings.config_path) : Config.new
|
|
62
|
+
config = @config = settings.config_path ? Duel.load_config(settings.config_path) : Config.new
|
|
49
63
|
Lock.hold(File.join(config.root, "tmp", "duel")) { compare(config, settings) }
|
|
50
64
|
rescue Error => e
|
|
51
65
|
@err.puts "duel: #{e}"
|
|
52
|
-
unfinished("couldn't compare", "error",
|
|
66
|
+
unfinished(Summary::FAILED_MARK, "couldn't compare", "error", e.to_s, 2)
|
|
53
67
|
rescue OptionParser::ParseError => e
|
|
54
68
|
@err.puts "duel: #{e}"
|
|
55
69
|
2
|
|
@@ -63,6 +77,25 @@ module Duel
|
|
|
63
77
|
raise OptionParser::InvalidArgument, "#{source} (#{e})"
|
|
64
78
|
end
|
|
65
79
|
|
|
80
|
+
# Whether the workflow comments on a pull request, when it comments as
|
|
81
|
+
# +mode+ says (Config#comment) and the run's +status+ is the JSON
|
|
82
|
+
# report's: "post" a new comment, "change", posting one only if it
|
|
83
|
+
# commented on the pull request before and its last comment's status
|
|
84
|
+
# (see MARKED) isn't this run's, or "none". It never edits a comment,
|
|
85
|
+
# so the pull request keeps every one.
|
|
86
|
+
def self.comment(mode, status)
|
|
87
|
+
return "none" if mode == :never
|
|
88
|
+
|
|
89
|
+
COMMENTED.fetch(mode).include?(status) ? "post" : "change"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# The mode --comment names (see Config#comment), or an invalid
|
|
93
|
+
# argument.
|
|
94
|
+
def self.comment_mode(name)
|
|
95
|
+
Config::COMMENTS.find { |mode| mode.to_s == name } or
|
|
96
|
+
raise OptionParser::InvalidArgument, "#{name} (give #{Config::COMMENTS.join(", ")})"
|
|
97
|
+
end
|
|
98
|
+
|
|
66
99
|
# The settings, from the environment and then the options.
|
|
67
100
|
def parse
|
|
68
101
|
settings = Settings.defaults
|
|
@@ -83,9 +116,12 @@ module Duel
|
|
|
83
116
|
@out.puts "duel: removed tmp/duel and its baseline's worktree"
|
|
84
117
|
return 0
|
|
85
118
|
end
|
|
86
|
-
|
|
119
|
+
if settings.base_dir.nil? && baseline.ref.nil?
|
|
120
|
+
return nothing("no release to compare against", "no release tag (#{config.release_tags}) before HEAD to compare against")
|
|
121
|
+
end
|
|
87
122
|
if !settings.force && baseline.unchanged?
|
|
88
|
-
return nothing("
|
|
123
|
+
return nothing("nothing to compare",
|
|
124
|
+
"#{baseline.watched.join(", ")} unchanged since the baseline, so there's nothing to compare (--force to anyway)")
|
|
89
125
|
end
|
|
90
126
|
|
|
91
127
|
candidate = settings.candidate_dir || config.root
|
|
@@ -96,42 +132,62 @@ module Duel
|
|
|
96
132
|
results = runner.run
|
|
97
133
|
status = runner.passed?(results) ? 0 : 1
|
|
98
134
|
publish(
|
|
135
|
+
status.zero? ? "passed" : "failed",
|
|
99
136
|
-> {
|
|
100
137
|
Summary.new(runner, results, settings: settings, baseline: described_baseline(config, settings, baseline),
|
|
101
138
|
candidate: described_candidate(config, settings)).markdown
|
|
102
139
|
},
|
|
103
|
-
-> {
|
|
104
|
-
JSONReport.new(runner, results, settings: settings, exit: status,
|
|
105
|
-
candidate: candidate_json(config, settings)).to_h
|
|
140
|
+
->(comment) {
|
|
141
|
+
JSONReport.new(runner, results, settings: settings, exit: status, comment: comment,
|
|
142
|
+
baseline: baseline_json(config, settings, baseline, base_root), candidate: candidate_json(config, settings)).to_h
|
|
106
143
|
}
|
|
107
144
|
)
|
|
108
145
|
status
|
|
109
146
|
end
|
|
110
147
|
|
|
111
|
-
# Says there's nothing to compare
|
|
112
|
-
|
|
148
|
+
# Says there's nothing to compare (+title+, in Markdown), and +why+,
|
|
149
|
+
# and passes.
|
|
150
|
+
def nothing(title, why)
|
|
113
151
|
@out.puts "duel: #{why}"
|
|
114
|
-
unfinished(
|
|
152
|
+
unfinished(Summary::NEUTRAL_MARK, title, "nothing_to_compare", why, 0)
|
|
115
153
|
end
|
|
116
154
|
|
|
117
|
-
# Reports a run that didn't compare (+title+
|
|
118
|
-
# and +status+ in JSON)
|
|
119
|
-
def unfinished(title, status,
|
|
120
|
-
# (Parsed, so set
|
|
155
|
+
# Reports a run that didn't compare (+mark+ and +title+ say so in
|
|
156
|
+
# Markdown, and +status+ in JSON), and +why+, and returns +exit+.
|
|
157
|
+
def unfinished(mark, title, status, why, exit)
|
|
158
|
+
# (Parsed, so set: see #run.)
|
|
121
159
|
settings = @settings #: Settings
|
|
122
|
-
publish(-> { Summary.note(title,
|
|
160
|
+
publish(status, -> { Summary.note(mark, title, why) }, ->(comment) { JSONReport.note(status, exit, "duel: #{why}", settings, comment) })
|
|
123
161
|
exit
|
|
124
162
|
end
|
|
125
163
|
|
|
126
|
-
#
|
|
127
|
-
#
|
|
128
|
-
#
|
|
129
|
-
#
|
|
130
|
-
def
|
|
164
|
+
# What the workflow does about its comment on a pull request (see
|
|
165
|
+
# .comment), for a run whose +status+ is the JSON report's: as
|
|
166
|
+
# --comment says, or else the configuration, or the default where the
|
|
167
|
+
# configuration didn't load.
|
|
168
|
+
def commenting(status)
|
|
169
|
+
# (Parsed, so set: see #run.)
|
|
170
|
+
settings = @settings #: Settings
|
|
171
|
+
CLI.comment(settings.comment || (@config || Config.new).comment, status)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Writes the reports of a run whose +status+ is the JSON report's
|
|
175
|
+
# where they're asked for: the JSON +json+ makes, given what the
|
|
176
|
+
# workflow does about its comment (see #commenting), to the file
|
|
177
|
+
# --json names, and the Markdown +markdown+ makes to the file
|
|
178
|
+
# --markdown names, and added to the job's summary on GitHub Actions
|
|
179
|
+
# (STEP_SUMMARY). Each is only made if it's written. On GitHub
|
|
180
|
+
# Actions, it also sets the step's outputs (STEP_OUTPUT) for the job
|
|
181
|
+
# that comments on a pull request: +comment+, and +status+ as the
|
|
182
|
+
# comment's marker has it (MARKED).
|
|
183
|
+
def publish(status, markdown, json)
|
|
184
|
+
comment = commenting(status)
|
|
185
|
+
set = variables
|
|
186
|
+
File.write(set.fetch(STEP_OUTPUT), "comment=#{comment}\nstatus=#{MARKED.fetch(status)}\n", mode: "a") if set.key?(STEP_OUTPUT)
|
|
131
187
|
path = @json
|
|
132
|
-
write(path, "#{JSON.pretty_generate(json.call)}\n") if path
|
|
188
|
+
write(path, "#{JSON.pretty_generate(json.call(comment))}\n") if path
|
|
133
189
|
path = @markdown
|
|
134
|
-
summary = (
|
|
190
|
+
summary = (set.fetch(STEP_SUMMARY) if set.key?(STEP_SUMMARY))
|
|
135
191
|
return unless path || summary
|
|
136
192
|
|
|
137
193
|
text = markdown.call
|
|
@@ -145,31 +201,42 @@ module Duel
|
|
|
145
201
|
File.write(path, text)
|
|
146
202
|
end
|
|
147
203
|
|
|
148
|
-
# The baseline, in Markdown: its directory, or its commit,
|
|
149
|
-
#
|
|
204
|
+
# The baseline, in Markdown: its directory, or its commit, bare (so
|
|
205
|
+
# GitHub links it) and followed by a space, never punctuation, and
|
|
206
|
+
# what chose it: the main branch, the push (on GitHub Actions, the
|
|
207
|
+
# commit before it, from DUEL_BASE), the last release, or --base.
|
|
150
208
|
def described_baseline(config, settings, baseline)
|
|
151
209
|
dir = settings.base_dir
|
|
152
210
|
return Summary.code(dir) if dir
|
|
153
211
|
|
|
154
212
|
# (There's one: #compare checked.)
|
|
155
213
|
ref = baseline.ref #: String
|
|
156
|
-
commit =
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
when
|
|
160
|
-
|
|
214
|
+
commit = git(config.root, "rev-parse", "--short", "#{ref}^{commit}").first.chomp
|
|
215
|
+
set = variables
|
|
216
|
+
chosen = case settings.base
|
|
217
|
+
when nil then "where HEAD left #{Summary.code(baseline.main_branch)}"
|
|
218
|
+
when Baseline::LAST_RELEASE then "the last release, #{Summary.code(ref)}"
|
|
219
|
+
else
|
|
220
|
+
pushed = set.key?("GITHUB_EVENT_NAME") && set.fetch("GITHUB_EVENT_NAME") == "push" && set.key?("DUEL_BASE") &&
|
|
221
|
+
set.fetch("DUEL_BASE") == ref
|
|
222
|
+
if !ref.match?(/\A\h+\z/) then Summary.code(ref)
|
|
223
|
+
elsif pushed then "the commit before the push"
|
|
224
|
+
else "from --base"
|
|
225
|
+
end
|
|
161
226
|
end
|
|
227
|
+
"#{commit} (#{chosen})"
|
|
162
228
|
end
|
|
163
229
|
|
|
164
|
-
# The candidate, in Markdown: its directory, or its commit,
|
|
165
|
-
#
|
|
230
|
+
# The candidate, in Markdown: its directory, or its commit, bare (see
|
|
231
|
+
# #described_baseline), and whether what's watched (Baseline#watched)
|
|
232
|
+
# has changes not committed.
|
|
166
233
|
def described_candidate(config, settings)
|
|
167
234
|
dir = settings.candidate_dir || config.root
|
|
168
235
|
head, found = git(dir, "rev-parse", "--short", "HEAD")
|
|
169
236
|
return Summary.code(dir) unless found.success?
|
|
170
237
|
|
|
171
|
-
_, clean = git(dir, "diff", "--quiet", "HEAD", "--", *config.
|
|
172
|
-
"#{
|
|
238
|
+
_, clean = git(dir, "diff", "--quiet", "HEAD", "--", *Baseline.new(config, settings).watched)
|
|
239
|
+
"#{head.chomp}#{" (with changes not committed)" unless clean.success?}"
|
|
173
240
|
end
|
|
174
241
|
|
|
175
242
|
# The baseline, for JSON: the ref that named it (the main branch,
|
|
@@ -189,15 +256,15 @@ module Duel
|
|
|
189
256
|
described.merge("ref" => named, "commit" => git(config.root, "rev-parse", "#{ref}^{commit}").first.chomp)
|
|
190
257
|
end
|
|
191
258
|
|
|
192
|
-
# The candidate, for JSON: its commit, and whether what
|
|
193
|
-
#
|
|
194
|
-
# its tree.
|
|
259
|
+
# The candidate, for JSON: its commit, and whether what's watched
|
|
260
|
+
# (Baseline#watched) has changes not committed (each nil outside a
|
|
261
|
+
# repository), and its tree.
|
|
195
262
|
def candidate_json(config, settings)
|
|
196
263
|
dir = File.expand_path(settings.candidate_dir || config.root)
|
|
197
264
|
head, found = git(dir, "rev-parse", "HEAD")
|
|
198
265
|
return {"commit" => nil, "uncommitted_changes" => nil, "root" => dir} unless found.success?
|
|
199
266
|
|
|
200
|
-
_, clean = git(dir, "diff", "--quiet", "HEAD", "--", *config.
|
|
267
|
+
_, clean = git(dir, "diff", "--quiet", "HEAD", "--", *Baseline.new(config, settings).watched)
|
|
201
268
|
{"commit" => head.chomp, "uncommitted_changes" => !clean.success?, "root" => dir}
|
|
202
269
|
end
|
|
203
270
|
|
|
@@ -253,6 +320,8 @@ module Duel
|
|
|
253
320
|
o.on("--markdown PATH", "Also write the report to PATH in Markdown, for a pull request comment (on GitHub",
|
|
254
321
|
"Actions, it's added to the job's summary too)") { |v| @markdown = v }
|
|
255
322
|
o.on("--json PATH", "Also write the report to PATH as JSON, for a program to read") { |v| @json = v }
|
|
323
|
+
o.on("--comment MODE", "When the workflow comments on a pull request: always, results (passed or failed),",
|
|
324
|
+
"failures, or never (default: c.comment, or failures)") { |v| settings.comment = CLI.comment_mode(v) }
|
|
256
325
|
o.on("--clean", "Remove tmp/duel, and the baseline's git worktree, and stop") { @clean = true }
|
|
257
326
|
o.on("-v", "--version", "Print the version") do
|
|
258
327
|
@out.puts "duel #{VERSION}"
|
data/lib/duel/config.rb
CHANGED
|
@@ -11,7 +11,8 @@ module Duel
|
|
|
11
11
|
# - a project with ext/ is built with `bundle exec rake compile` when
|
|
12
12
|
# a baseline is checked out
|
|
13
13
|
# - a change is compared with where it left the branch origin/HEAD
|
|
14
|
-
# points to (origin/main, say), when lib/ or ext/ changed
|
|
14
|
+
# points to (origin/main, say), when lib/ or ext/ changed, or what
|
|
15
|
+
# decides how they're measured (see #watch)
|
|
15
16
|
# - releases are tagged v*
|
|
16
17
|
#
|
|
17
18
|
# .duel.rb, at the root, changes what doesn't fit:
|
|
@@ -33,8 +34,11 @@ module Duel
|
|
|
33
34
|
# A glob, from the root, of the files defining benchmarks.
|
|
34
35
|
attr_accessor :benchmarks
|
|
35
36
|
|
|
36
|
-
# Paths, from the root, whose changes a comparison is
|
|
37
|
-
# of them changed since the baseline,
|
|
37
|
+
# Paths, from the root, of the code whose changes a comparison is
|
|
38
|
+
# about: when none of them changed since the baseline, nor what
|
|
39
|
+
# decides how it's measured (the benchmarks' directory, the
|
|
40
|
+
# configuration's file, and with each version on its own Ruby, the
|
|
41
|
+
# version files at the root), there's nothing to compare.
|
|
38
42
|
attr_accessor :watch
|
|
39
43
|
|
|
40
44
|
# The library to require from each version, as `require` takes it,
|
|
@@ -62,6 +66,35 @@ module Duel
|
|
|
62
66
|
# c.ruby = ->(root) { File.join(Dir.home, ".rubies", File.read(File.join(root, ".ruby-version")).strip, "bin", "ruby") }
|
|
63
67
|
attr_accessor :ruby
|
|
64
68
|
|
|
69
|
+
# When the workflow that `duel init` writes comments on a pull request
|
|
70
|
+
# with the report (`duel --comment` overrides it):
|
|
71
|
+
#
|
|
72
|
+
# - :always, on every run
|
|
73
|
+
# - :results, when a run passed or failed, but not with nothing
|
|
74
|
+
# compared
|
|
75
|
+
# - :failures (the default), only when a run failed or couldn't
|
|
76
|
+
# compare
|
|
77
|
+
# - :never
|
|
78
|
+
#
|
|
79
|
+
# It never edits a comment: each is a new one, so the pull request
|
|
80
|
+
# keeps them all. Once it has commented, a run that it wouldn't
|
|
81
|
+
# comment on still does, but :never's, when its status differs from
|
|
82
|
+
# the last comment's, so the last comment is never stale: after a
|
|
83
|
+
# failure, the first run that passes says so. Duel tells the workflow
|
|
84
|
+
# which to do (see CLI.comment).
|
|
85
|
+
#
|
|
86
|
+
# c.comment = :results
|
|
87
|
+
attr_reader :comment
|
|
88
|
+
|
|
89
|
+
# The values #comment takes.
|
|
90
|
+
COMMENTS = %i[always results failures never].freeze #: Array[comment]
|
|
91
|
+
|
|
92
|
+
def comment=(mode)
|
|
93
|
+
raise Error, "c.comment = #{mode.inspect}: give #{COMMENTS.map(&:inspect).join(", ")}" unless COMMENTS.include?(mode)
|
|
94
|
+
|
|
95
|
+
@comment = mode
|
|
96
|
+
end
|
|
97
|
+
|
|
65
98
|
# The metrics #metric added, in order (classes: a Duel::Metric
|
|
66
99
|
# subclass, or running totals' Snapshot), for a plugin's tests to
|
|
67
100
|
# look at (their .measures and .settings).
|
|
@@ -81,6 +114,7 @@ module Duel
|
|
|
81
114
|
@library = :conventional
|
|
82
115
|
@build = :conventional
|
|
83
116
|
@release_tags = "v*"
|
|
117
|
+
@comment = :failures
|
|
84
118
|
@prepares = SCOPES.to_h { |scope| [scope, []] }
|
|
85
119
|
@metrics = []
|
|
86
120
|
@in_workers = []
|
data/lib/duel/init.rb
CHANGED
|
@@ -156,14 +156,22 @@ module Duel
|
|
|
156
156
|
steps << "Set c.library in .duel.rb to what each version's workers require." unless required
|
|
157
157
|
steps << "Replace the example in #{benchmark_file} with benchmarks of your own code."
|
|
158
158
|
steps << "Run `bundle exec duel --force`. It compares the code with itself, and passes when the benchmarks are sound."
|
|
159
|
-
steps << "Commit and push. On a pull request, the workflow compares the code with the main branch, and comments with the
|
|
159
|
+
steps << "Commit and push. On a pull request, the workflow compares the code with the main branch, and comments with the " \
|
|
160
|
+
"report when a benchmark fails (c.comment in .duel.rb says when)."
|
|
160
161
|
"\nNext:\n#{steps.each_with_index.map { |step, i| " #{i + 1}. #{step}\n" }.join}"
|
|
161
162
|
end
|
|
162
163
|
|
|
163
164
|
def render(name) = ERB.new(File.read(File.join(TEMPLATES, "#{name}.erb")), trim_mode: "-").result(binding)
|
|
164
165
|
|
|
166
|
+
# The project's configuration, as the conventions have it.
|
|
167
|
+
def config = Config.new(@root)
|
|
168
|
+
|
|
165
169
|
# The library the gemspec names (see Config#library), or nil.
|
|
166
|
-
def library =
|
|
170
|
+
def library = config.library
|
|
171
|
+
|
|
172
|
+
# The glob release tags match (Config#release_tags), for the workflow
|
|
173
|
+
# to find the last release as Duel does.
|
|
174
|
+
def release_tags = config.release_tags
|
|
167
175
|
|
|
168
176
|
# The library, without a gemspec: lib/'s only Ruby file, or nil.
|
|
169
177
|
def guess
|
data/lib/duel/json_report.rb
CHANGED
|
@@ -16,17 +16,18 @@ module Duel
|
|
|
16
16
|
SCHEMA = 1
|
|
17
17
|
|
|
18
18
|
# A run that didn't compare: +status+ ("nothing_to_compare", or
|
|
19
|
-
# "error"), its +exit+ status,
|
|
20
|
-
|
|
19
|
+
# "error"), its +exit+ status, +message+, what it said, and what the
|
|
20
|
+
# workflow does about its +comment+ (see CLI.comment).
|
|
21
|
+
def self.note(status, exit, message, settings, comment)
|
|
21
22
|
none = [] #: Array[value]
|
|
22
23
|
nothing = {} #: report
|
|
23
|
-
head(status, exit, message, settings).merge("seconds" => nil, "baseline" => nil, "candidate" => nil, "plugins" => none,
|
|
24
|
+
head(status, exit, message, settings, comment).merge("seconds" => nil, "baseline" => nil, "candidate" => nil, "plugins" => none,
|
|
24
25
|
"benchmarks" => none, "across" => nothing, "errors" => none)
|
|
25
26
|
end
|
|
26
27
|
|
|
27
28
|
# What every report starts with.
|
|
28
|
-
def self.head(status, exit, message, settings)
|
|
29
|
-
{"schema" => SCHEMA, "duel_version" => VERSION, "status" => status, "exit" => exit, "message" => message,
|
|
29
|
+
def self.head(status, exit, message, settings, comment)
|
|
30
|
+
{"schema" => SCHEMA, "duel_version" => VERSION, "status" => status, "exit" => exit, "message" => message, "comment" => comment,
|
|
30
31
|
"settings" => {"confidence" => settings.confidence, "limit" => settings.limit, "filter" => settings.filter.source,
|
|
31
32
|
"jobs" => settings.jobs, "processes" => settings.processes, "timeout" => settings.timeout,
|
|
32
33
|
"warm" => settings.warm, "force" => settings.force}}
|
|
@@ -34,19 +35,20 @@ module Duel
|
|
|
34
35
|
|
|
35
36
|
# +runner+ ran, and made +results+, comparing +baseline+ with
|
|
36
37
|
# +candidate+ (each {"ref", "commit", "root"} or {"commit",
|
|
37
|
-
# "uncommitted_changes", "root"}) as +settings+ say,
|
|
38
|
-
# +exit
|
|
39
|
-
def initialize(runner, results, settings:, exit:, baseline:, candidate:)
|
|
38
|
+
# "uncommitted_changes", "root"}) as +settings+ say, exits with
|
|
39
|
+
# +exit+, and has the workflow do +comment+ (see CLI.comment).
|
|
40
|
+
def initialize(runner, results, settings:, exit:, comment:, baseline:, candidate:)
|
|
40
41
|
@runner = runner
|
|
41
42
|
@results = results
|
|
42
43
|
@settings = settings
|
|
43
44
|
@exit = exit
|
|
45
|
+
@comment = comment
|
|
44
46
|
@versions = {"baseline" => baseline, "candidate" => candidate}
|
|
45
47
|
end
|
|
46
48
|
|
|
47
49
|
def to_h
|
|
48
50
|
rubies = @runner.rubies
|
|
49
|
-
JSONReport.head(@exit.zero? ? "passed" : "failed", @exit, nil, @settings).merge(
|
|
51
|
+
JSONReport.head(@exit.zero? ? "passed" : "failed", @exit, nil, @settings, @comment).merge(
|
|
50
52
|
"seconds" => @runner.seconds,
|
|
51
53
|
**@versions.to_h { |side, version| [side, version.merge("ruby" => rubies.fetch(side))] },
|
|
52
54
|
"plugins" => @runner.plugins.map(&:to_s),
|
data/lib/duel/runner.rb
CHANGED
|
@@ -30,7 +30,7 @@ module Duel
|
|
|
30
30
|
# How a run is set up (see Duel::CLI for where each comes from).
|
|
31
31
|
# @api private
|
|
32
32
|
Settings = Struct.new(:base, :base_dir, :candidate_dir, :filter, :jobs, :processes, :limit, :confidence, :warm, :force,
|
|
33
|
-
:config_path, :timeout, :ruby) do
|
|
33
|
+
:config_path, :timeout, :ruby, :comment) do
|
|
34
34
|
# @type self: singleton(Settings)
|
|
35
35
|
def self.defaults = new(filter: //, jobs: 1, processes: 3, limit: 1.02, confidence: 0.99, warm: nil, force: false,
|
|
36
36
|
timeout: 60.0)
|