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

127 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-10-14 08:58 -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/LSC review id lists) 

145 and set `requested` on the reviewers that carried one. 

146 """ 

147 

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

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

150 

151 pullrequest = data.get("pullrequest") 

152 if isinstance(pullrequest, dict): 

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

154 if not isinstance(reviewer, dict): 

155 continue 

156 reviews = reviewer.get("reviews") 

157 if not isinstance(reviews, list): 

158 continue 

159 kept = [ 

160 r 

161 for r in reviews 

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

163 ] 

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

165 reviewer["reviews"] = kept 

166 reviewer["requested"] = True 

167 

168 review_results = data.get("review_results") 

169 if isinstance(review_results, dict): 

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

171 del review_results[host_id] 

172 

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

174 if isinstance(scope_result, dict) and isinstance( 

175 scope_result.get("reviews"), list 

176 ): 

177 scope_result["reviews"] = [ 

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

179 ] 

180 

181 lsc = data.get("large_scale_change_results") 

182 if isinstance(lsc, dict) and isinstance(lsc.get("reviews"), list): 

183 lsc["reviews"] = [r for r in lsc["reviews"] if not is_placeholder(r)] 

184 

185 return data 

186 

187 

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

189 """ 

190 Migrate v8 -> v9: 

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

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

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

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

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

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

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

198 is unrenderable forever. 

199 """ 

200 data.pop("agents", None) 

201 

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

203 if isinstance(config_result, dict) and isinstance( 

204 config_result.get("config"), dict 

205 ): 

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

207 

208 return data 

209 

210 

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

212 """ 

213 Migrate v9 -> v10: 

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

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

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

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

218 

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

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

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

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

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

224 

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

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

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

228 the split exists to stop showing. 

229 """ 

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

231 if not isinstance(agent_result, dict): 

232 continue 

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

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

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

236 continue 

237 if unstarted: 

238 agent_result["state"] = "queued" 

239 elif in_flight: 

240 agent_result["state"] = "running" 

241 else: 

242 agent_result["state"] = "disputed" 

243 

244 return data 

245 

246 

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

248 """ 

249 Migrate v10 -> v11: 

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

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

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

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

254 """ 

255 data.pop("agent_results", None) 

256 

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

258 if isinstance(config_result, dict) and isinstance( 

259 config_result.get("config"), dict 

260 ): 

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

262 

263 return data 

264 

265 

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

267 """ 

268 Migrate v11 -> v12: 

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

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

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

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

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

274 """ 

275 pullrequest = data.get("pullrequest") 

276 if isinstance(pullrequest, dict): 

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

278 if isinstance(reviewer, dict): 

279 reviewer.pop("removed_by_self", None) 

280 reviewer.pop("removed_by_bot", None) 

281 

282 return data 

283 

284 

285class ResultsMigrator: 

286 """ 

287 Handles versioned migrations for PullRequestResults data. 

288 """ 

289 

290 # Ordered list of migration functions. 

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

292 migrations = ( 

293 migrate_resview_results_scopes, 

294 migrate_drop_config_branches, 

295 migrate_drop_branch_fields, 

296 migrate_drop_reviewed_for, 

297 migrate_unwrap_review_results, 

298 migrate_drop_path_code_reviews, 

299 migrate_requested_reviews_to_flag, 

300 migrate_drop_agents, 

301 migrate_agent_result_states, 

302 migrate_drop_agent_results, 

303 migrate_drop_removal_kind_flags, 

304 ) 

305 

306 @classmethod 

307 def current_version(cls) -> int: 

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

309 return len(cls.migrations) + 1 

310 

311 @classmethod 

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

313 """ 

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

315 

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

317 """ 

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

319 

320 # Apply migrations from current version to latest 

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

322 data = migration(data) 

323 

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

325 return data