Coverage for src/pullapprove/results_migrations.py: 97%

127 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-10-21 11:55 -0500

1""" 

2Versioned migrations for PullRequestResults data. 

3 

4When the schema changes, add a new migration function and append it to ResultsMigrator.migrations. 

5Old stored data will be migrated on-the-fly when loaded via from_dict(). 

6 

7A migration must also work on a partial result: a list page reads only the 

8`version` and `scope_results` keys of a stored result and migrates just those 

9(PullRequestResults.notable_scope_results_from_keys), so reach every other key 

10with `.get()` and never derive one key from another. 

11""" 

12 

13from __future__ import annotations 

14 

15from typing import Any 

16 

17 

18def migrate_resview_results_scopes(data: dict[str, Any]) -> dict[str, Any]: 

19 """ 

20 Migrate v1 -> v2: 

21 - Rename ReviewResult.scopes -> matched_scopes 

22 """ 

23 if "review_results" in data: 

24 for review_result in data["review_results"].values(): 

25 # Rename scopes -> matched_scopes 

26 if "scopes" in review_result: 

27 review_result["matched_scopes"] = review_result.pop("scopes") 

28 

29 return data 

30 

31 

32def migrate_drop_config_branches(data: dict[str, Any]) -> dict[str, Any]: 

33 """ 

34 Migrate v2 -> v3: 

35 - Config-level `branches` was removed. Drop it from stored configs so old 

36 results re-parse cleanly. Scope-level `branches` is unaffected. 

37 """ 

38 for config_result in data.get("config_results", {}).values(): 

39 config = config_result.get("config") 

40 if isinstance(config, dict): 

41 config.pop("branches", None) 

42 

43 return data 

44 

45 

46def migrate_drop_branch_fields(data: dict[str, Any]) -> dict[str, Any]: 

47 """ 

48 Migrate v3 -> v4: 

49 - Scope-level `branches` was removed. 

50 - The PullRequest `base_branch`/`head_branch` fields were removed along with 

51 the `Branch` domain object. 

52 

53 Both models forbid extra keys, so any leftover value in an old stored result 

54 would break re-parsing. `base_branch`/`head_branch` were required (no 

55 default), so every pre-v4 result carries them. Drop them from the pullrequest 

56 and `branches` from every stored scope (configs and scope results). 

57 """ 

58 pullrequest = data.get("pullrequest") 

59 if isinstance(pullrequest, dict): 

60 pullrequest.pop("base_branch", None) 

61 pullrequest.pop("head_branch", None) 

62 

63 scopes: list[Any] = [] 

64 for config_result in data.get("config_results", {}).values(): 

65 if isinstance(config_result, dict) and isinstance( 

66 config_result.get("config"), dict 

67 ): 

68 scopes.extend(config_result["config"].get("scopes") or []) 

69 for scope_result in data.get("scope_results", {}).values(): 

70 if isinstance(scope_result, dict): 

71 scopes.append(scope_result.get("scope")) 

72 

73 for scope in scopes: 

74 if isinstance(scope, dict): 

75 scope.pop("branches", None) 

76 

77 return data 

78 

79 

80def migrate_drop_reviewed_for(data: dict[str, Any]) -> dict[str, Any]: 

81 """ 

82 Migrate v4 -> v5: 

83 - The scope-level `reviewed_for` setting was removed. ScopeModel forbids 

84 extra keys, so any stored scope that set `reviewed_for` to a non-default 

85 value (`required`/`ignored`) would break re-parsing. Drop it from every 

86 stored scope (configs and scope results). 

87 """ 

88 scopes: list[Any] = [] 

89 for config_result in data.get("config_results", {}).values(): 

90 if isinstance(config_result, dict) and isinstance( 

91 config_result.get("config"), dict 

92 ): 

93 scopes.extend(config_result["config"].get("scopes") or []) 

94 for scope_result in data.get("scope_results", {}).values(): 

95 if isinstance(scope_result, dict): 

96 scopes.append(scope_result.get("scope")) 

97 

98 for scope in scopes: 

99 if isinstance(scope, dict): 

100 scope.pop("reviewed_for", None) 

101 

102 return data 

103 

104 

105def migrate_unwrap_review_results(data: dict[str, Any]) -> dict[str, Any]: 

106 """ 

107 Migrate v5 -> v6: 

108 - The ReviewResult wrapper was removed; review_results now maps host_id -> 

109 Review directly. Unwrap each stored {"review": {...}, ...} into the bare 

110 review dict (which also drops the removed matched_scopes field). 

111 """ 

