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
« prev ^ index » next coverage.py v7.14.1, created at 2026-10-14 08:58 -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/LSC review id lists)
145 and set `requested` on the reviewers that carried one.
146 """
148 def is_placeholder(host_id: Any) -> bool:
149 return isinstance(host_id, str) and host_id.startswith("requested:")
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
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]
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 ]
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)]
185 return data
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)
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)
208 return data
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.
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).
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"
244 return data
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)
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)
263 return data
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)
282 return data
285class ResultsMigrator:
286 """
287 Handles versioned migrations for PullRequestResults data.
288 """
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 )
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
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.
316 Data without a version field is assumed to be v1.
317 """
318 version = data.get("version", 1)
320 # Apply migrations from current version to latest
321 for migration in cls.migrations[version - 1 :]:
322 data = migration(data)
324 data["version"] = cls.current_version()
325 return data