Coverage for webapp/api/launchpad_provenance.py: 97%

145 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-25 22:09 +0000

1import os 

2import re 

3from concurrent.futures import ThreadPoolExecutor 

4 

5from webapp.api.exceptions import ApiTimeoutError 

6from webapp.api.requests import Session 

7 

8MAX_CONCURRENT_SCANS = 10 

9 

10LAUNCHPAD_API_URL = os.getenv( 

11 "LAUNCHPAD_API_URL", "https://api.launchpad.net/devel/" 

12) 

13 

14 

15GITHUB_URL_RE = re.compile( 

16 r"^(?:https?://)?(?:www\.)?github\.com/" 

17 r"(?P<repo>[^/]+/[^/]+?)(?:\.git)?/?$" 

18) 

19 

20LAUNCHPAD_GIT_RE = re.compile( 

21 r"^https?://api\.launchpad\.net/[^/]+/" 

22 r"(?P<path>~[^/]+/(?:[^/]+/)*\+git/[^/]+?)/?$" 

23) 

24 

25 

26def failure_reason(exc): 

27 if isinstance(exc, ApiTimeoutError): 

28 return "launchpad_timeout" 

29 return "launchpad_error" 

30 

31 

32def extract_github_repository(git_repository_url): 

33 """Extract owner/repo from a GitHub repository URL, or None. 

34 

35 Anchored at both ends: the badge attests against this, so a URL merely 

36 containing "github.com/" must not match. 

37 """ 

38 if not git_repository_url: 

39 return None 

40 

41 match = GITHUB_URL_RE.match(git_repository_url) 

42 if match: 

43 return match.groupdict()["repo"] 

44 return None 

45 

46 

47def extract_launchpad_repository(git_repository_link): 

48 """Extract the git.launchpad.net path from a Launchpad link""" 

49 if not git_repository_link: 

50 return None 

51 

52 match = LAUNCHPAD_GIT_RE.match(git_repository_link) 

53 if match: 

54 return match.groupdict()["path"] 

55 return None 

56 

57 

58def build_commit_url(source, commit_sha): 

59 """Build a browsable commit URL for a recipe's source""" 

60 if source.get("github_repository"): 

61 return ( 

62 f"https://github.com/{source['github_repository']}" 

63 f"/commit/{commit_sha}" 

64 ) 

65 if source.get("launchpad_repository"): 

66 return ( 

67 f"https://git.launchpad.net/{source['launchpad_repository']}" 

68 f"/commit/?id={commit_sha}" 

69 ) 

70 return None 

71 

72 

73def recipe_source(recipe): 

74 """Resolve where a recipe's source is hosted.""" 

75 git_repository_url = recipe.get("git_repository_url") 

76 return { 

77 "git_repository_url": git_repository_url, 

78 "github_repository": extract_github_repository(git_repository_url), 

79 "launchpad_repository": extract_launchpad_repository( 

80 recipe.get("git_repository_link") 

81 ), 

82 } 

83 

84 

85class LaunchpadProvenance: 

86 """Read-only, anonymous client for Launchpad build provenance. 

87 

88 Unlike the authenticated ``canonicalwebteam.launchpad`` client, this one 

89 sends no OAuth credentials and does not filter recipes by owner, so it can 

90 read provenance for *any* public Launchpad recipe. It is used to link a 

91 store revision back to the public git commit it was built from. 

92 """ 

93 

94 def __init__(self, session=None, api_url=LAUNCHPAD_API_URL): 

95 self.api_url = api_url 

96 self._owns_session = session is None 

97 self.session = session or Session() 

98 self.session.headers["Accept"] = "application/json" 

99 

100 def _get(self, url, params=None, session=None): 

101 response = (session or self.session).get(url, params=params) 

102 response.raise_for_status() 

103 return response.json() 

104 

105 def _new_session(self): 

106 if not self._owns_session: 

107 return self.session 

108 session = Session() 

