raider_reporter 0.4.0 → 0.5.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 +11 -0
- data/README.md +43 -0
- data/lib/raider_reporter/azure_env.rb +36 -0
- data/lib/raider_reporter/ci_detector.rb +55 -1
- data/lib/raider_reporter/cli.rb +80 -5
- data/lib/raider_reporter/comment/azure_client.rb +110 -0
- data/lib/raider_reporter/comment/github_client.rb +1 -1
- data/lib/raider_reporter/configuration.rb +4 -2
- data/lib/raider_reporter/shard_detector.rb +16 -2
- data/lib/raider_reporter/version.rb +1 -1
- data/lib/raider_reporter.rb +1 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bcf65fcd172398a1040a5b15c3bff03150bde91ea1c80ddb9cb1a2ca731f1496
|
|
4
|
+
data.tar.gz: bfdaf38608619f2831d108b0a170a0da7aa8aa7eb65a1cf8d0b42703e44c58ad
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c635eb065857b147b25758e304e9a2efdf9e5885bfc1767beac062b3b9ea1dd6e344ca3463265f70f78ebb2ae3aca3f1388f8ed6f27bf425b8692e9eb846ed34
|
|
7
|
+
data.tar.gz: 8851d8bf5aa69eda7d96f42f6b8a72c893ba306fd82906b66dda19ab21dba0d8c122de1be2df94106454db4e27b1fbef06e6d8a8d4a659509763c2b4b53bee72
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.0 — 2026-10-12
|
|
4
|
+
|
|
5
|
+
- Add Azure Pipelines support: detect the build (`provider: azure_pipelines`, build URL, attempt, job, branch, commit), report Azure Repos pull requests in `run.git.pr`, and derive a stable id-based build key. `strategy: parallel` jobs are detected as shards of one build
|
|
6
|
+
- `raider-reporter comment` now works on Azure Pipelines: it creates or updates one tagged comment thread on the pull request (closed when the build passed) and sets a `raider/tests` PR status. Uses `System.AccessToken` with the PR statuses API version 7.1; the build service needs "Contribute to pull requests" on the repository. GitHub-hosted repositories built by Azure Pipelines are skipped with a notice
|
|
7
|
+
- Schema v1, additive: optional `run.git.pr.url` (set by GitHub Actions and Azure Pipelines)
|
|
8
|
+
- Unexpanded Azure macros such as `$(RAIDER_TOKEN)` or `$(System.AccessToken)` are treated as missing instead of being sent as a token
|
|
9
|
+
- Fix: redact the token before truncating error bodies in the GitHub and Azure clients, so a token straddling the 200-character cut can no longer leak partially
|
|
10
|
+
- Notices: the missing-permission notice uses `<project>` when the project name is unknown, and the success notice prints `PR #<n>` when the PR URL is unknown
|
|
11
|
+
- `raider-reporter comment` now runs only on GitHub Actions (`GITHUB_ACTIONS=true`) or Azure Pipelines; elsewhere it prints `skipped: unsupported CI` and exits 0
|
|
12
|
+
- No breaking changes
|
|
13
|
+
|
|
3
14
|
## 0.4.0 — 2026-10-11
|
|
4
15
|
|
|
5
16
|
- Add a Cucumber reporter (Cucumber >= 9, < 12; not a gem dependency). `require "raider_reporter/cucumber"` from `features/support/` registers it in plugin mode (recommended, works with any `--format` and no `--out`); `--format RaiderReporter::Cucumber::Formatter --out <file>` also works. Using both registers one listener
|
data/README.md
CHANGED
|
@@ -195,6 +195,49 @@ On pull requests, `raider-reporter comment` posts one comment with the build's r
|
|
|
195
195
|
- 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.
|
|
196
196
|
- It requires a Raider server that supports the summary endpoint and the `git.pr` field. Upgrade the server before using it.
|
|
197
197
|
|
|
198
|
+
## Azure Pipelines
|
|
199
|
+
|
|
200
|
+
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.
|
|
201
|
+
|
|
202
|
+
```yaml
|
|
203
|
+
jobs:
|
|
204
|
+
- job: tests
|
|
205
|
+
steps:
|
|
206
|
+
- task: UseRubyVersion@0
|
|
207
|
+
inputs: { versionSpec: '3.4' }
|
|
208
|
+
- script: bundle install
|
|
209
|
+
displayName: Install gems
|
|
210
|
+
- script: bundle exec rspec spec/ --format documentation
|
|
211
|
+
displayName: Run tests
|
|
212
|
+
env:
|
|
213
|
+
RAIDER_TOKEN: $(RAIDER_TOKEN)
|
|
214
|
+
RAIDER_ENDPOINT: $(RAIDER_ENDPOINT)
|
|
215
|
+
- task: PublishPipelineArtifact@1
|
|
216
|
+
condition: always()
|
|
217
|
+
inputs: { targetPath: raider-results, artifact: raider-results }
|
|
218
|
+
- job: raider_comment
|
|
219
|
+
dependsOn: tests
|
|
220
|
+
condition: and(always(), eq(variables['Build.Reason'], 'PullRequest'))
|
|
221
|
+
steps:
|
|
222
|
+
- task: UseRubyVersion@0
|
|
223
|
+
inputs: { versionSpec: '3.4' }
|
|
224
|
+
- script: bundle install
|
|
225
|
+
- script: bundle exec raider-reporter comment
|
|
226
|
+
displayName: Comment Raider results on the pull request
|
|
227
|
+
env:
|
|
228
|
+
RAIDER_TOKEN: $(RAIDER_TOKEN)
|
|
229
|
+
RAIDER_ENDPOINT: $(RAIDER_ENDPOINT)
|
|
230
|
+
SYSTEM_ACCESSTOKEN: $(System.AccessToken)
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
- **One-time grant:** give "Contribute to pull requests" to `<Project> Build Service (<org>)` on the repository (Project settings > Repositories > Security). Without it the command prints a notice naming the exact identity and exits 0.
|
|
234
|
+
- **Map secrets explicitly:** Azure does not expose secret variables to scripts automatically. Map `RAIDER_TOKEN` and `System.AccessToken` with `env:` as shown. If a mapped variable is undefined, Azure passes the literal `$(...)`; the gem treats it as missing: results are still written locally but not uploaded, and `raider-reporter comment` prints a skip notice.
|
|
235
|
+
- **Pull request builds:** for Azure Repos, YAML `pr:` triggers are ignored. PR builds (and therefore the comment job) run only when the pipeline is added as a **Build validation** branch policy on the target branch.
|
|
236
|
+
- **Same stage:** keep the comment job in the same stage as the test jobs; the build key includes `System.StageAttempt`.
|
|
237
|
+
- **Azure Repos only:** the PR comment works for repositories hosted in Azure Repos. A GitHub-hosted repository built by Azure Pipelines still uploads results, but `comment` prints `skipped: not an Azure Repos pull request`.
|
|
238
|
+
- **Parallel jobs:** `strategy: parallel: N` shards are detected automatically and grouped into one build, like GitHub matrix jobs.
|
|
239
|
+
- The command never fails your pipeline: it always exits 0 and prints notices to stderr, prefixed `[raider_reporter]`. It needs `SYSTEM_ACCESSTOKEN`, `RAIDER_TOKEN` and `RAIDER_ENDPOINT`, and requires a Raider server that supports the summary endpoint.
|
|
240
|
+
|
|
198
241
|
## Parallel runs
|
|
199
242
|
|
|
200
243
|
When a suite is split across several processes or CI jobs, each one uploads its own run. Raider groups runs of the same CI build, so the shards show up together as one build. Each run records its shard (`index`, `total` and a `label`) and, on GitHub Actions, a build key made from the repository, run id and run attempt. A re-run attempt of a workflow is a separate build.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RaiderReporter
|
|
4
|
+
# Reads Azure Pipelines variables. An undefined macro is left literal by Azure
|
|
5
|
+
# ("$(System.AccessToken)"), so such values count as missing.
|
|
6
|
+
module AzureEnv
|
|
7
|
+
MACRO = /\A\$\(.+\)\z/
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def value(env, name)
|
|
12
|
+
raw = env[name].to_s.strip
|
|
13
|
+
raw.empty? || raw.match?(MACRO) ? nil : raw
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def azure?(env)
|
|
17
|
+
env["GITHUB_ACTIONS"] != "true" && value(env, "TF_BUILD")&.casecmp?("true") == true
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def collection_base(env)
|
|
21
|
+
uri = value(env, "SYSTEM_COLLECTIONURI")
|
|
22
|
+
uri && "#{uri.sub(%r{/+\z}, '')}/"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def azure_repos_pr?(env)
|
|
26
|
+
value(env, "BUILD_REASON") == "PullRequest" && value(env, "BUILD_REPOSITORY_PROVIDER") == "TfsGit"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def strip_heads(ref)
|
|
30
|
+
return nil if ref.nil?
|
|
31
|
+
return ref.delete_prefix("refs/heads/") if ref.start_with?("refs/heads/")
|
|
32
|
+
|
|
33
|
+
ref.start_with?("refs/") ? nil : ref
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "erb"
|
|
3
4
|
require "open3"
|
|
5
|
+
require "raider_reporter/azure_env"
|
|
4
6
|
require "raider_reporter/github_event"
|
|
5
7
|
|
|
6
8
|
module RaiderReporter
|
|
@@ -14,6 +16,7 @@ module RaiderReporter
|
|
|
14
16
|
end
|
|
15
17
|
|
|
16
18
|
def ci
|
|
19
|
+
return azure_ci if AzureEnv.azure?(@env)
|
|
17
20
|
return nil unless @env["GITHUB_ACTIONS"] == "true"
|
|
18
21
|
|
|
19
22
|
run_id = @env["GITHUB_RUN_ID"]
|
|
@@ -38,6 +41,40 @@ module RaiderReporter
|
|
|
38
41
|
|
|
39
42
|
private
|
|
40
43
|
|
|
44
|
+
def azure_ci
|
|
45
|
+
v = ->(name) { AzureEnv.value(@env, name) }
|
|
46
|
+
base = AzureEnv.collection_base(@env)
|
|
47
|
+
project = v.("SYSTEM_TEAMPROJECT")
|
|
48
|
+
build_id = v.("BUILD_BUILDID")
|
|
49
|
+
attempt = v.("SYSTEM_STAGEATTEMPT") || "1"
|
|
50
|
+
build_url = ("#{base}#{ERB::Util.url_encode(project)}/_build/results?buildId=#{build_id}" if base && project && build_id)
|
|
51
|
+
ids = [base&.sub(%r{\Ahttps?://}i, "")&.chomp("/"), v.("SYSTEM_TEAMPROJECTID"), v.("BUILD_REPOSITORY_ID")]
|
|
52
|
+
build_key = ("azure_pipelines:#{ids.join('/')}:#{build_id}:#{attempt}" if ids.all? && build_id)
|
|
53
|
+
repo = v.("BUILD_REPOSITORY_NAME")
|
|
54
|
+
{ "provider" => "azure_pipelines", "build_url" => build_url, "build_id" => build_id, "attempt" => attempt,
|
|
55
|
+
"job" => v.("SYSTEM_JOBDISPLAYNAME"), "build_key" => build_key,
|
|
56
|
+
"repository" => (project && repo ? "#{project}/#{repo}" : nil) }.compact
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def azure_pull_request
|
|
60
|
+
return nil unless AzureEnv.azure_repos_pr?(@env)
|
|
61
|
+
|
|
62
|
+
v = ->(name) { AzureEnv.value(@env, name) }
|
|
63
|
+
number = Integer(v.("SYSTEM_PULLREQUEST_PULLREQUESTID").to_s, exception: false)
|
|
64
|
+
base = AzureEnv.strip_heads(v.("SYSTEM_PULLREQUEST_TARGETBRANCH"))
|
|
65
|
+
head = AzureEnv.strip_heads(v.("SYSTEM_PULLREQUEST_SOURCEBRANCH"))
|
|
66
|
+
sha = v.("SYSTEM_PULLREQUEST_SOURCECOMMITID")
|
|
67
|
+
return nil unless number&.positive? && [base, head, sha].all? { |s| s.is_a?(String) && !s.empty? }
|
|
68
|
+
|
|
69
|
+
collection = AzureEnv.collection_base(@env)
|
|
70
|
+
project = v.("SYSTEM_TEAMPROJECT")
|
|
71
|
+
repo = v.("BUILD_REPOSITORY_NAME")
|
|
72
|
+
url = if collection && project && repo
|
|
73
|
+
"#{collection}#{ERB::Util.url_encode(project)}/_git/#{ERB::Util.url_encode(repo)}/pullrequest/#{number}"
|
|
74
|
+
end
|
|
75
|
+
{ "number" => number, "base_branch" => base, "head_branch" => head, "head_sha" => sha, "url" => url }.compact
|
|
76
|
+
end
|
|
77
|
+
|
|
41
78
|
def event
|
|
42
79
|
return @event if defined?(@event)
|
|
43
80
|
|
|
@@ -57,6 +94,7 @@ module RaiderReporter
|
|
|
57
94
|
end
|
|
58
95
|
|
|
59
96
|
def pull_request
|
|
97
|
+
return azure_pull_request if AzureEnv.azure?(@env)
|
|
60
98
|
return nil unless event && PR_EVENTS.include?(@env["GITHUB_EVENT_NAME"])
|
|
61
99
|
|
|
62
100
|
pr = event["pull_request"]
|
|
@@ -69,7 +107,10 @@ module RaiderReporter
|
|
|
69
107
|
return nil unless number.is_a?(Integer) && number >= 1
|
|
70
108
|
return nil unless [base, head, sha].all? { |v| v.is_a?(String) && !v.empty? }
|
|
71
109
|
|
|
72
|
-
|
|
110
|
+
server = @env["GITHUB_SERVER_URL"].to_s
|
|
111
|
+
repo = @env["GITHUB_REPOSITORY"].to_s
|
|
112
|
+
url = ("#{server}/#{repo}/pull/#{number}" unless server.empty? || repo.empty?)
|
|
113
|
+
{ "number" => number, "base_branch" => base, "head_branch" => head, "head_sha" => sha, "url" => url }.compact
|
|
73
114
|
rescue StandardError
|
|
74
115
|
nil
|
|
75
116
|
end
|
|
@@ -88,6 +129,8 @@ module RaiderReporter
|
|
|
88
129
|
end
|
|
89
130
|
|
|
90
131
|
def branch
|
|
132
|
+
return azure_branch if AzureEnv.azure?(@env)
|
|
133
|
+
|
|
91
134
|
head_ref = @env["GITHUB_HEAD_REF"].to_s
|
|
92
135
|
return head_ref unless head_ref.empty?
|
|
93
136
|
|
|
@@ -97,7 +140,18 @@ module RaiderReporter
|
|
|
97
140
|
local == "HEAD" ? nil : local # "HEAD" means detached, not a branch name
|
|
98
141
|
end
|
|
99
142
|
|
|
143
|
+
def azure_branch
|
|
144
|
+
pr = pull_request
|
|
145
|
+
name = pr ? pr["head_branch"] : AzureEnv.strip_heads(AzureEnv.value(@env, "BUILD_SOURCEBRANCH"))
|
|
146
|
+
return name if name
|
|
147
|
+
|
|
148
|
+
local = @git.call("rev-parse", "--abbrev-ref", "HEAD")
|
|
149
|
+
local == "HEAD" ? nil : local
|
|
150
|
+
end
|
|
151
|
+
|
|
100
152
|
def sha
|
|
153
|
+
return AzureEnv.value(@env, "BUILD_SOURCEVERSION") || @git.call("rev-parse", "HEAD") if AzureEnv.azure?(@env)
|
|
154
|
+
|
|
101
155
|
@env["GITHUB_SHA"] || @git.call("rev-parse", "HEAD")
|
|
102
156
|
end
|
|
103
157
|
|
data/lib/raider_reporter/cli.rb
CHANGED
|
@@ -8,7 +8,13 @@ module RaiderReporter
|
|
|
8
8
|
class CLI
|
|
9
9
|
MARKER = "<!-- raider-reporter:build-comment -->"
|
|
10
10
|
CONTEXT = "raider/tests"
|
|
11
|
-
|
|
11
|
+
GITHUB_REQUIRED_ENV = %w[GITHUB_TOKEN GITHUB_REPOSITORY GITHUB_EVENT_PATH RAIDER_TOKEN RAIDER_ENDPOINT].freeze
|
|
12
|
+
REQUIRED_ENV = GITHUB_REQUIRED_ENV
|
|
13
|
+
AZURE_REQUIRED_ENV = %w[SYSTEM_ACCESSTOKEN SYSTEM_COLLECTIONURI SYSTEM_TEAMPROJECTID BUILD_REPOSITORY_ID
|
|
14
|
+
RAIDER_TOKEN RAIDER_ENDPOINT].freeze
|
|
15
|
+
THREAD_STATUS = { "passed" => "closed" }.freeze
|
|
16
|
+
AZURE_STATES = { "passed" => "succeeded", "failed" => "failed", "incomplete" => "pending" }.freeze
|
|
17
|
+
TOKEN_HINT = "map it with env: SYSTEM_ACCESSTOKEN: $(System.AccessToken)"
|
|
12
18
|
STATUS_STATES = { "passed" => "success", "failed" => "failure", "incomplete" => "pending" }.freeze
|
|
13
19
|
FALLBACK_DESCRIPTIONS = {
|
|
14
20
|
"passed" => "Raider: tests passed",
|
|
@@ -19,10 +25,14 @@ module RaiderReporter
|
|
|
19
25
|
USAGE = <<~TEXT
|
|
20
26
|
Usage: raider-reporter comment
|
|
21
27
|
|
|
22
|
-
Posts (or updates) the Raider build summary
|
|
23
|
-
|
|
28
|
+
Posts (or updates) the Raider build summary on the current pull request and sets a status.
|
|
29
|
+
Intended for GitHub Actions and Azure Pipelines (Azure Repos); always exits 0.
|
|
24
30
|
|
|
25
|
-
|
|
31
|
+
GitHub Actions: comment + "#{CONTEXT}" commit status.
|
|
32
|
+
Environment: #{GITHUB_REQUIRED_ENV.join(', ')} (GITHUB_API_URL optional).
|
|
33
|
+
Azure Pipelines: one PR thread updated in place + a "raider/tests" PR status.
|
|
34
|
+
Environment: #{AZURE_REQUIRED_ENV.join(', ')}.
|
|
35
|
+
Map the token in the pipeline: env: SYSTEM_ACCESSTOKEN: $(System.AccessToken)
|
|
26
36
|
TEXT
|
|
27
37
|
|
|
28
38
|
def initialize(argv, env: ENV, out: $stdout, err: $stderr)
|
|
@@ -48,7 +58,17 @@ module RaiderReporter
|
|
|
48
58
|
private
|
|
49
59
|
|
|
50
60
|
def comment
|
|
51
|
-
|
|
61
|
+
if @env["GITHUB_ACTIONS"] == "true"
|
|
62
|
+
github_comment
|
|
63
|
+
elsif AzureEnv.azure?(@env)
|
|
64
|
+
azure_comment
|
|
65
|
+
else
|
|
66
|
+
notice("skipped: unsupported CI")
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def github_comment
|
|
71
|
+
missing = GITHUB_REQUIRED_ENV.select { |name| @env[name].to_s.strip.empty? }
|
|
52
72
|
return notice("skipped: missing #{missing.join(', ')}") unless missing.empty?
|
|
53
73
|
|
|
54
74
|
event = GithubEvent.load(@env)
|
|
@@ -66,6 +86,61 @@ module RaiderReporter
|
|
|
66
86
|
notice("comment failed: #{e.class}: #{e.message}")
|
|
67
87
|
end
|
|
68
88
|
|
|
89
|
+
def azure_comment
|
|
90
|
+
missing = AZURE_REQUIRED_ENV.select { |name| AzureEnv.value(@env, name).nil? }
|
|
91
|
+
return notice(azure_missing_message(missing)) unless missing.empty?
|
|
92
|
+
|
|
93
|
+
detector = CiDetector.new(env: @env, git: ->(*) {})
|
|
94
|
+
pr = detector.git&.dig("pr")
|
|
95
|
+
return notice("skipped: not an Azure Repos pull request") unless pr
|
|
96
|
+
|
|
97
|
+
build_key = detector.ci&.dig("build_key")
|
|
98
|
+
return notice("skipped: could not determine the build key") unless build_key
|
|
99
|
+
|
|
100
|
+
publish_azure(pr, build_key)
|
|
101
|
+
rescue Comment::AzureClient::Forbidden
|
|
102
|
+
notice(azure_forbidden_message)
|
|
103
|
+
rescue StandardError => e
|
|
104
|
+
notice("comment failed: #{e.class}: #{e.message}")
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def azure_missing_message(missing)
|
|
108
|
+
message = "skipped: missing #{missing.join(', ')}"
|
|
109
|
+
message += "; #{TOKEN_HINT}" if missing.include?("SYSTEM_ACCESSTOKEN")
|
|
110
|
+
message
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def publish_azure(pr, build_key)
|
|
114
|
+
summaries = Comment::SummaryClient.new(endpoint: AzureEnv.value(@env, "RAIDER_ENDPOINT"),
|
|
115
|
+
token: AzureEnv.value(@env, "RAIDER_TOKEN"))
|
|
116
|
+
summary = summaries.fetch(build_key)
|
|
117
|
+
return notice("summary unavailable: #{summaries.last_error}") unless summary
|
|
118
|
+
|
|
119
|
+
azure = Comment::AzureClient.new(token: AzureEnv.value(@env, "SYSTEM_ACCESSTOKEN"), base_url: azure_pr_url(pr))
|
|
120
|
+
result = azure.upsert_thread(summary.markdown, MARKER, status: THREAD_STATUS.fetch(summary.state, "active"))
|
|
121
|
+
azure.set_status(state: AZURE_STATES.fetch(summary.state, "pending"), description: description(summary),
|
|
122
|
+
target_url: summary.url)
|
|
123
|
+
notice("PR thread #{result}: #{pr['url'] || "PR ##{pr['number']}"}")
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def azure_pr_url(pr)
|
|
127
|
+
"#{AzureEnv.collection_base(@env)}#{AzureEnv.value(@env, 'SYSTEM_TEAMPROJECTID')}/_apis/git/repositories/" \
|
|
128
|
+
"#{AzureEnv.value(@env, 'BUILD_REPOSITORY_ID')}/pullRequests/#{pr['number']}"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def azure_forbidden_message
|
|
132
|
+
project = AzureEnv.value(@env, "SYSTEM_TEAMPROJECT") || "<project>"
|
|
133
|
+
"skipped: the build service cannot write to this PR — grant \"Contribute to pull requests\" to " \
|
|
134
|
+
"\"#{project} Build Service (#{azure_org})\""
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def azure_org
|
|
138
|
+
uri = URI.parse(AzureEnv.collection_base(@env).to_s)
|
|
139
|
+
uri.host.to_s.end_with?("visualstudio.com") ? uri.host.split(".").first : uri.path.split("/").reject(&:empty?).first
|
|
140
|
+
rescue URI::InvalidURIError
|
|
141
|
+
nil
|
|
142
|
+
end
|
|
143
|
+
|
|
69
144
|
def publish(pr, build_key)
|
|
70
145
|
summaries = Comment::SummaryClient.new(endpoint: @env["RAIDER_ENDPOINT"], token: @env["RAIDER_TOKEN"])
|
|
71
146
|
summary = summaries.fetch(build_key)
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
module RaiderReporter
|
|
8
|
+
module Comment
|
|
9
|
+
# Minimal Azure DevOps REST client: one Raider thread per pull request, plus a PR status.
|
|
10
|
+
class AzureClient
|
|
11
|
+
class Error < StandardError; end
|
|
12
|
+
# 401/403/404: the build service identity cannot write to this PR.
|
|
13
|
+
class Forbidden < Error; end
|
|
14
|
+
|
|
15
|
+
API_VERSION = "7.1"
|
|
16
|
+
STATUS_API_VERSION = "7.1" # confirmed in the 7.1 Pull Request Statuses doc; see spec header
|
|
17
|
+
PROPERTY = "Raider.BuildComment"
|
|
18
|
+
DESCRIPTION_LIMIT = 140
|
|
19
|
+
|
|
20
|
+
def initialize(token:, base_url:, timeout: 30)
|
|
21
|
+
@token = token
|
|
22
|
+
@base_url = base_url.to_s.sub(%r{/+\z}, "")
|
|
23
|
+
@timeout = timeout
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def upsert_thread(markdown, marker, status:)
|
|
27
|
+
thread = find_thread(marker)
|
|
28
|
+
if thread
|
|
29
|
+
comment_id = thread["comments"].first["id"]
|
|
30
|
+
request(:patch, "/threads/#{thread['id']}/comments/#{comment_id}", { content: markdown })
|
|
31
|
+
request(:patch, "/threads/#{thread['id']}", { status: status })
|
|
32
|
+
:updated
|
|
33
|
+
else
|
|
34
|
+
request(:post, "/threads", { comments: [{ parentCommentId: 0, content: markdown, commentType: 1 }],
|
|
35
|
+
status: status,
|
|
36
|
+
properties: { PROPERTY => { "$type" => "System.Int32", "$value" => 1 } } })
|
|
37
|
+
:created
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def set_status(state:, description:, target_url:)
|
|
42
|
+
request(:post, "/statuses", { state: state, description: description.to_s[0, DESCRIPTION_LIMIT],
|
|
43
|
+
targetUrl: target_url, context: { genre: "raider", name: "tests" } },
|
|
44
|
+
version: STATUS_API_VERSION)
|
|
45
|
+
nil
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def find_thread(marker)
|
|
51
|
+
list = request(:get, "/threads")
|
|
52
|
+
threads = list.is_a?(Hash) ? list["value"] : nil
|
|
53
|
+
raise Error, "unexpected threads response" unless threads.is_a?(Array)
|
|
54
|
+
|
|
55
|
+
threads.find { |t| ours?(t, marker) }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Only a property-tagged thread whose live first comment starts with the marker is ours.
|
|
59
|
+
def ours?(thread, marker)
|
|
60
|
+
return false unless thread.is_a?(Hash) && thread["properties"].is_a?(Hash) && thread["properties"].key?(PROPERTY)
|
|
61
|
+
return false unless thread["comments"].is_a?(Array)
|
|
62
|
+
|
|
63
|
+
first = thread["comments"].first
|
|
64
|
+
first.is_a?(Hash) && !first["isDeleted"] && first["content"].to_s.start_with?(marker)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def redact(text)
|
|
68
|
+
@token.to_s.empty? ? text.to_s : text.to_s.gsub(@token.to_s, "[REDACTED]")
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def request(verb, path, payload = nil, version: nil)
|
|
72
|
+
uri = URI.parse("#{@base_url}#{path}?api-version=#{version || API_VERSION}")
|
|
73
|
+
raise Error, "Azure DevOps URL must start with http:// or https://" unless uri.is_a?(URI::HTTP)
|
|
74
|
+
|
|
75
|
+
req = build_request(verb, uri, payload)
|
|
76
|
+
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
|
|
77
|
+
open_timeout: @timeout, read_timeout: @timeout,
|
|
78
|
+
write_timeout: @timeout) { |http| http.request(req) }
|
|
79
|
+
handle(response)
|
|
80
|
+
rescue Error
|
|
81
|
+
raise
|
|
82
|
+
rescue StandardError => e
|
|
83
|
+
raise Error, redact("#{e.class}: #{e.message}")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def build_request(verb, uri, json)
|
|
87
|
+
klass = { get: Net::HTTP::Get, post: Net::HTTP::Post, patch: Net::HTTP::Patch }.fetch(verb)
|
|
88
|
+
req = klass.new(uri)
|
|
89
|
+
req["Authorization"] = "Bearer #{@token}"
|
|
90
|
+
req["Accept"] = "application/json"
|
|
91
|
+
req["User-Agent"] = "raider_reporter/#{VERSION}"
|
|
92
|
+
if json
|
|
93
|
+
req["Content-Type"] = "application/json"
|
|
94
|
+
req.body = JSON.generate(json)
|
|
95
|
+
end
|
|
96
|
+
req
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def handle(response)
|
|
100
|
+
code = response.code.to_i
|
|
101
|
+
raise Forbidden, "HTTP #{code}" if [401, 403, 404].include?(code)
|
|
102
|
+
raise Error, "HTTP #{code}: #{redact(response.body.to_s)[0, 200]}" unless (200..299).cover?(code)
|
|
103
|
+
|
|
104
|
+
JSON.parse(response.body.to_s)
|
|
105
|
+
rescue JSON::ParserError
|
|
106
|
+
raise Error, "Azure DevOps returned invalid JSON"
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -101,7 +101,7 @@ module RaiderReporter
|
|
|
101
101
|
def handle(response)
|
|
102
102
|
code = response.code.to_i
|
|
103
103
|
raise Forbidden, "HTTP #{code}" if [403, 404].include?(code)
|
|
104
|
-
raise Error,
|
|
104
|
+
raise Error, "HTTP #{code}: #{redact(response.body.to_s)[0, 200]}" unless (200..299).cover?(code)
|
|
105
105
|
|
|
106
106
|
JSON.parse(response.body.to_s)
|
|
107
107
|
rescue JSON::ParserError
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "raider_reporter/azure_env"
|
|
4
|
+
|
|
3
5
|
module RaiderReporter
|
|
4
6
|
class Configuration
|
|
5
7
|
attr_accessor :token, :endpoint, :output_dir, :clean_output_dir, :environment, :max_attachment_bytes
|
|
6
8
|
|
|
7
9
|
def initialize(env = ENV)
|
|
8
|
-
@token = env
|
|
9
|
-
@endpoint = env
|
|
10
|
+
@token = AzureEnv.value(env, "RAIDER_TOKEN")
|
|
11
|
+
@endpoint = AzureEnv.value(env, "RAIDER_ENDPOINT")
|
|
10
12
|
@output_dir = "raider-results"
|
|
11
13
|
@clean_output_dir = true
|
|
12
14
|
@environment = {}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "raider_reporter/azure_env"
|
|
4
|
+
|
|
3
5
|
module RaiderReporter
|
|
4
6
|
# Identifies which slice of a parallel suite this process is.
|
|
5
7
|
class ShardDetector
|
|
@@ -9,7 +11,7 @@ module RaiderReporter
|
|
|
9
11
|
end
|
|
10
12
|
|
|
11
13
|
def shard
|
|
12
|
-
index, total, suffix = composed ||
|
|
14
|
+
index, total, suffix = composed || outer || parallel_tests
|
|
13
15
|
return nil unless index
|
|
14
16
|
|
|
15
17
|
{ "index" => index, "total" => total, "label" => "#{@job || "local"}##{suffix || index}" }
|
|
@@ -19,7 +21,7 @@ module RaiderReporter
|
|
|
19
21
|
|
|
20
22
|
# A matrix leg that also runs parallel_tests: every process gets a unique index.
|
|
21
23
|
def composed
|
|
22
|
-
explicit_index, explicit_total =
|
|
24
|
+
explicit_index, explicit_total = outer
|
|
23
25
|
parallel_index, = parallel_tests
|
|
24
26
|
groups = positive(@env["PARALLEL_TEST_GROUPS"])
|
|
25
27
|
return nil unless explicit_index && parallel_index && groups
|
|
@@ -29,6 +31,18 @@ module RaiderReporter
|
|
|
29
31
|
"#{explicit_index}.#{parallel_index}"]
|
|
30
32
|
end
|
|
31
33
|
|
|
34
|
+
def outer
|
|
35
|
+
explicit || azure_parallel
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def azure_parallel
|
|
39
|
+
return nil unless AzureEnv.azure?(@env)
|
|
40
|
+
|
|
41
|
+
total = positive(AzureEnv.value(@env, "SYSTEM_TOTALJOBSINPHASE"))
|
|
42
|
+
index = positive(AzureEnv.value(@env, "SYSTEM_JOBPOSITIONINPHASE"))
|
|
43
|
+
[index, total] if index && total && total > 1
|
|
44
|
+
end
|
|
45
|
+
|
|
32
46
|
def explicit
|
|
33
47
|
index = positive(@env["RAIDER_SHARD_INDEX"])
|
|
34
48
|
[index, positive(@env["RAIDER_SHARD_TOTAL"])] if index
|
data/lib/raider_reporter.rb
CHANGED
|
@@ -15,6 +15,7 @@ require_relative "raider_reporter/shard_detector"
|
|
|
15
15
|
require_relative "raider_reporter/writers/uploader"
|
|
16
16
|
require_relative "raider_reporter/comment/summary_client"
|
|
17
17
|
require_relative "raider_reporter/comment/github_client"
|
|
18
|
+
require_relative "raider_reporter/comment/azure_client"
|
|
18
19
|
|
|
19
20
|
module RaiderReporter
|
|
20
21
|
class << self
|
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.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Augustin Gottlieb
|
|
@@ -24,9 +24,11 @@ files:
|
|
|
24
24
|
- README.md
|
|
25
25
|
- exe/raider-reporter
|
|
26
26
|
- lib/raider_reporter.rb
|
|
27
|
+
- lib/raider_reporter/azure_env.rb
|
|
27
28
|
- lib/raider_reporter/ci_detector.rb
|
|
28
29
|
- lib/raider_reporter/cli.rb
|
|
29
30
|
- lib/raider_reporter/collector.rb
|
|
31
|
+
- lib/raider_reporter/comment/azure_client.rb
|
|
30
32
|
- lib/raider_reporter/comment/github_client.rb
|
|
31
33
|
- lib/raider_reporter/comment/summary_client.rb
|
|
32
34
|
- lib/raider_reporter/configuration.rb
|