raider_reporter 0.1.1 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ef0048723c95ef6c51b350fe1f10c8521c743b86b1b237209bdfd759dbd2e478
4
- data.tar.gz: a44337cbf25f7bb230e1055a66d875e58e0f9e7dbf3d9614e136cca607672102
3
+ metadata.gz: c4d38649c7323d93145079d6de1aac3a00f4724a0d906a2f004ddf331e201b77
4
+ data.tar.gz: 8f5ad9717d74c2c626ba0ab7507e2ebc413f9f80337d0ecfd02343c69bc52051
5
5
  SHA512:
6
- metadata.gz: 4d368aed07b839de0959305bf04b2138abb5a43e0ca90c5920604d896375c765d09afefbf5ec4ffc8e9b67cfb54d8c355c70ee1f5d0253d8e57fc583a5a4ca3c
7
- data.tar.gz: 3034b72e7a56ae66e1cd8477f2ed7b6c84428df5c77276ebfe08020792dae220cd0f6665df5f000704d744b025f9bec6aaddbe1d12bc4d449e71f5366fcbce6c
6
+ metadata.gz: d43a79f1ff81eff80f4fa8a9afbff388df98671e65920013b88cdb8cdd24ab3313266646e27e72bbebfbad4213231855c6d7470c28177ba601d0db1ddb1aea76
7
+ data.tar.gz: 61cd6025ab3d9c713fb075d5288eec3ce46c219eb317995129a6bc41fa139ecccf4026eca160cafeeeae0f25f8d4da290c9e661429cb1f21e8ad8520da007c94
data/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0 — 2026-10-11
4
+
5
+ - Report pull request identity: `run.ci.repository`, `run.git.default_branch` and `run.git.pr` (number, base/head branch, head SHA) on `pull_request` and `pull_request_target` events. Requires a Raider server that accepts `git.pr`
6
+ - Add `raider-reporter comment`: posts (and updates) one results comment on the pull request and sets the `raider/tests` commit status; fail-open, always exits 0, skips fork PRs
7
+ - Print the `Raider report: <url>` line to stderr instead of stdout, so stdout-parsing tools (such as `--format json`) stay clean
8
+ - Report the PR head commit's SHA and message on pull request events instead of the merge commit's
9
+ - Harden the GitHub client: only a bot-authored comment starting with the marker is updated, any number of trailing slashes in `GITHUB_API_URL` are accepted, and the token is redacted from error messages
10
+
11
+ ## 0.2.0 — 2026-10-11
12
+
13
+ - Report the CI build key, attempt and job so runs of one build are grouped (GitHub Actions)
14
+ - Detect shards from `RAIDER_SHARD_INDEX`/`RAIDER_SHARD_TOTAL` or `parallel_tests` (`TEST_ENV_NUMBER`, `PARALLEL_TEST_GROUPS`) and report them per run; a matrix leg running parallel_tests is composed into unique shards
15
+ - Write parallel-safe local output (`run-<n>.json`, `attachments-<n>/`) when sharded; fixes parallel_tests processes overwriting each other's results
16
+ - Print the Raider build URL after upload, falling back to the run URL
17
+
3
18
  ## 0.1.1 — 2026-10-11
4
19
 
5
20
  - Send run timestamps with millisecond precision, so durations under a second are no longer reported as 0
data/README.md CHANGED
@@ -63,7 +63,7 @@ end
63
63
  | `token` | `ENV["RAIDER_TOKEN"]` | API token used for uploads. |
64
64
  | `endpoint` | `ENV["RAIDER_ENDPOINT"]` | Base URL of the Raider server to upload to. |
65
65
  | `output_dir` | `"raider-results"` | Directory for local output. |
66
- | `clean_output_dir` | `true` | Remove the previous `run.json` and `attachments/` directory inside `output_dir` before writing. Nothing else in `output_dir` is touched. |
66
+ | `clean_output_dir` | `true` | Remove the previous `run.json` and `attachments/` directory (`run-<n>.json` and `attachments-<n>/` when sharded) inside `output_dir` before writing. Nothing else in `output_dir` is touched. |
67
67
  | `environment` | `{}` | Free-form key/values stored with the run (browser, suite name, and so on). Values are JSON values: strings, booleans or numbers. |
68
68
 
69
69
  ## Local output
@@ -74,7 +74,7 @@ raider-results/
74
74
  attachments/ # copies of attached screenshots and videos
75
75
  ```
76
76
 
77
- Add `raider-results/` to your `.gitignore`.
77
+ Add `raider-results/` to your `.gitignore`. When a run is sharded, the files are named per shard; see [Parallel runs](#parallel-runs).
78
78
 
79
79
  ## Uploading to Raider
80
80
 
@@ -84,7 +84,7 @@ If both a token and an endpoint are set, the run (plus attachments) is uploaded
84
84
  RAIDER_TOKEN=... RAIDER_ENDPOINT=https://your-raider-server bundle exec rspec
85
85
  ```
86
86
 
