Skip to content

Commit 85f8240

Browse files
feat(ci): add gotestsum + JUnit test failure reporting (#1599)
Replace raw `go test` with `gotestsum --format testname` across all CI workflows. Test failures are now surfaced via: 1. Formatted table in the "Test Report" step log (visible on click) 2. PR comment with failure table (updated on each push) 3. ::error annotations in the check run 4. Markdown table in $GITHUB_STEP_SUMMARY The report script (.github/scripts/junit-report.py) is shared across all workflows. PR comments are best-effort (skipped on fork PRs where the token lacks write access). Changes: - ci.yml: unit tests + integration tests use gotestsum with JUnit XML - integration.yml: same treatment - windows-ci.yml: same treatment - ci.yml coverage job: adds tr -d '()' for gotestsum output format - New: .github/scripts/junit-report.py shared report script Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent fa8a7d1 commit 85f8240

4 files changed

Lines changed: 179 additions & 4 deletions

File tree

.github/scripts/junit-report.py

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
#!/usr/bin/env python3
2+
"""Parse JUnit XML and report test failures.
3+
4+
Outputs:
5+
1. Formatted table to stdout (visible in the step's log)
6+
2. ::error annotations (visible in the check run annotations)
7+
3. Markdown table to $GITHUB_STEP_SUMMARY
8+
4. PR comment via gh CLI (best-effort, skips on permission errors)
9+
10+
Usage: python3 junit-report.py <junit-xml-path> <heading>
11+
"""
12+
import os
13+
import subprocess
14+
import sys
15+
import xml.etree.ElementTree as ET
16+
17+
def main():
18+
if len(sys.argv) < 2:
19+
print("Usage: junit-report.py <junit.xml> [heading]", file=sys.stderr)
20+
sys.exit(1)
21+
22+
xml_path = sys.argv[1]
23+
heading = sys.argv[2] if len(sys.argv) > 2 else "Test Failures"
24+
25+
if not os.path.exists(xml_path):
26+
return
27+
28+
tree = ET.parse(xml_path)
29+
failures = []
30+
for tc in tree.iter("testcase"):
31+
f = tc.find("failure")
32+
if f is not None:
33+
pkg = tc.get("classname", "")
34+
name = tc.get("name", "")
35+
# Prefer the failure body for detail, fall back to message attr
36+
detail = (f.text or f.get("message") or "").strip()
37+
# Find the first meaningful line (skip === RUN, blank lines, timestamps)
38+
short = ""
39+
for line in detail.split("\n"):
40+
line = line.strip()
41+
if line and not line.startswith("=== RUN") and not line.startswith("--- FAIL"):
42+
short = line[:200]
43+
break
44+
if not short:
45+
short = f.get("message", "failed")
46+
failures.append((pkg, name, short, detail))
47+
48+
if not failures:
49+
return
50+
51+
# 1. Formatted output to stdout (visible in step log)
52+
print(f"\n{'=' * 60}")
53+
print(f" {heading}: {len(failures)} failed")
54+
print(f"{'=' * 60}\n")
55+
for pkg, name, short, detail in failures:
56+
print(f" FAIL {pkg}.{name}")
57+
# Indent the detail for readability
58+
for line in detail.split("\n")[:15]:
59+
print(f" {line}")
60+
print()
61+
print(f"{'=' * 60}\n")
62+
63+
# 2. ::error annotations
64+
for pkg, name, short, _ in failures:
65+
print(f"::error title=FAIL {pkg}.{name}::{short}")
66+
67+
# 3. $GITHUB_STEP_SUMMARY
68+
summary_path = os.environ.get("GITHUB_STEP_SUMMARY", "")
69+
if summary_path:
70+
with open(summary_path, "a") as out:
71+
out.write(f"## {heading}\n\n")
72+
out.write("| Package | Test | Error |\n")
73+
out.write("|---------|------|-------|\n")
74+
for pkg, name, short, _ in failures:
75+
# Escape pipes in the message for markdown tables
76+
safe = short.replace("|", "\\|")
77+
out.write(f"| `{pkg}` | `{name}` | {safe} |\n")
78+
79+
# 4. PR comment (best-effort)
80+
pr_number = os.environ.get("PR_NUMBER", "")
81+
if not pr_number:
82+
return
83+
84+
comment_marker = f"<!-- junit-report: {heading} -->"
85+
body_lines = [
86+
comment_marker,
87+
f"## {heading}",
88+
"",
89+
"| Package | Test | Error |",
90+
"|---------|------|-------|",
91+
]
92+
for pkg, name, short, _ in failures:
93+
safe = short.replace("|", "\\|")
94+
body_lines.append(f"| `{pkg}` | `{name}` | {safe} |")
95+
body_lines.append("")
96+
body_lines.append("_Updated by CI — this comment is replaced on each push._")
97+
body = "\n".join(body_lines)
98+
99+
try:
100+
# Check for existing comment to update
101+
result = subprocess.run(
102+
["gh", "pr", "view", pr_number, "--json", "comments",
103+
"--jq", f'.comments[] | select(.body | startswith("{comment_marker}")) | .url'],
104+
capture_output=True, text=True, timeout=15,
105+
)
106+
existing_url = result.stdout.strip().split("\n")[0] if result.stdout.strip() else ""
107+
108+
if existing_url:
109+
# Extract comment ID from URL and update
110+
comment_id = existing_url.rstrip("/").split("/")[-1]
111+
subprocess.run(
112+
["gh", "api", f"repos/{{owner}}/{{repo}}/issues/comments/{comment_id}",
113+
"-X", "PATCH", "-f", f"body={body}"],
114+
capture_output=True, timeout=15,
115+
)
116+
else:
117+
subprocess.run(
118+
["gh", "pr", "comment", pr_number, "--body", body],
119+
capture_output=True, timeout=15,
120+
)
121+
except Exception:
122+
pass # Best-effort — don't fail the step
123+
124+
# Exit non-zero so the step shows as failed (red X) in the UI
125+
sys.exit(1)
126+
127+
if __name__ == "__main__":
128+
main()

.github/workflows/ci.yml

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,13 +83,24 @@ jobs:
8383
git config --global user.name "CI Bot"
8484
git config --global user.email "ci@gastown.test"
8585
86+
- name: Install gotestsum
87+
run: go install gotest.tools/gotestsum@latest
88+
8689
- name: Build
8790
run: go build -v ./cmd/gt
8891

8992
- name: Test with Coverage
9093
run: |
9194
set -o pipefail
92-
go test -race -short -coverprofile=coverage.out ./... 2>&1 | tee test-output.txt
95+
gotestsum --format testname --junitfile junit.xml -- -race -short -coverprofile=coverage.out ./... 2>&1 | tee test-output.txt
96+
97+
- name: Test Report
98+
if: always()
99+
env:
100+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
101+
PR_NUMBER: ${{ github.event.pull_request.number }}
102+
run: |
103+
python3 .github/scripts/junit-report.py junit.xml "Unit Test Failures"
93104
94105
- name: Upload Coverage Data
95106
if: github.event_name == 'pull_request'
@@ -136,7 +147,9 @@ jobs:
136147
echo "|---------|----------|" >> coverage-report.md
137148
138149
# Extract package coverage from all test output lines
150+
# tr -d '()' handles both raw go test and gotestsum output formats
139151
grep -E "github.com/steveyegge/gastown.*coverage:" test-output.txt | \
152+
tr -d '()' | \
140153
sed 's/.*github.com\/steveyegge\/gastown\///' | \
141154
awk '{
142155
pkg = $1
@@ -251,6 +264,9 @@ jobs:
251264
if: steps.cache-beads-int.outputs.cache-hit != 'true'
252265
run: go install github.com/steveyegge/beads/cmd/bd@v0.52.0
253266

267+
- name: Install gotestsum
268+
run: go install gotest.tools/gotestsum@latest
269+
254270
- name: Build gt
255271
run: |
256272
make build
@@ -260,4 +276,12 @@ jobs:
260276
run: echo "$(go env GOPATH)/bin" >> $GITHUB_PATH
261277

262278
- name: Integration Tests
263-
run: go test -tags=integration -timeout=5m -v ./internal/cmd/...
279+
run: gotestsum --format testname --junitfile junit-integration.xml -- -tags=integration -timeout=5m -v ./internal/cmd/...
280+
281+
- name: Test Report
282+
if: always()
283+
env:
284+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
285+
PR_NUMBER: ${{ github.event.pull_request.number }}
286+
run: |
287+
python3 .github/scripts/junit-report.py junit-integration.xml "Integration Test Failures"

.github/workflows/integration.yml

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,12 +53,23 @@ jobs:
5353
dolt config --global --add user.name "CI Bot"
5454
dolt config --global --add user.email "ci@gastown.test"
5555
56+
- name: Install gotestsum
57+
run: go install gotest.tools/gotestsum@latest
58+
5659
- name: Generate embedded files
5760
run: go generate ./internal/formula/...
5861

5962
- name: Build
6063
run: go build -v ./cmd/gt
6164

6265
- name: Run integration tests
63-
run: go test -v -tags=integration -timeout=8m ./internal/cmd/...
66+
run: gotestsum --format testname --junitfile junit.xml -- -v -tags=integration -timeout=8m ./internal/cmd/...
67+
68+
- name: Test Report
69+
if: always()
70+
env:
71+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
72+
PR_NUMBER: ${{ github.event.pull_request.number }}
73+
run: |
74+
python3 .github/scripts/junit-report.py junit.xml "Integration Test Failures"
6475

.github/workflows/windows-ci.yml

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,23 @@ jobs:
3535
run: |
3636
echo "$(go env GOPATH)/bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
3737
38+
- name: Install gotestsum
39+
run: go install gotest.tools/gotestsum@latest
40+
3841
- name: Generate embedded files
3942
run: go generate ./internal/formula/...
4043

4144
- name: Build
4245
run: go build -v ./cmd/gt
4346

4447
- name: Unit Tests
45-
run: go test -short ./...
48+
run: gotestsum --format testname --junitfile junit.xml -- -short ./...
49+
50+
- name: Test Report
51+
if: always()
52+
shell: bash
53+
env:
54+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
55+
PR_NUMBER: ${{ github.event.pull_request.number }}
56+
run: |
57+
python3 .github/scripts/junit-report.py junit.xml "Windows Test Failures"

0 commit comments

Comments
 (0)