Skip to content

Feat add dynamic pr count badge endpoint with caching and UI integration - #51

Merged
livrasand merged 4 commits into
mainfrom
feat--add-dynamic-PR-count-badge-endpoint-with-caching-and-UI-integration
Feb 23, 2026
Merged

Feat add dynamic pr count badge endpoint with caching and UI integration#51
livrasand merged 4 commits into
mainfrom
feat--add-dynamic-PR-count-badge-endpoint-with-caching-and-UI-integration

Conversation

@livrasand

@livrasand livrasand commented Feb 23, 2026

Copy link
Copy Markdown
Owner

Summary by CodeRabbit

Release Notes

  • New Features
    • Added PR count badge type: Users can now generate and display badges showing the anonymous PR count for specific repositories
    • New badge type selector in the UI allows toggling between friendly and PR count badge modes
    • Badges are dynamically generated as SVG images with automatic formatting based on PR count

…tion

Agregado endpoint GET /badge/:owner/:repo para badge SVG dinámico con conteo de PRs anónimos por repositorio. Implementada función GetPRCountByRepo() en internal/database/supabase.go usando Content-Range header para obtener total count. Creado handler BadgePRCountHandler con sistema de caché en memoria (TTL 5 minutos) usando sync.Mutex. Agregado selector de tipo de badge en UI con opciones "Anonymous Contributor Friendly" y "Anonymous PR
…N caching

Modificado header Cache-Control en BadgePRCountHandler de "no-cache, max-age=300" a "public, max-age=300" para permitir caching en CDNs y proxies intermedios mientras mantiene TTL de 5 minutos.
Agregado push-options en capabilities string de ReceivePackDiscoveryHandler para habilitar soporte de opciones de push en protocolo Git HTTP.
Modificada lógica de actualización de caché en BadgePRCountHandler para solo actualizar cuando la base de datos responde correctamente (dbOk=true) o cuando ya existe un valor previo en caché (ok=true). Evita sobrescribir caché con valores incorrectos en caso de fallo de DB.
@livrasand livrasand self-assigned this Feb 23, 2026
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@coderabbitai

coderabbitai Bot commented Feb 23, 2026

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

The changes introduce a new PR count badge feature across the database, HTTP, and frontend layers. A new database method retrieves PR counts, a new HTTP handler serves dynamic SVG badges with caching, a new route registers the endpoint, and the frontend UI allows users to select between badge types and generate PR count badges.

Changes

Cohort / File(s) Summary
Database Client
internal/database/supabase.go
Adds GetPRCountByRepo() method to fetch PR counts for a given owner/repo, using Content-Range header parsing for count extraction.
HTTP Handler & Routing
internal/http/handlers.go, internal/http/router.go
Introduces BadgePRCountHandler() serving dynamic SVG badges with in-memory TTL-based caching (300s). Includes cache validation, database refresh, and dynamic SVG generation. Handler code appears duplicated within handlers.go. Routes new GET endpoint /badge/:owner/:repo in router setup. Also extends ReceivePackDiscoveryHandler capabilities string with push-options.
Frontend UI
web/index.html
Adds "Badge type" dropdown selector (friendly vs. pr-count mode). Enhances generateBadge() to conditionally build badge URLs and markdown based on selected type, with owner/repo validation for pr-count mode.

Sequence Diagram

sequenceDiagram
    participant Client as Client
    participant Handler as HTTP Handler
    participant Cache as In-Memory Cache
    participant DB as Supabase Database
    
    Client->>Handler: GET /badge/:owner/:repo
    Handler->>Handler: Validate owner/repo format
    Handler->>Cache: Check cache for owner/repo
    
    alt Cache Miss or Expired
        Handler->>DB: GetPRCountByRepo(owner, repo)
        DB-->>Handler: PR count
        Handler->>Cache: Update cache with count & TTL
    else Cache Hit
        Cache-->>Handler: Return cached count
    end
    
    Handler->>Handler: Generate SVG badge<br/>(dynamic width based on count)
    Handler-->>Client: SVG response<br/>(Content-Type: image/svg+xml<br/>Cache-Control: max-age=300)
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • Anonymous contribution via gitGost #18: Introduces foundational Supabase integration; this PR builds directly on that by adding a new client method and badge endpoint that leverages the Supabase client infrastructure.