87
- On success the formatter prints `Raider report: <url>` (or `Raider report uploaded (run <id>)` if the server's response has no URL). If either value is missing, nothing is uploaded and the local files are still written.
87
+ On success the formatter prints `Raider report: <url>` to stderr (so tools that parse RSpec's stdout, such as `--format json`, stay clean), using the build URL when the server returns one and the run URL otherwise (or `Raider report uploaded (run <id>)` if the response has no URL). If either value is missing, nothing is uploaded and the local files are still written.
88
88
 
89
89
  An upload carries at most 45 MB of attachments and at most 1000 attachment files. Attachments past that budget are left out of the upload (in order, with one warning) but stay in the local `raider-results/`.
90
90
 
@@ -92,6 +92,94 @@ Reporting is fail-open: no error inside the gem, network failure, timeout or non
92
92
 
93
93
  The hosted Raider service is not public yet; uploads go to a Raider server whose address you provide in `RAIDER_ENDPOINT`.
94
94
 
95
+ ## Pull request comments
96
+
97
+ On pull requests, `raider-reporter comment` posts one comment with the build's results (and sets a `raider/tests` commit status on the PR head commit). Run it in a job that runs after your test jobs, so every shard has uploaded. Use the workflow's `GITHUB_TOKEN` (or a GitHub App token): a personal access token posts as a user, so the step adds a new comment on every run instead of updating the existing one.
98
+
99
+ ```yaml
100
+ raider-comment:
101
+ needs: [test] # every test job id
102
+ if: always() && github.event_name == 'pull_request'
103
+ runs-on: ubuntu-latest
104
+ permissions:
105
+ contents: read
106
+ pull-requests: write
107
+ statuses: write
108
+ steps:
109
+ - uses: actions/checkout@v4
110
+ - uses: ruby/setup-ruby@v1
111
+ with: { ruby-version: "3.4", bundler-cache: true }
112
+ - name: Comment Raider results on the pull request
113
+ run: bundle exec raider-reporter comment
114
+ env:
115
+ RAIDER_TOKEN: ${{ secrets.RAIDER_TOKEN }}
116
+ RAIDER_ENDPOINT: ${{ vars.RAIDER_ENDPOINT }}
117
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
118
+ ```
119
+
120
+ - The comment is rendered by the Raider server (`GET /api/v1/builds/summary`); the gem only fetches the Markdown and posts it. The same comment is updated on every run instead of adding a new one.
121
+ - Pull requests from forks are skipped: GitHub gives their workflows a read-only token, so the command prints a notice and exits 0.
122
+ - 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.
123
+ - It requires a Raider server that supports the summary endpoint and the `git.pr` field. Upgrade the server before using it.
124
+
125
+ ## Parallel runs
126
+
127
+ 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.
128
+
129
+ Shard detection, in order of precedence:
130
+
131
+ 1. `RAIDER_SHARD_INDEX` (1 or more) and, optionally, `RAIDER_SHARD_TOTAL`.
132
+ 2. `parallel_tests` / `parallel_rspec`: `TEST_ENV_NUMBER` (empty means shard 1) and `PARALLEL_TEST_GROUPS` for the total.
133
+ 3. Otherwise the run is not sharded.
134
+
135
+ A GitHub Actions matrix needs only the two variables:
136
+
137
+ ```yaml
138
+ jobs:
139
+ test:
140
+ runs-on: ubuntu-latest
141
+ strategy:
142
+ matrix:
143
+ shard: [1, 2, 3, 4]
144
+ steps:
145
+ - uses: actions/checkout@v4
146
+ - uses: ruby/setup-ruby@v1
147
+ with:
148
+ bundler-cache: true
149
+ - run: bundle exec rspec # split the spec files by shard however you like
150
+ env:
151
+ RAIDER_TOKEN: ${{ secrets.RAIDER_TOKEN }}
152
+ RAIDER_ENDPOINT: ${{ vars.RAIDER_ENDPOINT }}
153
+ RAIDER_SHARD_INDEX: ${{ matrix.shard }}
154
+ RAIDER_SHARD_TOTAL: 4
155
+ ```
156
+
157
+ `parallel_rspec` works with no configuration, because it sets `TEST_ENV_NUMBER` and `PARALLEL_TEST_GROUPS` for each process.
158
+
159
+ When a run is sharded, local output is written per shard so that processes sharing one `raider-results/` do not overwrite each other. Cleaning only removes the current shard's own files:
160
+
161
+ ```
162
+ raider-results/
163
+ run-1.json
164
+ attachments-1/
165
+ run-2.json
166
+ attachments-2/
167
+ ```
168
+
169
+ Unsharded runs still write `run.json` and `attachments/`. Tools that read local results should load `run*.json`.
170
+
171
+ ### Matrix and parallel_tests together
172
+
173
+ A GitHub matrix leg can also run `parallel_rspec -n 2`. When `RAIDER_SHARD_INDEX` and `TEST_ENV_NUMBER` are both present and `PARALLEL_TEST_GROUPS` is a positive integer `g`, the two are composed so every process gets a unique shard:
174
+
175
+ - `index = (RAIDER_SHARD_INDEX - 1) * g + parallel_index`
176
+ - `total = RAIDER_SHARD_TOTAL * g`
177
+ - `label = "<job>#<RAIDER_SHARD_INDEX>.<parallel_index>"`
178
+
179
+ For example, matrix leg 2 of 2 with `parallel_rspec -n 2` yields shards 3 and 4 of 4, labelled `#2.1` and `#2.2`. `RAIDER_SHARD_TOTAL` must match the matrix size. Without it, `total` is null. If `PARALLEL_TEST_GROUPS` is missing or invalid, only `RAIDER_SHARD_INDEX` is used. Every matrix leg must use the same parallel_tests process count (`-n`) and set `RAIDER_SHARD_TOTAL`; mixing different `-n` values, or composed and plain legs, can repeat shard numbers.
180
+
181
+ Tip: run `rm -rf raider-results` before a parallel run. Each process only cleans its own files, so files from an earlier run with more processes stay behind.
182
+
95
183
  ## Examples
96
184
 
97
185
  - `examples/rspec_basic` is a runnable RSpec project with a passing, a failing (with an attachment) and a skipped example. Run it with `cd examples/rspec_basic && bundle install && bundle exec rspec`; the failure is deliberate.
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
5
+ require "raider_reporter"
6
+ require "raider_reporter/cli"
7
+
8
+ exit RaiderReporter::CLI.new(ARGV).run
@@ -1,12 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "open3"
4
+ require "raider_reporter/github_event"
4
5
 
5
6
  module RaiderReporter
6
7
  class CiDetector
7
- def initialize(env: ENV, git: method(:git_command))
8
+ PR_EVENTS = %w[pull_request pull_request_target].freeze
9
+
10
+ def initialize(env: ENV, git: method(:git_command), event_reader: nil)
8
11
  @env = env
9
12
  @git = git
13
+ @event_reader = event_reader || -> { GithubEvent.load(@env) }
10
14
  end
11
15
 
12
16
  def ci
@@ -16,17 +20,73 @@ module RaiderReporter
16
20
  build_url = if @env["GITHUB_SERVER_URL"] && @env["GITHUB_REPOSITORY"] && run_id
17
21
  "#{@env['GITHUB_SERVER_URL']}/#{@env['GITHUB_REPOSITORY']}/actions/runs/#{run_id}"
18
22
  end
19
- { "provider" => "github_actions", "build_url" => build_url, "build_id" => run_id }.compact
23
+ attempt = @env["GITHUB_RUN_ATTEMPT"].to_s.empty? ? "1" : @env["GITHUB_RUN_ATTEMPT"]
24
+ repo = @env["GITHUB_REPOSITORY"]
25
+ build_key = ("github_actions:#{repo}:#{run_id}:#{attempt}" if repo && run_id)
26
+ { "provider" => "github_actions", "build_url" => build_url, "build_id" => run_id,
27
+ "attempt" => attempt, "job" => @env["GITHUB_JOB"], "build_key" => build_key,
28
+ "repository" => (repo unless repo.to_s.empty?) }.compact
20
29
  end
21
30
 
22
31
  def git
23
- info = { "branch" => branch, "commit_sha" => sha, "commit_message" => @git.call("log", "-1", "--pretty=%s") }
32
+ pr = pull_request
33
+ info = { "branch" => branch, "commit_sha" => pr ? pr["head_sha"] : sha, "commit_message" => commit_message(pr),
34
+ "default_branch" => default_branch, "pr" => pr }
24
35
  info = info.compact
25
36
  info.empty? ? nil : info
26
37
  end
27
38
 
28
39
  private
29
40
 
41
+ def event
42
+ return @event if defined?(@event)
43
+
44
+ loaded = begin
45
+ @event_reader.call
46
+ rescue StandardError
47
+ nil
48
+ end
49
+ @event = loaded.is_a?(Hash) ? loaded : nil
50
+ end
51
+
52
+ def default_branch
53
+ value = event&.dig("repository", "default_branch")
54
+ value.is_a?(String) && !value.empty? ? value : nil
55
+ rescue StandardError
56
+ nil
57
+ end
58
+
59
+ def pull_request
60
+ return nil unless event && PR_EVENTS.include?(@env["GITHUB_EVENT_NAME"])
61
+
62
+ pr = event["pull_request"]
63
+ return nil unless pr.is_a?(Hash)
64
+
65
+ number = pr["number"] || event["number"]
66
+ base = pr.dig("base", "ref")
67
+ head = pr.dig("head", "ref")
68
+ sha = pr.dig("head", "sha")
69
+ return nil unless number.is_a?(Integer) && number >= 1
70
+ return nil unless [base, head, sha].all? { |v| v.is_a?(String) && !v.empty? }
71
+
72
+ { "number" => number, "base_branch" => base, "head_branch" => head, "head_sha" => sha }
73
+ rescue StandardError
74
+ nil
75
+ end
76
+
77
+ # On a PR, GitHub checks out a synthetic merge commit; its message is not the author's.
78
+ def commit_message(pr)
79
+ if pr && merge_commit?
80
+ message = @git.call("log", "-1", "--format=%s", "HEAD^2")
81
+ return message unless message.to_s.empty?
82
+ end
83
+ @git.call("log", "-1", "--pretty=%s")
84
+ end
85
+
86
+ def merge_commit?
87
+ @git.call("rev-list", "--parents", "-n", "1", "HEAD").to_s.split.size == 3
88
+ end
89
+
30
90
  def branch
31
91
  head_ref = @env["GITHUB_HEAD_REF"].to_s
32
92
  return head_ref unless head_ref.empty?
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "raider_reporter"
4
+
5
+ module RaiderReporter
6
+ # `raider-reporter comment`: posts the build summary as a PR comment and sets a commit status.
7
+ # Fail-open: #run always returns 0, notices go to stderr, nothing goes to stdout.
8
+ class CLI
9
+ MARKER = "<!-- raider-reporter:build-comment -->"
10
+ CONTEXT = "raider/tests"
11
+ REQUIRED_ENV = %w[GITHUB_TOKEN GITHUB_REPOSITORY GITHUB_EVENT_PATH RAIDER_TOKEN RAIDER_ENDPOINT].freeze
12
+ STATUS_STATES = { "passed" => "success", "failed" => "failure", "incomplete" => "pending" }.freeze
13
+ FALLBACK_DESCRIPTIONS = {
14
+ "passed" => "Raider: tests passed",
15
+ "failed" => "Raider: tests failed",
16
+ "incomplete" => "Raider: build incomplete"
17
+ }.freeze
18
+ DEFAULT_API_URL = "https://api.github.com"
19
+ USAGE = <<~TEXT
20
+ Usage: raider-reporter comment
21
+
22
+ Posts (or updates) the Raider build summary as a comment on the current pull request
23
+ and sets the "#{CONTEXT}" commit status. Intended for GitHub Actions; always exits 0.
24
+
25
+ Environment: #{REQUIRED_ENV.join(', ')} (GITHUB_API_URL optional).
26
+ TEXT
27
+
28
+ def initialize(argv, env: ENV, out: $stdout, err: $stderr)
29
+ @argv = argv
30
+ @env = env
31
+ @out = out
32
+ @err = err
33
+ end
34
+
35
+ def run
36
+ command = @argv.first
37
+ if command == "comment" && !@argv.include?("--help") && !@argv.include?("-h")
38
+ comment
39
+ else
40
+ @err.puts(USAGE)
41
+ end
42
+ 0
43
+ rescue StandardError => e
44
+ notice("comment failed: #{e.class}: #{e.message}")
45
+ 0
46
+ end
47
+
48
+ private
49
+
50
+ def comment
51
+ missing = REQUIRED_ENV.select { |name| @env[name].to_s.strip.empty? }
52
+ return notice("skipped: missing #{missing.join(', ')}") unless missing.empty?
53
+
54
+ event = GithubEvent.load(@env)
55
+ detector = CiDetector.new(env: @env, event_reader: -> { event })
56
+ pr = detector.git&.dig("pr")
57
+ return notice("skipped: not a pull request event") unless pr
58
+
59
+ build_key = detector.ci&.dig("build_key")
60
+ return notice("skipped: could not determine the build key") unless build_key
61
+
62
+ publish(pr, build_key)
63
+ rescue Comment::GithubClient::Forbidden
64
+ notice("skipped: token cannot write to this PR")
65
+ rescue StandardError => e
66
+ notice("comment failed: #{e.class}: #{e.message}")
67
+ end
68
+
69
+ def publish(pr, build_key)
70
+ summaries = Comment::SummaryClient.new(endpoint: @env["RAIDER_ENDPOINT"], token: @env["RAIDER_TOKEN"])
71
+ summary = summaries.fetch(build_key)
72
+ return notice("summary unavailable: #{summaries.last_error}") unless summary
73
+
74
+ github = Comment::GithubClient.new(token: @env["GITHUB_TOKEN"], repository: @env["GITHUB_REPOSITORY"],
75
+ api_url: api_url)
76
+ html_url = github.upsert_comment(pr["number"], summary.markdown, MARKER)
77
+ github.set_status(pr["head_sha"], state: STATUS_STATES.fetch(summary.state, "pending"),
78
+ target_url: summary.url, description: description(summary),
79
+ context: CONTEXT)
80
+ notice("PR comment updated: #{html_url}")
81
+ end
82
+
83
+ def api_url
84
+ url = @env["GITHUB_API_URL"].to_s.strip
85
+ url.empty? ? DEFAULT_API_URL : url
86
+ end
87
+
88
+ # "**Raider: failed** - 2 failed, 148 passed, 3 skipped (1m 12s)" -> "2 failed, 148 passed, 3 skipped".
89
+ # Falls back to a fixed text per state when the Markdown carries no headline counts.
90
+ def description(summary)
91
+ counts = headline_counts(summary.markdown)
92
+ text = counts || FALLBACK_DESCRIPTIONS.fetch(summary.state, FALLBACK_DESCRIPTIONS["incomplete"])
93
+ text[0, Comment::GithubClient::DESCRIPTION_LIMIT]
94
+ end
95
+
96
+ def headline_counts(markdown)
97
+ line = markdown.to_s.lines.find { |l| l.include?("Raider:") }
98
+ return nil unless line
99
+
100
+ counts = line.scan(/\b\d+ (?:failed|passed|skipped|pending)\b/)
101
+ counts.empty? ? nil : counts.join(", ")
102
+ end
103
+
104
+ def notice(message)
105
+ @err.puts("[raider_reporter] #{message}")
106
+ nil
107
+ end
108
+ end
109
+ end
@@ -61,10 +61,10 @@ module RaiderReporter
61
61
  result
62
62
  end
63
63
 
64
- def finish_run(ci:, git:, environment:)
64
+ def finish_run(ci:, git:, environment:, shard: nil)
65
65
  Run.new(
66
66
  client_run_id: @uuid.call, started_at: @started_at, finished_at: @clock.call,
67
- ci: ci, git: git, environment: environment,
67
+ ci: ci, git: git, environment: environment, shard: shard,
68
68
  results: @results.map { |r| r.with(attachments: r.attachments.select { |a| keep?(a) }) }
69
69
  )
70
70
  end
@@ -0,0 +1,112 @@
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 GitHub REST client: upsert one marked PR comment and set a commit status.
10
+ class GithubClient
11
+ class Error < StandardError; end
12
+ # 403/404: the token cannot write here (e.g. a fork's read-only token).
13
+ class Forbidden < Error; end
14
+
15
+ MAX_PAGES = 10
16
+ PER_PAGE = 100
17
+ DESCRIPTION_LIMIT = 140
18
+
19
+ def initialize(token:, repository:, api_url: "https://api.github.com", timeout: 30)
20
+ @token = token
21
+ @repository = repository
22
+ @api_url = api_url.to_s.strip.sub(%r{/+\z}, "")
23
+ @timeout = timeout
24
+ end
25
+
26
+ def upsert_comment(pr_number, body, marker)
27
+ existing = find_comment(pr_number, marker)
28
+ response = if existing
29
+ request(:patch, "/repos/#{@repository}/issues/comments/#{existing['id']}", body: body)
30
+ else
31
+ request(:post, "/repos/#{@repository}/issues/#{pr_number}/comments", body: body)
32
+ end
33
+ html_url = response.is_a?(Hash) ? response["html_url"] : nil
34
+ raise Error, "GitHub response had no html_url" if html_url.to_s.empty?
35
+
36
+ html_url
37
+ end
38
+
39
+ def set_status(sha, state:, target_url:, description:, context:)
40
+ return nil if sha.to_s.strip.empty?
41
+
42
+ payload = { state: state, target_url: target_url, context: context,
43
+ description: description.to_s[0, DESCRIPTION_LIMIT] }
44
+ request(:post, "/repos/#{@repository}/statuses/#{sha}", payload: payload)
45
+ nil
46
+ end
47
+
48
+ private
49
+
50
+ def find_comment(pr_number, marker)
51
+ (1..MAX_PAGES).each do |page|
52
+ comments = request(:get, "/repos/#{@repository}/issues/#{pr_number}/comments?per_page=#{PER_PAGE}&page=#{page}")
53
+ raise Error, "unexpected comments response" unless comments.is_a?(Array)
54
+
55
+ found = comments.find { |c| ours?(c, marker) }
56
+ return found if found
57
+ return nil if comments.size < PER_PAGE
58
+ end
59
+ nil
60
+ end
61
+
62
+ # Only our own (bot-authored) marked comment is ever edited.
63
+ def ours?(comment, marker)
64
+ comment.is_a?(Hash) && comment["body"].to_s.start_with?(marker) &&
65
+ comment.dig("user", "type") == "Bot"
66
+ end
67
+
68
+ def redact(text)
69
+ @token.to_s.empty? ? text.to_s : text.to_s.gsub(@token.to_s, "[REDACTED]")
70
+ end
71
+
72
+ def request(verb, path, body: nil, payload: nil)
73
+ uri = URI.parse(@api_url + path)
74
+ raise Error, "GITHUB_API_URL must start with http:// or https://" unless uri.is_a?(URI::HTTP)
75
+
76
+ req = build_request(verb, uri, payload || ({ body: body } if body))
77
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
78
+ open_timeout: @timeout, read_timeout: @timeout,
79
+ write_timeout: @timeout) { |http| http.request(req) }
80
+ handle(response)
81
+ rescue Error
82
+ raise
83
+ rescue StandardError => e
84
+ raise Error, redact("#{e.class}: #{e.message}")
85
+ end
86
+
87
+ def build_request(verb, uri, json)
88
+ klass = { get: Net::HTTP::Get, post: Net::HTTP::Post, patch: Net::HTTP::Patch }.fetch(verb)
89
+ req = klass.new(uri)
90
+ req["Authorization"] = "Bearer #{@token}"
91
+ req["Accept"] = "application/vnd.github+json"
92
+ req["X-GitHub-Api-Version"] = "2022-11-28"
93
+ req["User-Agent"] = "raider_reporter/#{VERSION}"
94
+ if json
95
+ req["Content-Type"] = "application/json"
96
+ req.body = JSON.generate(json)
97
+ end
98
+ req
99
+ end
100
+
101
+ def handle(response)
102
+ code = response.code.to_i
103
+ raise Forbidden, "HTTP #{code}" if [403, 404].include?(code)
104
+ raise Error, redact("HTTP #{code}: #{response.body.to_s[0, 200]}") unless (200..299).cover?(code)
105
+
106
+ JSON.parse(response.body.to_s)
107
+ rescue JSON::ParserError
108
+ raise Error, "GitHub returned invalid JSON"
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+ require "cgi/escape"
7
+
8
+ module RaiderReporter
9
+ module Comment
10
+ # Fetches the rendered build summary from the Raider API. Never raises:
11
+ # failures return nil and set #last_error to a printable reason.
12
+ class SummaryClient
13
+ Summary = Data.define(:state, :markdown, :url)
14
+
15
+ attr_reader :last_error
16
+
17
+ def initialize(endpoint:, token:, timeout: 30)
18
+ @endpoint = endpoint
19
+ @token = token
20
+ @timeout = timeout
21
+ @last_error = nil
22
+ end
23
+
24
+ def fetch(build_key)
25
+ @last_error = nil
26
+ uri = summary_uri(build_key)
27
+ request = Net::HTTP::Get.new(uri)
28
+ request["Authorization"] = "Bearer #{@token}"
29
+ request["Accept"] = "application/json"
30
+ request["User-Agent"] = "raider_reporter/#{VERSION}"
31
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
32
+ open_timeout: @timeout, read_timeout: @timeout,
33
+ write_timeout: @timeout) { |http| http.request(request) }
34
+ parse(response)
35
+ rescue StandardError => e
36
+ fail_with("#{e.class}: #{e.message}")
37
+ end
38
+
39
+ private
40
+
41
+ def summary_uri(build_key)
42
+ uri = URI.parse("#{@endpoint.to_s.strip.chomp('/')}/api/v1/builds/summary?build_key=#{CGI.escape(build_key.to_s)}")
43
+ raise ArgumentError, "endpoint must start with http:// or https://" unless uri.is_a?(URI::HTTP)
44
+
45
+ uri
46
+ end
47
+
48
+ def parse(response)
49
+ case response.code.to_i
50
+ when 200..299 then build_summary(response.body)
51
+ when 401 then fail_with("HTTP 401: unauthorized (check RAIDER_TOKEN)")
52
+ when 404 then fail_with("HTTP 404: build not found for this project")
53
+ else fail_with("HTTP #{response.code}: #{response.body.to_s[0, 200]}")
54
+ end
55
+ end
56
+
57
+ def build_summary(text)
58
+ data = JSON.parse(text.to_s)
59
+ unless data.is_a?(Hash) && %w[state markdown url].all? { |k| data[k].is_a?(String) }
60
+ return fail_with("invalid summary response")
61
+ end
62
+
63
+ Summary.new(state: data["state"], markdown: data["markdown"], url: data["url"])
64
+ rescue JSON::ParserError
65
+ fail_with("invalid summary response (not JSON)")
66
+ end
67
+
68
+ def fail_with(reason)
69
+ @last_error = @token.to_s.empty? ? reason : reason.gsub(@token.to_s, "[REDACTED]")
70
+ nil
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module RaiderReporter
6
+ # Reads the webhook payload GitHub Actions leaves at GITHUB_EVENT_PATH.
7
+ module GithubEvent
8
+ # Returns the parsed event Hash, or nil when missing, unreadable or invalid.
9
+ def self.load(env = ENV)
10
+ path = env["GITHUB_EVENT_PATH"].to_s
11
+ return nil if path.empty?
12
+
13
+ data = JSON.parse(File.read(path))
14
+ data.is_a?(Hash) ? data : nil
15
+ rescue StandardError
16
+ nil
17
+ end
18
+ end
19
+ end
@@ -10,5 +10,5 @@ module RaiderReporter
10
10
  :identifier, :full_name, :file_path, :line_number, :status,
