Coverage for src / updates2mqtt / integrations / github_enrich.py: 58%
78 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-20 23:13 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-20 23:13 +0000
1import re
2from typing import TYPE_CHECKING, Any
4import structlog
6from updates2mqtt.config import (
7 VERSION_RE,
8 GitHubConfig,
9)
10from updates2mqtt.helpers import fetch_url, httpx_json_content
11from updates2mqtt.integrations.docker_enrich import REGISTRY_GHCR, DockerImageInfo
12from updates2mqtt.model import ReleaseDetail
14if TYPE_CHECKING:
15 from httpx import Response
17log: Any = structlog.get_logger()
20class GithubReleaseEnricher:
21 def __init__(self, gh_cfg: GitHubConfig) -> None:
22 self.log: Any = structlog.get_logger().bind(integration="github")
23 self.gh_cfg: GitHubConfig = gh_cfg
24 if self.gh_cfg.access_token:
25 self.log.debug("Using configured bearer token (%s chars) for GitHub API", len(self.gh_cfg.access_token))
27 def enrich(self, image: DockerImageInfo, detail: ReleaseDetail) -> None:
29 if not detail.source_repo_url or not image.name:
30 return
32 if not detail.version or not re.fullmatch(VERSION_RE, detail.version):
33 self.log.debug("No valid version found for GitHub release %s: %s", image.name, detail.version)
34 if image.image_digest and image.index_name == REGISTRY_GHCR: 34 ↛ 49line 34 didn't jump to line 49 because the condition on line 34 was always true
35 matched = self.match_packages(image.unqualified_name, image.image_digest, detail.source_repo_url)
36 if matched: 36 ↛ 41line 36 didn't jump to line 41 because the condition on line 36 was never true
37 tags: list[str]
38 _release_url: str | None
39 created_ts: str | None
40 updated_ts: str | None
41 tags, _release_url, created_ts, updated_ts = matched
42 for t in tags:
43 if re.fullmatch(VERSION_RE, t):
44 self.log.debug(
45 "Matched %s version %s created on %s last updated %s", image.name, t, created_ts, updated_ts
46 )
47 detail.version = t
49 if detail.version is not None:
50 base_api = detail.source_repo_url.replace("https://github.com", "https://api.github.com/repos")
52 api_response: Response | None = fetch_url(
53 f"{base_api}/releases/tags/{detail.version}",
54 bearer_token=self.gh_cfg.access_token,
55 cache_ttl=self.gh_cfg.mutable_cache_ttl,
56 allow_stale=True, # not assuming immutable tags
57 )
58 if api_response and api_response.status_code == 404:
59 # possible that source version doesn't match release gag
60 alt_api_response: Response | None = fetch_url(
61 f"{base_api}/releases/latest",
62 cache_ttl=self.gh_cfg.mutable_cache_ttl,
63 bearer_token=self.gh_cfg.access_token,
64 )
65 if alt_api_response and alt_api_response.is_success: 65 ↛ 80line 65 didn't jump to line 80 because the condition on line 65 was always true
66 alt_api_results = httpx_json_content(alt_api_response, {})
67 if alt_api_results and re.fullmatch(f"(V|v|r|R)?{detail.version}", alt_api_results.get("tag_name")):
68 self.log.info(f"Matched {image.name} {detail.version} to latest release {alt_api_results['tag_name']}")
69 api_response = alt_api_response
70 elif alt_api_results: 70 ↛ 80line 70 didn't jump to line 80 because the condition on line 70 was always true
71 self.log.debug(
72 "Failed to match %s release %s on GitHub, found tag %s for name %s published at %s",
73 image.name,
74 detail.version,
75 alt_api_results.get("tag_name"),
76 alt_api_results.get("name"),
77 alt_api_results.get("published_at"),
78 )
80 if api_response and api_response.is_success:
81 api_results: Any = httpx_json_content(api_response, {})
82 detail.summary = api_results.get("body") # ty:ignore[possibly-missing-attribute]
83 reactions = api_results.get("reactions") # ty:ignore[possibly-missing-attribute]
84 if reactions:
85 detail.net_score = reactions.get("+1", 0) - reactions.get("-1", 0)
86 return
87 if api_response:
88 api_results = httpx_json_content(api_response, default={})
89 self.log.debug(
90 "Failed to find %s release %s on GitHub, status %s, errors; %s",
91 image.name,
92 detail.version,
93 api_response.status_code,
94 api_results.get("errors"),
95 )
96 else:
97 self.log.debug(
98 "Failed to fetch GitHub release info",
99 url=f"{base_api}/releases/tags/{detail.version}",
100 status_code=(api_response and api_response.status_code) or None,
101 )
103 def match_packages(
104 self, package: str, package_digest: str, source_repo_url: str
105 ) -> tuple[list[str], str | None, str | None, str | None] | None:
107 if not self.gh_cfg.access_token: 107 ↛ 111line 107 didn't jump to line 111 because the condition on line 107 was always true
108 self.log.debug("No access token available for packages API")
109 return None
111 match = re.match(r"https://github\.com/([^/]+)/(?:[^/]+?)(?:\.git)?$", source_repo_url)
112 if not match:
113 self.log.debug("Invalid source repo URL for GitHub API use: %s", source_repo_url)
114 return None
115 org_or_user: str = match.group(1)
117 api_result: Response | None = fetch_url(
118 f"https://api.github.com/orgs/{org_or_user}/packages/container/{package}/versions",
119 cache_ttl=self.gh_cfg.mutable_cache_ttl,
120 bearer_token=self.gh_cfg.access_token,
121 )
122 if api_result and api_result.status_code == 404:
123 api_result = fetch_url(
124 f"https://api.github.com/users/{org_or_user}/packages/container/{package}/versions",
125 cache_ttl=self.gh_cfg.mutable_cache_ttl,
126 bearer_token=self.gh_cfg.access_token,
127 )
128 if not api_result:
129 self.log.warn("Unable to retrieve GitHub packages API result, null response")
130 return None
131 if not api_result.is_success:
132 self.log.warn(
133 "Unable to retrieve GitHub packages API result, %s response: %s", api_result.status_code, api_result.text
134 )
135 if api_result.status_code == 401:
136 self.log.warn("Disabling Github access token for this session")
137 self.gh_cfg.access_token = None
138 return None
140 pkg_releases = api_result.json()
141 for release in pkg_releases:
142 if release.get("name") == package_digest:
143 self.log.info(
144 "Matched %s image digest using GitHub API for release %s, metadata %s",
145 package,
146 release.get("id"),
147 release.get("metadata"),
148 )
149 return (
150 release.get("metadata", {}).get("container", {}).get("tags", []),
151 release.get("html_url"),
152 release.get("created_at"),
153 release.get("updated_at"),
154 )
155 self.log.debug("No matching %s release found using GitHub API for %s", package, package_digest)
156 return None