patchahead 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. patchahead/__init__.py +8 -0
  2. patchahead/analysis/__init__.py +52 -0
  3. patchahead/analysis/edits.py +143 -0
  4. patchahead/analysis/index.py +203 -0
  5. patchahead/analysis/python_ast.py +457 -0
  6. patchahead/apidiff/__init__.py +23 -0
  7. patchahead/apidiff/compare.py +366 -0
  8. patchahead/apidiff/download.py +95 -0
  9. patchahead/apidiff/surface.py +337 -0
  10. patchahead/ci.py +301 -0
  11. patchahead/cli.py +627 -0
  12. patchahead/config.py +284 -0
  13. patchahead/demo/__init__.py +256 -0
  14. patchahead/demo/fixtures/changes/field-rename.md +14 -0
  15. patchahead/demo/fixtures/changes/invoice-field-rename.md +21 -0
  16. patchahead/demo/fixtures/changes/kwarg-rename.md +14 -0
  17. patchahead/demo/fixtures/changes/method-rename.md +12 -0
  18. patchahead/demo/fixtures/changes/pagination-cursor.json +24 -0
  19. patchahead/demo/fixtures/changes/pagination-cursor.md +20 -0
  20. patchahead/demo/fixtures/changes/sdk-v2.md +31 -0
  21. patchahead/demo/fixtures/orders-service/README.md +51 -0
  22. patchahead/demo/fixtures/orders-service/app/__init__.py +0 -0
  23. patchahead/demo/fixtures/orders-service/app/client.py +15 -0
  24. patchahead/demo/fixtures/orders-service/app/models.py +10 -0
  25. patchahead/demo/fixtures/orders-service/app/order_report.py +24 -0
  26. patchahead/demo/fixtures/orders-service/app/order_sync.py +21 -0
  27. patchahead/demo/fixtures/orders-service/conftest.py +6 -0
  28. patchahead/demo/fixtures/orders-service/pyproject.toml +16 -0
  29. patchahead/demo/fixtures/orders-service/tests/test_client.py +14 -0
  30. patchahead/demo/fixtures/orders-service/tests/test_order_report.py +24 -0
  31. patchahead/demo/fixtures/orders-service/tests/test_order_sync.py +11 -0
  32. patchahead/demo/fixtures/orders-service/upstream/__init__.py +0 -0
  33. patchahead/demo/fixtures/orders-service/upstream/api_v1.py +34 -0
  34. patchahead/demo/fixtures/orders-service/upstream/api_v2.py +56 -0
  35. patchahead/demo/serve.py +189 -0
  36. patchahead/domain/__init__.py +67 -0
  37. patchahead/domain/change.py +269 -0
  38. patchahead/domain/completeness.py +91 -0
  39. patchahead/domain/impact.py +248 -0
  40. patchahead/domain/patch.py +81 -0
  41. patchahead/domain/plan.py +170 -0
  42. patchahead/domain/result.py +210 -0
  43. patchahead/domain/validation.py +200 -0
  44. patchahead/engine.py +609 -0
  45. patchahead/handlers/__init__.py +35 -0
  46. patchahead/handlers/base.py +211 -0
  47. patchahead/handlers/field_rename.py +425 -0
  48. patchahead/handlers/kwarg_rename.py +201 -0
  49. patchahead/handlers/method_rename.py +608 -0
  50. patchahead/handlers/pagination.py +582 -0
  51. patchahead/ingest/__init__.py +32 -0
  52. patchahead/ingest/base.py +102 -0
  53. patchahead/ingest/markdown.py +1138 -0
  54. patchahead/ingest/structured.py +218 -0
  55. patchahead/llm/__init__.py +28 -0
  56. patchahead/llm/client.py +152 -0
  57. patchahead/llm/proposer.py +620 -0
  58. patchahead/observability.py +223 -0
  59. patchahead/reporting.py +451 -0
  60. patchahead/testing/__init__.py +22 -0
  61. patchahead/testing/discovery.py +113 -0
  62. patchahead/testing/runner.py +138 -0
  63. patchahead/validation/__init__.py +5 -0
  64. patchahead/validation/completeness.py +265 -0
  65. patchahead/validation/engine.py +531 -0
  66. patchahead/web/__init__.py +13 -0
  67. patchahead/web/server.py +279 -0
  68. patchahead/web/static/index.html +650 -0
  69. patchahead/workspace.py +382 -0
  70. patchahead-0.3.0.dist-info/METADATA +368 -0
  71. patchahead-0.3.0.dist-info/RECORD +75 -0
  72. patchahead-0.3.0.dist-info/WHEEL +5 -0
  73. patchahead-0.3.0.dist-info/entry_points.txt +2 -0
  74. patchahead-0.3.0.dist-info/licenses/LICENSE +21 -0
  75. patchahead-0.3.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,337 @@