11
11
  :duration_ms, :attempts, :tags, :failure, :attachments
12
12
  )
13
- Run = Data.define(:client_run_id, :started_at, :finished_at, :ci, :git, :environment, :results)
13
+ Run = Data.define(:client_run_id, :started_at, :finished_at, :ci, :git, :environment, :results, :shard)
14
14
  end
@@ -28,12 +28,18 @@ module RaiderReporter
28
28
  }
29
29
  end
30
30
 
31
+ def shard_index = @run.shard&.fetch("index")
32
+
33
+ def attachments_dir = shard_index ? "attachments-#{shard_index}" : "attachments"
34
+
35
+ def run_file = shard_index ? "run-#{shard_index}.json" : "run.json"
36
+
31
37
  def attachment_entries
32
38
  @attachment_entries ||= @run.results.flat_map(&:attachments).each_with_index.map do |attachment, index|
33
39
  {
34
40
  attachment: attachment,
35
41
  part: "att_#{index}",
36
- path: "attachments/#{index}-#{File.basename(attachment.source_path)}"
42
+ path: "#{attachments_dir}/#{index}-#{File.basename(attachment.source_path)}"
37
43
  }
38
44
  end
39
45
  end
@@ -46,7 +52,8 @@ module RaiderReporter
46
52
  "finished_at" => @run.finished_at&.utc&.iso8601(3),
