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

113 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-09-04 22:20 -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""" 

7 

8from __future__ import annotations 

9 

10from typing import Any 

11 

12 

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

14 """ 

15 Migrate v1 -> v2: 

16 - Rename ReviewResult.scopes -> matched_scopes 

17 """ 

18 if "review_results" in data: 

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

20 # Rename scopes -> matched_scopes 

21 if "scopes" in review_result: 

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

23 

24 return data 

25 

26 

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

28 """ 

29 Migrate v2 -> v3: 

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

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

32 """ 

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

34 config = config_result.get("config") 

35 if isinstance(config, dict): 

36 config.pop("branches", None) 

37 

38 return data 

39 

40 

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

42 """ 

43 Migrate v3 -> v4: 

44 - Scope-level `branches` was removed. 

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

46 the `Branch` domain object. 

47 

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

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

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

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

52 """ 

53 pullrequest = data.get("pullrequest") 

54 if isinstance(pullrequest, dict): 

55 pullrequest.pop("base_branch", None) 

56 pullrequest.pop("head_branch", None) 

57 

58 scopes: list[Any] = [] 

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

60 if isinstance(config_result, dict) and isinstance( 

61 config_result.get("config"), dict 

62 ): 

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

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

65 if isinstance(scope_result, dict): 

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

67 

68 for scope in scopes: 

69 if isinstance(scope, dict): 

70 scope.pop("branches", None) 

71 

72 return data 

73 

74 

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

76 """ 

77 Migrate v4 -> v5: 

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

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

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

81 stored scope (configs and scope results). 

82 """ 

83 scopes: list[Any] = [] 

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

85 if isinstance(config_result, dict) and isinstance( 

86 config_result.get("config"), dict 

87 ): 

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

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

90 if isinstance(scope_result, dict): 

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

92 

93 for scope in scopes: 

94 if isinstance(scope, dict): 

95 scope.pop("reviewed_for", None) 

96 

97 return data 

98 

99 

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

101 """ 

102 Migrate v5 -> v6: 

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

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

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

106 """ 

107 review_results = data.get("review_results") 

108 if isinstance(review_results, dict): 

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

110 if isinstance(review_result, dict) and isinstance( 

111 review_result.get("review"), dict 

112 ): 

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

114 

115 return data 

116 

117 

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

119 """ 

120 Migrate v6 -> v7: 

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

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

123 stored path result and code result. 

124 """ 

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

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

127 if isinstance(result, dict): 

128 result.pop("reviews", None) 

129 

130 return data 

131 

132 

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

134 """ 

135 Migrate v7 -> v8: 

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

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

138 Reviewer instead. Strip the synthetic reviews everywhere they were 

139 stored (reviewer reviews, review_results, scope/LSC review id lists) 

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

141 """ 

142 

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

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

145 

146 pullrequest = data.get("pullrequest") 

147 if isinstance(pullrequest, dict): 

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

149 if not isinstance(reviewer, dict): 

150 continue 

151 reviews = reviewer.get("reviews") 

152 if not isinstance(reviews, list): 

153 continue 

154 kept = [ 

155 r 

156 for r in reviews 

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

158 ] 

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

160 reviewer["reviews"] = kept 

161 reviewer["requested"] = True 

162 

163 review_results = data.get("review_results") 

164 if isinstance(review_results, dict): 

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

166 del review_results[host_id] 

167 

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

169 if isinstance(scope_result, dict) and isinstance( 

170 scope_result.get("reviews"), list 

171 ): 

172 scope_result["reviews"] = [ 

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

174 ] 

175 

176 lsc = data.get("large_scale_change_results") 

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

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

179 

180 return data 

181 

182 

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

184 """ 

185 Migrate v8 -> v9: 

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

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

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

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

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

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

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

193 is unrenderable forever. 

194 """ 

195 data.pop("agents", None) 

196 

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

198 if isinstance(config_result, dict) and isinstance( 

199 config_result.get("config"), dict 

200 ): 

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

202 

203 return data 

204 

205 

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

207 """ 

208 Migrate v9 -> v10: 

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

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

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

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

213 

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

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

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

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

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

219 

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

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

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

223 the split exists to stop showing. 

224 """ 

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

226 if not isinstance(agent_result, dict): 

227 continue 

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

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

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

231 continue 

232 if unstarted: 

233 agent_result["state"] = "queued" 

234 elif in_flight: 

235 agent_result["state"] = "running" 

236 else: 

237 agent_result["state"] = "disputed" 

238 

239 return data 

240 

241 

242class ResultsMigrator: 

243 """ 

244 Handles versioned migrations for PullRequestResults data. 

245 """ 

246 

247 # Ordered list of migration functions. 

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

249 migrations = ( 

250 migrate_resview_results_scopes, 

251 migrate_drop_config_branches, 

252 migrate_drop_branch_fields, 

253 migrate_drop_reviewed_for, 

254 migrate_unwrap_review_results, 

255 migrate_drop_path_code_reviews, 

256 migrate_requested_reviews_to_flag, 

257 migrate_drop_agents, 

258 migrate_agent_result_states, 

259 ) 

260 

261 @classmethod 

262 def current_version(cls) -> int: 

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

264 return len(cls.migrations) + 1 

265 

266 @classmethod 

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

268 """ 

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

270 

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

272 """ 

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

274 

275 # Apply migrations from current version to latest 

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

277 data = migration(data) 

278 

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

280 return data