Suggested reviewers

  • gitgost-anonymous

Poem

🐰 A badge shines bright in the open air,
Counting PRs with rabbit care,
SVG strokes in colors so keen,
The finest badges ever seen!
Hop, cache, and display with cheer,

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 60.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: adding a dynamic PR count badge endpoint with caching and UI integration, which aligns with all the modified files and their collective purpose.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat--add-dynamic-PR-count-badge-endpoint-with-caching-and-UI-integration

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@livrasand livrasand linked an issue Feb 23, 2026 that may be closed by this pull request
4 tasks
@livrasand
livrasand merged commit 3e70bf7 into main Feb 23, 2026
1 check was pending
@livrasand
livrasand deleted the feat--add-dynamic-PR-count-badge-endpoint-with-caching-and-UI-integration branch February 23, 2026 14:57

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
web/index.html (2)

1494-1526: ⚠️ Potential issue | 🟡 Minor

getElementById('badge-copy-row') always returns null — copy section is permanently hidden.

badge-copy-row is a CSS class name, not an element id. getElementById returns null, causing the if (copyInput && copyRow) guard (line 1524) to always be false. As a result:

  • badge-markdown is never populated with the generated markdown.
  • badge-copy-section (id badge-copy-section) is never shown, so the copy button is inaccessible to users.

This is pre-existing, but the new markdown variable introduced by this PR flows through the same broken path for both badge types.

🐛 Proposed fix
-    const copyRow = document.getElementById('badge-copy-row');
+    const copySection = document.getElementById('badge-copy-section');
     const copyInput = document.getElementById('badge-markdown');
 
     // ... in the success branch:
