raider_reporter 0.8.0 → 0.9.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 +4 -4
- data/CHANGELOG.md +7 -0
- data/README.md +36 -0
- data/lib/raider_reporter/azure_env.rb +10 -0
- data/lib/raider_reporter/cli.rb +17 -5
- data/lib/raider_reporter/comment/azure_client.rb +2 -2
- data/lib/raider_reporter/comment/github_client.rb +8 -1
- data/lib/raider_reporter/gate.rb +193 -0
- data/lib/raider_reporter/gate_client.rb +84 -0
- data/lib/raider_reporter/gate_status.rb +73 -0
- data/lib/raider_reporter/version.rb +1 -1
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: dda0a98d0886eefbfd851be91d57c881e74abee367956b0272fc9b7a464045cf
|
|
4
|
+
data.tar.gz: 8012de5d41e890b7dae144ab5f208c20ed2ffa94736645a4308901f6abe24174
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: fa44978dfb6d93f9bbb316fe36d837b3ecb1ae668c7292b7f7e6ac345646b4c9099dd30375d8881e466f8f21eeb4ae6ae01d53e76652f975ffeaaa83b263939c
|
|
7
|
+
data.tar.gz: 6a43fa45d7a90997e64e95b9f5e40e978c398343695ce74950809f9cadbafd7420edce162c4560d01dd67892861b61180c5c7938afc2cf28a6191f1d1e18a54b
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.0 — 2026-10-24
|
|
4
|
+
|
|
5
|
+
- `raider-reporter gate` waits for the Raider quality gate verdict of the CI build (`GET /api/v1/builds/gate`, every 10 seconds, up to `RAIDER_GATE_TIMEOUT` seconds, default 600, 1–3600) and exits 0 when the gate passed or the project has no gate rules, 1 when it failed (naming the failed rules), and 2 when it gets no verdict (timeout, Raider unreachable, token rejected, build not found, any other error). 429, 5xx and network errors are retried within the time limit
|
|
6
|
+
- It posts a `raider/gate` status: a GitHub commit status on the PR head commit (`pull_request` events) or `GITHUB_SHA`, or an Azure Repos PR status; pending while it waits, then success, failure or error. Best effort: a post that fails prints a warning and never changes the exit code
|
|
7
|
+
- `exec`, uploads and `comment` are unchanged and stay fail-open
|
|
8
|
+
- Needs a Raider server with the gate endpoint
|
|
9
|
+
|
|
3
10
|
## 0.8.0 — 2026-10-24
|
|
4
11
|
|
|
5
12
|
- Quarantine: with `RAIDER_QUARANTINE=1` (or `config.quarantine = true`) and a token and endpoint, the formatters fetch the project's quarantine list once per run (`GET /api/v1/quarantine`, 3-second timeout) and report matching failures as `quarantined`, keeping their message, backtrace and attachments. Fail-open: any fetch problem prints `[raider_reporter] quarantine list unavailable (<reason>); no tests quarantined` and nothing is quarantined
|
data/README.md
CHANGED
|
@@ -228,6 +228,42 @@ On pull requests, `raider-reporter comment` posts one comment with the build's r
|
|
|
228
228
|
- The command never fails your CI: it always exits 0 and prints notices to stderr, prefixed `[raider_reporter]`. It needs `GITHUB_TOKEN`, `GITHUB_REPOSITORY`, `GITHUB_EVENT_PATH`, `RAIDER_TOKEN` and `RAIDER_ENDPOINT`; if one is missing, or the event is not a pull request, it does nothing.
|
|
229
229
|
- It requires a Raider server that supports the summary endpoint and the `git.pr` field. Upgrade the server before using it.
|
|
230
230
|
|
|
231
|
+
## Quality gate
|
|
232
|
+
|
|
233
|
+
A Raider project can turn on gate rules (minimum pass rate, no new failures against the default branch, no new flaky tests) under its Gate page. Raider computes one verdict per build once every shard is in, and never changes it. `raider-reporter gate` waits for that verdict in CI and fails the job when the gate fails. Run it once, after every test job:
|
|
234
|
+
|
|
235
|
+
```yaml
|
|
236
|
+
raider-gate:
|
|
237
|
+
needs: [test] # every test job id
|
|
238
|
+
if: always() && vars.RAIDER_GATE == '1'
|
|
239
|
+
runs-on: ubuntu-latest
|
|
240
|
+
permissions:
|
|
241
|
+
contents: read
|
|
242
|
+
statuses: write
|
|
243
|
+
steps:
|
|
244
|
+
- uses: actions/checkout@v4
|
|
245
|
+
- uses: ruby/setup-ruby@v1
|
|
246
|
+
with: { ruby-version: "3.4", bundler-cache: true }
|
|
247
|
+
- name: Wait for the Raider quality gate
|
|
248
|
+
run: bundle exec raider-reporter gate
|
|
249
|
+
env:
|
|
250
|
+
RAIDER_TOKEN: ${{ secrets.RAIDER_TOKEN }}
|
|
251
|
+
RAIDER_ENDPOINT: ${{ vars.RAIDER_ENDPOINT }}
|
|
252
|
+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
| Outcome | Exit | Output |
|
|
256
|
+
|---|---|---|
|
|
257
|
+
| passed | 0 | stdout: `raider-reporter gate: passed` and one line per rule |
|
|
258
|
+
| no gate rules | 0 | stdout: `raider-reporter gate: no gate rules for this project` |
|
|
259
|
+
| failed | 1 | stderr: `raider-reporter gate: failed`, one line per failed rule (` ✗ pass rate: 96% < 98%`) and the build URL |
|
|
260
|
+
| no verdict | 2 | stderr: timeout, Raider unreachable, token rejected, build not found, or another error |
|
|
261
|
+
|
|
262
|
+
- It polls every 10 seconds (10-second timeouts) for up to `RAIDER_GATE_TIMEOUT` seconds (default 600, 1–3600) and retries 429, 5xx and network errors. While waiting it prints, at most once a minute, `waiting for the verdict (build incomplete: 2 of 4 shards)` or `(analysing results)`.
|
|
263
|
+
- It needs `RAIDER_TOKEN`, `RAIDER_ENDPOINT` and the CI build key that uploads and `comment` use (GitHub Actions or Azure Pipelines); without them it exits 2.
|
|
264
|
+
- It posts a `raider/gate` status: pending while it waits, then success, failure (the failed rules) or error. On GitHub it needs `GITHUB_TOKEN` and `statuses: write`, and uses the PR head commit on `pull_request` events, else `GITHUB_SHA`. On Azure Repos pull requests it needs `SYSTEM_ACCESSTOKEN: $(System.AccessToken)`; other Azure builds get no status. A status that cannot be posted prints a warning and never changes the exit code. Require the `raider/gate` check in branch protection to block merges.
|
|
265
|
+
- Unlike `comment`, `gate` fails closed. Turn it on per repository (the `RAIDER_GATE` variable above) so pull requests from forks and projects without a token are not blocked. The token is never printed.
|
|
266
|
+
|
|
231
267
|
## Azure Pipelines
|
|
232
268
|
|
|
233
269
|
The gem detects Azure Pipelines when `TF_BUILD` is `true`. It reports the build (`provider: azure_pipelines`, build URL, attempt, job), the branch and commit, the pull request and a build key, so shards of one build group together. On a pull request from Azure Repos, `raider-reporter comment` posts one comment thread with the results and sets a `raider/tests` status on the PR.
|
|
@@ -26,6 +26,16 @@ module RaiderReporter
|
|
|
26
26
|
value(env, "BUILD_REASON") == "PullRequest" && value(env, "BUILD_REPOSITORY_PROVIDER") == "TfsGit"
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
+
# The REST base of a pull request (threads, statuses), or nil when a part is unknown.
|
|
30
|
+
def pull_request_api_url(env, number)
|
|
31
|
+
base = collection_base(env)
|
|
32
|
+
project = value(env, "SYSTEM_TEAMPROJECTID")
|
|
33
|
+
repo = value(env, "BUILD_REPOSITORY_ID")
|
|
34
|
+
return nil unless base && project && repo && number
|
|
35
|
+
|
|
36
|
+
"#{base}#{project}/_apis/git/repositories/#{repo}/pullRequests/#{number}"
|
|
37
|
+
end
|
|
38
|
+
|
|
29
39
|
def strip_heads(ref)
|
|
30
40
|
return nil if ref.nil?
|
|
31
41
|
return ref.delete_prefix("refs/heads/") if ref.start_with?("refs/heads/")
|
data/lib/raider_reporter/cli.rb
CHANGED
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
require "raider_reporter"
|
|
4
4
|
require "raider_reporter/exec"
|
|
5
|
+
require "raider_reporter/gate"
|
|
5
6
|
|
|
6
7
|
module RaiderReporter
|
|
7
8
|
# `raider-reporter exec -- <command…>` runs the test suite and decides the build's exit code (see Exec).
|
|
9
|
+
# `raider-reporter gate` waits for the Raider quality gate verdict and exits 0, 1 or 2 (see Gate).
|
|
8
10
|
# `raider-reporter comment` posts the build summary as a PR comment and sets a commit status
|
|
9
11
|
# (fail-open: always exits 0, notices go to stderr). `raider-reporter report [dir]` rebuilds the
|
|
10
12
|
# local HTML report (exits 1 when there are no run files or the report cannot be written).
|
|
@@ -24,10 +26,11 @@ module RaiderReporter
|
|
|
24
26
|
"failed" => "Raider: tests failed",
|
|
25
27
|
"incomplete" => "Raider: build incomplete"
|
|
26
28
|
}.freeze
|
|
27
|
-
DEFAULT_API_URL = "https://api.github.com"
|
|
28
29
|
USAGE = <<~TEXT
|
|
29
30
|
#{Exec::USAGE.chomp}
|
|
30
31
|
|
|
32
|
+
#{Gate::USAGE.chomp}
|
|
33
|
+
|
|
31
34
|
Usage: raider-reporter report [dir]
|
|
32
35
|
|
|
33
36
|
Rebuilds <dir>/index.html (default raider-results) from every run*.json in it. Prints its path.
|
|
@@ -53,6 +56,7 @@ module RaiderReporter
|
|
|
53
56
|
|
|
54
57
|
def run
|
|
55
58
|
return run_exec if @argv.first == "exec"
|
|
59
|
+
return run_gate if @argv.first == "gate"
|
|
56
60
|
|
|
57
61
|
run_reporting
|
|
58
62
|
end
|
|
@@ -65,6 +69,16 @@ module RaiderReporter
|
|
|
65
69
|
Exec.new(command, err: @err).run
|
|
66
70
|
end
|
|
67
71
|
|
|
72
|
+
# gate decides the CI job's exit code; Gate owns its own failure handling.
|
|
73
|
+
def run_gate
|
|
74
|
+
if @argv.include?("--help") || @argv.include?("-h")
|
|
75
|
+
@err.puts(Gate::USAGE)
|
|
76
|
+
return 0
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
Gate.new(env: @env, out: @out, err: @err).run
|
|
80
|
+
end
|
|
81
|
+
|
|
68
82
|
def run_reporting
|
|
69
83
|
return report if @argv.first == "report" && !@argv.include?("--help") && !@argv.include?("-h")
|
|
70
84
|
|
|
@@ -161,8 +175,7 @@ module RaiderReporter
|
|
|
161
175
|
end
|
|
162
176
|
|
|
163
177
|
def azure_pr_url(pr)
|
|
164
|
-
|
|
165
|
-
"#{AzureEnv.value(@env, 'BUILD_REPOSITORY_ID')}/pullRequests/#{pr['number']}"
|
|
178
|
+
AzureEnv.pull_request_api_url(@env, pr["number"])
|
|
166
179
|
end
|
|
167
180
|
|
|
168
181
|
def azure_forbidden_message
|
|
@@ -193,8 +206,7 @@ module RaiderReporter
|
|
|
193
206
|
end
|
|
194
207
|
|
|
195
208
|
def api_url
|
|
196
|
-
|
|
197
|
-
url.empty? ? DEFAULT_API_URL : url
|
|
209
|
+
Comment::GithubClient.api_url(@env)
|
|
198
210
|
end
|
|
199
211
|
|
|
200
212
|
# "**Raider: failed** - 2 failed, 1 quarantined, 148 passed (1m 12s)" -> "2 failed, 1 quarantined, 148 passed".
|
|
@@ -38,9 +38,9 @@ module RaiderReporter
|
|
|
38
38
|
end
|
|
39
39
|
end
|
|
40
40
|
|
|
41
|
-
def set_status(state:, description:, target_url:)
|
|
41
|
+
def set_status(state:, description:, target_url:, name: "tests")
|
|
42
42
|
request(:post, "/statuses", { state: state, description: description.to_s[0, DESCRIPTION_LIMIT],
|
|
43
|
-
targetUrl: target_url, context: { genre: "raider", name:
|
|
43
|
+
targetUrl: target_url, context: { genre: "raider", name: name } },
|
|
44
44
|
version: STATUS_API_VERSION)
|
|
45
45
|
nil
|
|
46
46
|
end
|
|
@@ -15,8 +15,15 @@ module RaiderReporter
|
|
|
15
15
|
MAX_PAGES = 10
|
|
16
16
|
PER_PAGE = 100
|
|
17
17
|
DESCRIPTION_LIMIT = 140
|
|
18
|
+
DEFAULT_API_URL = "https://api.github.com"
|
|
18
19
|
|
|
19
|
-
|
|
20
|
+
# GITHUB_API_URL from a CI env, falling back to github.com when unset or blank.
|
|
21
|
+
def self.api_url(env)
|
|
22
|
+
url = env["GITHUB_API_URL"].to_s.strip
|
|
23
|
+
url.empty? ? DEFAULT_API_URL : url
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def initialize(token:, repository:, api_url: DEFAULT_API_URL, timeout: 30)
|
|
20
27
|
@token = token
|
|
21
28
|
@repository = repository
|
|
22
29
|
@api_url = api_url.to_s.strip.sub(%r{/+\z}, "")
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "raider_reporter/azure_env"
|
|
4
|
+
require "raider_reporter/ci_detector"
|
|
5
|
+
require "raider_reporter/gate_client"
|
|
6
|
+
require "raider_reporter/gate_status"
|
|
7
|
+
|
|
8
|
+
module RaiderReporter
|
|
9
|
+
# `raider-reporter gate` (spec §5): waits for the Raider server's frozen quality gate
|
|
10
|
+
# verdict of this CI build. Exits 0 when it passed or the project has no gate rules,
|
|
11
|
+
# 1 when it failed, and 2 for every way it gets no verdict. Unlike `exec` and
|
|
12
|
+
# `comment`, it fails closed. The tokens are never printed.
|
|
13
|
+
class Gate
|
|
14
|
+
PREFIX = "raider-reporter gate:"
|
|
15
|
+
PASSED = 0
|
|
16
|
+
FAILED = 1
|
|
17
|
+
NO_VERDICT = 2
|
|
18
|
+
DEFAULT_TIMEOUT = 600
|
|
19
|
+
TIMEOUT_RANGE = (1..3600)
|
|
20
|
+
INTERVAL = 10
|
|
21
|
+
PROGRESS_EVERY = 60
|
|
22
|
+
LABELS = { "pass_rate" => "pass rate", "new_failures" => "new failures", "new_flaky" => "new flaky" }.freeze
|
|
23
|
+
MARKS = { "passed" => "✓", "failed" => "✗", "skipped" => "–" }.freeze
|
|
24
|
+
USAGE = <<~TEXT
|
|
25
|
+
Usage: raider-reporter gate
|
|
26
|
+
|
|
27
|
+
Waits for the Raider quality gate verdict of this CI build and posts a "raider/gate" status.
|
|
28
|
+
Exits 0 when the gate passed or the project has no gate rules, 1 when it failed, and 2 when no
|
|
29
|
+
verdict arrived (timeout, Raider unreachable, token rejected, build not found).
|
|
30
|
+
Environment: RAIDER_TOKEN, RAIDER_ENDPOINT, RAIDER_GATE_TIMEOUT (seconds, 1-3600, default 600).
|
|
31
|
+
Status: GITHUB_TOKEN (GitHub Actions) or SYSTEM_ACCESSTOKEN (Azure Repos pull requests).
|
|
32
|
+
TEXT
|
|
33
|
+
|
|
34
|
+
def initialize(env: ENV, out: $stdout, err: $stderr, clock: nil, sleeper: nil)
|
|
35
|
+
@env = env
|
|
36
|
+
@out = out
|
|
37
|
+
@err = err
|
|
38
|
+
@clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
39
|
+
@sleeper = sleeper || ->(seconds) { sleep(seconds) }
|
|
40
|
+
@status = nil
|
|
41
|
+
@url = nil
|
|
42
|
+
@detail = nil
|
|
43
|
+
@last_progress = nil
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def run
|
|
47
|
+
build_key = CiDetector.new(env: @env, git: ->(*) {}).ci&.dig("build_key")
|
|
48
|
+
return refuse("no CI build key (run this in GitHub Actions or Azure Pipelines)") unless build_key
|
|
49
|
+
|
|
50
|
+
token = AzureEnv.value(@env, "RAIDER_TOKEN")
|
|
51
|
+
endpoint = AzureEnv.value(@env, "RAIDER_ENDPOINT")
|
|
52
|
+
return refuse("RAIDER_TOKEN and RAIDER_ENDPOINT are required") unless token && endpoint
|
|
53
|
+
|
|
54
|
+
timeout = parse_timeout
|
|
55
|
+
return refuse("RAIDER_GATE_TIMEOUT must be a whole number of seconds from 1 to 3600") unless timeout
|
|
56
|
+
|
|
57
|
+
@status = GateStatus.new(env: @env, warn: ->(message) { say(@err, "#{PREFIX} #{message}") })
|
|
58
|
+
@status.post(:pending, "Waiting for the Raider verdict")
|
|
59
|
+
finish(poll(GateClient.new(endpoint: endpoint, token: token), build_key, timeout), timeout)
|
|
60
|
+
rescue StandardError => e
|
|
61
|
+
no_verdict("#{e.class}: #{e.message}")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def parse_timeout
|
|
67
|
+
raw = AzureEnv.value(@env, "RAIDER_GATE_TIMEOUT")
|
|
68
|
+
return DEFAULT_TIMEOUT if raw.nil?
|
|
69
|
+
|
|
70
|
+
value = Integer(raw, 10, exception: false)
|
|
71
|
+
value if value && TIMEOUT_RANGE.cover?(value)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# The last response that ends the wait, or nil when the time limit ran out.
|
|
75
|
+
def poll(client, build_key, timeout)
|
|
76
|
+
deadline = @clock.call + timeout
|
|
77
|
+
loop do
|
|
78
|
+
response = client.fetch(build_key)
|
|
79
|
+
return response unless response.kind == :retry || pending?(response)
|
|
80
|
+
|
|
81
|
+
note(response)
|
|
82
|
+
remaining = deadline - @clock.call
|
|
83
|
+
return nil if remaining <= 0
|
|
84
|
+
|
|
85
|
+
@sleeper.call([INTERVAL, remaining].min)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def pending?(response)
|
|
90
|
+
response.kind == :verdict && response.data["state"] == "pending"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def note(response)
|
|
94
|
+
if response.kind == :retry
|
|
95
|
+
@detail = response.reason
|
|
96
|
+
return
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
remember_url(response.data)
|
|
100
|
+
@detail = pending_detail(response.data)
|
|
101
|
+
now = @clock.call
|
|
102
|
+
return if @last_progress && now - @last_progress < PROGRESS_EVERY
|
|
103
|
+
|
|
104
|
+
@last_progress = now
|
|
105
|
+
say(@out, "#{PREFIX} waiting for the verdict (#{@detail})")
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def pending_detail(data)
|
|
109
|
+
shards = data["shards"].is_a?(Hash) ? data["shards"] : {}
|
|
110
|
+
received = shards["received"]
|
|
111
|
+
expected = shards["expected"]
|
|
112
|
+
if received.is_a?(Integer) && expected.is_a?(Integer) && received < expected
|
|
113
|
+
"build incomplete: #{received} of #{expected} shards"
|
|
114
|
+
else
|
|
115
|
+
"analysing results"
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def finish(response, timeout)
|
|
120
|
+
return no_verdict("no verdict after #{timeout}s (#{@detail || 'no response'})") unless response
|
|
121
|
+
|
|
122
|
+
case response.kind
|
|
123
|
+
when :rejected then no_verdict("token rejected (HTTP #{response.code})")
|
|
124
|
+
when :not_found then no_verdict("build not found (did the tests upload?)")
|
|
125
|
+
when :error then no_verdict(response.reason)
|
|
126
|
+
else report(response.data)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def report(data)
|
|
131
|
+
remember_url(data)
|
|
132
|
+
rules = data["rules"].select { |rule| rule.is_a?(Hash) }
|
|
133
|
+
case data["state"]
|
|
134
|
+
when "none"
|
|
135
|
+
say(@out, "#{PREFIX} no gate rules for this project")
|
|
136
|
+
@status.post(:success, "No gate rules", target_url: @url)
|
|
137
|
+
PASSED
|
|
138
|
+
when "passed"
|
|
139
|
+
say(@out, "#{PREFIX} passed")
|
|
140
|
+
rules.each { |rule| say(@out, rule_line(rule)) }
|
|
141
|
+
@status.post(:success, "Gate passed", target_url: @url)
|
|
142
|
+
PASSED
|
|
143
|
+
else
|
|
144
|
+
report_failed(rules.select { |rule| rule["outcome"] == "failed" })
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def report_failed(failed)
|
|
149
|
+
say(@err, "#{PREFIX} failed")
|
|
150
|
+
failed.each { |rule| say(@err, rule_line(rule)) }
|
|
151
|
+
say(@err, " #{@url}") if @url
|
|
152
|
+
description = failed.map { |rule| rule_text(rule) }.join("; ")
|
|
153
|
+
@status.post(:failure, description.empty? ? "Gate failed" : description, target_url: @url)
|
|
154
|
+
FAILED
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def rule_line(rule)
|
|
158
|
+
" #{MARKS.fetch(rule['outcome'].to_s, '?')} #{rule_text(rule)}"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def rule_text(rule)
|
|
162
|
+
name = rule["rule"].to_s
|
|
163
|
+
"#{LABELS.fetch(name, name)}: #{rule['detail']}"
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def remember_url(data)
|
|
167
|
+
@url = data["url"] if data["url"].is_a?(String) && !data["url"].empty?
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Exit 2 before polling: nothing was posted, so there is no status to clear.
|
|
171
|
+
def refuse(message)
|
|
172
|
+
say(@err, "#{PREFIX} #{message}")
|
|
173
|
+
NO_VERDICT
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Exit 2 after the pending status: replace it, so a required check is never left pending.
|
|
177
|
+
def no_verdict(message)
|
|
178
|
+
say(@err, "#{PREFIX} #{message}")
|
|
179
|
+
@status&.post(:error, "No verdict", target_url: @url)
|
|
180
|
+
NO_VERDICT
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def say(io, text)
|
|
184
|
+
io.puts(redact(text))
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def redact(text)
|
|
188
|
+
secrets = [AzureEnv.value(@env, "RAIDER_TOKEN"), AzureEnv.value(@env, "GITHUB_TOKEN"),
|
|
189
|
+
AzureEnv.value(@env, "SYSTEM_ACCESSTOKEN")].compact
|
|
190
|
+
secrets.reduce(text.to_s) { |memo, secret| memo.gsub(secret, "[REDACTED]") }
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cgi/escape"
|
|
4
|
+
require "json"
|
|
5
|
+
require "net/http"
|
|
6
|
+
require "openssl"
|
|
7
|
+
require "timeout"
|
|
8
|
+
require "uri"
|
|
9
|
+
require "raider_reporter/version"
|
|
10
|
+
|
|
11
|
+
module RaiderReporter
|
|
12
|
+
# One GET /api/v1/builds/gate call for `raider-reporter gate` (spec §5), classified
|
|
13
|
+
# so the caller can decide: a verdict, retry (429, 5xx, network), or stop.
|
|
14
|
+
class GateClient
|
|
15
|
+
class InvalidResponse < StandardError; end
|
|
16
|
+
|
|
17
|
+
PATH = "/api/v1/builds/gate"
|
|
18
|
+
TIMEOUT = 10
|
|
19
|
+
STATES = %w[pending none passed failed].freeze
|
|
20
|
+
NETWORK_ERRORS = [Timeout::Error, SystemCallError, SocketError, IOError, OpenSSL::SSL::SSLError,
|
|
21
|
+
Net::ProtocolError, Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError].freeze
|
|
22
|
+
|
|
23
|
+
# kind: :verdict, :retry, :rejected, :not_found or :error.
|
|
24
|
+
Response = Data.define(:kind, :data, :code, :reason)
|
|
25
|
+
|
|
26
|
+
def initialize(endpoint:, token:)
|
|
27
|
+
@endpoint = endpoint
|
|
28
|
+
@token = token
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def fetch(build_key)
|
|
32
|
+
classify(request(build_key))
|
|
33
|
+
rescue *NETWORK_ERRORS => e
|
|
34
|
+
Response.new(kind: :retry, data: nil, code: nil, reason: redact("#{e.class}: #{e.message}"))
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def request(build_key)
|
|
40
|
+
uri = URI.parse("#{@endpoint.to_s.strip.chomp('/')}#{PATH}?build_key=#{CGI.escape(build_key.to_s)}")
|
|
41
|
+
raise ArgumentError, "RAIDER_ENDPOINT must start with http:// or https://" unless uri.is_a?(URI::HTTP)
|
|
42
|
+
|
|
43
|
+
req = Net::HTTP::Get.new(uri)
|
|
44
|
+
req["Authorization"] = "Bearer #{@token}"
|
|
45
|
+
req["Accept"] = "application/json"
|
|
46
|
+
req["User-Agent"] = "raider_reporter/#{VERSION}"
|
|
47
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: TIMEOUT,
|
|
48
|
+
read_timeout: TIMEOUT, max_retries: 0) { |http| http.request(req) }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def classify(response)
|
|
52
|
+
code = response.code.to_i
|
|
53
|
+
case code
|
|
54
|
+
when 200 then parse(response.body)
|
|
55
|
+
when 401, 403 then Response.new(kind: :rejected, data: nil, code: code, reason: nil)
|
|
56
|
+
when 404 then Response.new(kind: :not_found, data: nil, code: code, reason: nil)
|
|
57
|
+
when 429, 500..599 then Response.new(kind: :retry, data: nil, code: code, reason: "HTTP #{code}")
|
|
58
|
+
else error_response(code, "unexpected HTTP #{code}")
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def parse(body)
|
|
63
|
+
data = JSON.parse(body.to_s)
|
|
64
|
+
return error_response(200, "invalid gate response") unless valid?(data)
|
|
65
|
+
|
|
66
|
+
Response.new(kind: :verdict, data: data, code: 200, reason: nil)
|
|
67
|
+
rescue JSON::ParserError => e
|
|
68
|
+
Response.new(kind: :error, data: nil, code: 200, reason: redact("#{e.class}: #{e.message}"))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def valid?(data)
|
|
72
|
+
data.is_a?(Hash) && STATES.include?(data["state"]) && data["rules"].is_a?(Array)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Spec §5 "other error" form: <class>: <message>.
|
|
76
|
+
def error_response(code, message)
|
|
77
|
+
Response.new(kind: :error, data: nil, code: code, reason: "#{InvalidResponse}: #{message}")
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def redact(text)
|
|
81
|
+
@token.to_s.empty? ? text : text.gsub(@token.to_s, "[REDACTED]")
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "raider_reporter/version"
|
|
4
|
+
require "raider_reporter/azure_env"
|
|
5
|
+
require "raider_reporter/ci_detector"
|
|
6
|
+
require "raider_reporter/comment/github_client"
|
|
7
|
+
require "raider_reporter/comment/azure_client"
|
|
8
|
+
|
|
9
|
+
module RaiderReporter
|
|
10
|
+
# Posts the "raider/gate" status for `raider-reporter gate` (spec §5): a GitHub commit
|
|
11
|
+
# status (PR head SHA on pull_request events, else GITHUB_SHA) or an Azure Repos PR
|
|
12
|
+
# status (genre raider, name gate). Best effort: a failed post warns and never raises.
|
|
13
|
+
class GateStatus
|
|
14
|
+
CONTEXT = "raider/gate"
|
|
15
|
+
AZURE_NAME = "gate"
|
|
16
|
+
TIMEOUT = 10
|
|
17
|
+
GITHUB_STATES = { pending: "pending", success: "success", failure: "failure", error: "error" }.freeze
|
|
18
|
+
AZURE_STATES = { pending: "pending", success: "succeeded", failure: "failed", error: "error" }.freeze
|
|
19
|
+
|
|
20
|
+
def initialize(env:, warn:)
|
|
21
|
+
@env = env
|
|
22
|
+
@warn = warn
|
|
23
|
+
@provider = begin
|
|
24
|
+
detect
|
|
25
|
+
rescue StandardError
|
|
26
|
+
nil
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def active?
|
|
31
|
+
!@provider.nil?
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# state: :pending, :success, :failure or :error.
|
|
35
|
+
def post(state, description, target_url: nil)
|
|
36
|
+
case @provider
|
|
37
|
+
when :github
|
|
38
|
+
github_client.set_status(@sha, state: GITHUB_STATES.fetch(state), target_url: target_url,
|
|
39
|
+
description: description, context: CONTEXT)
|
|
40
|
+
when :azure
|
|
41
|
+
azure_client.set_status(state: AZURE_STATES.fetch(state), description: description, target_url: target_url,
|
|
42
|
+
name: AZURE_NAME)
|
|
43
|
+
end
|
|
44
|
+
nil
|
|
45
|
+
rescue StandardError => e
|
|
46
|
+
@warn.call("could not post the #{CONTEXT} status (#{e.class}: #{e.message})")
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def detect
|
|
53
|
+
detector = CiDetector.new(env: @env, git: ->(*) {})
|
|
54
|
+
if @env["GITHUB_ACTIONS"] == "true"
|
|
55
|
+
@sha = detector.git&.dig("commit_sha")
|
|
56
|
+
:github if [@env["GITHUB_TOKEN"], @env["GITHUB_REPOSITORY"], @sha].none? { |value| value.to_s.strip.empty? }
|
|
57
|
+
elsif AzureEnv.azure?(@env)
|
|
58
|
+
pr = detector.git&.dig("pr")
|
|
59
|
+
@azure_url = pr && AzureEnv.pull_request_api_url(@env, pr["number"])
|
|
60
|
+
:azure if @azure_url && AzureEnv.value(@env, "SYSTEM_ACCESSTOKEN")
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def github_client
|
|
65
|
+
Comment::GithubClient.new(token: @env["GITHUB_TOKEN"], repository: @env["GITHUB_REPOSITORY"],
|
|
66
|
+
api_url: Comment::GithubClient.api_url(@env), timeout: TIMEOUT)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def azure_client
|
|
70
|
+
Comment::AzureClient.new(token: AzureEnv.value(@env, "SYSTEM_ACCESSTOKEN"), base_url: @azure_url, timeout: TIMEOUT)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: raider_reporter
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.9.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Augustin Gottlieb
|
|
@@ -38,6 +38,9 @@ files:
|
|
|
38
38
|
- lib/raider_reporter/cucumber/formatter.rb
|
|
39
39
|
- lib/raider_reporter/exec.rb
|
|
40
40
|
- lib/raider_reporter/exec_summary.rb
|
|
41
|
+
- lib/raider_reporter/gate.rb
|
|
42
|
+
- lib/raider_reporter/gate_client.rb
|
|
43
|
+
- lib/raider_reporter/gate_status.rb
|
|
41
44
|
- lib/raider_reporter/github_event.rb
|
|
42
45
|
- lib/raider_reporter/models.rb
|
|
43
46
|
- lib/raider_reporter/payload.rb
|
|
@@ -71,7 +74,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
71
74
|
- !ruby/object:Gem::Version
|
|
72
75
|
version: '0'
|
|
73
76
|
requirements: []
|
|
74
|
-
rubygems_version: 4.0.
|
|
77
|
+
rubygems_version: 4.0.6
|
|
75
78
|
specification_version: 4
|
|
76
79
|
summary: RSpec and Cucumber formatters that write Raider reports locally and upload
|
|
77
80
|
them to Raider
|