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
« prev ^ index » next coverage.py v7.14.1, created at 2026-10-21 11:55 -0500
1"""
2Versioned migrations for PullRequestResults data.
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().
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"""
13from __future__ import annotations
15from typing import Any
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")
29 return data
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)
43 return data
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.
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)
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"))
73 for scope in scopes:
74 if isinstance(scope, dict):
75 scope.pop("branches", None)
77 return data
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"))
98 for scope in scopes:
99 if isinstance(scope, dict):
100 scope.pop("reviewed_for", None)
102 return data
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"]
120 return data
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)
135 return data
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 """
149 def is_placeholder(host_id: Any) -> bool:
150 return isinstance(host_id, str) and host_id.startswith("requested:")
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
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]
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 ]
182 return data
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)
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)
205 return data
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.
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).
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"
241 return data
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)
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)
260 return data
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)
279 return data
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)
293 return data
296class ResultsMigrator:
297 """
298 Handles versioned migrations for PullRequestResults data.
299 """
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 )
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
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.
328 Data without a version field is assumed to be v1.
329 """
330 version = data.get("version", 1)
332 # Apply migrations from current version to latest
333 for migration in cls.migrations[version - 1 :]:
334 data = migration(data)
336 data["version"] = cls.current_version()
337 return data