-    if (copyInput && copyRow) {
+    if (copyInput && copySection) {
         copyInput.value = markdown;
-        copyRow.style.display = 'grid';
+        copySection.style.display = 'block';
     }
 
     // ... in the failure/error branches:
-    if (copyRow) copyRow.style.display = 'none';
+    if (copySection) copySection.style.display = 'none';
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/index.html` around lines 1494 - 1526, The copy row is selected with
getElementById('badge-copy-row') but the element uses a class, so copyRow is
always null; change the selection to document.querySelector('.badge-copy-row')
(or to the proper id if you prefer to convert the element to an id) and keep
using copyInput (getElementById('badge-markdown')) and the existing guard (if
(copyInput && copyRow)) so that copyInput.value = markdown and
copyRow.style.display = 'grid' run when a badge is fetched; also verify any
reference to 'badge-copy-section' uses the correct id/class consistent with the
DOM.

1527-1531: ⚠️ Potential issue | 🟡 Minor

Misleading error message for pr-count badge type.

BadgePRCountHandler always returns HTTP 200 with an SVG (displaying 0 when no PRs exist), so !response.ok for a pr-count request means a server-side validation failure (400), not a missing .gitgost.yml. The current error copy instructs users to add a file that has no effect on the PR-count badge.

🐛 Proposed fix
-                    result.innerHTML = '<p>Repository not verified. Add a .gitgost.yml file to your repo root to enable the dynamic badge.</p>';
+                    if (badgeType === 'pr-count') {
+                        result.innerHTML = '<p>Could not load badge. Check that owner/repo is correct.</p>';
+                    } else {
+                        result.innerHTML = '<p>Repository not verified. Add a <code>.gitgost.yml</code> file to your repo root to enable the dynamic badge.</p>';
+                    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/index.html` around lines 1527 - 1531, The current branch sets a
misleading "add a .gitgost.yml" message when response.ok is false even for
pr-count badges; update the error handling that sets result.innerHTML (and hides
copyRow) to distinguish badge types (e.g., check the requested badgeType or if
badgeType === 'pr-count') and for 'pr-count' show a server/validation error
message including the HTTP status and brief guidance (e.g., "Server validation
failed (status X) — check request parameters or server logs") instead of
instructing to add .gitgost.yml; adjust the code around result.innerHTML and
copyRow handling so other badge types keep the existing .gitgost.yml guidance.
🧹 Nitpick comments (5)
internal/http/handlers.go (3)

1046-1068: Thundering herd on cache expiry.

After the lock is released at line 1050, multiple concurrent goroutines can all observe a stale entry and proceed to hit the DB simultaneously. At low traffic this is harmless; at higher badge request rates it amplifies DB load on every TTL boundary.

Consider wrapping the refresh path with golang.org/x/sync/singleflight so only one goroutine fetches while others wait and reuse the result:

// package-level
var badgeSFG singleflight.Group

// inside handler, replacing the refresh block
if !ok || time.Since(cachedAt) > badgeCacheTTL {
    val, _, _ := badgeSFG.Do(cacheKey, func() (interface{}, error) {
        n, err := dbClient.GetPRCountByRepo(c.Request.Context(), owner, repo)
        if err != nil {
            return count, err // serve stale on error
        }
        badgeCacheMu.Lock()
        badgeCache[cacheKey] = n
        badgeCacheAt[cacheKey] = time.Now()
        badgeCacheMu.Unlock()
        return n, nil
    })
    count = val.(int)
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/http/handlers.go` around lines 1046 - 1068, The current refresh
logic around badgeCache/badgeCacheAt guarded by badgeCacheMu allows a thundering
herd after the lock is released; replace the refresh path so only one goroutine
performs the DB fetch using a package-level golang.org/x/sync/singleflight.Group
(e.g., badgeSFG.Do) keyed by cacheKey, have that single-flight call invoke
dbClient.GetPRCountByRepo(ctx, owner, repo), update badgeCache and badgeCacheAt
under badgeCacheMu inside the singleflight function on success, and on DB error
return the previous cached count so callers can serve stale data while others
reuse the singleflight result. Ensure count is set from the singleflight result
after Do returns.

1028-1033: Decouple badgeCacheTTL and Cache-Control max-age — they diverge silently.

badgeCacheTTL = 5 * time.Minute (line 1032) and "public, max-age=300" (line 1115) both represent 5 minutes but are defined independently. A future edit to one that misses the other will make the HTTP cache and the in-memory cache disagree.

♻️ Derive `max-age` from the TTL constant
 var (
     badgeCache    = make(map[string]int)
     badgeCacheAt  = make(map[string]time.Time)
     badgeCacheMu  sync.Mutex
     badgeCacheTTL = 5 * time.Minute
 )
-    c.Header("Cache-Control", "public, max-age=300")
+    c.Header("Cache-Control", fmt.Sprintf("public, max-age=%d", int(badgeCacheTTL.Seconds())))

Also applies to: 1115-1115

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/http/handlers.go` around lines 1028 - 1033, The TTL constant
badgeCacheTTL is duplicated by a hardcoded Cache-Control value ("public,
max-age=300"); make the HTTP header derive max-age from badgeCacheTTL so they
cannot drift: replace the literal "public, max-age=300" with a value computed
from badgeCacheTTL (e.g. using badgeCacheTTL.Seconds()/int conversion or
fmt.Sprintf) wherever the badge response is written, leaving badgeCache,
badgeCacheAt and badgeCacheMu unchanged.

1082-1112: %d0 coordinate trick is non-obvious — consider using explicit multiplied arguments.

Lines like x="%d0" append a literal ASCII 0 to the format verb so that labelMid=50 produces x="500", exploiting the fact that the SVG uses a 10× internal coordinate system with transform="scale(.1)". While correct, this is a maintenance trap for anyone reading the template.

♻️ Proposed clearer approach
-    labelMid := 50
-    valueMid := 100 + valueWidth/2
+    labelMidSVG := 500          // 50px × 10 (SVG 10× scale)
+    valueMidSVG := (100 + valueWidth/2) * 10

     svg := fmt.Sprintf(`...
-        <text x="%d0" y="150" ...>%s</text>
-        <text x="%d0" y="140" ...>%s</text>
-        <text x="%d0" y="150" ...textLength="%d0"...>%s</text>
-        <text x="%d0" y="140" ...textLength="%d0"...>%s</text>
+        <text x="%d" y="150" ...>%s</text>
+        <text x="%d" y="140" ...>%s</text>
+        <text x="%d" y="150" ...textLength="%d"...>%s</text>
+        <text x="%d" y="140" ...textLength="%d"...>%s</text>
     ...`,
-        ..., labelMid, label, labelMid, label,
-        valueMid, (valueWidth-16), value, valueMid, (valueWidth-16), value,
+        ..., labelMidSVG, label, labelMidSVG, label,
+        valueMidSVG, (valueWidth-16)*10, value, valueMidSVG, (valueWidth-16)*10, value,
     )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/http/handlers.go` around lines 1082 - 1112, The SVG fmt.Sprintf uses
the non-obvious "%d0" trick for coordinates/textLength (in the svg string built
in handlers.go) which appends a literal "0" instead of explicitly multiplying by
10; update the format string to use "%d" for those x/textLength placeholders
(the ones using labelMid, valueMid and textLength fields) and pass explicitly
multiplied values (e.g., labelMid*10, valueMid*10, (valueWidth-16)*10) when
calling fmt.Sprintf so the internal 10× coordinate system with
transform="scale(.1)" remains correct and the template is readable and
maintainable.
web/index.html (1)

1337-1343: <select> element missing CSS styling — will render with browser defaults.

All other .badge-form inputs have explicit styling (background: var(--code-bg); border: 1px solid var(--border); ...), but there is no matching rule for select. The dropdown will look visually inconsistent across browsers.

♻️ Add select styling alongside existing input rules
 .badge-generator input {
     background: var(--code-bg);
     border: 1px solid var(--border);
     border-radius: var(--radius-sm);
     padding: .75rem .9rem;
     color: var(--fg);
     font-family: 'IBM Plex Mono', monospace;
 }
+.badge-generator select {
+    background: var(--code-bg);
+    border: 1px solid var(--border);
+    border-radius: var(--radius-sm);
+    padding: .75rem .9rem;
+    color: var(--fg);
+    font-family: 'IBM Plex Mono', monospace;
+    width: 100%;
+}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/index.html` around lines 1337 - 1343, The select with id "badge-type" in
the badge-form lacks the existing input styling and will render with browser
defaults; add a CSS rule targeting the select (e.g., `#badge-type` or .badge-form
select) that mirrors the input styles (background: var(--code-bg); border: 1px
solid var(--border); color, padding, border-radius, font, and focus outline) so
the dropdown visually matches other inputs like the .badge-form input elements
and behaves consistently across browsers.
internal/database/supabase.go (1)

403-445: Extract the duplicated Content-Range parsing into a private helper.

The Content-Range extraction/parsing block (lines 433-442) is now duplicated verbatim in GetTotalPRs, GetReportCount, and GetPRCountByRepo. A single helper would reduce noise and centralise the error messages.

♻️ Proposed helper extraction
+// parseContentRangeCount parses the total from a Supabase "0-0/{total}" or "*/{total}" Content-Range header.
+func parseContentRangeCount(contentRange string) (int, error) {
+    if contentRange == "" {
+        return 0, fmt.Errorf("missing Content-Range header in response")
+    }
+    slashIdx := strings.LastIndex(contentRange, "/")
+    if slashIdx == -1 {
+        return 0, fmt.Errorf("invalid Content-Range format: %s", contentRange)
+    }
+    count, err := strconv.Atoi(contentRange[slashIdx+1:])
+    if err != nil {
+        return 0, fmt.Errorf("failed to parse total count from Content-Range '%s': %v", contentRange, err)
+    }
+    return count, nil
+}

Then replace the 3-method duplicated blocks with a single call, e.g.:

-    contentRange := resp.Header.Get("Content-Range")
-    if contentRange == "" {
-        return 0, fmt.Errorf("missing Content-Range header in response")
-    }
-    slashIdx := strings.LastIndex(contentRange, "/")
-    if slashIdx == -1 {
-        return 0, fmt.Errorf("invalid Content-Range format: %s", contentRange)
-    }
-    totalStr := contentRange[slashIdx+1:]
-    count, err := strconv.Atoi(totalStr)
-    if err != nil {
-        return 0, fmt.Errorf("failed to parse total count from Content-Range '%s': %v", contentRange, err)
-    }
-    return count, nil
+    return parseContentRangeCount(resp.Header.Get("Content-Range"))
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/database/supabase.go` around lines 403 - 445, Extract the duplicated
Content-Range parsing logic into a single unexported helper (e.g.,
parseContentRange(contentRange string) (int, error)) and replace the repeated
blocks in GetPRCountByRepo, GetTotalPRs, and GetReportCount with calls to that
helper; the helper should validate non-empty header, find the last "/" and
convert the trailing substring to an int, and return a clear error message on
any failure so all three callers use the centralized parsing and error text.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@web/index.html`:
- Around line 1494-1526: The copy row is selected with
getElementById('badge-copy-row') but the element uses a class, so copyRow is
always null; change the selection to document.querySelector('.badge-copy-row')
(or to the proper id if you prefer to convert the element to an id) and keep
using copyInput (getElementById('badge-markdown')) and the existing guard (if
(copyInput && copyRow)) so that copyInput.value = markdown and
copyRow.style.display = 'grid' run when a badge is fetched; also verify any
reference to 'badge-copy-section' uses the correct id/class consistent with the
DOM.
- Around line 1527-1531: The current branch sets a misleading "add a
.gitgost.yml" message when response.ok is false even for pr-count badges; update
the error handling that sets result.innerHTML (and hides copyRow) to distinguish
badge types (e.g., check the requested badgeType or if badgeType === 'pr-count')
and for 'pr-count' show a server/validation error message including the HTTP
status and brief guidance (e.g., "Server validation failed (status X) — check
request parameters or server logs") instead of instructing to add .gitgost.yml;
adjust the code around result.innerHTML and copyRow handling so other badge
types keep the existing .gitgost.yml guidance.

---

Nitpick comments:
In `@internal/database/supabase.go`:
- Around line 403-445: Extract the duplicated Content-Range parsing logic into a
single unexported helper (e.g., parseContentRange(contentRange string) (int,
error)) and replace the repeated blocks in GetPRCountByRepo, GetTotalPRs, and
GetReportCount with calls to that helper; the helper should validate non-empty
header, find the last "/" and convert the trailing substring to an int, and
return a clear error message on any failure so all three callers use the
centralized parsing and error text.

In `@internal/http/handlers.go`:
- Around line 1046-1068: The current refresh logic around
badgeCache/badgeCacheAt guarded by badgeCacheMu allows a thundering herd after
the lock is released; replace the refresh path so only one goroutine performs
the DB fetch using a package-level golang.org/x/sync/singleflight.Group (e.g.,
badgeSFG.Do) keyed by cacheKey, have that single-flight call invoke
dbClient.GetPRCountByRepo(ctx, owner, repo), update badgeCache and badgeCacheAt
under badgeCacheMu inside the singleflight function on success, and on DB error
return the previous cached count so callers can serve stale data while others
reuse the singleflight result. Ensure count is set from the singleflight result
after Do returns.
- Around line 1028-1033: The TTL constant badgeCacheTTL is duplicated by a
hardcoded Cache-Control value ("public, max-age=300"); make the HTTP header
derive max-age from badgeCacheTTL so they cannot drift: replace the literal
"public, max-age=300" with a value computed from badgeCacheTTL (e.g. using
badgeCacheTTL.Seconds()/int conversion or fmt.Sprintf) wherever the badge
response is written, leaving badgeCache, badgeCacheAt and badgeCacheMu
unchanged.
- Around line 1082-1112: The SVG fmt.Sprintf uses the non-obvious "%d0" trick
for coordinates/textLength (in the svg string built in handlers.go) which
appends a literal "0" instead of explicitly multiplying by 10; update the format
string to use "%d" for those x/textLength placeholders (the ones using labelMid,
valueMid and textLength fields) and pass explicitly multiplied values (e.g.,
labelMid*10, valueMid*10, (valueWidth-16)*10) when calling fmt.Sprintf so the
internal 10× coordinate system with transform="scale(.1)" remains correct and
the template is readable and maintainable.

In `@web/index.html`:
- Around line 1337-1343: The select with id "badge-type" in the badge-form lacks
the existing input styling and will render with browser defaults; add a CSS rule
targeting the select (e.g., `#badge-type` or .badge-form select) that mirrors the
input styles (background: var(--code-bg); border: 1px solid var(--border);
color, padding, border-radius, font, and focus outline) so the dropdown visually
matches other inputs like the .badge-form input elements and behaves
consistently across browsers.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between ea26f04 and c4d0e23.

📒 Files selected for processing (4)
  • internal/database/supabase.go
  • internal/http/handlers.go
  • internal/http/router.go
  • web/index.html

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEATURE] Dynamic Anonymous Contributor Badge with PR Counter

2 participants