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

99 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-07-28 16:50 -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 

206class ResultsMigrator: 

207 """ 

208 Handles versioned migrations for PullRequestResults data. 

209 """ 

210 

211 # Ordered list of migration functions. 

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

213 migrations = [ 

214 migrate_resview_results_scopes, 

215 migrate_drop_config_branches, 

216 migrate_drop_branch_fields, 

217 migrate_drop_reviewed_for, 

218 migrate_unwrap_review_results, 

219 migrate_drop_path_code_reviews, 

220 migrate_requested_reviews_to_flag, 

221 migrate_drop_agents, 

222 ] 

223 

224 @classmethod 

225 def current_version(cls) -> int: 

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

227 return len(cls.migrations) + 1 

228 

229 @classmethod 

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

231 """ 

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

233 

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

235 """ 

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

237 

238 # Apply migrations from current version to latest 

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

240 data = migration(data) 

241 

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

243 return data