47
53
  "ci" => @run.ci,
48
54
  "git" => @run.git,
49
- "environment" => @run.environment
55
+ "environment" => @run.environment,
56
+ "shard" => @run.shard
50
57
  }.compact
51
58
  end
52
59
 
@@ -45,7 +45,7 @@ module RaiderReporter
45
45
  end
46
46
 
47
47
  def close(_notification)
48
- guard { RaiderReporter.finish!(@output) }
48
+ guard { RaiderReporter.finish! }
49
49
  end
50
50
 
51
51
  private
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RaiderReporter
4
+ # Identifies which slice of a parallel suite this process is.
5
+ class ShardDetector
6
+ def initialize(env: ENV, job: nil)
7
+ @env = env
8
+ @job = job.to_s.strip.empty? ? nil : job.to_s
9
+ end
10
+
11
+ def shard
12
+ index, total, suffix = composed || explicit || parallel_tests
13
+ return nil unless index
14
+
15
+ { "index" => index, "total" => total, "label" => "#{@job || "local"}##{suffix || index}" }
16
+ end
17
+
18
+ private
19
+
20
+ # A matrix leg that also runs parallel_tests: every process gets a unique index.
21
+ def composed
22
+ explicit_index, explicit_total = explicit
23
+ parallel_index, = parallel_tests
24
+ groups = positive(@env["PARALLEL_TEST_GROUPS"])
25
+ return nil unless explicit_index && parallel_index && groups
26
+
27
+ [(explicit_index - 1) * groups + parallel_index,
28
+ explicit_total && explicit_total * groups,
29
+ "#{explicit_index}.#{parallel_index}"]
30
+ end
31
+
32
+ def explicit
33
+ index = positive(@env["RAIDER_SHARD_INDEX"])
34
+ [index, positive(@env["RAIDER_SHARD_TOTAL"])] if index
35
+ end
36
+
37
+ def parallel_tests
38
+ return nil unless @env.key?("TEST_ENV_NUMBER")
39
+
40
+ raw = @env["TEST_ENV_NUMBER"].to_s
41
+ index = raw.empty? ? 1 : positive(raw)
42
+ [index, positive(@env["PARALLEL_TEST_GROUPS"])] if index
43
+ end
44
+
45
+ def positive(value)
46
+ Integer(value.to_s, exception: false)&.then { |n| n.positive? ? n : nil }
47
+ end
48
+ end
49
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RaiderReporter
4
- VERSION = "0.1.1"
4
+ VERSION = "0.3.0"
5
5
  end