112 review_results = data.get("review_results") 

113 if isinstance(review_results, dict): 

114 for host_id, review_result in review_results.items(): 

115 if isinstance(review_result, dict) and isinstance( 

116 review_result.get("review"), dict 

117 ): 

118 review_results[host_id] = review_result["review"] 

119 

120 return data 

121 

122 

123def migrate_drop_path_code_reviews(data: dict[str, Any]) -> dict[str, Any]: 

124 """ 

125 Migrate v6 -> v7: 

126 - PathResult.reviews / CodeResult.reviews were removed (write-only dead 

127 state). Both models forbid extra keys, so strip `reviews` from every 

128 stored path result and code result. 

129 """ 

130 for key in ("path_results", "code_results"): 

131 for result in data.get(key, {}).values(): 

132 if isinstance(result, dict): 

133 result.pop("reviews", None) 

134 

135 return data 

136 

137 

138def migrate_requested_reviews_to_flag(data: dict[str, Any]) -> dict[str, Any]: 

139 """ 

140 Migrate v7 -> v8: 

141 - Review requests used to be represented as synthetic PENDING reviews with 

142 host_id "requested:<user id>". They are now a `requested` flag on the 

143 Reviewer instead. Strip the synthetic reviews everywhere they were 

144 stored (reviewer reviews, review_results, scope review id lists) and 

145 set `requested` on the reviewers that carried one. The large-scale 

146 change's review id list had them too; v13 drops that whole block. 

147 """ 

148 

149 def is_placeholder(host_id: Any) -> bool: 

150 return isinstance(host_id, str) and host_id.startswith("requested:") 

151 

152 pullrequest = data.get("pullrequest") 

153 if isinstance(pullrequest, dict): 

154 for reviewer in pullrequest.get("reviewers") or []: 

155 if not isinstance(reviewer, dict): 

156 continue 

157 reviews = reviewer.get("reviews") 

158 if not isinstance(reviews, list): 

159 continue 

160 kept = [ 

161 r 

162 for r in reviews 

163 if not (isinstance(r, dict) and is_placeholder(r.get("host_id"))) 

164 ] 

165 if len(kept) != len(reviews): 

166 reviewer["reviews"] = kept 

167 reviewer["requested"] = True 

168 

169 review_results = data.get("review_results") 

170 if isinstance(review_results, dict): 

171 for host_id in [k for k in review_results if is_placeholder(k)]: 

172 del review_results[host_id] 

173 

174 for scope_result in data.get("scope_results", {}).values(): 

175 if isinstance(scope_result, dict) and isinstance( 

176 scope_result.get("reviews"), list 

177 ): 

178 scope_result["reviews"] = [ 

179 r for r in scope_result["reviews"] if not is_placeholder(r) 

180 ] 

181 

182 return data 

183 

184 

185def migrate_drop_agents(data: dict[str, Any]) -> dict[str, Any]: 

186 """ 

187 Migrate v8 -> v9: 

188 - `[[agents]]` was removed (replaced by scope-level `unless`). Results 

189 stored while a config declared agents carry an `agents` list at the top 

190 level and an `agents` key inside stored config dumps; the results model 

191 forbids extra keys and ConfigModel now rejects any `agents` key 

192 outright, so both must be stripped for old results to re-parse. Open 

193 pull requests would self-heal on their next processing run, but 

194 merged/closed ones never reprocess -- without this their stored history 

195 is unrenderable forever. 

196 """ 

197 data.pop("agents", None) 

198 

199 for config_result in data.get("config_results", {}).values(): 

200 if isinstance(config_result, dict) and isinstance( 

201 config_result.get("config"), dict 

202 ): 

203 config_result["config"].pop("agents", None) 

204 

205 return data 

206 

207 

208def migrate_agent_result_states(data: dict[str, Any]) -> dict[str, Any]: 

209 """ 

210 Migrate v9 -> v10: 

211 - The single "pending" agent state split into "queued", "running" and 

212 "disputed", and the `in_flight`/`unstarted` booleans that used to carry 

213 that distinction moved onto the state itself. AgentResult forbids extra 

214 keys, so the old flags have to go or old results stop re-parsing. 

215 

216 The flags are also the only record of WHICH of the three a stored 

217 "pending" was, so they are read before they are dropped: `unstarted` 

218 marked a review nothing had started, `in_flight` one that was on its 

219 way, and a dispute carried neither (both defaulted False, and results 

220 are dumped with exclude_defaults, so a dispute stored no flag at all). 

221 

222 Merged and closed pull requests never reprocess, so what this writes is 

223 what their history keeps. Collapsing the three into one would relabel 

224 every settled dispute as a review still running — the exact sentence 

225 the split exists to stop showing. 

226 """ 