109 session.headers["Accept"] = "application/json" 

110 return session 

111 

112 def get_recipes(self, store_name, max_recipes): 

113 """Find the public Launchpad recipes for a store name, best first. 

114 

115 store_name is free text, so one name matches dozens of recipes 

116 (firefox matches 62), mostly personal ones that never upload. Ranked 

117 by upload capability, then recency; at most ``max_recipes``. 

118 """ 

119 data = self._get( 

120 f"{self.api_url}+snaps", 

121 params={ 

122 "ws.op": "findByStoreName", 

123 "store_name": f'"{store_name}"', 

124 }, 

125 ) 

126 

127 matches = [ 

128 entry 

129 for entry in data.get("entries", []) 

130 if entry.get("store_name") == store_name 

131 ] 

132 

133 # Two stable sorts: the second keeps the date order within each group. 

134 matches.sort( 

135 key=lambda e: e.get("date_last_modified") or "", reverse=True 

136 ) 

137 matches.sort(key=lambda e: not e.get("can_upload_to_store")) 

138 

139 return matches[:max_recipes] 

140 

141 def iter_builds( 

142 self, collection_link, max_pages, session=None, wanted=None 

143 ): 

144 """Collect completed builds. 

145 

146 `wanted` is the set of store revisions the caller is after. Once a 

147 page has covered all of them there is no point requesting the next 

148 one: every extra request is time spent and a chance to fail. 

149 """ 

150 entries = [] 

151 url = collection_link 

152 pages = 0 

153 missing = {str(r) for r in wanted} if wanted else None 

154 

155 while url and pages < max_pages: 

156 try: 

157 data = self._get(url, session=session) 

158 except Exception as exc: 

159 return entries, failure_reason(exc) 

160 page_entries = data.get("entries", []) 

161 entries.extend(page_entries) 

162 url = data.get("next_collection_link") 

163 pages += 1 

164 

165 if missing is not None: 

166 missing.difference_update( 

167 str(build["store_upload_revision"]) 

168 for build in page_entries 

169 if build.get("store_upload_status") == "Uploaded" 

170 and build.get("store_upload_revision") 

171 and build.get("revision_id") 

172 and build.get("arch_tag") 

173 ) 

174 if not missing: 

175 break 

176 

177 return entries, None 

178 

179 def _scan_recipe(self, recipe, max_pages, wanted=None): 

180 session = self._new_session() 

181 try: 

182 return self.iter_builds( 

183 recipe["completed_builds_collection_link"], 

184 max_pages, 

185 session=session, 

186 wanted=wanted, 

187 ) 

188 finally: 

189 if session is not self.session: 

190 session.close() 

191 

192 def _merge_builds(self, builds, source, revisions): 

193 """Fold one recipe's builds into the shared revision map. 

194 

195 Returns True if anything was added; earlier recipes win on conflict. 

196 """ 

197 added = False 

198 

199 for build in builds: 

200 if build.get("store_upload_status") != "Uploaded": 

201 continue 

202 

203 revision = build.get("store_upload_revision") 

204 commit_sha = build.get("revision_id") 

205 arch = build.get("arch_tag") 

206 

207 if not revision or not commit_sha or not arch: 

208 continue 

209 

210 revision_key = str(revision) 

211 # Newest first, so the first build seen for a revision+arch wins. 

212 arch_map = revisions.setdefault(revision_key, {}) 

213 if arch in arch_map: 

214 continue 

215 

216 commit_url = build_commit_url(source, commit_sha) 

217 

218 build_id = None 

219 build_url = None 

220 self_link = build.get("self_link") 

221 if self_link: 

222 build_id = self_link.rstrip("/").split("/")[-1] 

223 # API self_link -> human-facing web URL. 

224 build_url = self_link.replace( 

225 "api.launchpad.net/devel/", "launchpad.net/" 

226 ) 

227 