@@ -12,12 +12,12 @@ module RaiderReporter
12
12
 
13
13
  def write(payload)
14
14
  dir = File.expand_path(@config.output_dir)
15
- clean(dir) if @config.clean_output_dir
16
- FileUtils.mkdir_p(File.join(dir, "attachments"))
15
+ clean(dir, payload) if @config.clean_output_dir
16
+ FileUtils.mkdir_p(File.join(dir, payload.attachments_dir))
17
17
  payload.attachment_entries.each do |entry|
18
18
  FileUtils.cp(entry[:attachment].source_path, File.join(dir, entry[:path]))
19
19
  end
20
- run_json = File.join(dir, "run.json")
20
+ run_json = File.join(dir, payload.run_file)
21
21
  File.write(run_json, JSON.pretty_generate(payload.to_h(mode: :local)))
22
22
  run_json
23
23
  end
@@ -25,9 +25,9 @@ module RaiderReporter
25
25
  private
26
26
 
27
27
  # Remove only what this writer creates, so output_dir "." or "spec" is safe.
28
- def clean(dir)
29
- FileUtils.rm_f(File.join(dir, "run.json"))
30
- FileUtils.rm_rf(File.join(dir, "attachments"))
28
+ def clean(dir, payload)
29
+ FileUtils.rm_f(File.join(dir, payload.run_file))
30
+ FileUtils.rm_rf(File.join(dir, payload.attachments_dir))
31
31
  end
