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.
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` names has changes that aren't committed. `null` outside a git repository. |
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
 
@@ -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/` changed since the baseline, there's
9
- nothing to compare, and Duel passes. To compare anyway, use `--force`.
10
- With nothing changed, Duel compares your code with itself, which checks
11
- that your benchmarks run. To change what a change must touch to be
12
- compared, set `c.watch`. See [Configure Duel](configuration.md).
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 Config#watch names changed since the baseline (only
43
- # for a git baseline compared with the working tree).
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, "--", *@config.watch)
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). --markdown writes that to a
20
- # file, and --json the report as JSON (see JSONReport), whatever the
21
- # run's outcome, but not for a command line that isn't valid.
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", "duel: #{e}", 2)
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
- return nothing("no release tag (#{config.release_tags}) before HEAD to compare against") if settings.base_dir.nil? && baseline.ref.nil?
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("#{config.watch.join(", ")} unchanged since the baseline, so there's nothing to compare (--force to anyway)")
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, baseline: baseline_json(config, settings, baseline, base_root),
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, and why, and passes.
112
- def nothing(why)
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("nothing to compare", "nothing_to_compare", "duel: #{why}", 0)
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+ says why in Markdown,
118
- # and +status+ in JSON) with +message+, and returns +exit+.
119
- def unfinished(title, status, message, exit)
120
- # (Parsed, so set, where there's JSON to write: see #publish.)
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, message) }, -> { JSONReport.note(status, exit, message, settings) })
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
- # Writes the reports where they're asked for: the JSON +json+ makes
127
- # to the file --json names, and the Markdown +markdown+ makes to the
128
- # file --markdown names, and added to the job's summary on GitHub
129
- # Actions (STEP_SUMMARY). Each is only made if it's written.
130
- def publish(markdown, json)
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 = (variables.fetch(STEP_SUMMARY) if variables.key?(STEP_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, and what
149
- # named it.
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 = Summary.code(git(config.root, "rev-parse", "--short", "#{ref}^{commit}").first.chomp)
157
- case settings.base
158
- when nil then "#{commit}, where HEAD left #{Summary.code(baseline.main_branch)}"
159
- when Baseline::LAST_RELEASE then "#{Summary.code(ref)} (#{commit}), the last release"
160
- else ref.match?(/\A\h+\z/) ? commit : "#{Summary.code(ref)} (#{commit})"
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, and
165
- # whether what Config#watch names has changes not committed.
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.watch)
172
- "#{Summary.code(head.chomp)}#{", with changes not committed" unless clean.success?}"
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 Config#watch
193
- # names has changes not committed (each nil outside a repository), and
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.watch)
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 about: when none
37
- # of them changed since the baseline, there's nothing to compare.
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 report."
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 = Config.new(@root).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
@@ -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, and +message+, what it said.
20
- def self.note(status, exit, message, settings)
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, and exits with
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)