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
« prev ^ index » next coverage.py v7.14.1, created at 2026-09-04 22:20 -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().
6"""
8from __future__ import annotations
10from typing import Any
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")
24 return data
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)
38 return data
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.
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)
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"))
68 for scope in scopes:
69 if isinstance(scope, dict):
70 scope.pop("branches", None)
72 return data
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"))
93 for scope in scopes:
94 if isinstance(scope, dict):
95 scope.pop("reviewed_for", None)
97 return data
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"]
115 return data
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)
130 return data
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 """
143 def is_placeholder(host_id: Any) -> bool:
144 return isinstance(host_id, str) and host_id.startswith("requested:")
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
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]
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 ]
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)]
180 return data
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)
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)
203 return data
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.
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).
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"
239 return data
242class ResultsMigrator:
243 """
244 Handles versioned migrations for PullRequestResults data.
245 """
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 )
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
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.
271 Data without a version field is assumed to be v1.
272 """
273 version = data.get("version", 1)
275 # Apply migrations from current version to latest
276 for migration in cls.migrations[version - 1 :]:
277 data = migration(data)
279 data["version"] = cls.current_version()
280 return data