32
32
  end
33
33
  end
@@ -8,7 +8,7 @@ require "securerandom"
8
8
  module RaiderReporter
9
9
  module Writers
10
10
  class Uploader
11
- Result = Data.define(:ok, :url, :run_id, :error)
11
+ Result = Data.define(:ok, :url, :run_id, :build_url, :error)
12
12
  # Keeps uploads under the server's 50 MB request limit and multipart part limit.
13
13
  MAX_UPLOAD_ATTACHMENT_BYTES = 45 * 1024 * 1024
14
14
  MAX_UPLOAD_ATTACHMENTS = 1000
@@ -42,7 +42,7 @@ module RaiderReporter
42
42
  end
43
43
  to_result(response)
44
44
  rescue StandardError => e
45
- Result.new(ok: false, url: nil, run_id: nil, error: "#{e.class}: #{e.message}")
45
+ Result.new(ok: false, url: nil, run_id: nil, build_url: nil, error: "#{e.class}: #{e.message}")
46
46
  end
47
47
 
48
48
  private
@@ -93,9 +93,11 @@ module RaiderReporter
93
93
  def to_result(response)
94
94
  if response.is_a?(Net::HTTPSuccess)
95
95
  body = parse_body(response.body)
96
- Result.new(ok: true, url: present(body["url"]), run_id: present(body["run_id"]), error: nil)
96
+ build = body["build"].is_a?(Hash) ? body["build"] : {}
97
+ Result.new(ok: true, url: present(body["url"]), run_id: present(body["run_id"]),
98
+ build_url: present(build["url"]), error: nil)
97
99
  else
