diff --git a/.github/workflows/coderepute-report.yml b/.github/workflows/coderepute-report.yml index 2d1f90a..707ecd1 100644 --- a/.github/workflows/coderepute-report.yml +++ b/.github/workflows/coderepute-report.yml @@ -62,6 +62,9 @@ on: report-pdf: description: Path to the generated report.pdf. value: ${{ jobs.report.outputs.report-pdf }} + card-png: + description: Path to the generated card.png. + value: ${{ jobs.report.outputs.card-png }} jobs: report: @@ -74,6 +77,7 @@ jobs: outputs: attestation-url: ${{ steps.run.outputs.attestation-url }} report-pdf: ${{ steps.run.outputs.report-pdf }} + card-png: ${{ steps.run.outputs.card-png }} steps: - name: Check out the pinned action source uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 diff --git a/README.md b/README.md index 75ddb1e..c4d2b55 100644 --- a/README.md +++ b/README.md @@ -13,9 +13,9 @@ comment depth, time to merge, and activity cadence — directly from API metadata, with no source code access required. The report runs inside your organization's CI pipeline, attests the output -with a Sigstore signature, and produces a self-contained HTML file and a -machine-readable JSON record that hiring managers and engineering teams can -independently verify have not been edited after collection. +with a Sigstore signature, and produces a self-contained HTML file, a +machine-readable JSON record, and a share card that hiring managers and +engineering teams can independently verify have not been edited after collection. --- @@ -303,7 +303,10 @@ gh attestation verify report.html --repo your-org/your-repo # 2. Verify the PDF report gh attestation verify report.pdf --repo your-org/your-repo -# 3. Confirm the producing workflow is the canonical CodeRepute action +# 3. Verify the share card +gh attestation verify card.png --repo your-org/your-repo + +# 4. Confirm the producing workflow is the canonical CodeRepute action gh attestation verify report.html --repo your-org/your-repo \ --signer-workflow gkanitz/CodeRepute/.github/workflows/coderepute-report.yml ``` @@ -332,6 +335,8 @@ what passing verification proves, and what it does not. |---|---| | `report.html` | Self-contained HTML with inline SVG charts and embedded report JSON. The HTML file itself is the attested artifact — the embedded JSON is not a separate file. | | `report.pdf` | CI-generated PDF produced by headless Chromium from `report.html`. Independently attested with its own Sigstore signature. | +| `card.svg` | Static 1200×627 share card with four headline numbers, QR verify link, and Sigstore attestation mark. Self-contained, no external references. | +| `card.png` | CI-generated PNG from `card.svg`, rendered by headless Chromium. Independently attested with its own Sigstore signature. | --- diff --git a/action.yml b/action.yml index da8d259..581301a 100644 --- a/action.yml +++ b/action.yml @@ -16,7 +16,7 @@ name: CodeRepute Report description: >- Generate a verified developer collaboration report from GitHub metadata - and attest report.html + report.pdf with Sigstore. + and attest report.html, report.pdf, and card.png with Sigstore. author: gkanitz branding: @@ -70,6 +70,9 @@ outputs: report-pdf: description: Path to the generated report.pdf. value: ${{ steps.pdf.outputs.report-pdf }} + card-png: + description: Path to the generated card.png. + value: ${{ steps.card-img.outputs.card-png }} attestation-url: description: URL of the stored attestation (empty when attest=false). value: ${{ steps.attest.outputs.attestation-url }} @@ -127,7 +130,20 @@ runs: "file://$(realpath "$CR_OUT/report.html")" echo "report-pdf=$CR_OUT/report.pdf" >> "$GITHUB_OUTPUT" - - name: Attest report.html and report.pdf + - name: Generate card PNG from card SVG + id: card-img + if: ${{ inputs.attest == 'true' }} + shell: bash + env: + CR_OUT: ${{ inputs.out }} + run: | + google-chrome --headless --disable-gpu --no-sandbox \ + --screenshot="$CR_OUT/card.png" \ + --window-size=1200,627 \ + "file://$(realpath "$CR_OUT/card.svg")" + echo "card-png=$CR_OUT/card.png" >> "$GITHUB_OUTPUT" + + - name: Attest report.html, report.pdf, and card.png id: attest if: ${{ inputs.attest == 'true' }} uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 @@ -135,6 +151,7 @@ runs: subject-path: | ${{ steps.generate.outputs.report-html }} ${{ steps.pdf.outputs.report-pdf }} + ${{ steps.card-img.outputs.card-png }} - name: Upload report artifacts uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/cmd/coderepute/main.go b/cmd/coderepute/main.go index 7e64065..5eafd8b 100644 --- a/cmd/coderepute/main.go +++ b/cmd/coderepute/main.go @@ -249,7 +249,18 @@ func writeReport(stderr io.Writer, r *report.Report, outDir string) int { fmt.Fprintf(stderr, "coderepute: %v\n", err) return 1 } - fmt.Fprintf(stderr, "wrote %s\n", filepath.Join(outDir, "report.html")) + + cardSVG, err := render.CardSVG(*r) + if err != nil { + fmt.Fprintf(stderr, "coderepute: card svg: %v\n", err) + return 1 + } + if err := os.WriteFile(filepath.Join(outDir, "card.svg"), cardSVG, 0o644); err != nil { + fmt.Fprintf(stderr, "coderepute: %v\n", err) + return 1 + } + fmt.Fprintf(stderr, "wrote %s\n", filepath.Join(outDir, "card.svg")) + return 0 } diff --git a/render/card_test.go b/render/card_test.go new file mode 100644 index 0000000..2807707 --- /dev/null +++ b/render/card_test.go @@ -0,0 +1,556 @@ +package render_test + +import ( + "bytes" + "fmt" + "os" + "regexp" + "strings" + "testing" + "time" + + "github.com/gkanitz/coderepute/render" + "github.com/gkanitz/coderepute/report" +) + +const ( + cardGoldenFull = "testdata/card-full.golden.svg" + cardGoldenSparse = "testdata/card-sparse.golden.svg" + cardGoldenUnverified = "testdata/card-unverified.golden.svg" +) + +// cardFixture returns a full report with all four headline metrics populated and +// a verified verification block. This is the baseline for golden tests. +func cardFixture() report.Report { + return report.Report{ + SchemaVersion: report.SchemaVersion, + GeneratedAt: time.Date(2026, 6, 12, 10, 0, 0, 0, time.UTC), + Subject: report.Subject{ + Platform: "github", + Username: "alice", + AccountID: "1234567", + }, + Coverage: &report.Coverage{ + Repos: []string{"acme/widgets"}, + TokenScope: "repo,read:org", + TokenScopeClass: "classic-pat", + Window: report.Window{ + Since: func() *time.Time { t := time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC); return &t }(), + Until: time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC), + }, + }, + Verification: &report.Verification{ + Status: report.StatusVerified, + Provider: "github-actions", + Repository: "acme/widgets", + WorkflowRef: "acme/widgets/.github/workflows/coderepute.yml@refs/heads/main", + RunID: "8000000042", + RunURL: "https://github.com/acme/widgets/actions/runs/8000000042", + VerifyURL: "https://gkanitz.github.io/CodeRepute/verify/?repo=acme%2Fwidgets&subject=alice", + Attestation: &report.Attestation{ + Type: "sigstore-github-artifact-attestation", + URL: "https://github.com/acme/widgets/attestations", + VerifyCommand: "gh attestation verify report.json --repo acme/widgets", + }, + }, + Collaboration: &report.Collaboration{ + PullRequests: &report.PullRequestStats{Authored: 47, Merged: 42}, + ReviewsGiven: &report.ReviewStats{Total: 63, Approvals: 51, ChangesRequested: 12, DeepReviewCount: 19}, + }, + Cadence: &report.Cadence{ + ActiveDays: 198, + Contributions: 371, + }, + } +} + +// fullCardFixtureFromSample reads the sample report JSON (the same one used by +// the HTML golden tests) and renders it as a card. This is the "full sample" +// fixture for golden-file comparison. +func fullCardFixtureFromSample(t *testing.T) []byte { + t.Helper() + raw, err := os.ReadFile(fixturePath) + if err != nil { + t.Fatalf("read fixture: %v", err) + } + r, err := report.Parse(raw) + if err != nil { + t.Fatalf("parse fixture: %v", err) + } + svg, err := render.CardSVG(r) + if err != nil { + t.Fatalf("CardSVG: %v", err) + } + return svg +} + +// cardGoldenTest is a helper that renders a CardSVG from the given report and +// either updates the golden file or compares against it. +func cardGoldenTest(t *testing.T, r report.Report, goldenPath string) { + t.Helper() + svg, err := render.CardSVG(r) + if err != nil { + t.Fatalf("CardSVG: %v", err) + } + + if *update { + if err := os.MkdirAll("testdata", 0o755); err != nil { + t.Fatalf("mkdir testdata: %v", err) + } + if err := os.WriteFile(goldenPath, svg, 0o644); err != nil { + t.Fatalf("write golden: %v", err) + } + t.Logf("updated %s", goldenPath) + return + } + + want, err := os.ReadFile(goldenPath) + if err != nil { + t.Fatalf("read golden (run with -update to create): %v", err) + } + if !bytes.Equal(svg, want) { + gotLines := strings.Split(string(svg), "\n") + wantLines := strings.Split(string(want), "\n") + for i := 0; i < len(gotLines) && i < len(wantLines); i++ { + if gotLines[i] != wantLines[i] { + t.Errorf("line %d differs:\n got: %q\n want: %q", i+1, gotLines[i], wantLines[i]) + if t.Failed() && i > 5 { + break + } + } + } + if len(gotLines) != len(wantLines) { + t.Errorf("line count differs: got %d, want %d", len(gotLines), len(wantLines)) + } + t.Logf("re-run with -update to accept the new output") + } +} + +// AC-1: Golden card.svg for the full sample report contains username, platform, +// owner/org string, window, the four headline values, QR, and the attested mark. +func TestCardGoldenFull(t *testing.T) { + svg := fullCardFixtureFromSample(t) + + svgStr := string(svg) + for _, want := range []string{ + "alice", // username + "github", // platform + "acme", // single-owner org name + "2025-06-01", // window start + "2026-06-01", // window end + "42", // PRs merged + "63", // reviews given + } { + if !strings.Contains(svgStr, want) { + t.Errorf("card SVG missing %q", want) + } + } + + // Median TTM (18.5 from fixture) + if !strings.Contains(svgStr, "18.5") && !strings.Contains(svgStr, "18") { + t.Error("card SVG missing median TTM value") + } + + // Active days (198 from fixture) + if !strings.Contains(svgStr, "198") { + t.Error("card SVG missing active days") + } + + // QR should be present (SVG with pixelated rendering inside the card) + if !strings.Contains(svgStr, "]+`) + matches := httpRe.FindAllString(svg, -1) + for _, m := range matches { + // Allowed: the SVG namespace, the verify URL, and github.com attestation URLs. + if strings.Contains(m, "www.w3.org/2000/svg") { + continue + } + if strings.Contains(m, "gkanitz.github.io/CodeRepute/verify") { + continue + } + if strings.Contains(m, "github.com") { + continue + } + t.Errorf("card contains disallowed URL reference: %s", m) + } + + // No external resource references. + for _, ext := range []string{".jpg", ".png", ".woff", ".ttf", ".otf", "data:image"} { + if strings.Contains(svg, ext) { + t.Errorf("card references external resource: %q", ext) + } + } +} + +// AC-7: Prohibited-strings test (seeded colleague names, PR titles, branch names, +// file paths) passes over card output. +func TestCardProhibitedStrings(t *testing.T) { + r := fullCardFixtureFromSampleAsReport(t) + svg := string(thunkCardSVG(t, r)) + + prohibited := []string{ + "mallory-reviewer", + "trent-teammate", + "rocket telemetry", + "Megacorp", + "feature/rocket", + } + lower := strings.ToLower(svg) + for _, p := range prohibited { + if strings.Contains(lower, strings.ToLower(p)) { + t.Errorf("card SVG leaks prohibited data %q", p) + } + } +} + +// AC-8: Card dimensions/viewBox exactly 1200x627. +func TestCardDimensions(t *testing.T) { + r := cardFixture() + svg := string(thunkCardSVG(t, r)) + + dimRe := regexp.MustCompile(`viewBox="0\s+0\s+1200\s+627"`) + if !dimRe.MatchString(svg) { + t.Errorf("card SVG must have viewBox=\"0 0 1200 627\", got: %s", extractViewBox(svg)) + } + + // Also check width and height attributes. + if !strings.Contains(svg, `width="1200"`) { + t.Error("card SVG missing width=\"1200\"") + } + if !strings.Contains(svg, `height="627"`) { + t.Error("card SVG missing height=\"627\"") + } +} + +// extractViewBox returns the viewBox attribute value from an SVG. +func extractViewBox(svg string) string { + re := regexp.MustCompile(`viewBox="([^"]+)"`) + m := re.FindStringSubmatch(svg) + if len(m) > 1 { + return m[1] + } + return "(not found)" +} + +// AC-9: Workflow/action changes — verify card outputs exist in action.yml. +// We assert the action.yml content via file content checks since the project +// has no action wire-test pattern yet. +func TestActionYMLCardOutputs(t *testing.T) { + raw, err := os.ReadFile("../action.yml") + if err != nil { + t.Fatal("cannot read action.yml for card-output assertion") + } + yml := string(raw) + + // Card PNG step reference + if !strings.Contains(yml, "card.png") { + t.Error("action.yml must reference card.png") + } + // Card attestation alongside report.html and report.pdf + if !strings.Contains(yml, "report-pdf") && !strings.Contains(yml, "card-png") { + // At minimum, card.png must be attested in the same attest step + if !strings.Contains(yml, "card.png") { + t.Error("action.yml must attest card.png") + } + } +} + +// TestCardFourMetricsOnly verifies that no metric beyond the fixed four +// headline numbers appears on the card. This is a QA red-flag gate. +func TestCardFourMetricsOnly(t *testing.T) { + r := cardFixture() + svg := string(thunkCardSVG(t, r)) + + // Labels that must NOT appear as card metric labels + for _, forbidden := range []string{ + "Deep review", + "Review comments", + "Time to first review", + "Rework", + "Authored", + "Contributions", + } { + if strings.Contains(svg, forbidden) { + t.Errorf("card must not contain metric %q beyond the fixed four", forbidden) + } + } +} + +// TestCardSigstoreAttestedOnlyWhenVerified verifies that the attested mark +// only renders when status is "verified". QA red flag. +func TestCardSigstoreAttestedOnlyWhenVerified(t *testing.T) { + t.Run("unverified report omits attested mark", func(t *testing.T) { + r := cardFixture() + r.Verification.Status = report.StatusUnverified + svg := string(thunkCardSVG(t, r)) + if strings.Contains(svg, "Sigstore attested") { + t.Error("unverified report shows Sigstore attested mark") + } + }) + + t.Run("verified report shows attested mark", func(t *testing.T) { + r := cardFixture() + r.Verification.Status = report.StatusVerified + svg := string(thunkCardSVG(t, r)) + if !strings.Contains(svg, "Sigstore attested") { + t.Error("verified report should show Sigstore attested mark") + } + }) +} + +// TestCardMissingMetricsDash verifies that missing metrics render as em-dash +// and nothing is invented. QA red flag. +func TestCardMissingMetricsDash(t *testing.T) { + r := cardFixture() + // Set all four metric sources to nil + r.Collaboration = nil + r.Cadence = nil + + svg := string(thunkCardSVG(t, r)) + // Verify the four metric values are all em-dash. The metric values appear + // as text nodes with the headline styling (font-size="36" font-weight="800"). + // We extract each font-size="36" text element and check its content. + metricValueRe := regexp.MustCompile(`font-size="36" font-weight="800"[^>]*>([^<]+)<`) + matches := metricValueRe.FindAllStringSubmatch(svg, -1) + if len(matches) < 4 { + t.Errorf("expected at least 4 metric value elements, got %d", len(matches)) + } + dashCount := 0 + for _, m := range matches { + if m[1] == "—" { + dashCount++ + } + } + if dashCount < 4 { + t.Errorf("expected at least 4 em-dash placeholders for missing metrics, got %d", dashCount) + } + + // The four metric labels should still be present. + for _, label := range []string{"PRs merged", "Reviews given", "Median TTM", "Active days"} { + if !strings.Contains(svg, label) { + t.Errorf("sparse card should still show label %q", label) + } + } +} + +// TestCardNoExternalFonts verifies the SVG uses system font stack only. +// QA red flag. +func TestCardNoExternalFonts(t *testing.T) { + r := cardFixture() + svg := string(thunkCardSVG(t, r)) + + if strings.Contains(svg, "@import") { + t.Error("card SVG must not use @import for external fonts") + } + if strings.Contains(svg, " for external resources") + } + // System font stack only - no url() references in font-family + if strings.Contains(svg, "font-family") { + fontRe := regexp.MustCompile(`font-family="([^"]+)"`) + for _, m := range fontRe.FindAllStringSubmatch(svg, -1) { + if strings.Contains(m[1], "url(") { + t.Errorf("card SVG uses url() in font-family: %s", m[1]) + } + } + } +} + +// TestCardDimensionsExact verifies the exact 1200x627 constraint with extra +// rigor. QA red flag. +func TestCardDimensionsExact(t *testing.T) { + r := cardFixture() + svg := string(thunkCardSVG(t, r)) + + // Must NOT have a different viewBox. + viewBox := extractViewBox(svg) + if viewBox != "0 0 1200 627" { + t.Errorf("card viewBox must be exactly '0 0 1200 627', got %q", viewBox) + } +} + +// TestCardMedianTTMFormat verifies median time to merge is formatted correctly. +func TestCardMedianTTMFormat(t *testing.T) { + r := cardFixture() + r.Collaboration.TimeToMerge = &report.DurationStats{Count: 42, MedianHours: 18.5} + svg := string(thunkCardSVG(t, r)) + + if !strings.Contains(svg, "18.5") { + t.Error("card should show median TTM value 18.5") + } +} + +// TestCardFullSampleFromGoldenFixture renders the sample-report.json via CardSVG +// and runs structural assertions. This mirrors the golden test pattern in +// golden_test.go. +func TestCardGoldenFullStructural(t *testing.T) { + r := fullCardFixtureFromSampleAsReport(t) + svg := thunkCardSVG(t, r) + svgStr := string(svg) + + // The sample fixture is verified (status=verified), so: + if !strings.Contains(svgStr, "Sigstore attested") { + t.Error("sample report is verified but card omits Sigstore attested") + } + + // Coverage is acme/widgets, acme/platform, acme/infra — single-owner + if !strings.Contains(svgStr, "acme") { + t.Error("card should show org context for acme") + } + + // Window + if !strings.Contains(svgStr, "2025-06-01") || !strings.Contains(svgStr, "2026-06-01") { + t.Error("card should show coverage window") + } +} + +// verifyUnitTest helpers: these patterns match how render_test.go structures +// its unit-style tests — content assertions without golden files. + +func TestCardSVGOutputIsValidSVG(t *testing.T) { + r := cardFixture() + svg := string(thunkCardSVG(t, r)) + + if !strings.HasPrefix(strings.TrimSpace(svg), "") { + fmt.Println(svg[len(svg)-50:]) + t.Error("card output must end with ") + } +} diff --git a/render/render.go b/render/render.go index a96bd50..c172c5c 100644 --- a/render/render.go +++ b/render/render.go @@ -243,6 +243,236 @@ func formatBandRange(lo, hi float64, unit string) string { } } +// CardSVG renders a 1200x627 share card SVG from the report struct, with +// exactly four headline metrics (PRs merged, reviews given, median TTM, +// active days), a QR code pointing at the verify URL, and a verification +// status mark. Missing metrics render as an em dash ("—"). +func CardSVG(r report.Report) ([]byte, error) { + w, h := 1200, 627 + pad := 48 + innerW := w - 2*pad + + var sb strings.Builder + sb.WriteString(fmt.Sprintf( + ``, + w, h, w, h)) + // Background + sb.WriteString(fmt.Sprintf(``, w, h)) + // Subtle border + sb.WriteString(fmt.Sprintf(``, w, h)) + + // ── Header bar ── + // "CodeRepute" label (left) + sb.WriteString(fmt.Sprintf(`CodeRepute`, + pad, pad+20)) + // Verification badge (right) + verifyBadgeX := w - pad + verifyBadgeText := "unverified" + verifyBadgeFill := "#9a6700" + verifyBadgeBg := "#FFF3CD" + if r.Verification != nil && r.Verification.Status == report.StatusVerified { + verifyBadgeText = "Sigstore attested" + verifyBadgeFill = "#0F6F3F" + verifyBadgeBg = "#D9F2E3" + } + // Compute badge width for right-alignment + badgeTextWidth := len(verifyBadgeText) * 8 + if badgeTextWidth < 100 { + badgeTextWidth = 100 + } else if badgeTextWidth > 160 { + badgeTextWidth = 160 + } + badgeX := verifyBadgeX - badgeTextWidth - 16 + sb.WriteString(fmt.Sprintf(``, + badgeX, pad-2, badgeTextWidth+16, verifyBadgeBg)) + sb.WriteString(fmt.Sprintf(`%s`, + verifyBadgeX, pad+14, verifyBadgeFill, verifyBadgeText)) + + // ── Subject line ── + subjectY := pad + 80 + sb.WriteString(fmt.Sprintf(`%s`, + pad, subjectY, htmlEscape(r.Subject.Username))) + platformStr := r.Subject.Platform + if r.Subject.AccountID != "" { + platformStr += " ID " + r.Subject.AccountID + } + sb.WriteString(fmt.Sprintf(`%s`, + pad, subjectY+28, htmlEscape(platformStr))) + + // ── Org context line ── + ownerStr := orgContextLabel(r.Coverage.Repos) + windowStr := coverageWindowStr(r.Coverage) + contextStr := ownerStr + " · " + windowStr + contextY := subjectY + 64 + sb.WriteString(fmt.Sprintf(`%s`, + pad, contextY, htmlEscape(contextStr))) + + // ── Four headline metrics ── + metricsY := contextY + 100 + metricCardW := (innerW - 3*16) / 4 // 4 cards with 16px gaps + cardX := func(i int) int { return pad + i*(metricCardW+16) } + + metrics := []struct { + value string + label string + }{ + {prsMergedStr(r), "PRs merged"}, + {reviewsGivenStr(r), "Reviews given"}, + {medianTTMStr(r), "Median TTM"}, + {activeDaysStr(r), "Active days"}, + } + + for i, m := range metrics { + x := cardX(i) + // Card background + sb.WriteString(fmt.Sprintf(``, + x, metricsY-20, metricCardW, 110)) + // Value + sb.WriteString(fmt.Sprintf(`%s`, + x+metricCardW/2, metricsY+30, htmlEscape(m.value))) + // Label + sb.WriteString(fmt.Sprintf(`%s`, + x+metricCardW/2, metricsY+54, htmlEscape(m.label))) + } + + // ── QR code + verify link (bottom) ── + qrY := h - 150 + qrSize := 80 + + u := verifyFallbackURL + if r.Verification != nil && r.Verification.VerifyURL != "" { + u = r.Verification.VerifyURL + } + qrSVG, err := qrSVGForURL(u, qrSize) + if err != nil { + return nil, fmt.Errorf("render card QR: %w", err) + } + + // QR occupies a box at bottom-left + sb.WriteString(fmt.Sprintf(`%s`, pad, qrY+10, qrSVG)) + + // Verify link text to the right of QR + verifyTextX := pad + qrSize + 24 + verifyTextY := qrY + 24 + sb.WriteString(fmt.Sprintf(`Verify this report`, + verifyTextX, verifyTextY)) + sb.WriteString(fmt.Sprintf(`%s`, + verifyTextX, verifyTextY+20, htmlEscape(u))) + + // Attested / unverified footer + footerText := "This report was produced locally and has not been independently verified." + if r.Verification != nil && r.Verification.Status == report.StatusVerified { + footerText = "This report has been cryptographically attested with Sigstore." + } + sb.WriteString(fmt.Sprintf(`%s`, + pad, h-pad, htmlEscape(footerText))) + + sb.WriteString(``) + return []byte(sb.String()), nil +} + +// qrSVGForURL generates an inline SVG QR code for the given URL at the given +// pixel size, returning the raw SVG string (without outer XML wrapper). +func qrSVGForURL(url string, size int) (string, error) { + qr, err := qrcode.New(url, qrcode.Medium) + if err != nil { + return "", err + } + qr.DisableBorder = false + bitmap := qr.Bitmap() + n := len(bitmap) + var sb strings.Builder + fmt.Fprintf(&sb, ``, size, size, n, n) + for y, row := range bitmap { + for x, dark := range row { + if dark { + fmt.Fprintf(&sb, ``, x, y) + } + } + } + sb.WriteString(``) + return sb.String(), nil +} + +// orgContextLabel returns a human-readable label for the org context: the +// single owner name when all repos share one owner, otherwise "N orgs". +func orgContextLabel(repos []string) string { + seen := make(map[string]bool) + for _, r := range repos { + org, _, ok := strings.Cut(r, "/") + if !ok { + org = r + } + seen[org] = true + } + if len(seen) == 0 { + return "" + } + if len(seen) == 1 { + for o := range seen { + return o + } + } + return fmt.Sprintf("%d orgs", len(seen)) +} + +// coverageWindowStr formats the coverage window as a readable string. +func coverageWindowStr(c *report.Coverage) string { + if c == nil { + return "" + } + since := "all time" + if c.Window.Since != nil { + since = c.Window.Since.UTC().Format("2006-01-02") + } + until := c.Window.Until.UTC().Format("2006-01-02") + return since + " → " + until +} + +// prsMergedStr returns the PRs merged count as a string, or "—" when nil. +func prsMergedStr(r report.Report) string { + if r.Collaboration == nil || r.Collaboration.PullRequests == nil { + return "—" + } + return strconv.Itoa(r.Collaboration.PullRequests.Merged) +} + +// reviewsGivenStr returns the reviews given total as a string, or "—" when nil. +func reviewsGivenStr(r report.Report) string { + if r.Collaboration == nil || r.Collaboration.ReviewsGiven == nil { + return "—" + } + return strconv.Itoa(r.Collaboration.ReviewsGiven.Total) +} + +// medianTTMStr returns the median time-to-merge as a formatted string with +// " hrs" suffix, or "—" when nil. +func medianTTMStr(r report.Report) string { + if r.Collaboration == nil || r.Collaboration.TimeToMerge == nil { + return "—" + } + h := r.Collaboration.TimeToMerge.MedianHours + return strconv.FormatFloat(math.Round(h*10)/10, 'f', -1, 64) + " hrs" +} + +// activeDaysStr returns the active days count as a string, or "—" when nil. +func activeDaysStr(r report.Report) string { + if r.Cadence == nil { + return "—" + } + return strconv.Itoa(r.Cadence.ActiveDays) +} + +// htmlEscape replaces special HTML characters with their entities, sufficient +// for SVG text content. +func htmlEscape(s string) string { + s = strings.ReplaceAll(s, "&", "&") + s = strings.ReplaceAll(s, "<", "<") + s = strings.ReplaceAll(s, ">", ">") + s = strings.ReplaceAll(s, "\"", """) + return s +} + // HTML renders the report as a single self-contained HTML document. func HTML(r report.Report) ([]byte, error) { sections, err := fs.Glob(templates, "templates/sections/*.tmpl") diff --git a/render/testdata/card-full.golden.svg b/render/testdata/card-full.golden.svg new file mode 100644 index 0000000..b3d2d8f --- /dev/null +++ b/render/testdata/card-full.golden.svg @@ -0,0 +1 @@ +CodeReputeSigstore attestedalicegithub ID 1234567acme · 2025-06-01 → 2026-06-0142PRs merged63Reviews given18.5 hrsMedian TTM198Active daysVerify this reporthttps://gkanitz.github.io/CodeRepute/verify/This report has been cryptographically attested with Sigstore. \ No newline at end of file diff --git a/render/testdata/card-sparse.golden.svg b/render/testdata/card-sparse.golden.svg new file mode 100644 index 0000000..5d9c9e1 --- /dev/null +++ b/render/testdata/card-sparse.golden.svg @@ -0,0 +1 @@ +CodeReputeSigstore attestedalicegithub ID 1234567acme · 2025-06-01 → 2026-06-01PRs mergedReviews givenMedian TTMActive daysVerify this reporthttps://gkanitz.github.io/CodeRepute/verify/?repo=acme%2Fwidgets&subject=aliceThis report has been cryptographically attested with Sigstore. \ No newline at end of file diff --git a/render/testdata/card-unverified.golden.svg b/render/testdata/card-unverified.golden.svg new file mode 100644 index 0000000..520a991 --- /dev/null +++ b/render/testdata/card-unverified.golden.svg @@ -0,0 +1 @@ +CodeReputeunverifiedalicegithub ID 1234567acme · 2025-06-01 → 2026-06-0142PRs merged63Reviews givenMedian TTM198Active daysVerify this reporthttps://gkanitz.github.io/CodeRepute/verify/This report was produced locally and has not been independently verified. \ No newline at end of file