228 arch_map[arch] = { 

229 "commit_sha": commit_sha, 

230 "commit_url": commit_url, 

231 "build_id": build_id, 

232 "build_url": build_url, 

233 # Per entry: a merged map can span recipes with different 

234 # sources. 

235 "github_repository": source.get("github_repository"), 

236 "launchpad_repository": source.get("launchpad_repository"), 

237 } 

238 added = True 

239 

240 return added 

241 

242 def build_provenance_map( 

243 self, store_name, max_pages, max_recipes, wanted=None 

244 ): 

245 """Return a provenance map joining store revisions to git commits. 

246 

247 Shape: 

248 { 

249 "github_repository": "owner/repo" | None, 

250 "launchpad_repository": "~owner/proj/+git/name" | None, 

251 "git_repository_url": "https://..." | None, 

252 "revisions": { 

253 "<store_revision>": { 

254 "<arch>": { 

255 "commit_sha": "...", 

256 "commit_url": "https://github.com/.../commit/..." 

257 | None, 

258 "build_id": "216436", 

259 }, 

260 }, 

261 }, 

262 } 

263 

264 Builds are merged across candidate recipes, since one store name can 

265 span several legitimate recipes each holding part of the history. 

266 Only uploaded builds with a ``revision_id`` are included; revision 

267 keys are strings so the map survives JSON round-trips. 

268 """ 

269 result = { 

270 "github_repository": None, 

271 "launchpad_repository": None, 

272 "git_repository_url": None, 

273 "revisions": {}, 

274 # Set when an upstream request failed, so callers can avoid 

275 # caching a transient failure as a negative answer. 

276 "failed": False, 

277 "reason": None, 

278 } 

279 

280 try: 

281 recipes = self.get_recipes(store_name, max_recipes) 

282 except Exception as exc: 

283 result["failed"] = True 

284 result["reason"] = failure_reason(exc) 

285 return result 

286 

287 if not recipes: 

288 return result 

289 

290 candidates = [ 

291 recipe 

292 for recipe in recipes 

293 if recipe.get("completed_builds_collection_link") 

294 ] 

295 if not candidates: 

296 return result 

297 

298 # Share the page budget across candidates: revisions being resolved 

299 # are recent and builds are newest first, so breadth beats depth. 

300 pages_each = max(1, max_pages // len(candidates)) 

301 

302 # receopes are fetched in parallel 

303 with ThreadPoolExecutor( 

304 max_workers=min(MAX_CONCURRENT_SCANS, len(candidates)) 

305 ) as executor: 

306 scans = list( 

307 executor.map( 

308 lambda recipe: self._scan_recipe( 

309 recipe, pages_each, wanted 

310 ), 

311 candidates, 

312 ) 

313 ) 

314 

315 revisions = result["revisions"] 

316 source = None 

317 fallback = None 

318 

319 for recipe, (builds, reason) in zip(candidates, scans): 

320 recipe_src = recipe_source(recipe) 

321 if fallback is None: 

322 fallback = recipe_src 

323 

324 if ( 

325 self._merge_builds(builds, recipe_src, revisions) 

326 and source is None 

327 ): 

328 # The recipe that produced revisions, not the first sorted. 

329 source = recipe_src 

330 

331 if reason and not result["failed"]: 

332 result["failed"] = True 

333 result["reason"] = reason 

334 

335 # A failed request is irrelevant if every wanted revision was found 

336 # anyway, e.g. another recipe had them or they were on earlier pages. 

337 if ( 

338 result["failed"] 

339 and wanted 

340 and all(str(revision) in revisions for revision in wanted) 

341 ): 

342 result["failed"] = False 

343 result["reason"] = None 

344 

345 if source is None: 

346 source = fallback 

347 if source: 

348 result["git_repository_url"] = source["git_repository_url"] 

349 result["github_repository"] = source["github_repository"] 

350 result["launchpad_repository"] = source["launchpad_repository"] 

351 

352 return result