98
- Result.new(ok: false, url: nil, run_id: nil, error: "HTTP #{response.code}: #{response.body.to_s[0, 200]}")
100
+ Result.new(ok: false, url: nil, run_id: nil, build_url: nil, error: "HTTP #{response.code}: #{response.body.to_s[0, 200]}")
99
101
  end
100
102
  end
101
103
 
@@ -10,7 +10,10 @@ require_relative "raider_reporter/content_types"
10
10
  require_relative "raider_reporter/collector"
11
11
  require_relative "raider_reporter/writers/local"
12
12
  require_relative "raider_reporter/ci_detector"
13
+ require_relative "raider_reporter/shard_detector"
13
14
  require_relative "raider_reporter/writers/uploader"
15
+ require_relative "raider_reporter/comment/summary_client"
16
+ require_relative "raider_reporter/comment/github_client"
14
17
 
15
18
  module RaiderReporter
16
19
  class << self
@@ -65,11 +68,13 @@ module RaiderReporter
65
68
  $stderr.puts("[raider_reporter] #{message}")
66
69
  end
67
70
 
68
- def finish!(output = $stdout)
71
+ def finish!(output = $stderr)
69
72
  return nil if dry_run?
70
73
 
71
74
  detector = CiDetector.new
72
- run = collector.finish_run(ci: detector.ci, git: detector.git, environment: default_environment)
75
+ ci = detector.ci
76
+ shard = ShardDetector.new(job: ci&.fetch("job", nil)).shard
77
+ run = collector.finish_run(ci: ci, git: detector.git, environment: default_environment, shard: shard)
73
78
  payload = Payload.new(run)