227 for agent_result in data.get("agent_results", {}).values(): 

228 if not isinstance(agent_result, dict): 

229 continue 

230 unstarted = agent_result.pop("unstarted", False) 

231 in_flight = agent_result.pop("in_flight", False) 

232 if agent_result.get("state") != "pending": 

233 continue 

234 if unstarted: 

235 agent_result["state"] = "queued" 

236 elif in_flight: 

237 agent_result["state"] = "running" 

238 else: 

239 agent_result["state"] = "disputed" 

240 

241 return data 

242 

243 

244def migrate_drop_agent_results(data: dict[str, Any]) -> dict[str, Any]: 

245 """ 

246 Migrate v10 -> v11: 

247 - The agent gate was removed with the hosted agent reviews. Results stored 

248 while it existed carry an `agent_results` receipt at the top level, and 

249 config dumps carry an `agents` list; both models forbid extra keys, so 

250 both are stripped for old results to re-parse. 

251 """ 

252 data.pop("agent_results", None) 

253 

254 for config_result in data.get("config_results", {}).values(): 

255 if isinstance(config_result, dict) and isinstance( 

256 config_result.get("config"), dict 

257 ): 

258 config_result["config"].pop("agents", None) 

259 

260 return data 

261 

262 

263def migrate_drop_removal_kind_flags(data: dict[str, Any]) -> dict[str, Any]: 

264 """ 

265 Migrate v11 -> v12: 

266 - A withdrawn review request used to be read three ways (self, someone 

267 else, bot), carried as `removed_by_self` / `removed_by_bot` on each 

268 reviewer. There is one rule now and the flags are gone. Reviewer forbids 

269 extra keys, so results stored in the window that wrote them must have 

270 them stripped or they stop re-parsing. `removed` itself stays. 

271 """ 

272 pullrequest = data.get("pullrequest") 

273 if isinstance(pullrequest, dict): 

274 for reviewer in pullrequest.get("reviewers") or []: 

275 if isinstance(reviewer, dict): 

276 reviewer.pop("removed_by_self", None) 

277 reviewer.pop("removed_by_bot", None) 

278 

279 return data 

280 

281 

282def migrate_drop_large_scale_change_results(data: dict[str, Any]) -> dict[str, Any]: 

283 """ 

284 Migrate v12 -> v13: 

285 - A large-scale change is now reviewed as a scope in `scope_results`, 

286 instead of in its own `large_scale_change_results` block. The results 

287 model forbids extra keys, so the block is stripped. A migration can't 

288 turn it into a scope (see the module docstring), so an old large-scale 

289 change reads as having no scopes until it is processed again. 

290 """ 

291 data.pop("large_scale_change_results", None) 

292 

293 return data 

294 

295 

296class ResultsMigrator: 

297 """ 

298 Handles versioned migrations for PullRequestResults data. 

299 """ 

300 

301 # Ordered list of migration functions. 

302 # Index 0 = v1->v2, index 1 = v2->v3, etc. 

303 migrations = ( 

304 migrate_resview_results_scopes, 

305 migrate_drop_config_branches, 

306 migrate_drop_branch_fields, 

307 migrate_drop_reviewed_for, 

308 migrate_unwrap_review_results, 

309 migrate_drop_path_code_reviews, 

310 migrate_requested_reviews_to_flag, 

311 migrate_drop_agents, 

312 migrate_agent_result_states, 

313 migrate_drop_agent_results, 

314 migrate_drop_removal_kind_flags, 

315 migrate_drop_large_scale_change_results, 

316 ) 

317 

318 @classmethod 

319 def current_version(cls) -> int: 

320 """Current version is always 1 more than the number of migrations.""" 

321 return len(cls.migrations) + 1 

322 

323 @classmethod 

324 def migrate(cls, data: dict[str, Any]) -> dict[str, Any]: 

325 """ 

326 Apply all necessary migrations to bring data to current version. 

327 

328 Data without a version field is assumed to be v1. 

329 """ 

330 version = data.get("version", 1) 

331 

332 # Apply migrations from current version to latest 

333 for migration in cls.migrations[version - 1 :]: 

334 data = migration(data) 

335 

336 data["version"] = cls.current_version() 

337 return data