1
+ """The public API of a Python library, read from its source without running it.
2
+
3
+ A library is parsed, never imported: importing runs its code, and the point of
4
+ reading two versions side by side is to do it before trusting either. What is
5
+ recorded is what a caller can depend on -- public functions, classes, methods,
6
+ and each parameter's name, kind, position, and whether it has a default.
7
+
8
+ "Public" follows the conventions a caller relies on: a name without a leading
9
+ underscore, in a module whose path has no private component, filtered by
10
+ ``__all__`` where a module declares one -- plus anything a public module
11
+ re-exports with ``from .impl import Name``, which is how most libraries expose
12
+ code that lives in private modules.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import ast
18
+ import logging
19
+ import re
20
+ from dataclasses import dataclass, field
21
+ from pathlib import Path
22
+
23
+ from patchahead.analysis.edits import read_source
24
+
25
+ log = logging.getLogger(__name__)
26
+
27
+ #: Directories in a source tree or wheel that are not the library's code.
28
+ _SKIP_DIRS = {"tests", "test", "testing", "docs", "examples", "benchmarks"}
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class Param:
33
+ """One parameter, as a caller sees it."""
34
+
35
+ name: str
36
+ #: ``positional`` (positional-only), ``normal``, ``var_positional``,
37
+ #: ``keyword`` (keyword-only), or ``var_keyword``.
38
+ kind: str
39
+ has_default: bool
40
+
41
+ def render(self) -> str:
42
+ prefix = {"var_positional": "*", "var_keyword": "**"}.get(self.kind, "")
43
+ return f"{prefix}{self.name}{'=...' if self.has_default else ''}"
44
+
45
+
46
+ @dataclass
47
+ class Member:
48
+ """A public function, class, or method, where it is defined."""
49
+
50
+ #: Dotted module the definition lives in, e.g. ``storekit._client``.
51
+ module: str
52
+ #: ``fetch_all`` for a function, ``Client`` for a class, ``Client.fetch_all``
53
+ #: for a method.
54
+ qualname: str
55
+ kind: str # "function" | "class" | "method"
56
+ params: tuple[Param, ...] = ()
57
+ #: For a class: its public method names, used to recognize a renamed class.
58
+ methods: frozenset[str] = frozenset()
59
+ #: Marked deprecated: a ``@deprecated`` decorator, or a deprecation warning.
60
+ deprecated: bool = False
61
+ #: The sibling a deprecated member names as its replacement, in its
62
+ #: warning, decorator, or docstring.
63
+ deprecated_for: str = ""
64
+ line: int = 0
65
+
66
+ @property
67
+ def name(self) -> str:
68
+ return self.qualname.rsplit(".", 1)[-1]
69
+
70
+ @property
71
+ def container(self) -> str:
72
+ """What holds it: the class for a method, the module otherwise."""
73
+ return self.qualname.rsplit(".", 1)[0] if "." in self.qualname else ""
74
+
75
+ def signature(self) -> str:
76
+ return f"{self.qualname}({', '.join(p.render() for p in self.params)})"
77
+
78
+
79
+ @dataclass
80
+ class Surface:
81
+ """Every public member of a library, keyed by the dotted path a caller uses."""
82
+
83
+ #: ``storekit.Client.fetch_all`` -> the member, however it got there.
84
+ public: dict[str, Member] = field(default_factory=dict)
85
+ #: Modules that could not be parsed, with the reason.
86
+ skipped: dict[str, str] = field(default_factory=dict)
87
+
88
+ def by_definition(self) -> dict[tuple[str, str], Member]:
89
+ """The same members keyed by where they are defined."""
90
+ return {(m.module, m.qualname): m for m in self.public.values()}
91
+
92
+
93
+ def read(root: Path) -> Surface:
94
+ """Read the public API of every top-level package or module under ``root``.
95
+
96
+ ``root`` may also be a package directory itself; its parent is read then.
97
+ """
98
+ if (root / "__init__.py").exists():
99
+ root = root.parent
100
+ modules = _modules(root)
101
+ definitions: dict[str, dict[str, Member]] = {}
102
+ imports: dict[str, dict[str, tuple[str, str]]] = {}
103
+ exported: dict[str, set[str] | None] = {}
104
+ surface = Surface()
105
+
106
+ for dotted, path in modules.items():
107
+ try:
108
+ tree = ast.parse(read_source(path), filename=str(path))
109
+ except (SyntaxError, UnicodeDecodeError, OSError) as exc:
110
+ surface.skipped[dotted] = str(exc)
111
+ continue
112
+ definitions[dotted] = _definitions(dotted, tree)
113
+ imports[dotted] = _imports(dotted, tree, path.name == "__init__.py")
114
+ exported[dotted] = _all(tree)
115
+
116
+ for dotted in modules:
117
+ if dotted not in definitions or _private_module(dotted):
118
+ continue
119
+ allowed = exported[dotted]
120
+
121
+ def visible(name: str, allowed: set[str] | None = allowed) -> bool:
122
+ return name in allowed if allowed is not None else not name.startswith("_")
123
+
124
+ for name, member in definitions[dotted].items():
125
+ if "." not in name and visible(name):
126
+ _publish(surface, f"{dotted}.{name}", member, definitions[dotted])
127
+ for alias, (source, original) in imports[dotted].items():
128
+ member = definitions.get(source, {}).get(original)
129
+ if member is not None and visible(alias):
130
+ _publish(surface, f"{dotted}.{alias}", member, definitions[source])
131
+ return surface
132
+
133
+
134
+ def _publish(surface: Surface, path: str, member: Member, siblings: dict[str, Member]) -> None:
135
+ surface.public.setdefault(path, member)
136
+ if member.kind == "class":
137
+ for qualname, method in siblings.items():
138
+ if qualname.startswith(f"{member.qualname}."):
139
+ surface.public.setdefault(f"{path}.{method.name}", method)
140
+
141
+
142
+ def _modules(root: Path) -> dict[str, Path]:
143
+ """Dotted module name -> file, for every package or module at ``root``."""
144
+ found: dict[str, Path] = {}
145
+ for path in sorted(root.rglob("*.py")):
146
+ relative = path.relative_to(root)
147
+ parts = relative.parts
148
+ if any(p.endswith((".dist-info", ".egg-info", ".data")) for p in parts):
149
+ continue
150
+ if any(p in _SKIP_DIRS for p in parts[:-1]):
151
+ continue
152
+ # Only the importable tree: every directory above the file is a package.
153
+ if any(
154
+ not (root.joinpath(*parts[:i]) / "__init__.py").exists() for i in range(1, len(parts))
155
+ ):
156
+ continue
157
+ dotted = ".".join(parts[:-1] + ((path.stem,) if path.stem != "__init__" else ()))
158
+ if dotted:
159
+ found[dotted] = path
160
+ return found
161
+
162
+
163
+ def _private_module(dotted: str) -> bool:
164
+ return any(part.startswith("_") for part in dotted.split("."))
165
+
166
+
167
+ def _all(tree: ast.Module) -> set[str] | None:
168
+ """A module's literal ``__all__``, or None when it has none we can read."""
169
+ for node in tree.body:
170
+ names_all = isinstance(node, ast.Assign) and any(
171
+ isinstance(t, ast.Name) and t.id == "__all__" for t in node.targets
172
+ )
173
+ if (
174
+ names_all
175
+ and isinstance(node.value, (ast.List, ast.Tuple))
176
+ and all(
177
+ isinstance(e, ast.Constant) and isinstance(e.value, str) for e in node.value.elts
178
+ )
179
+ ):
180
+ return {e.value for e in node.value.elts}
181
+ return None
182
+
183
+
184
+ def _imports(dotted: str, tree: ast.Module, is_package: bool) -> dict[str, tuple[str, str]]:
185
+ """``alias -> (source module, original name)`` for a module's top-level imports."""
186
+ package = dotted if is_package else dotted.rpartition(".")[0]
187
+ found: dict[str, tuple[str, str]] = {}
188
+ for node in tree.body:
189
+ if not isinstance(node, ast.ImportFrom):
190
+ continue
191
+ if node.level:
192
+ base = package.split(".")
193
+ base = base[: len(base) - (node.level - 1)] if node.level > 1 else base
194
+ source = ".".join(base + ([node.module] if node.module else []))
195
+ else:
196
+ source = node.module or ""
197
+ for alias in node.names:
198
+ if alias.name != "*":
199
+ found[alias.asname or alias.name] = (source, alias.name)
200
+ return found
201
+
202
+
203
+ def _definitions(dotted: str, tree: ast.Module) -> dict[str, Member]:
204
+ members: dict[str, Member] = {}
205
+ for node in tree.body:
206
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
207
+ members[node.name] = _function(dotted, node.name, node, "function", tree.body)
208
+ elif isinstance(node, ast.ClassDef):
209
+ methods = [
210
+ child
211
+ for child in node.body
212
+ if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef))
213
+ and (not child.name.startswith("_") or child.name == "__init__")
214
+ ]
215
+ init = next((m for m in methods if m.name == "__init__"), None)
216
+ members[node.name] = Member(
217
+ module=dotted,
218
+ qualname=node.name,
219
+ kind="class",
220
+ params=_params(init, drop_first=True) if init else (),
221
+ methods=frozenset(m.name for m in methods if m.name != "__init__"),
222
+ line=node.lineno,
223
+ )
224
+ for method in methods:
225
+ if method.name == "__init__":
226
+ continue
227
+ qualname = f"{node.name}.{method.name}"
228
+ members[qualname] = _function(dotted, qualname, method, "method", node.body)
229
+ return members
230
+
231
+
232
+ def _function(dotted: str, qualname: str, node, kind: str, scope: list[ast.stmt]) -> Member:
233
+ is_static = any(_decorator_name(d) == "staticmethod" for d in node.decorator_list)
234
+ deprecated, replacement = _deprecation(node, scope)
235
+ return Member(
236
+ module=dotted,
237
+ qualname=qualname,
238
+ kind=kind,
239
+ params=_params(node, drop_first=kind == "method" and not is_static),
240
+ deprecated=deprecated,
241
+ deprecated_for=replacement,
242
+ line=node.lineno,
243
+ )
244
+
245
+
246
+ def _params(node, drop_first: bool) -> tuple[Param, ...]:
247
+ args = node.args
248
+ positional = [*args.posonlyargs, *args.args]
249
+ defaults_from = len(positional) - len(args.defaults)
250
+ params: list[Param] = []
251
+ for index, arg in enumerate(positional):
252
+ kind = "positional" if index < len(args.posonlyargs) else "normal"
253
+ params.append(Param(arg.arg, kind, index >= defaults_from))
254
+ if args.vararg:
255
+ params.append(Param(args.vararg.arg, "var_positional", False))
256
+ for arg, default in zip(args.kwonlyargs, args.kw_defaults, strict=True):
257
+ params.append(Param(arg.arg, "keyword", default is not None))
258
+ if args.kwarg:
259
+ params.append(Param(args.kwarg.arg, "var_keyword", False))
260
+ return tuple(params[1:] if drop_first and params and params[0].kind != "keyword" else params)
261
+
262
+
263
+ def _decorator_name(node: ast.expr) -> str:
264
+ target = node.func if isinstance(node, ast.Call) else node
265
+ if isinstance(target, ast.Attribute):
266
+ return target.attr
267
+ return target.id if isinstance(target, ast.Name) else ""
268
+
269
+
270
+ _DEPRECATION_CATEGORIES = {"DeprecationWarning", "PendingDeprecationWarning", "FutureWarning"}
271
+
272
+
273
+ def _deprecation(node, scope: list[ast.stmt]) -> tuple[bool, str]:
274
+ """Whether a function is deprecated, and the sibling it names as replacement.
275
+
276
+ Deprecated means a ``@deprecated`` decorator, or a ``warnings.warn`` the body
277
+ always raises that is about deprecation: a deprecation category, or
278
+ "deprecat" in the message. A warning about something else -- an insecure
279
+ option -- is not, and nor is one raised only for a deprecated argument.
280
+
281
+ The replacement must be *named*: a sibling (a method of the same class, or
282
+ a function of the same module) mentioned in the warning, the decorator, or
283
+ the docstring. Calling a sibling is not naming it -- a deprecated function
284
+ usually calls helpers, often as its whole body.
285
+ """
286
+ texts: list[str] = []
287
+ deprecated = False
288
+ for decorator in node.decorator_list:
289
+ if "deprecat" in _decorator_name(decorator).lower():
290
+ deprecated = True
291
+ texts.extend(_strings(decorator))
292
+ # Only a warning the body always raises deprecates the function. One inside
293
+ # an `if` -- `if skip_defaults is not None: warn(...)` -- deprecates an
294
+ # argument, and the function itself is as current as ever.
295
+ warn_calls = [
296
+ stmt.value
297
+ for stmt in node.body
298
+ if isinstance(stmt, ast.Expr)
299
+ and isinstance(stmt.value, ast.Call)
300
+ and _decorator_name(stmt.value) == "warn"
301
+ ]
302
+ for call in warn_calls:
303
+ if _about_deprecation(call):
304
+ deprecated = True
305
+ texts.extend(_strings(call))
306
+ if not deprecated:
307
+ return False, ""
308
+ docstring = ast.get_docstring(node)
309
+ if docstring:
310
+ texts.append(docstring)
311
+
312
+ siblings = {
313
+ n.name
314
+ for n in scope
315
+ if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef)) and n.name != node.name
316
+ }
317
+ named = {
318
+ name for name in siblings for text in texts if re.search(rf"\b{re.escape(name)}\b", text)
319
+ }
320
+ return True, named.pop() if len(named) == 1 else ""
321
+
322
+
323
+ def _strings(node: ast.AST) -> list[str]:
324
+ return [
325
+ child.value
326
+ for child in ast.walk(node)
327
+ if isinstance(child, ast.Constant) and isinstance(child.value, str)
328
+ ]
329
+
330
+
331
+ def _about_deprecation(call: ast.Call) -> bool:
332
+ for arg in [*call.args, *(k.value for k in call.keywords)]:
333
+ if _decorator_name(arg) in _DEPRECATION_CATEGORIES:
334
+ return True
335
+ if any("deprecat" in text.lower() for text in _strings(arg)):
336
+ return True
337
+ return False
patchahead/ci.py ADDED
@@ -0,0 +1,301 @@
1
+ """PatchAhead as a CI step: ``python -m patchahead.ci``.
2
+
3
+ Built for the pull requests Dependabot and Renovate open. Such a pull request
4
+ already says which packages moved between which versions, and usually quotes
5
+ their release notes. This module reads both:
6
+
7
+ 1. **The release notes** in the pull request body (Dependabot writes them as
8
+ HTML; the Markdown parser reads that).
9
+ 2. **The library itself.** For each package the pull request bumps, the two
10
+ versions are compared with :mod:`patchahead.apidiff`. That finds renames the
11
+ notes never mention -- and it checks the notes: a rename to a name the new
12
+ version does not have is a misreading, and is dropped with a note.
13
+
14
+ The changes are combined into one change document, migrated by the ordinary
15
+ engine in a temporary copy, and reported the way GitHub Actions expects: step
16
+ outputs in ``$GITHUB_OUTPUT``, a summary in ``$GITHUB_STEP_SUMMARY``, and the
17
+ combined diff and pull-request summary as files. It never writes to the
18
+ repository and never fails the step for a verdict -- ``action.yml`` decides
19
+ that from the outputs, according to the workflow's ``fail-on``.
20
+
21
+ Configuration comes from ``PATCHAHEAD_*`` environment variables, set by
22
+ ``action.yml`` from the action's inputs.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import logging
29
+ import os
30
+ import re
31
+ import tempfile
32
+ from dataclasses import dataclass, field
33
+ from pathlib import Path
34
+
35
+ from patchahead import apidiff, engine, reporting
36
+ from patchahead.config import ConfigError
37
+ from patchahead.domain.change import BreakingChange, ChangeKind
38
+ from patchahead.ingest import IngestError, parse_file
39
+ from patchahead.ingest.base import ChangeDocument, parse_document
40
+ from patchahead.ingest.structured import change_to_mapping
41
+ from patchahead.workspace import RepositoryError, WorkspaceError
42
+
43
+ log = logging.getLogger("patchahead.ci")
44
+
45
+ #: Marks PatchAhead's pull-request comment, so a re-run updates it in place.
46
+ COMMENT_MARKER = "<!-- patchahead -->"
47
+
48
+ _VERSION = r"v?([0-9][\w.+!-]*?)\.?"
49
+ _UPGRADE_PATTERNS = (
50
+ # Dependabot: "Bumps [storekit](https://...) from 4.9.0 to 5.0.0."
51
+ re.compile(rf"Bumps \[([^\]]+)\]\([^)]*\) from {_VERSION} to {_VERSION}(?=\s|$)"),
52
+ # Dependabot, grouped: "Updates `storekit` from 4.9.0 to 5.0.0"
53
+ re.compile(rf"Updates `([^`]+)` from {_VERSION} to {_VERSION}(?=\s|$)"),
54
+ # Dependabot title: "Bump storekit from 4.9.0 to 5.0.0"
55
+ re.compile(rf"\b[Bb]ump ([\w.\-\[\]]+) from {_VERSION} to {_VERSION}(?=\s|$)"),
56
+ # Renovate's table, whatever columns sit between the package and the change:
57
+ # "| [storekit](https://...) | major | `4.9.0` -> `5.0.0` |"
58
+ re.compile(rf"\|\s*\[([\w.\-]+)\]\([^)]*\)\s*\|[^\n]*?`{_VERSION}`\s*(?:->|→)\s*`{_VERSION}`"),
59
+ )
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class Upgrade:
64
+ package: str
65
+ old: str
66
+ new: str
67
+
68
+ def __str__(self) -> str:
69
+ return f"{self.package} {self.old} -> {self.new}"
70
+
71
+
72
+ @dataclass
73
+ class Plan:
74
+ """What a CI run will migrate, and what it learned getting there."""
75
+
76
+ changes: list[BreakingChange] = field(default_factory=list)
77
+ upgrades: list[Upgrade] = field(default_factory=list)
78
+ sources: list[str] = field(default_factory=list)
79
+ notes: list[str] = field(default_factory=list)
80
+
81
+
82
+ def upgrades_in(*texts: str) -> list[Upgrade]:
83
+ """The package upgrades a Dependabot or Renovate pull request describes."""
84
+ found: dict[str, Upgrade] = {}
85
+ for text in texts:
86
+ for pattern in _UPGRADE_PATTERNS:
87
+ for match in pattern.finditer(text or ""):
88
+ package, old, new = match.group(1), match.group(2), match.group(3)
89
+ found.setdefault(package.lower(), Upgrade(package, old, new))
90
+ return list(found.values())
91
+
92
+
93
+ def plan(
94
+ *,
95
+ change_path: str = "",
96
+ pull_request: dict | None = None,
97
+ compare_versions: bool = True,
98
+ scratch: Path,
99
+ ) -> Plan:
100
+ """Collect the changes to migrate from every source the run was given."""
101
+ result = Plan()
102
+ noted: list[BreakingChange] = []
103
+
104
+ if change_path:
105
+ noted.extend(parse_file(change_path))
106
+ result.sources.append(f"the change document `{change_path}`")
107
+
108
+ if pull_request:
109
+ body = pull_request.get("body") or ""
110
+ title = pull_request.get("title") or ""
111
+ result.upgrades = upgrades_in(title, body)
112
+ if body.strip():
113
+ document = ChangeDocument(text=body, path="pull request body", suffix=".md")
114
+ read = [c for c in parse_document(document) if c.kind is not ChangeKind.UNKNOWN]
115
+ if read:
116
+ noted.extend(read)
117
+ result.sources.append("the release notes in this pull request")
118
+
119
+ surfaces: list[apidiff.Surface] = []
120
+ compared: list[BreakingChange] = []
121
+ if compare_versions:
122
+ for upgrade in result.upgrades:
123
+ try:
124
+ old = apidiff.read(apidiff.fetch(upgrade.package, upgrade.old, scratch / "old"))
125
+ new = apidiff.read(apidiff.fetch(upgrade.package, upgrade.new, scratch / "new"))
126
+ except apidiff.ApiDiffError as exc:
127
+ result.notes.append(f"could not compare {upgrade}: {exc}")
128
+ continue
129
+ surfaces.append(new)
130
+ diff = apidiff.compare(old, new, upgrade.package, (upgrade.old, upgrade.new))
131
+ compared.extend(diff.changes)
132
+ result.sources.append(f"a comparison of {upgrade}")
133
+
134
+ # A release note that renames something to a name the new version does not
135
+ # have was misread -- or describes a different package. Either way it is
136
+ # not something to rewrite code toward.
137
+ for change in noted:
138
+ missing = _absent_from(change, surfaces) if surfaces else ""
139
+ if missing:
140
+ result.notes.append(
141
+ f"dropped the reading `{change.target.symbol}` -> `{missing}` from the "
142
+ f"release notes: no `{missing}` exists in the new version's public API"
143
+ )
144
+ continue
145
+ result.changes.append(change)
146
+
147
+ known = {_identity(c) for c in result.changes}
148
+ for change in compared:
149
+ if _identity(change) not in known:
150
+ known.add(_identity(change))
151
+ result.changes.append(change)
152
+ return result
153
+
154
+
155
+ def _identity(change: BreakingChange) -> tuple[str, str, str, str]:
156
+ """What makes two readings the same change. The owner is part of it: the same
157
+ keyword renamed on two functions is two changes."""
158
+ target = change.target
159
+ return change.kind.value, target.symbol, target.replacement, target.owner.lower()
160
+
161
+
162
+ def _absent_from(change: BreakingChange, surfaces: list[apidiff.Surface]) -> str:
163
+ """The new name of a rename that none of the new versions define, else ""."""
164
+ new = change.target.replacement
165
+ if change.kind is ChangeKind.METHOD_RENAME:
166
+ names = {member.name for s in surfaces for member in s.public.values()}
167
+ elif change.kind is ChangeKind.KWARG_RENAME:
168
+ names = {p.name for s in surfaces for member in s.public.values() for p in member.params}
169
+ else:
170
+ # A field is data the API returns, not a name in the library's code.
171
+ return ""
172
+ return "" if new in names else new
173
+
174
+
175
+ def run(environ: dict[str, str] | None = None) -> dict[str, str]:
176
+ """Do one CI run. Returns the step outputs it also writes to ``$GITHUB_OUTPUT``."""
177
+ env = dict(os.environ if environ is None else environ)
178
+ out_dir = Path(env.get("RUNNER_TEMP") or tempfile.gettempdir()) / "patchahead"
179
+ out_dir.mkdir(parents=True, exist_ok=True)
180
+
181
+ pull_request = None
182
+ if _flag(env, "PATCHAHEAD_FROM_PULL_REQUEST") and env.get("GITHUB_EVENT_PATH"):
183
+ event = json.loads(Path(env["GITHUB_EVENT_PATH"]).read_text(encoding="utf-8"))
184
+ pull_request = event.get("pull_request")
185
+ if pull_request is None:
186
+ log.warning("from-pull-request is set, but this run was not triggered by one")
187
+
188
+ outputs = {"outcome": "", "succeeded": "false", "complete": "true", "exit-code": "0"}
189
+ try:
190
+ with tempfile.TemporaryDirectory(prefix="patchahead-ci-") as scratch:
191
+ work = plan(
192
+ change_path=env.get("PATCHAHEAD_CHANGE", ""),
193
+ pull_request=pull_request,
194
+ compare_versions=_flag(env, "PATCHAHEAD_COMPARE_VERSIONS", default=True),
195
+ scratch=Path(scratch),
196
+ )
197
+ if not work.changes:
198
+ outputs["outcome"] = "nothing_to_migrate"
199
+ summary = _summary_header(outputs["outcome"], work, None)
200
+ else:
201
+ changes_path = out_dir / "changes.json"
202
+ changes_path.write_text(
203
+ json.dumps({"changes": [change_to_mapping(c) for c in work.changes]}, indent=2),
204
+ encoding="utf-8",
205
+ )
206
+ run_result = engine.migrate(
207
+ env.get("PATCHAHEAD_REPO") or ".",
208
+ changes_path,
209
+ engine.EngineOptions(
210
+ write_artifacts=False, test_command=env.get("PATCHAHEAD_TEST_COMMAND", "")
211
+ ),
212
+ )
213
+ from patchahead.cli import _migration_exit_code
214
+
215
+ code = _migration_exit_code(
216
+ run_result,
217
+ require_complete=_flag(env, "PATCHAHEAD_REQUIRE_COMPLETE"),
218
+ )
219
+ outcome = run_result.outcome
220
+ outputs.update(
221
+ {
222
+ "outcome": outcome.value if outcome else "nothing_to_migrate",
223
+ "succeeded": str(run_result.succeeded).lower(),
224
+ "complete": str(run_result.complete).lower(),
225
+ "exit-code": str(code),
226
+ "changes": str(changes_path),
227
+ }
228
+ )
229
+ if run_result.diff:
230
+ diff_path = out_dir / "patchahead.diff"
231
+ diff_path.write_text(run_result.diff, encoding="utf-8")
232
+ outputs["diff"] = str(diff_path)
233
+ summary = _summary_header(outputs["outcome"], work, run_result) + "\n\n".join(
234
+ reporting.render_pr_markdown(result) for result in run_result.results
235
+ )
236
+ except (IngestError, RepositoryError, ConfigError, WorkspaceError) as exc:
237
+ outputs.update({"outcome": "error", "exit-code": "2"})
238
+ summary = f"## PatchAhead could not run\n\n{exc}\n"
239
+ log.error("%s", exc)
240
+
241
+ summary_path = out_dir / "summary.md"
242
+ summary_path.write_text(f"{COMMENT_MARKER}\n{summary}", encoding="utf-8")
243
+ outputs["summary"] = str(summary_path)
244
+ _append(env.get("GITHUB_STEP_SUMMARY"), summary)
245
+ _append(env.get("GITHUB_OUTPUT"), "".join(f"{k}={v}\n" for k, v in outputs.items()))
246
+ return outputs
247
+
248
+
249
+ _HEADLINES = {
250
+ "migrated": "verified migration",
251
+ "patched_unverified": "patched, not verified",
252
+ "validation_failed": "rejected by the tests",
253
+ "not_plannable": "found affected code it would not rewrite",
254
+ "patch_failed": "could not apply the patch",
255
+ "unsupported_change": "nothing it can migrate",
256
+ "no_impact": "no affected code",
257
+ "nothing_to_migrate": "nothing to migrate",
258
+ }
259
+
260
+
261
+ def _summary_header(outcome: str, work: Plan, run_result) -> str:
262
+ lines = [f"## PatchAhead: {_HEADLINES.get(outcome, outcome)}", ""]
263
+ if run_result is not None and run_result.diff:
264
+ lines.append(
265
+ "PatchAhead proposed a patch in a temporary copy of this branch; nothing was "
266
+ "committed. The diff and the evidence are below."
267
+ )
268
+ if work.sources:
269
+ lines.append(f"Read from {', '.join(work.sources)}.")
270
+ elif not work.changes:
271
+ lines.append(
272
+ "No change document was given, and this pull request names no breaking change "
273
+ "PatchAhead could read."
274
+ )
275
+ if run_result is not None and not run_result.complete:
276
+ lines.append("Code or tests still use an old name; see each change's last section.")
277
+ if work.notes:
278
+ lines += ["", "**Notes**", ""] + [f"- {note}" for note in work.notes]
279
+ return "\n".join(lines) + "\n\n"
280
+
281
+
282
+ def _flag(env: dict[str, str], name: str, default: bool = False) -> bool:
283
+ value = env.get(name, "").strip().lower()
284
+ return default if not value else value in ("1", "true", "yes", "on")
285
+
286
+
287
+ def _append(path: str | None, text: str) -> None:
288
+ if path:
289
+ with open(path, "a", encoding="utf-8") as handle:
290
+ handle.write(text)
291
+
292
+
293
+ def main() -> int:
294
+ logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
295
+ outputs = run()
296
+ log.info("outcome: %s (exit code %s)", outputs["outcome"], outputs["exit-code"])
297
+ return 0
298
+
299
+
300
+ if __name__ == "__main__": # pragma: no cover
301
+ raise SystemExit(main())