Coverage for backend / app / routers / badge.py: 100%
64 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-02 15:51 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-02 15:51 +0000
1import re
2from typing import Annotated
4from fastapi import APIRouter, Depends, Request, Response
5from fastapi.responses import RedirectResponse
6from redis import RedisError
7from redis.asyncio import Redis
9from app.constants import BADGE_CACHE_KEY
10from app.dependencies.gh_client import get_github_client
11from app.utils.github_client import GithubClient
12from app.schemas import GhCommitStatus
13from app.dependencies.redis_client import get_redis_client
15COV_RE = re.compile(r"([\d.]+)%")
18router = APIRouter()
20BADGE_SVG = """\
21<svg xmlns="http://www.w3.org/2000/svg" width="110" height="20">
22 <title>coverage: {cov}</title>
23 <defs>
24 <linearGradient id="workflow-fill" x1="50%" y1="0%" x2="50%" y2="100%">
25 <stop stop-color="#444D56" offset="0%"/>
26 <stop stop-color="#24292E" offset="100%"/>
27 </linearGradient>
28 <linearGradient id="state-fill" x1="50%" y1="0%" x2="50%" y2="100%">
29 <stop stop-color="{color_top}" offset="0%"/>
30 <stop stop-color="{color_bot}" offset="100%"/>
31 </linearGradient>
32 </defs>
33 <g font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
34 <path d="M0,3 C0,1.3431 1.3552,0 3.02702703,0 L70,0 L70,20 L3.02702703,20 C1.3552,20 0,18.6569 0,17 L0,3 Z" fill="url(#workflow-fill)"/>
35 <text fill="#010101" fill-opacity=".3">
36 <tspan x="10" y="15">coverage</tspan>
37 </text>
38 <text fill="#FFF">
39 <tspan x="10" y="14">coverage</tspan>
40 </text>
41 </g>
42 <g transform="translate(70)" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
43 <path d="M0 0h36.939C38.629 0 40 1.343 40 3v14c0 1.657-1.37 3-3.061 3H0V0z" fill="url(#state-fill)"/>
44 <text text-anchor="middle" fill="#010101" fill-opacity=".3">
45 <tspan x="20" y="15">{cov}</tspan>
46 </text>
47 <text text-anchor="middle" fill="#FFF">
48 <tspan x="20" y="14">{cov}</tspan>
49 </text>
50 </g>
51</svg>
52"""
55def _badge_color(
56 coverage_percent: float | None, status: GhCommitStatus | None
57) -> tuple[str, str]:
58 if coverage_percent is None:
59 return "#9F9F9F", "#8C8C8C"
60 assert status is not None
61 if status.state == "success":
62 return "#34D058", "#28A745"
63 return "#CB2431", "#B31D28"
66async def _get_coverage(
67 org: str, repo: str, gh_client: GithubClient
68) -> tuple[float, GhCommitStatus] | tuple[None, None]:
70 # TODO: add caching
71 context_pattern = re.compile("coverage", re.IGNORECASE)
72 commits = await gh_client.get_latest_commits(owner=org, repo=repo, limit=5)
73 for commit in commits:
74 if "\n\n[skip ci]" in commit.message:
75 continue
76 statuses = await gh_client.get_commit_statuses(
77 owner=org, repo=repo, sha=commit.sha
78 )
79 for status in statuses:
80 if context_pattern.search(status.context):
81 m = COV_RE.search(status.description)
82 if m:
83 return float(m.group(1)), status
84 return None, None
85 return None, None
88@router.get("/redirect/{org}/{repo}/")
89async def redirect(
90 *,
91 org: str,
92 repo: str,
93 gh_client: Annotated[GithubClient, Depends(get_github_client)],
94) -> Response:
95 _, status = await _get_coverage(org, repo, gh_client)
96 if status:
97 return RedirectResponse(status.target_url)
98 return Response(content="Status Not found", status_code=404)
101def get_response(request: Request, content: str) -> Response:
102 user_agent = request.headers.get("user-agent", "")
103 headers = (
104 {
105 "cache-control": "private, no-store",
106 "cdn-cache-control": "no-store",
107 }
108 if user_agent.startswith("github-camo")
109 else {
110 "cache-control": "public, max-age=10",
111 "cdn-cache-control": "max-age=10",
112 }
113 )
114 return Response(
115 content=content,
116 media_type="image/svg+xml",
117 headers=headers,
118 )
121@router.get("/{org}/{repo}.svg")
122async def badge(
123 *,
124 org: str,
125 repo: str,
126 gh_client: Annotated[GithubClient, Depends(get_github_client)],
127 request: Request,
128 redis_client: Annotated[Redis, Depends(get_redis_client)],
129) -> Response:
131 cache_key = BADGE_CACHE_KEY.format(org=org, repo=repo)
132 try:
133 cached = await redis_client.get(cache_key)
134 except RedisError as e:
135 print(f"Error accessing cache: {e}")
136 cached = None
138 if cached:
139 return get_response(request, cached.decode("utf-8"))
141 coverage, status = await _get_coverage(org, repo, gh_client)
143 color_top, color_bot = _badge_color(coverage, status)
144 cov_text = f"{coverage:.0f}%" if coverage is not None else "??%"
146 svg = (
147 BADGE_SVG.replace("{cov}", cov_text)
148 .replace("{color_top}", color_top)
149 .replace("{color_bot}", color_bot)
150 )
152 try:
153 await redis_client.set(
154 cache_key,
155 svg.encode("utf-8"),
156 ex=60,
157 )
158 except RedisError as e:
159 print(f"Error caching data: {e}")
161 return get_response(request, svg)