74
79
  begin
75
80
  Writers::Local.new(configuration).write(payload)
@@ -92,7 +97,8 @@ module RaiderReporter
92
97
  private
93
98
 
94
99
  def success_line(result)
95
- return "Raider report: #{result.url}" if result.url
100
+ link = result.build_url || result.url
101
+ return "Raider report: #{link}" if link
96
102
  return "Raider report uploaded (run #{result.run_id})" if result.run_id
97
103
 
98
104
  "Raider report uploaded"
metadata CHANGED
@@ -1,34 +1,41 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: raider_reporter
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Augustin Gottlieb
8
- bindir: bin
8
+ bindir: exe
9
9
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
10
+ date: 2026-10-11 00:00:00.000000000 Z
11
11
  dependencies: []
12
12
  description: RSpec formatter that records results, screenshots and videos in the Raider
13
13
  schema, writes them locally, and optionally uploads them to a Raider server.
14
14
  email:
15
15
  - 33221555+aguspe@users.noreply.github.com
16
- executables: []
16
+ executables:
17
+ - raider-reporter
17
18
  extensions: []
18
19
  extra_rdoc_files: []
19
20
  files:
20
21
  - CHANGELOG.md
21
22
  - LICENSE
22
23
  - README.md
24
+ - exe/raider-reporter
23
25
  - lib/raider_reporter.rb
24
26
  - lib/raider_reporter/ci_detector.rb
27
+ - lib/raider_reporter/cli.rb
25
28
  - lib/raider_reporter/collector.rb
29
+ - lib/raider_reporter/comment/github_client.rb
30
+ - lib/raider_reporter/comment/summary_client.rb
26
31
  - lib/raider_reporter/configuration.rb
27
32
  - lib/raider_reporter/content_types.rb
33
+ - lib/raider_reporter/github_event.rb
28
34
  - lib/raider_reporter/models.rb
29
35
  - lib/raider_reporter/payload.rb
30
36
  - lib/raider_reporter/rspec.rb
31
37
  - lib/raider_reporter/rspec/formatter.rb
38
+ - lib/raider_reporter/shard_detector.rb
32
39
  - lib/raider_reporter/version.rb
33
40
  - lib/raider_reporter/writers/local.rb
34
41
  - lib/raider_reporter/writers/uploader.rb
@@ -53,7 +60,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
53
60
  - !ruby/object:Gem::Version
54
61
  version: '0'
55
62
  requirements: []
56
- rubygems_version: 4.0.6
63
+ rubygems_version: 3.6.2
57
64
  specification_version: 4
58
65
  summary: 'Test reporting for Ruby Raider: local run.json and Raider uploads'
59
66
  test_files: []