nashira 0.1.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 +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +39 -0
- data/Rakefile +8 -0
- data/action.yml +144 -0
- data/exe/nashira +6 -0
- data/lib/nashira/version.rb +5 -0
- data/lib/nashira.rb +278 -0
- data/sig/nashira.rbs +9 -0
- metadata +74 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: f10b503ffd6a27b47c7b2ceca97319e9e34c48561a0a019e3d7d3ff68fcf4a8e
|
|
4
|
+
data.tar.gz: 0ccde2a93e6a4a8a10f9e561e4d74e0e3ec07032634cad8275ede23eeb75687e
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 3e1d6a744ca35819a42a45b113c9b24d64c5e566b9e1aa90fbfe8e5e6f755caec68835a74db35c2114f8d82b70b16c56cb2be7ec5d672ebc4303624ef313d22c
|
|
7
|
+
data.tar.gz: 6a5c13f7fefa5512015376fc153bc7477f6f05dbb0dfe1542333125d8d3070e4cefb2f899ec7670acbf81a4ab62664aca0e6a7098d546c95a7bc10095d60b971
|
data/CHANGELOG.md
ADDED
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yudai Takada
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Nashira
|
|
2
|
+
|
|
3
|
+
Deterministic CI report cards for JUnit, SimpleCov, and benchmark JSON. Nashira
|
|
4
|
+
always writes both a PNG card and a Markdown summary, so the result remains
|
|
5
|
+
useful when images are unavailable.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
gem install nashira
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
nashira build --junit 'tmp/junit/*.xml' \
|
|
17
|
+
--coverage coverage/.last_run.json \
|
|
18
|
+
--history .ci-history/history.json \
|
|
19
|
+
--now 2026-09-19T14:32:00Z \
|
|
20
|
+
--out card.png --summary summary.md
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Use `--fail-on coverage-drop` (or `--fail-on tests`) in CI. The composite
|
|
24
|
+
action in [`action.yml`](action.yml) installs the gem and appends the summary to
|
|
25
|
+
`GITHUB_STEP_SUMMARY`. Set `history-branch` to persist the history and image on
|
|
26
|
+
an orphan branch; that workflow needs `contents: write`. Without it, the
|
|
27
|
+
action falls back to the cache and still emits the Markdown summary.
|
|
28
|
+
|
|
29
|
+
## Development
|
|
30
|
+
|
|
31
|
+
Run `rake spec` and `gem build --strict nashira.gemspec`.
|
|
32
|
+
|
|
33
|
+
## Contributing
|
|
34
|
+
|
|
35
|
+
Bug reports and pull requests are welcome at https://github.com/noxdea/nashira.
|
|
36
|
+
|
|
37
|
+
## License
|
|
38
|
+
|
|
39
|
+
MIT.
|
data/Rakefile
ADDED
data/action.yml
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
name: Nashira CI report
|
|
2
|
+
description: Render a deterministic CI report card and Job Summary
|
|
3
|
+
inputs:
|
|
4
|
+
junit:
|
|
5
|
+
description: JUnit glob
|
|
6
|
+
required: false
|
|
7
|
+
coverage:
|
|
8
|
+
description: SimpleCov JSON path
|
|
9
|
+
required: false
|
|
10
|
+
bench:
|
|
11
|
+
description: Benchmark JSON path
|
|
12
|
+
required: false
|
|
13
|
+
history:
|
|
14
|
+
description: History JSON path
|
|
15
|
+
required: false
|
|
16
|
+
default: .ci-history/history.json
|
|
17
|
+
out:
|
|
18
|
+
description: PNG output path
|
|
19
|
+
required: false
|
|
20
|
+
default: nashira-card.png
|
|
21
|
+
summary:
|
|
22
|
+
description: Markdown output path
|
|
23
|
+
required: false
|
|
24
|
+
default: nashira-summary.md
|
|
25
|
+
history-branch:
|
|
26
|
+
description: Optional branch name reserved for persistent history
|
|
27
|
+
required: false
|
|
28
|
+
comment-on-pr:
|
|
29
|
+
description: Upsert the report as a pull request comment
|
|
30
|
+
required: false
|
|
31
|
+
default: 'false'
|
|
32
|
+
github-token:
|
|
33
|
+
description: Token used for pull request comments
|
|
34
|
+
required: false
|
|
35
|
+
default: ${{ github.token }}
|
|
36
|
+
runs:
|
|
37
|
+
using: composite
|
|
38
|
+
steps:
|
|
39
|
+
- name: Set up Ruby
|
|
40
|
+
uses: ruby/setup-ruby@984c0c890880bbf811283d6f09c4607c62d210a4 # v1.323.0
|
|
41
|
+
with:
|
|
42
|
+
ruby-version: '3.3'
|
|
43
|
+
- name: Install Nashira
|
|
44
|
+
shell: bash
|
|
45
|
+
env:
|
|
46
|
+
NASHIRA_ACTION_PATH: ${{ github.action_path }}
|
|
47
|
+
run: | # zizmor: ignore[adhoc-packages]
|
|
48
|
+
set -euo pipefail
|
|
49
|
+
cd "$NASHIRA_ACTION_PATH"
|
|
50
|
+
gem build --strict nashira.gemspec
|
|
51
|
+
gem install ./nashira-*.gem --no-document
|
|
52
|
+
- name: Restore report history
|
|
53
|
+
if: inputs.history-branch == ''
|
|
54
|
+
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
|
55
|
+
with:
|
|
56
|
+
path: ${{ inputs.history }}
|
|
57
|
+
key: nashira-history-${{ github.repository }}-${{ github.ref_name }}-${{ github.run_id }}
|
|
58
|
+
restore-keys: |
|
|
59
|
+
nashira-history-${{ github.repository }}-${{ github.ref_name }}-
|
|
60
|
+
- name: Restore branch history
|
|
61
|
+
if: inputs.history-branch != ''
|
|
62
|
+
shell: bash
|
|
63
|
+
env:
|
|
64
|
+
HISTORY_BRANCH: ${{ inputs.history-branch }}
|
|
65
|
+
HISTORY_PATH: ${{ inputs.history }}
|
|
66
|
+
run: |
|
|
67
|
+
set -euo pipefail
|
|
68
|
+
if git fetch --no-tags origin "refs/heads/${HISTORY_BRANCH}:refs/remotes/origin/${HISTORY_BRANCH}"; then
|
|
69
|
+
mkdir -p "$(dirname "$HISTORY_PATH")"
|
|
70
|
+
git show "refs/remotes/origin/${HISTORY_BRANCH}:${HISTORY_PATH}" > "$HISTORY_PATH" || true
|
|
71
|
+
fi
|
|
72
|
+
- name: Build report
|
|
73
|
+
shell: bash
|
|
74
|
+
env:
|
|
75
|
+
NASHIRA_JUNIT: ${{ inputs.junit }}
|
|
76
|
+
NASHIRA_COVERAGE: ${{ inputs.coverage }}
|
|
77
|
+
NASHIRA_BENCH: ${{ inputs.bench }}
|
|
78
|
+
NASHIRA_HISTORY: ${{ inputs.history }}
|
|
79
|
+
NASHIRA_OUT: ${{ inputs.out }}
|
|
80
|
+
NASHIRA_SUMMARY: ${{ inputs.summary }}
|
|
81
|
+
run: |
|
|
82
|
+
args=(build --out "$NASHIRA_OUT" --summary "$NASHIRA_SUMMARY" --history "$NASHIRA_HISTORY")
|
|
83
|
+
[[ -n "$NASHIRA_JUNIT" ]] && args+=(--junit "$NASHIRA_JUNIT")
|
|
84
|
+
[[ -n "$NASHIRA_COVERAGE" ]] && args+=(--coverage "$NASHIRA_COVERAGE")
|
|
85
|
+
[[ -n "$NASHIRA_BENCH" ]] && args+=(--bench "$NASHIRA_BENCH")
|
|
86
|
+
nashira "${args[@]}"
|
|
87
|
+
- name: Publish branch history and image
|
|
88
|
+
if: inputs.history-branch != ''
|
|
89
|
+
shell: bash
|
|
90
|
+
env:
|
|
91
|
+
HISTORY_BRANCH: ${{ inputs.history-branch }}
|
|
92
|
+
HISTORY_PATH: ${{ inputs.history }}
|
|
93
|
+
IMAGE_PATH: ${{ inputs.out }}
|
|
94
|
+
SUMMARY_PATH: ${{ inputs.summary }}
|
|
95
|
+
run: |
|
|
96
|
+
set -euo pipefail
|
|
97
|
+
workspace="$GITHUB_WORKSPACE"
|
|
98
|
+
branch_dir="$(mktemp -d)"
|
|
99
|
+
cleanup() { git worktree remove --force "$branch_dir" >/dev/null 2>&1 || true; rm -rf "$branch_dir"; }
|
|
100
|
+
trap cleanup EXIT
|
|
101
|
+
git worktree add --detach "$branch_dir" HEAD >/dev/null
|
|
102
|
+
cd "$branch_dir"
|
|
103
|
+
git checkout --orphan "$HISTORY_BRANCH" >/dev/null 2>&1
|
|
104
|
+
git rm -rf . >/dev/null 2>&1 || true
|
|
105
|
+
mkdir -p "$(dirname "$HISTORY_PATH")"
|
|
106
|
+
cp "$workspace/$HISTORY_PATH" "$HISTORY_PATH"
|
|
107
|
+
image_name="$(basename "$IMAGE_PATH")"
|
|
108
|
+
cp "$workspace/$IMAGE_PATH" "$image_name"
|
|
109
|
+
git add "$HISTORY_PATH" "$image_name"
|
|
110
|
+
git -c user.name=nashira -c user.email=nashira@users.noreply.github.com commit -m "Update CI report" >/dev/null
|
|
111
|
+
raw_url="https://raw.githubusercontent.com/${GITHUB_REPOSITORY}/${HISTORY_BRANCH}/${image_name}"
|
|
112
|
+
git push origin "HEAD:${HISTORY_BRANCH}" --force >/dev/null || {
|
|
113
|
+
echo "nashira: unable to publish history branch; summary remains available"
|
|
114
|
+
exit 0
|
|
115
|
+
}
|
|
116
|
+
summary="$workspace/$SUMMARY_PATH"
|
|
117
|
+
if [[ -f "$summary" ]]; then
|
|
118
|
+
sed -i.bak "s#](${IMAGE_PATH})#](${raw_url})#g" "$summary"
|
|
119
|
+
rm -f "$summary.bak"
|
|
120
|
+
fi
|
|
121
|
+
- name: Append report summary
|
|
122
|
+
shell: bash
|
|
123
|
+
env:
|
|
124
|
+
SUMMARY_PATH: ${{ inputs.summary }}
|
|
125
|
+
run: cat "$SUMMARY_PATH" >> "$GITHUB_STEP_SUMMARY"
|
|
126
|
+
- name: Upsert pull request comment
|
|
127
|
+
if: inputs.comment-on-pr == 'true' && github.event_name == 'pull_request'
|
|
128
|
+
shell: bash
|
|
129
|
+
env:
|
|
130
|
+
GH_TOKEN: ${{ inputs.github-token }}
|
|
131
|
+
PR_NUMBER: ${{ github.event.pull_request.number }}
|
|
132
|
+
SUMMARY_PATH: ${{ inputs.summary }}
|
|
133
|
+
run: |
|
|
134
|
+
marker='<!-- nashira-report -->'
|
|
135
|
+
body="$(printf '%s\n\n%s' "$marker" "$(cat "$SUMMARY_PATH")")"
|
|
136
|
+
if ! existing=$(gh api --paginate "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" --jq '.[] | select(.body | contains("<!-- nashira-report -->")) | .id' | tail -1); then
|
|
137
|
+
echo 'nashira: unable to read PR comments; summary remains available'
|
|
138
|
+
exit 0
|
|
139
|
+
fi
|
|
140
|
+
if [[ -n "$existing" ]]; then
|
|
141
|
+
gh api --method PATCH "repos/${GITHUB_REPOSITORY}/issues/comments/${existing}" -f body="$body"
|
|
142
|
+
else
|
|
143
|
+
gh api --method POST "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" -f body="$body"
|
|
144
|
+
fi || echo 'nashira: unable to update PR comment; summary remains available'
|
data/exe/nashira
ADDED
data/lib/nashira.rb
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "optparse"
|
|
6
|
+
require "rexml/document"
|
|
7
|
+
require "time"
|
|
8
|
+
require "zlib"
|
|
9
|
+
require "zaniah"
|
|
10
|
+
require "zaniah/ui"
|
|
11
|
+
begin
|
|
12
|
+
require "auva"
|
|
13
|
+
rescue LoadError
|
|
14
|
+
end
|
|
15
|
+
require_relative "nashira/version"
|
|
16
|
+
|
|
17
|
+
module Nashira
|
|
18
|
+
class Error < StandardError; end
|
|
19
|
+
Tests = Data.define(:total, :failed, :skipped, :duration, :slowest, :failures)
|
|
20
|
+
CoverageData = Data.define(:percent, :covered, :total, :delta)
|
|
21
|
+
Bench = Data.define(:name, :value, :unit, :delta)
|
|
22
|
+
HistoryPoint = Data.define(:commit, :coverage, :tests, :at)
|
|
23
|
+
Report = Data.define(:status, :repository, :branch, :commit, :run_url, :finished_at,
|
|
24
|
+
:duration, :tests, :coverage, :benchmarks, :history) do
|
|
25
|
+
def self.build(tests: nil, coverage: nil, benchmarks: [], history: [], **metadata)
|
|
26
|
+
status = tests&.failed.to_i.positive? ? :fail : :pass
|
|
27
|
+
new(status: status, repository: metadata[:repository], branch: metadata[:branch],
|
|
28
|
+
commit: metadata[:commit], run_url: metadata[:run_url], finished_at: metadata[:finished_at],
|
|
29
|
+
duration: tests&.duration, tests: tests, coverage: coverage,
|
|
30
|
+
benchmarks: benchmarks || [], history: history || [])
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
ParseResult = Data.define(:value, :warnings)
|
|
34
|
+
|
|
35
|
+
module JUnit
|
|
36
|
+
module_function
|
|
37
|
+
|
|
38
|
+
def parse(paths, top: 5)
|
|
39
|
+
warnings = []
|
|
40
|
+
cases = []
|
|
41
|
+
Array(paths).flat_map { |path| Dir[path.to_s] }.sort.each do |path|
|
|
42
|
+
begin
|
|
43
|
+
root = REXML::Document.new(File.read(path, encoding: "UTF-8").delete_prefix("\uFEFF")).root
|
|
44
|
+
raise REXML::ParseException, "missing root" unless root
|
|
45
|
+
root.each_element(".//testcase") do |testcase|
|
|
46
|
+
time = Float(testcase.attributes["time"] || 0)
|
|
47
|
+
name = [testcase.attributes["classname"], testcase.attributes["name"]].compact.join("#")
|
|
48
|
+
kind = testcase.elements["failure"] ? :failure : testcase.elements["error"] ? :error : testcase.elements["skipped"] ? :skipped : :pass
|
|
49
|
+
cases << [name.empty? ? "unknown" : name, time, kind]
|
|
50
|
+
rescue ArgumentError
|
|
51
|
+
cases << [testcase.attributes["name"] || "unknown", 0.0, :pass]
|
|
52
|
+
warnings << "#{path}: invalid testcase time"
|
|
53
|
+
end
|
|
54
|
+
rescue StandardError => error
|
|
55
|
+
warnings << "#{path}: #{error.message}"
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
failures = cases.filter_map { |name, _, kind| name if %i[failure error].include?(kind) }
|
|
59
|
+
result = Tests.new(total: cases.length, failed: failures.length,
|
|
60
|
+
skipped: cases.count { |_, _, kind| kind == :skipped }, duration: cases.sum { |_, time, _| time },
|
|
61
|
+
slowest: cases.sort_by { |_, time, _| -time }.first(top).map { |name, time, _| [name, time] }, failures: failures.first(8))
|
|
62
|
+
ParseResult.new(value: result, warnings: warnings)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
module CoverageParser
|
|
67
|
+
module_function
|
|
68
|
+
|
|
69
|
+
def parse(path, base: nil)
|
|
70
|
+
return ParseResult.new(value: nil, warnings: []) unless path && File.file?(path)
|
|
71
|
+
warnings = []
|
|
72
|
+
data = JSON.parse(File.read(path, encoding: "UTF-8").delete_prefix("\uFEFF"))
|
|
73
|
+
lines = data.dig("result", "line") || data.dig("result", "lines") || data.dig("metrics", "lines") || data["lines"] || {}
|
|
74
|
+
percent = lines["percent"] || lines["covered_percent"] || data["covered_percent"] || data["percent"]
|
|
75
|
+
covered = lines["covered"] || data["covered"]
|
|
76
|
+
total = lines["total"] || data["total"]
|
|
77
|
+
percent = percent.to_f
|
|
78
|
+
percent = covered.to_f * 100 / total if percent.zero? && covered && total && total.to_f.positive?
|
|
79
|
+
ParseResult.new(value: CoverageData.new(percent: percent, covered: covered, total: total,
|
|
80
|
+
delta: base.nil? ? nil : percent - base.to_f), warnings: warnings)
|
|
81
|
+
rescue JSON::ParserError, Errno::ENOENT, TypeError, NoMethodError => error
|
|
82
|
+
ParseResult.new(value: nil, warnings: ["#{path}: #{error.message}"])
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
module Benchmarks
|
|
87
|
+
module_function
|
|
88
|
+
|
|
89
|
+
def parse(path)
|
|
90
|
+
return ParseResult.new(value: [], warnings: []) unless path && File.file?(path)
|
|
91
|
+
value = JSON.parse(File.read(path, encoding: "UTF-8").delete_prefix("\uFEFF"))
|
|
92
|
+
values = value.is_a?(Array) ? value : value.fetch("benchmarks", value.fetch("results", []))
|
|
93
|
+
values = values.map.with_index do |entry, index|
|
|
94
|
+
entry = {"value" => entry} unless entry.is_a?(Hash)
|
|
95
|
+
Bench.new(name: (entry["name"] || entry["label"] || "bench-#{index}"), value: entry["value"].to_f,
|
|
96
|
+
unit: entry["unit"] || "", delta: entry["delta"])
|
|
97
|
+
end
|
|
98
|
+
ParseResult.new(value: values, warnings: [])
|
|
99
|
+
rescue JSON::ParserError, Errno::ENOENT, TypeError, NoMethodError => error
|
|
100
|
+
ParseResult.new(value: [], warnings: ["#{path}: #{error.message}"])
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
module History
|
|
105
|
+
MAX = 50
|
|
106
|
+
module_function
|
|
107
|
+
|
|
108
|
+
def load(path)
|
|
109
|
+
return [] unless path && File.file?(path)
|
|
110
|
+
values = JSON.parse(File.read(path, encoding: "UTF-8").delete_prefix("\uFEFF"))
|
|
111
|
+
return [] unless values.is_a?(Array)
|
|
112
|
+
values.filter_map do |entry|
|
|
113
|
+
next unless entry.is_a?(Hash)
|
|
114
|
+
HistoryPoint.new(commit: entry["commit"], coverage: entry["coverage"], tests: entry["tests"], at: entry["at"])
|
|
115
|
+
end
|
|
116
|
+
rescue JSON::ParserError, Errno::ENOENT, TypeError
|
|
117
|
+
[]
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def append(path, point)
|
|
121
|
+
values = load(path) + [point]
|
|
122
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
123
|
+
File.write(path, JSON.pretty_generate(values.last(MAX).map(&:to_h)) + "\n")
|
|
124
|
+
values.last(MAX)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
module Summary
|
|
129
|
+
module_function
|
|
130
|
+
|
|
131
|
+
def markdown(report, image: nil)
|
|
132
|
+
lines = ["## Nashira: #{report.status.to_s.upcase}", ""]
|
|
133
|
+
lines << "" if image
|
|
134
|
+
lines << "| Metric | Value |" << "| --- | --- |"
|
|
135
|
+
if report.tests
|
|
136
|
+
lines << "| Tests | #{report.tests.total} (#{report.tests.failed} failed, #{report.tests.skipped} skipped) |"
|
|
137
|
+
end
|
|
138
|
+
if report.coverage
|
|
139
|
+
delta = report.coverage.delta ? format(" (%+.2f%%)", report.coverage.delta) : ""
|
|
140
|
+
lines << "| Coverage | #{format("%.2f", report.coverage.percent)}%#{delta} |"
|
|
141
|
+
end
|
|
142
|
+
report.benchmarks.each { |bench| lines << "| #{bench.name} | #{bench.value} #{bench.unit} |" }
|
|
143
|
+
if report.status != :fail && report.history.length > 1
|
|
144
|
+
trend = report.history.last(20).filter_map(&:coverage).map { |value| format("%.2f", value) }.join(" → ")
|
|
145
|
+
lines << "| Coverage trend | #{trend} |"
|
|
146
|
+
end
|
|
147
|
+
if report.tests&.slowest&.any?
|
|
148
|
+
lines.concat(["", "### Slowest tests", *report.tests.slowest.map { |name, seconds| "- `#{name}` (#{format("%.3f", seconds)}s)" }])
|
|
149
|
+
end
|
|
150
|
+
if report.tests&.failures&.any?
|
|
151
|
+
lines.concat(["", "### Failed tests", *report.tests.failures.map { |name| "- `#{name}`" }]) if report.status == :fail
|
|
152
|
+
end
|
|
153
|
+
lines.join("\n") + "\n"
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
module View
|
|
158
|
+
module_function
|
|
159
|
+
|
|
160
|
+
def call(report, theme: Zaniah::Theme.dark)
|
|
161
|
+
title = "[#{report.status.to_s.upcase}] #{report.repository || "CI report"}"
|
|
162
|
+
status = report.status == :pass ? :success : :danger
|
|
163
|
+
header = Zaniah::UI::Card.new(
|
|
164
|
+
Zaniah::UI::Label.new(title, size: :xl),
|
|
165
|
+
Zaniah::UI::Badge.new(report.status.to_s.upcase, variant: status)
|
|
166
|
+
)
|
|
167
|
+
metrics = Zaniah::Div.new.flex_row.gap(12).children([
|
|
168
|
+
metric("Tests", report.tests && "#{report.tests.total} (#{report.tests.failed} failed)", theme),
|
|
169
|
+
metric("Coverage", report.coverage && format("%.2f%%", report.coverage.percent), theme),
|
|
170
|
+
*report.benchmarks.first(3).map { |bench| metric(bench.name, "#{bench.value} #{bench.unit}", theme) }
|
|
171
|
+
].compact)
|
|
172
|
+
trend = if report.status != :fail && report.history.length > 1
|
|
173
|
+
values = report.history.last(20).filter_map(&:coverage)
|
|
174
|
+
Zaniah::UI::Card.new(Zaniah::UI::Label.new("Coverage trend", size: :md), Zaniah::UI::Sparkline.new(values, width: 640, height: 120)) unless values.empty?
|
|
175
|
+
elsif report.tests&.failures&.any?
|
|
176
|
+
Zaniah::UI::Card.new(Zaniah::UI::Label.new("Failed tests", size: :md),
|
|
177
|
+
*report.tests.failures.first(8).map { |name| Zaniah::UI::Label.new(name, tone: :muted, size: :sm) })
|
|
178
|
+
end
|
|
179
|
+
slow = if report.tests&.slowest&.any?
|
|
180
|
+
rows = report.tests.slowest.map { |name, seconds| {name: name, duration: format("%.3fs", seconds)} }
|
|
181
|
+
Zaniah::UI::Table.new(rows, columns: [
|
|
182
|
+
{key: :name, label: "Test", width: 560, sortable: false},
|
|
183
|
+
{key: :duration, label: "Duration", width: 140, sortable: false}
|
|
184
|
+
], height: [rows.length * 32 + 40, 100].max, selection: :none)
|
|
185
|
+
end
|
|
186
|
+
root = Zaniah::Div.new.flex_col.p(48).gap(18).bg(theme.colors.background).child(header).child(metrics)
|
|
187
|
+
root.child(trend) if trend
|
|
188
|
+
root.child(Zaniah::UI::Card.new(Zaniah::UI::Label.new("Slowest tests", size: :md), slow)) if slow
|
|
189
|
+
root
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def metric(label, value, _theme)
|
|
193
|
+
return unless value
|
|
194
|
+
Zaniah::UI::Card.new(Zaniah::UI::Label.new(label, tone: :muted, size: :xs), Zaniah::UI::Label.new(value, size: :lg))
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
class Renderer
|
|
199
|
+
def initialize(theme: Zaniah::Theme.dark, width: 1200, height: 800)
|
|
200
|
+
@theme, @width, @height = theme, width, height
|
|
201
|
+
@font_db = Zaniah::TextSystem::FontDB.new(paths: [])
|
|
202
|
+
@font = @font_db.find(family: theme.typography.font_sans)
|
|
203
|
+
@text_system = Zaniah::TextSystem::Renderer.new(font: @font, font_db: @font_db)
|
|
204
|
+
rescue StandardError
|
|
205
|
+
@font_db = @font = @text_system = nil
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def render(report)
|
|
209
|
+
app = Zaniah::App.new
|
|
210
|
+
window = app.open_window(backend: :headless, width: @width, height: @height)
|
|
211
|
+
app.global(:theme, @theme)
|
|
212
|
+
window.text_system = @text_system if @text_system
|
|
213
|
+
window.draw { View.call(report, theme: @theme) }
|
|
214
|
+
window.tick
|
|
215
|
+
device = window.device
|
|
216
|
+
Zaniah::PNG.encode(device.width.to_i, device.height.to_i, device.pixels)
|
|
217
|
+
ensure
|
|
218
|
+
window&.close
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
module_function
|
|
223
|
+
|
|
224
|
+
def theme(value)
|
|
225
|
+
return value if value.respond_to?(:colors)
|
|
226
|
+
return Auva.load(value) if defined?(Auva) && File.file?(value.to_s)
|
|
227
|
+
return Auva.builtin(value) if defined?(Auva)
|
|
228
|
+
Zaniah::Theme.public_send(value.to_s)
|
|
229
|
+
rescue StandardError
|
|
230
|
+
raise Error, "unknown theme: #{value}"
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
class CLI
|
|
234
|
+
def self.run(argv, out: $stdout, err: $stderr)
|
|
235
|
+
options = {junit: [], coverage: nil, bench: nil, history: nil, out: "card.png", summary: "summary.md", base: nil, now: Time.now.utc.iso8601, title: nil, theme: :dark, branch: nil, commit: nil, run_url: nil, fail_on: nil}
|
|
236
|
+
OptionParser.new do |opts|
|
|
237
|
+
opts.banner = "Usage: nashira build [options]"
|
|
238
|
+
opts.on("--junit GLOB") { |v| options[:junit] << v }
|
|
239
|
+
opts.on("--coverage PATH") { |v| options[:coverage] = v }
|
|
240
|
+
opts.on("--bench PATH") { |v| options[:bench] = v }
|
|
241
|
+
opts.on("--history PATH") { |v| options[:history] = v }
|
|
242
|
+
opts.on("--base-coverage VALUE", Float) { |v| options[:base] = v }
|
|
243
|
+
opts.on("--now TIME") { |v| options[:now] = v }
|
|
244
|
+
opts.on("--out PATH") { |v| options[:out] = v }
|
|
245
|
+
opts.on("--summary PATH") { |v| options[:summary] = v }
|
|
246
|
+
opts.on("--title TITLE") { |v| options[:title] = v }
|
|
247
|
+
opts.on("--theme NAME") { |v| options[:theme] = v }
|
|
248
|
+
opts.on("--branch NAME") { |v| options[:branch] = v }
|
|
249
|
+
opts.on("--commit SHA") { |v| options[:commit] = v }
|
|
250
|
+
opts.on("--run-url URL") { |v| options[:run_url] = v }
|
|
251
|
+
opts.on("--fail-on NAME") { |v| options[:fail_on] = v }
|
|
252
|
+
end.parse!(argv.drop(argv.first == "build" ? 1 : 0))
|
|
253
|
+
tests_result = JUnit.parse(options[:junit])
|
|
254
|
+
coverage_result = CoverageParser.parse(options[:coverage], base: options[:base])
|
|
255
|
+
benchmarks_result = Benchmarks.parse(options[:bench])
|
|
256
|
+
tests = tests_result.value
|
|
257
|
+
coverage = coverage_result.value
|
|
258
|
+
benchmarks = benchmarks_result.value
|
|
259
|
+
warnings = tests_result.warnings + coverage_result.warnings + benchmarks_result.warnings
|
|
260
|
+
history = History.load(options[:history])
|
|
261
|
+
point = options[:history] && HistoryPoint.new(commit: options[:commit], coverage: coverage&.percent,
|
|
262
|
+
tests: tests&.total, at: options[:now])
|
|
263
|
+
report = Report.build(tests: tests, coverage: coverage, benchmarks: benchmarks,
|
|
264
|
+
history: point ? history + [point] : history,
|
|
265
|
+
repository: options[:title], branch: options[:branch], commit: options[:commit], run_url: options[:run_url], finished_at: options[:now])
|
|
266
|
+
FileUtils.mkdir_p(File.dirname(options[:out]))
|
|
267
|
+
FileUtils.mkdir_p(File.dirname(options[:summary]))
|
|
268
|
+
File.binwrite(options[:out], Renderer.new(theme: Nashira.theme(options[:theme])).render(report))
|
|
269
|
+
File.write(options[:summary], Summary.markdown(report, image: options[:out]))
|
|
270
|
+
History.append(options[:history], point) if point
|
|
271
|
+
warnings.each { |warning| err.puts "nashira: warning: #{warning}" }
|
|
272
|
+
(options[:fail_on] == "coverage-drop" && coverage&.delta.to_f.negative?) || (options[:fail_on] == "tests" && tests&.failed.to_i.positive?) ? 1 : 0
|
|
273
|
+
rescue OptionParser::ParseError, KeyError, Error => error
|
|
274
|
+
err.puts "nashira: #{error.message}"
|
|
275
|
+
1
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
end
|
data/sig/nashira.rbs
ADDED
metadata
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: nashira
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Yudai Takada
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: zaniah
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: 0.6.0
|
|
19
|
+
- - "<"
|
|
20
|
+
- !ruby/object:Gem::Version
|
|
21
|
+
version: '0.7'
|
|
22
|
+
type: :runtime
|
|
23
|
+
prerelease: false
|
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
25
|
+
requirements:
|
|
26
|
+
- - ">="
|
|
27
|
+
- !ruby/object:Gem::Version
|
|
28
|
+
version: 0.6.0
|
|
29
|
+
- - "<"
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: '0.7'
|
|
32
|
+
description: Parse test, coverage, and benchmark reports into deterministic PNG and
|
|
33
|
+
Markdown summaries.
|
|
34
|
+
email:
|
|
35
|
+
- t.yudai92@gmail.com
|
|
36
|
+
executables:
|
|
37
|
+
- nashira
|
|
38
|
+
extensions: []
|
|
39
|
+
extra_rdoc_files: []
|
|
40
|
+
files:
|
|
41
|
+
- CHANGELOG.md
|
|
42
|
+
- LICENSE.txt
|
|
43
|
+
- README.md
|
|
44
|
+
- Rakefile
|
|
45
|
+
- action.yml
|
|
46
|
+
- exe/nashira
|
|
47
|
+
- lib/nashira.rb
|
|
48
|
+
- lib/nashira/version.rb
|
|
49
|
+
- sig/nashira.rbs
|
|
50
|
+
homepage: https://github.com/noxdea/nashira
|
|
51
|
+
licenses:
|
|
52
|
+
- MIT
|
|
53
|
+
metadata:
|
|
54
|
+
allowed_push_host: https://rubygems.org
|
|
55
|
+
source_code_uri: https://github.com/noxdea/nashira
|
|
56
|
+
rubygems_mfa_required: 'true'
|
|
57
|
+
rdoc_options: []
|
|
58
|
+
require_paths:
|
|
59
|
+
- lib
|
|
60
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
61
|
+
requirements:
|
|
62
|
+
- - ">="
|
|
63
|
+
- !ruby/object:Gem::Version
|
|
64
|
+
version: 3.2.0
|
|
65
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
66
|
+
requirements:
|
|
67
|
+
- - ">="
|
|
68
|
+
- !ruby/object:Gem::Version
|
|
69
|
+
version: '0'
|
|
70
|
+
requirements: []
|
|
71
|
+
rubygems_version: 4.0.16
|
|
72
|
+
specification_version: 4
|
|
73
|
+
summary: CI report cards for GitHub summaries and pull requests
|
|
74
|
+
test_files: []
|