Skip to content

Commit 7c60dab

Browse files
authored
Deduplicate regression issue reporting (#3767)
1 parent d8840c4 commit 7c60dab

3 files changed

Lines changed: 206 additions & 20 deletions

File tree

benchmarks/lib/github_cli.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ def capture(*args, env: {}, error_message: nil, stdin_data: nil)
1212
[stdout, status]
1313
end
1414

15-
def capture_success(*args, error_message:, env: {})
16-
stdout, status = capture(*args, env: env, error_message: error_message)
15+
def capture_success(*args, error_message:, env: {}, stdin_data: nil)
16+
stdout, status = capture(*args, env: env, error_message: error_message, stdin_data: stdin_data)
1717
return stdout if status.success?
1818

1919
nil

benchmarks/report_regressions.rb

Lines changed: 90 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -45,15 +45,20 @@
4545
# rubocop:disable Metrics/ClassLength
4646
class RegressionIssueReporter
4747
LABEL = "performance-regression"
48+
CACHE_MISS = :cache_miss
49+
ISSUE_NUMBER_UNKNOWN_AFTER_CREATE = :issue_number_unknown_after_create
50+
COMMENT_ID_UNKNOWN_AFTER_CREATE = :comment_id_unknown_after_create
4851

4952
def self.report(summary:, **attributes)
5053
new(**attributes).report(summary)
5154
end
5255

53-
def initialize(suite_name:, github_run_url:, bencher_url:)
56+
def initialize(suite_name:, github_run_url:, bencher_url:, issue_number_cache: nil, report_comment_id_cache: nil)
5457
@suite_name = suite_name
5558
@github_run_url = github_run_url
5659
@bencher_url = bencher_url
60+
@issue_number_cache = issue_number_cache
61+
@report_comment_id_cache = report_comment_id_cache
5762
@commit_short = ENV.fetch("GITHUB_SHA")[0, 7]
5863
end
5964

@@ -71,7 +76,7 @@ def report(summary)
7176

7277
private
7378

74-
attr_reader :suite_name, :github_run_url, :bencher_url, :commit_short
79+
attr_reader :suite_name, :github_run_url, :bencher_url, :issue_number_cache, :report_comment_id_cache, :commit_short
7580

7681
def ensure_regression_label
7782
GithubCli.run(
@@ -127,6 +132,9 @@ def section_end
127132
end
128133

129134
def regression_comment_id(issue_number)
135+
cached_comment_id = cached_regression_comment_id
136+
return cached_comment_id unless cached_comment_id == CACHE_MISS
137+
130138
stdout = GithubCli.capture_success(
131139
"gh", "api", "repos/#{github_repository}/issues/#{issue_number}/comments",
132140
"--paginate",
@@ -138,7 +146,9 @@ def regression_comment_id(issue_number)
138146
# existing report comment yet (caller should create one).
139147
return nil unless stdout
140148

141-
stdout.lines.first.to_s.strip
149+
stdout.lines.first.to_s.strip.tap do |comment_id|
150+
cache_regression_comment_id(comment_id) unless comment_id.empty?
151+
end
142152
end
143153

144154
def regression_comment_body(comment_id)
@@ -197,10 +207,7 @@ def create_or_update_regression_comment(issue_number, summary)
197207

198208
if comment_id.empty?
199209
body = "#{comment_header}#{section}"
200-
return GithubCli.run(
201-
"gh", "issue", "comment", issue_number, "--body", body,
202-
error_message: "Failed to create regression report comment on issue ##{issue_number}"
203-
)
210+
return create_regression_comment(issue_number, body)
204211
end
205212

206213
existing_body = regression_comment_body(comment_id)
@@ -219,11 +226,70 @@ def create_or_update_regression_comment(issue_number, summary)
219226
end
220227

221228
def find_or_create_regression_issue
229+
cached_issue_number = cached_regression_issue_number
230+
return cached_issue_number unless cached_issue_number == CACHE_MISS
231+
222232
issue_number = existing_regression_issue
223233
return nil if issue_number.nil?
224-
return issue_number unless issue_number.empty?
225234

226-
create_regression_issue
235+
return cache_regression_issue_number(issue_number) unless issue_number.empty?
236+
237+
create_regression_issue.tap do |created_issue_number|
238+
# nil means create_regression_issue already stored ISSUE_NUMBER_UNKNOWN_AFTER_CREATE;
239+
# this no-ops for nil so later suites still avoid duplicate issue creation.
240+
cache_regression_issue_number(created_issue_number)
241+
end
242+
end
243+
244+
def cached_regression_issue_number
245+
return CACHE_MISS unless issue_number_cache
246+
247+
issue_number_cache.fetch(issue_title, CACHE_MISS).then do |cached_issue_number|
248+
cached_issue_number == ISSUE_NUMBER_UNKNOWN_AFTER_CREATE ? nil : cached_issue_number
249+
end
250+
end
251+
252+
def cache_regression_issue_number(issue_number)
253+
issue_number_cache&.store(issue_title, issue_number) unless issue_number.to_s.empty?
254+
issue_number
255+
end
256+
257+
def cached_regression_comment_id
258+
return CACHE_MISS unless report_comment_id_cache
259+
260+
report_comment_id_cache.fetch(comment_marker, CACHE_MISS).then do |cached_comment_id|
261+
cached_comment_id == COMMENT_ID_UNKNOWN_AFTER_CREATE ? nil : cached_comment_id
262+
end
263+
end
264+
265+
def cache_regression_comment_id(comment_id)
266+
report_comment_id_cache&.store(comment_marker, comment_id) unless comment_id.to_s.empty?
267+
comment_id
268+
end
269+
270+
def create_regression_comment(issue_number, body)
271+
stdout = GithubCli.capture_success(
272+
"gh", "api", "-X", "POST",
273+
"repos/#{github_repository}/issues/#{issue_number}/comments",
274+
"--input", "-",
275+
"--jq", ".id",
276+
error_message: "Failed to create regression report comment on issue ##{issue_number}",
277+
stdin_data: JSON.generate(body: body)
278+
)
279+
return false unless stdout
280+
281+
comment_id = stdout.lines.first.to_s.strip
282+
if comment_id.empty?
283+
report_comment_id_cache&.store(comment_marker, COMMENT_ID_UNKNOWN_AFTER_CREATE)
284+
Github.warning(
285+
"Created regression report comment on issue ##{issue_number} but could not parse its id; " \
286+
"this suite and later suites in this run will be marked as failed to avoid duplicate comments."
287+
)
288+
return false
289+
end
290+
291+
cache_regression_comment_id(comment_id)
292+
true
227293
end
228294

229295
def create_regression_issue
@@ -244,6 +310,7 @@ def create_regression_issue
244310
# so a parse miss is a warning, not an error.
245311
number = stdout[%r{/issues/(\d+)}, 1]
246312
unless number
313+
issue_number_cache&.store(issue_title, ISSUE_NUMBER_UNKNOWN_AFTER_CREATE)
247314
Github.warning(
248315
"Created the issue but could not parse its number from gh output " \
249316
"(#{stdout.strip.inspect}); its comment section may be missing"
@@ -362,13 +429,22 @@ def report_regressions(artifacts_dir)
362429
# One section per suite: a sharded suite emits one payload per shard, so combine
363430
# them rather than filing a section per shard. Suites sorted for stable output.
364431
by_suite = readable.group_by { |payload| payload.fetch(RegressionReport::SUITE_NAME) }
365-
reported_ok = by_suite.keys.sort.map { |suite_name| report_suite(suite_name, by_suite.fetch(suite_name)) }.all?
432+
issue_number_cache = {}
433+
report_comment_id_cache = {}
434+
reported_ok = by_suite.keys.sort.map do |suite_name|
435+
report_suite(
436+
suite_name,
437+
by_suite.fetch(suite_name),
438+
issue_number_cache: issue_number_cache,
439+
report_comment_id_cache: report_comment_id_cache
440+
)
441+
end.all?
366442

367443
# Fail if any payload was unreadable: a lost report must not pass as success.
368444
reported_ok && readable.size == payloads.size
369445
end
370446

371-
def report_suite(suite_name, payloads)
447+
def report_suite(suite_name, payloads, issue_number_cache: nil, report_comment_id_cache: nil)
372448
# Order shard summaries by shard number ("2/5" before "10/5"); each already
373449
# self-labels with its shard in its headers, so concatenation reads cleanly.
374450
summary = payloads
@@ -381,7 +457,9 @@ def report_suite(suite_name, payloads)
381457
suite_name: suite_name,
382458
github_run_url: Github.run_url,
383459
bencher_url: BENCHER_URL,
384-
summary: summary
460+
summary: summary,
461+
issue_number_cache: issue_number_cache,
462+
report_comment_id_cache: report_comment_id_cache
385463
)
386464

387465
if issue_number.empty?

benchmarks/spec/report_regressions_spec.rb

Lines changed: 114 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,10 @@ def stub_env
4242
let(:posted_bodies) { {} }
4343

4444
before do
45-
allow(GithubCli).to receive(:capture_success) do |*args, **_kwargs|
45+
allow(GithubCli).to receive(:capture_success) do |*args, **kwargs|
4646
key = capture_key(args)
4747
calls << key
48+
posted_bodies[key] = extract_body(args, kwargs)
4849
capture_responses.fetch(key) { raise "unexpected capture_success: #{key}" }
4950
end
5051

@@ -69,6 +70,7 @@ def extract_body(args, kwargs)
6970
def capture_key(args)
7071
return "issue list" if args[1..2] == %w[issue list]
7172
return "issue create" if args[1..2] == %w[issue create]
73+
return "comment create" if args[1..3] == %w[api -X POST]
7274
return "comment list" if args[1] == "api" && args[2].match?(%r{/issues/\d+/comments\z})
7375
return "comment body" if args[1] == "api" && args[2].match?(%r{/issues/comments/\d+\z})
7476

@@ -77,7 +79,6 @@ def capture_key(args)
7779

7880
def run_key(args)
7981
return "label create" if args[1..2] == %w[label create]
80-
return "issue comment" if args[1..2] == %w[issue comment]
8182
return "comment patch" if args[1..3] == %w[api -X PATCH]
8283

8384
raise "unrecognized run args: #{args.inspect}"
@@ -92,16 +93,66 @@ def report
9293
)
9394
end
9495

96+
def report_with_cache(suite_name, issue_number_cache, report_comment_id_cache = nil)
97+
described_class.report(
98+
summary: "#{suite_name} regressed",
99+
suite_name: suite_name,
100+
github_run_url: "https://github.com/run/1",
101+
bencher_url: "https://bencher.dev/dash",
102+
issue_number_cache: issue_number_cache,
103+
report_comment_id_cache: report_comment_id_cache
104+
)
105+
end
106+
95107
context "when no regression issue exists yet" do
96108
before do
97109
capture_responses["issue list"] = ""
98110
capture_responses["issue create"] = "https://github.com/shakacode/react_on_rails/issues/123\n"
99111
capture_responses["comment list"] = ""
112+
capture_responses["comment create"] = "999\n"
100113
end
101114

102115
it "creates the issue, parses the number from the URL, and posts the first comment" do
103116
expect(report).to eq("123")
104-
expect(calls).to eq(["label create", "issue list", "issue create", "comment list", "issue comment"])
117+
expect(calls).to eq(["label create", "issue list", "issue create", "comment list", "comment create"])
118+
end
119+
120+
it "reuses a just-created issue number for later suites in the same reporter run" do
121+
issue_number_cache = {}
122+
123+
expect(report_with_cache("Core", issue_number_cache)).to eq("123")
124+
expect(report_with_cache("Pro", issue_number_cache)).to eq("123")
125+
126+
expect(calls.count("issue list")).to eq(1)
127+
expect(calls.count("issue create")).to eq(1)
128+
end
129+
130+
it "reuses a just-created report comment for later suites in the same reporter run" do
131+
issue_number_cache = {}
132+
report_comment_id_cache = {}
133+
capture_responses["comment body"] = "Core regressed"
134+
135+
expect(report_with_cache("Core", issue_number_cache, report_comment_id_cache)).to eq("123")
136+
expect(report_with_cache("Pro", issue_number_cache, report_comment_id_cache)).to eq("123")
137+
138+
expect(calls.count("comment list")).to eq(1)
139+
expect(calls.count("comment create")).to eq(1)
140+
expect(calls).to include("comment patch")
141+
expect(posted_bodies.fetch("comment patch")).to include("Core regressed")
142+
expect(posted_bodies.fetch("comment patch")).to include("Pro regressed")
143+
end
144+
145+
it "does not create duplicate same-run comments after a comment id parse miss" do
146+
issue_number_cache = {}
147+
report_comment_id_cache = {}
148+
capture_responses["comment create"] = "\n"
149+
150+
expect(report_with_cache("Core", issue_number_cache, report_comment_id_cache)).to eq("")
151+
expect(report_with_cache("Pro", issue_number_cache, report_comment_id_cache)).to eq("")
152+
153+
expect(calls.count("comment list")).to eq(1)
154+
expect(calls.count("comment create")).to eq(1)
155+
expect(calls).not_to include("comment patch")
105156
end
106157
end
107158

@@ -163,7 +214,7 @@ def section_for(suite, content = "")
163214

164215
it "aborts without posting a duplicate comment" do
165216
expect(report).to eq("")
166-
expect(calls).not_to include("issue comment")
217+
expect(calls).not_to include("comment create")
167218
expect(calls).not_to include("comment patch")
168219
end
169220
end
@@ -205,6 +256,16 @@ def section_for(suite, content = "")
205256
.and output("").to_stderr
206257
expect(calls).not_to include("comment list")
207258
end
259+
260+
it "does not create duplicate same-run issues after the parse miss" do
261+
issue_number_cache = {}
262+
263+
expect(report_with_cache("Core", issue_number_cache)).to eq("")
264+
expect(report_with_cache("Pro", issue_number_cache)).to eq("")
265+
266+
expect(calls.count("issue list")).to eq(1)
267+
expect(calls.count("issue create")).to eq(1)
268+
end
208269
end
209270
end
210271

@@ -221,11 +282,13 @@ def section_for(suite, content = "")
221282
#!/usr/bin/env bash
222283
if [ "$1" = "issue" ] && [ "$2" = "create" ]; then
223284
echo "https://github.com/shakacode/react_on_rails/issues/7"
285+
elif [ "$1" = "api" ] && [ "$2" = "-X" ] && [ "$3" = "POST" ]; then
286+
echo "777"
224287
fi
225288
exit 0
226289
BASH
227290

228-
def run_script(script, artifacts_dir, gh_stub:)
291+
def run_script(script, artifacts_dir, gh_stub:, extra_env: {})
229292
Dir.mktmpdir do |bin_dir|
230293
File.write(File.join(bin_dir, "gh"), gh_stub)
231294
File.chmod(0o755, File.join(bin_dir, "gh"))
@@ -238,7 +301,7 @@ def run_script(script, artifacts_dir, gh_stub:)
238301
"GITHUB_RUN_ID" => "999",
239302
"GITHUB_RUN_NUMBER" => "42",
240303
"GITHUB_ACTOR" => "octocat"
241-
}
304+
}.merge(extra_env)
242305
Open3.capture2e(env, "ruby", script, artifacts_dir)
243306
end
244307
end
@@ -275,6 +338,51 @@ def write_payload(dir, artifact:, suite:, shard_label: "1/1", summary: nil, regr
275338
end
276339
end
277340

341+
it "creates only one issue for multiple suites even when live issue lookup stays empty" do
342+
Dir.mktmpdir do |dir|
343+
write_payload(dir, artifact: "regression-core", suite: "Core")
344+
write_payload(dir, artifact: "regression-pro", suite: "Pro")
345+
346+
call_log = File.join(dir, "gh-calls.log")
347+
counting_gh = <<~BASH
348+
#!/usr/bin/env bash
349+
printf '%s\\n' "$*" >> "$GH_CALL_LOG"
350+
if [ "$1" = "issue" ] && [ "$2" = "create" ]; then
351+
echo "https://github.com/shakacode/react_on_rails/issues/7"
352+
elif [ "$1" = "api" ] && [ "$2" = "-X" ] && [ "$3" = "POST" ]; then
353+
echo "777"
354+
fi
355+
exit 0
356+
BASH
357+
358+
output, status = run_script(script, dir, gh_stub: counting_gh, extra_env: { "GH_CALL_LOG" => call_log })
359+
360+
expect(status).to be_success
361+
expect(output).to match(/issue #7/)
362+
expect(File.readlines(call_log).count { |line| line.start_with?("issue create") }).to eq(1)
363+
expect(File.readlines(call_log).count { |line| line.start_with?("api -X POST") }).to eq(1)
364+
end
365+
end
366+
367+
it "shares one issue-number cache across suite reports in the same run" do
368+
Dir.mktmpdir do |dir|
369+
write_payload(dir, artifact: "regression-core", suite: "Core")
370+
write_payload(dir, artifact: "regression-pro", suite: "Pro")
371+
372+
caches = []
373+
allow(Github).to receive(:run_url).and_return("https://github.com/run/1")
374+
allow(RegressionIssueReporter).to receive(:report) do |issue_number_cache:, **_attributes|
375+
caches << issue_number_cache
376+
issue_number_cache["created"] = "7"
377+
"7"
378+
end
379+
380+
expect(report_regressions(dir)).to be(true)
381+
expect(caches.size).to eq(2)
382+
expect(caches[0]).to equal(caches[1])
383+
end
384+
end
385+
278386
it "combines a sharded suite's payloads into a single report" do
279387
Dir.mktmpdir do |dir|
280388
write_payload(dir, artifact: "regression-pro-shard-1", suite: "Pro", shard_label: "1/2")

0 commit comments

Comments
 (0)