patchahead 0.3.0__tar.gz → 0.4.0__tar.gz

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 (101) hide show
  1. {patchahead-0.3.0/src/patchahead.egg-info → patchahead-0.4.0}/PKG-INFO +10 -8
  2. {patchahead-0.3.0 → patchahead-0.4.0}/README.md +8 -6
  3. {patchahead-0.3.0 → patchahead-0.4.0}/pyproject.toml +2 -2
  4. {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/__init__.py +1 -1
  5. {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/ci.py +6 -4
  6. {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/demo/__init__.py +1 -1
  7. patchahead-0.4.0/src/patchahead/fixpr.py +317 -0
  8. {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/reporting.py +6 -1
  9. {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/web/static/index.html +191 -199
  10. {patchahead-0.3.0 → patchahead-0.4.0/src/patchahead.egg-info}/PKG-INFO +10 -8
  11. {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead.egg-info/SOURCES.txt +2 -0
  12. {patchahead-0.3.0 → patchahead-0.4.0}/tests/test_demo.py +1 -1
  13. patchahead-0.4.0/tests/test_fixpr.py +234 -0
  14. {patchahead-0.3.0 → patchahead-0.4.0}/LICENSE +0 -0
  15. {patchahead-0.3.0 → patchahead-0.4.0}/setup.cfg +0 -0
  16. {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/analysis/__init__.py +0 -0
  17. {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/analysis/edits.py +0 -0
  18. {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/analysis/index.py +0 -0
  19. {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/analysis/python_ast.py +0 -0
  20. {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/apidiff/__init__.py +0 -0
  21. {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/apidiff/compare.py +0 -0
  22. {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/apidiff/download.py +0 -0
  23. {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/apidiff/surface.py +0 -0
  24. {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/cli.py +0 -0
  25. {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/config.py +0 -0
  26. {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/demo/fixtures/changes/field-rename.md +0 -0
  27. {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/demo/fixtures/changes/invoice-field-rename.md +0 -0
  28. {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/demo/fixtures/changes/kwarg-rename.md +0 -0
  29. {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/demo/fixtures/changes/method-rename.md +0 -0
  30. {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/demo/fixtures/changes/pagination-cursor.json +0 -0
  31. {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/demo/fixtures/changes/pagination-cursor.md +0 -0
  32. {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/demo/fixtures/changes/sdk-v2.md +0 -0
  33. {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/demo/fixtures/orders-service/README.md +0 -0
  34. {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/demo/fixtures/orders-service/app/__init__.py +0 -0
  35. {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/demo/fixtures/orders-service/app/client.py +0 -0
  36. {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/demo/fixtures/orders-service/app/models.py +0 -0
  37. {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/demo/fixtures/orders-service/app/order_report.py +0 -0
  38. {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/demo/fixtures/orders-service/app/order_sync.py +0 -0
  39. {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/demo/fixtures/orders-service/conftest.py +0 -0
  40. {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/demo/fixtures/orders-service/pyproject.toml +0 -0
  41. {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/demo/fixtures/orders-service/tests/test_client.py +0 -0
  42. {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/demo/fixtures/orders-service/tests/test_order_report.py +0 -0
  43. {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/demo/fixtures/orders-service/tests/test_order_sync.py +0 -0
  44. {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/demo/fixtures/orders-service/upstream/__init__.py +0 -0
  45. {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/demo/fixtures/orders-service/upstream/api_v1.py +0 -0
  46. {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/demo/fixtures/orders-service/upstream/api_v2.py +0 -0
  47. {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/demo/serve.py +0 -0
  48. {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/domain/__init__.py +0 -0
  49. {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/domain/change.py +0 -0
  50. {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/domain/completeness.py +0 -0
  51. {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/domain/impact.py +0 -0
  52. {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/domain/patch.py +0 -0
  53. {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/domain/plan.py +0 -0
  54. {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/domain/result.py +0 -0
  55. {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/domain/validation.py +0 -0
  56. {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/engine.py +0 -0
  57. {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/handlers/__init__.py +0 -0
  58. {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/handlers/base.py +0 -0
  59. {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/handlers/field_rename.py +0 -0
  60. {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/handlers/kwarg_rename.py +0 -0
  61. {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/handlers/method_rename.py +0 -0
  62. {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/handlers/pagination.py +0 -0
  63. {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/ingest/__init__.py +0 -0
  64. {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/ingest/base.py +0 -0
  65. {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/ingest/markdown.py +0 -0
  66. {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/ingest/structured.py +0 -0
  67. {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/llm/__init__.py +0 -0
  68. {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/llm/client.py +0 -0
  69. {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/llm/proposer.py +0 -0
  70. {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/observability.py +0 -0
  71. {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/testing/__init__.py +0 -0
  72. {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/testing/discovery.py +0 -0
  73. {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/testing/runner.py +0 -0
  74. {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/validation/__init__.py +0 -0
  75. {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/validation/completeness.py +0 -0
  76. {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/validation/engine.py +0 -0
  77. {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/web/__init__.py +0 -0
  78. {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/web/server.py +0 -0
  79. {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/workspace.py +0 -0
  80. {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead.egg-info/dependency_links.txt +0 -0
  81. {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead.egg-info/entry_points.txt +0 -0
  82. {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead.egg-info/requires.txt +0 -0
  83. {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead.egg-info/top_level.txt +0 -0
  84. {patchahead-0.3.0 → patchahead-0.4.0}/tests/test_analysis.py +0 -0
  85. {patchahead-0.3.0 → patchahead-0.4.0}/tests/test_apidiff.py +0 -0
  86. {patchahead-0.3.0 → patchahead-0.4.0}/tests/test_ci.py +0 -0
  87. {patchahead-0.3.0 → patchahead-0.4.0}/tests/test_cli.py +0 -0
  88. {patchahead-0.3.0 → patchahead-0.4.0}/tests/test_completeness.py +0 -0
  89. {patchahead-0.3.0 → patchahead-0.4.0}/tests/test_config.py +0 -0
  90. {patchahead-0.3.0 → patchahead-0.4.0}/tests/test_engine_e2e.py +0 -0
  91. {patchahead-0.3.0 → patchahead-0.4.0}/tests/test_eval_harness.py +0 -0
  92. {patchahead-0.3.0 → patchahead-0.4.0}/tests/test_evals.py +0 -0
  93. {patchahead-0.3.0 → patchahead-0.4.0}/tests/test_handlers.py +0 -0
  94. {patchahead-0.3.0 → patchahead-0.4.0}/tests/test_ingest.py +0 -0
  95. {patchahead-0.3.0 → patchahead-0.4.0}/tests/test_llm.py +0 -0
  96. {patchahead-0.3.0 → patchahead-0.4.0}/tests/test_migrate_tests.py +0 -0
  97. {patchahead-0.3.0 → patchahead-0.4.0}/tests/test_observability.py +0 -0
  98. {patchahead-0.3.0 → patchahead-0.4.0}/tests/test_packaging.py +0 -0
  99. {patchahead-0.3.0 → patchahead-0.4.0}/tests/test_validation.py +0 -0
  100. {patchahead-0.3.0 → patchahead-0.4.0}/tests/test_web.py +0 -0
  101. {patchahead-0.3.0 → patchahead-0.4.0}/tests/test_workspace.py +0 -0
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: patchahead
3
- Version: 0.3.0
4
- Summary: Find downstream code broken by upstream API changes, propose a minimal migration, and verify it with your tests.
3
+ Version: 0.4.0
4
+ Summary: Updates your Python code when an API you depend on changes, and proves the fix with your tests.
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://github.com/FrimpsManu/patchahead
7
7
  Project-URL: Repository, https://github.com/FrimpsManu/patchahead
@@ -90,7 +90,7 @@ This opens a local page with six example scenarios against a small, deliberately
90
90
  broken service. Three end in a verified fix. The other three show it refusing,
91
91
  being rejected by the tests, and reporting a patch it could not prove.
92
92
 
93
- ![A verified migration in the PatchAhead demo](https://raw.githubusercontent.com/FrimpsManu/patchahead/main/docs/media/demo-verified.png)
93
+ ![PatchAhead's demo: a verified migration, then a refusal](https://raw.githubusercontent.com/FrimpsManu/patchahead/main/docs/media/demo.gif)
94
94
 
95
95
  Python 3.10 or newer. `pip install patchahead` alone is the core tool, with no
96
96
  third-party dependencies on 3.11+; [docs/usage.md](docs/usage.md#install) lists
@@ -197,7 +197,7 @@ jobs:
197
197
  runs-on: ubuntu-latest
198
198
  steps:
199
199
  - uses: actions/checkout@v4
200
- - uses: FrimpsManu/patchahead@v0.3.0
200
+ - uses: FrimpsManu/patchahead@v0.4.0
201
201
  with:
202
202
  from-pull-request: true
203
203
  install-command: pip install -r requirements-dev.txt
@@ -209,8 +209,11 @@ versions of each package it bumps. The two check each other: a release note
209
209
  that renames something to a name the new version does not have is dropped, with
210
210
  a note saying so. Then it migrates a temporary copy, runs your tests, and posts
211
211
  the verdict, the diff, and what is left of the old API as one comment, updated
212
- in place on re-runs. It never commits; `apply: true` writes a verified patch
213
- into the checkout for a later step to commit. All inputs are in
212
+ in place on re-runs.
213
+
214
+ It never commits to your branches. With `open-pull-request: true`,
215
+ a verified fix is opened as its own pull request against the bump's branch, so
216
+ merging it adds the fix to the bump. All inputs are in
214
217
  [docs/usage.md](docs/usage.md#github-action).
215
218
 
216
219
  ## How it stays safe
@@ -341,11 +344,10 @@ Run it yourself with `python evals/run.py`.
341
344
  - **No type inference.** It matches names. In `for o in orders: o["total"]` it
342
345
  cannot prove `o` is an order, so it reports the site and does not patch it.
343
346
  - **One pagination loop shape.** Other shapes are refused.
344
- - **Local and single-repository.** No GitHub integration yet.
347
+ - **One repository at a time.**
345
348
 
346
349
  ## Roadmap
347
350
 
348
- - Opening the verified fix as a pull request of its own, rather than a comment
349
351
  - Reading OpenAPI spec changes directly
350
352
  - More kinds of change, such as moved endpoints and changed response shapes
351
353
  - Tracking a renamed value through variables (`current = order`)
@@ -39,7 +39,7 @@ This opens a local page with six example scenarios against a small, deliberately
39
39
  broken service. Three end in a verified fix. The other three show it refusing,
40
40
  being rejected by the tests, and reporting a patch it could not prove.
41
41
 
42
- ![A verified migration in the PatchAhead demo](https://raw.githubusercontent.com/FrimpsManu/patchahead/main/docs/media/demo-verified.png)
42
+ ![PatchAhead's demo: a verified migration, then a refusal](https://raw.githubusercontent.com/FrimpsManu/patchahead/main/docs/media/demo.gif)
43
43
 
44
44
  Python 3.10 or newer. `pip install patchahead` alone is the core tool, with no
45
45
  third-party dependencies on 3.11+; [docs/usage.md](docs/usage.md#install) lists
@@ -146,7 +146,7 @@ jobs:
146
146
  runs-on: ubuntu-latest
147
147
  steps:
148
148
  - uses: actions/checkout@v4
149
- - uses: FrimpsManu/patchahead@v0.3.0
149
+ - uses: FrimpsManu/patchahead@v0.4.0
150
150
  with:
151
151
  from-pull-request: true
152
152
  install-command: pip install -r requirements-dev.txt
@@ -158,8 +158,11 @@ versions of each package it bumps. The two check each other: a release note
158
158
  that renames something to a name the new version does not have is dropped, with
159
159
  a note saying so. Then it migrates a temporary copy, runs your tests, and posts
160
160
  the verdict, the diff, and what is left of the old API as one comment, updated
161
- in place on re-runs. It never commits; `apply: true` writes a verified patch
162
- into the checkout for a later step to commit. All inputs are in
161
+ in place on re-runs.
162
+
163
+ It never commits to your branches. With `open-pull-request: true`,
164
+ a verified fix is opened as its own pull request against the bump's branch, so
165
+ merging it adds the fix to the bump. All inputs are in
163
166
  [docs/usage.md](docs/usage.md#github-action).
164
167
 
165
168
  ## How it stays safe
@@ -290,11 +293,10 @@ Run it yourself with `python evals/run.py`.
290
293
  - **No type inference.** It matches names. In `for o in orders: o["total"]` it
291
294
  cannot prove `o` is an order, so it reports the site and does not patch it.
292
295
  - **One pagination loop shape.** Other shapes are refused.
293
- - **Local and single-repository.** No GitHub integration yet.
296
+ - **One repository at a time.**
294
297
 
295
298
  ## Roadmap
296
299
 
297
- - Opening the verified fix as a pull request of its own, rather than a comment
298
300
  - Reading OpenAPI spec changes directly
299
301
  - More kinds of change, such as moved endpoints and changed response shapes
300
302
  - Tracking a renamed value through variables (`current = order`)
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "patchahead"
7
- version = "0.3.0"
8
- description = "Find downstream code broken by upstream API changes, propose a minimal migration, and verify it with your tests."
7
+ version = "0.4.0"
8
+ description = "Updates your Python code when an API you depend on changes, and proves the fix with your tests."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
11
11
  license = { text = "MIT" }
@@ -3,6 +3,6 @@
3
3
  Static evidence identifies risk. AI can propose. Tests verify. Humans approve.
4
4
  """
5
5
 
6
- __version__ = "0.3.0"
6
+ __version__ = "0.4.0"
7
7
 
8
8
  __all__ = ["__version__"]
@@ -44,6 +44,11 @@ log = logging.getLogger("patchahead.ci")
44
44
 
45
45
  #: Marks PatchAhead's pull-request comment, so a re-run updates it in place.
46
46
  COMMENT_MARKER = "<!-- patchahead -->"
47
+ #: True of the comment, and not of the pull request ``patchahead.fixpr`` opens.
48
+ NOT_COMMITTED = (
49
+ "PatchAhead proposed a patch in a temporary copy of this branch; nothing was "
50
+ "committed. The diff and the evidence are below."
51
+ )
47
52
 
48
53
  _VERSION = r"v?([0-9][\w.+!-]*?)\.?"
49
54
  _UPGRADE_PATTERNS = (
@@ -261,10 +266,7 @@ _HEADLINES = {
261
266
  def _summary_header(outcome: str, work: Plan, run_result) -> str:
262
267
  lines = [f"## PatchAhead: {_HEADLINES.get(outcome, outcome)}", ""]
263
268
  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
- )
269
+ lines.append(NOT_COMMITTED)
268
270
  if work.sources:
269
271
  lines.append(f"Read from {', '.join(work.sources)}.")
270
272
  elif not work.changes:
@@ -206,7 +206,7 @@ SCENARIOS: tuple[Scenario, ...] = (
206
206
  "disabled -- the shape of any repository whose tests do not cover a change."
207
207
  ),
208
208
  watch_for=(
209
- "Identical diff, different verdict: PATCHED, NOT VERIFIED. Without a test "
209
+ "Identical diff, different verdict: patched, not verified. Without a test "
210
210
  "that failed before and passes after, there is nothing to verify it."
211
211
  ),
212
212
  ),
@@ -0,0 +1,317 @@
1
+ """Open a verified migration as a pull request: ``python -m patchahead.fixpr``.
2
+
3
+ Runs after :mod:`patchahead.ci`, and only when that run ended in a verified,
4
+ complete migration. It commits the diff to a branch PatchAhead owns and opens a
5
+ pull request for it, or updates the one it opened before.
6
+
7
+ Where the pull request points:
8
+
9
+ - **On a pull request** (a Dependabot or Renovate bump), it targets that pull
10
+ request's branch. Merging it adds the fix to the bump, and the bump's own
11
+ branch is never pushed to -- the bot that owns it would rebase the commit away.
12
+ - **Anywhere else** (a push, a manual run), it targets the branch the run was on.
13
+
14
+ The branch is ``patchahead/<target>``. It is rewritten on each run, so a re-run
15
+ updates the same pull request rather than opening another, and it is never
16
+ rewritten once someone else has pushed to it.
17
+
18
+ The commit is made in a separate git worktree, so the checkout the workflow
19
+ uses is left exactly as it was. Pushing uses the credentials ``actions/checkout``
20
+ left in the repository; the GitHub API uses ``PATCHAHEAD_GITHUB_TOKEN``.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import logging
27
+ import os
28
+ import shutil
29
+ import subprocess
30
+ import tempfile
31
+ import urllib.error
32
+ import urllib.request
33
+ from dataclasses import dataclass
34
+ from pathlib import Path
35
+
36
+ from patchahead.ci import COMMENT_MARKER, NOT_COMMITTED
37
+ from patchahead.reporting import PROPOSED_NOTE
38
+
39
+ log = logging.getLogger("patchahead.fixpr")
40
+
41
+ BOT_NAME = "github-actions[bot]"
42
+ BOT_EMAIL = "41898282+github-actions[bot]@users.noreply.github.com"
43
+ BRANCH_PREFIX = "patchahead/"
44
+ #: GitHub rejects a pull-request body longer than this.
45
+ MAX_BODY = 65_000
46
+
47
+ PERMISSION_HELP = (
48
+ "The token cannot open pull requests. Give the job `contents: write` and "
49
+ "`pull-requests: write`, and turn on 'Allow GitHub Actions to create and approve "
50
+ "pull requests' in the repository's Settings -> Actions -> General."
51
+ )
52
+
53
+
54
+ class FixPrError(Exception):
55
+ """The pull request was asked for and could not be opened."""
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class Target:
60
+ """The branch the fix is proposed against, and the commit it starts from."""
61
+
62
+ branch: str
63
+ sha: str
64
+ #: The pull request that triggered the run, when there was one.
65
+ number: int | None = None
66
+
67
+ @property
68
+ def fix_branch(self) -> str:
69
+ return BRANCH_PREFIX + self.branch
70
+
71
+
72
+ def target_from(env: dict[str, str]) -> tuple[Target | None, str]:
73
+ """Where the fix goes, or ``(None, why not)``."""
74
+ event = {}
75
+ if env.get("GITHUB_EVENT_PATH"):
76
+ event = json.loads(Path(env["GITHUB_EVENT_PATH"]).read_text(encoding="utf-8"))
77
+ pull_request = event.get("pull_request")
78
+ if pull_request:
79
+ head = pull_request.get("head") or {}
80
+ head_repo = (head.get("repo") or {}).get("full_name", "")
81
+ if head_repo and head_repo != env.get("GITHUB_REPOSITORY"):
82
+ return None, (
83
+ f"the pull request comes from the fork {head_repo}, and a fix cannot be "
84
+ "pushed to another repository"
85
+ )
86
+ return Target(head["ref"], head["sha"], pull_request.get("number")), ""
87
+ branch = env.get("GITHUB_REF_NAME", "")
88
+ if not branch or env.get("GITHUB_REF_TYPE", "branch") != "branch":
89
+ return None, "the run is not on a branch"
90
+ if branch.startswith(BRANCH_PREFIX):
91
+ return None, f"the run is on PatchAhead's own branch `{branch}`"
92
+ return Target(branch, env.get("GITHUB_SHA", "")), ""
93
+
94
+
95
+ # --------------------------------------------------------------------------
96
+ # git
97
+ # --------------------------------------------------------------------------
98
+
99
+
100
+ def _git(cwd: Path | str, *args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
101
+ result = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True)
102
+ if check and result.returncode != 0:
103
+ raise FixPrError(f"git {' '.join(args)} failed: {result.stderr.strip()}")
104
+ return result
105
+
106
+
107
+ def _remote_tip(top: Path, branch: str) -> str:
108
+ listed = _git(top, "ls-remote", "origin", f"refs/heads/{branch}").stdout.split()
109
+ return listed[0] if listed else ""
110
+
111
+
112
+ def _have(top: Path, sha: str) -> bool:
113
+ return _git(top, "cat-file", "-e", f"{sha}^{{commit}}", check=False).returncode == 0
114
+
115
+
116
+ def _fetch(top: Path, sha: str) -> None:
117
+ if not _have(top, sha):
118
+ _git(top, "fetch", "--no-tags", "--depth=1", "origin", sha)
119
+
120
+
121
+ def commit_fix(repo: Path, target: Target, diff: Path, message: str, scratch: Path) -> str:
122
+ """Commit ``diff`` on top of ``target.sha`` and push it to the fix branch.
123
+
124
+ Returns "" when the branch was pushed (or already held this fix), else why not.
125
+ """
126
+ top = Path(_git(repo, "rev-parse", "--show-toplevel").stdout.strip())
127
+ # The diff's paths are relative to the repository PatchAhead migrated, which
128
+ # can be a directory inside the checkout.
129
+ prefix = _git(repo, "rev-parse", "--show-prefix").stdout.strip()
130
+
131
+ existing = _remote_tip(top, target.fix_branch)
132
+ if existing:
133
+ _fetch(top, existing)
134
+ author = _git(top, "log", "-1", "--format=%ae", existing).stdout.strip()
135
+ if author != BOT_EMAIL:
136
+ return (
137
+ f"`{target.fix_branch}` has commits from someone else ({author}); "
138
+ "PatchAhead does not overwrite them"
139
+ )
140
+
141
+ _fetch(top, target.sha)
142
+ tree = scratch / "worktree"
143
+ _git(top, "worktree", "add", "--detach", str(tree), target.sha)
144
+ try:
145
+ apply = ["apply", "--index"] + ([f"--directory={prefix}"] if prefix else []) + [str(diff)]
146
+ applied = _git(tree, *apply, check=False)
147
+ if applied.returncode != 0:
148
+ return (
149
+ f"the verified patch does not apply to `{target.branch}`: {applied.stderr.strip()}"
150
+ )
151
+ identity = ["-c", f"user.name={BOT_NAME}", "-c", f"user.email={BOT_EMAIL}"]
152
+ _git(tree, *identity, "commit", "--quiet", "-m", message)
153
+ if existing and _tree(tree, "HEAD") == _tree(tree, existing):
154
+ log.info("%s already holds this fix", target.fix_branch)
155
+ return ""
156
+ lease = f"--force-with-lease=refs/heads/{target.fix_branch}:{existing}"
157
+ _git(tree, "push", "--quiet", lease, "origin", f"HEAD:refs/heads/{target.fix_branch}")
158
+ return ""
159
+ finally:
160
+ _git(top, "worktree", "remove", "--force", str(tree), check=False)
161
+
162
+
163
+ def _tree(cwd: Path, commit: str) -> str:
164
+ return _git(cwd, "rev-parse", f"{commit}^{{tree}}").stdout.strip()
165
+
166
+
167
+ # --------------------------------------------------------------------------
168
+ # GitHub
169
+ # --------------------------------------------------------------------------
170
+
171
+
172
+ class GitHub:
173
+ """The few REST calls this needs."""
174
+
175
+ def __init__(self, token: str, repository: str, api_url: str = "https://api.github.com"):
176
+ self.token = token
177
+ self.repository = repository
178
+ self.api_url = api_url.rstrip("/")
179
+
180
+ def request(self, method: str, path: str, body: dict | None = None):
181
+ request = urllib.request.Request(
182
+ f"{self.api_url}/repos/{self.repository}{path}",
183
+ method=method,
184
+ data=None if body is None else json.dumps(body).encode(),
185
+ headers={
186
+ "Authorization": f"Bearer {self.token}",
187
+ "Accept": "application/vnd.github+json",
188
+ "X-GitHub-Api-Version": "2022-11-28",
189
+ },
190
+ )
191
+ try:
192
+ with urllib.request.urlopen(request, timeout=30) as response:
193
+ return json.loads(response.read() or b"null")
194
+ except urllib.error.HTTPError as exc:
195
+ detail = exc.read().decode(errors="replace")
196
+ if exc.code in (401, 403, 404) or "not permitted to create" in detail:
197
+ raise FixPrError(f"{PERMISSION_HELP} (GitHub said {exc.code}: {detail})") from exc
198
+ raise FixPrError(f"GitHub said {exc.code}: {detail}") from exc
199
+
200
+
201
+ def open_or_update(api: GitHub, target: Target, title: str, body: str) -> dict:
202
+ """Open the fix's pull request, or update the body of the one already open."""
203
+ owner = api.repository.split("/")[0]
204
+ open_ones = api.request(
205
+ "GET", f"/pulls?state=open&head={owner}:{target.fix_branch}&base={target.branch}"
206
+ )
207
+ if open_ones:
208
+ number = open_ones[0]["number"]
209
+ return api.request("PATCH", f"/pulls/{number}", {"body": body})
210
+ return api.request(
211
+ "POST",
212
+ "/pulls",
213
+ {"title": title, "head": target.fix_branch, "base": target.branch, "body": body},
214
+ )
215
+
216
+
217
+ def describe(target: Target, summary: str) -> tuple[str, str]:
218
+ """The pull request's title and body."""
219
+ if target.number:
220
+ title = f"PatchAhead: verified migration for #{target.number}"
221
+ lead = (
222
+ f"PatchAhead migrated the code #{target.number} breaks, and a test that failed "
223
+ f"before the patch passes after it. Merging this adds the fix to "
224
+ f"`{target.branch}`."
225
+ )
226
+ else:
227
+ title = f"PatchAhead: verified migration on {target.branch}"
228
+ lead = (
229
+ "PatchAhead migrated the code a dependency change breaks, and a test that failed "
230
+ "before the patch passes after it."
231
+ )
232
+ # The summary was written for a comment on a patch nobody committed. This
233
+ # pull request is that commit, so the lines saying otherwise go.
234
+ summary = summary.replace(f"{COMMENT_MARKER}\n", "").replace(f"{NOT_COMMITTED}\n", "")
235
+ summary = summary.replace(
236
+ PROPOSED_NOTE,
237
+ "> Committed by **PatchAhead** after the tests verified it. Review before merging.",
238
+ )
239
+ body = f"{lead}\n\n{summary}"
240
+ if len(body) > MAX_BODY:
241
+ body = body[:MAX_BODY] + "\n\n*(Summary cut short. The full report is in the run.)*"
242
+ return title, body
243
+
244
+
245
+ # --------------------------------------------------------------------------
246
+ # entry point
247
+ # --------------------------------------------------------------------------
248
+
249
+
250
+ def run(environ: dict[str, str] | None = None, api: GitHub | None = None) -> dict[str, str]:
251
+ """Open or update the fix's pull request. Returns the step outputs.
252
+
253
+ A run that should not open one (not verified, a fork) is skipped with a
254
+ reason. One that should and cannot raises :class:`FixPrError`.
255
+ """
256
+ env = dict(os.environ if environ is None else environ)
257
+ outputs = {"pull-request-url": "", "pull-request-number": ""}
258
+
259
+ def skip(why: str) -> dict[str, str]:
260
+ log.info("no pull request: %s", why)
261
+ outputs["pull-request-skipped"] = why
262
+ return outputs
263
+
264
+ if env.get("PATCHAHEAD_OUTCOME") != "migrated" or env.get("PATCHAHEAD_EXIT_CODE") != "0":
265
+ return skip("the run did not end in a verified, complete migration")
266
+ diff = env.get("PATCHAHEAD_DIFF", "")
267
+ if not diff or not Path(diff).is_file():
268
+ return skip("there is no patch")
269
+ target, why = target_from(env)
270
+ if target is None:
271
+ return skip(why)
272
+
273
+ summary_path = env.get("PATCHAHEAD_SUMMARY", "")
274
+ summary = Path(summary_path).read_text(encoding="utf-8") if summary_path else ""
275
+ title, body = describe(target, summary)
276
+
277
+ scratch = Path(tempfile.mkdtemp(prefix="patchahead-pr-", dir=env.get("RUNNER_TEMP") or None))
278
+ try:
279
+ repo = Path(env.get("PATCHAHEAD_REPO") or ".")
280
+ why = commit_fix(repo, target, Path(diff), title, scratch)
281
+ finally:
282
+ shutil.rmtree(scratch, ignore_errors=True)
283
+ if why:
284
+ log.warning("no pull request: %s", why)
285
+ outputs["pull-request-skipped"] = why
286
+ return outputs
287
+
288
+ if api is None:
289
+ api = GitHub(
290
+ env.get("PATCHAHEAD_GITHUB_TOKEN", ""),
291
+ env["GITHUB_REPOSITORY"],
292
+ env.get("GITHUB_API_URL") or "https://api.github.com",
293
+ )
294
+ pull = open_or_update(api, target, title, body)
295
+ outputs["pull-request-url"] = pull["html_url"]
296
+ outputs["pull-request-number"] = str(pull["number"])
297
+ log.info("pull request: %s", pull["html_url"])
298
+ return outputs
299
+
300
+
301
+ def main() -> int:
302
+ logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
303
+ try:
304
+ outputs = run()
305
+ except FixPrError as exc:
306
+ log.error("%s", exc)
307
+ return 1
308
+ path = os.environ.get("GITHUB_OUTPUT")
309
+ if path:
310
+ with open(path, "a", encoding="utf-8") as handle:
311
+ # One line per output: git's messages can span several.
312
+ handle.write("".join(f"{k}={' '.join(v.split())}\n" for k, v in outputs.items()))
313
+ return 0
314
+
315
+
316
+ if __name__ == "__main__": # pragma: no cover
317
+ raise SystemExit(main())
@@ -25,6 +25,11 @@ _COLORS = {
25
25
  }
26
26
 
27
27
 
28
+ #: Heads each change's summary. A comment's patch is neither merged nor applied;
29
+ #: ``patchahead.fixpr`` swaps this line for one true of a pull request.
30
+ PROPOSED_NOTE = "> Proposed by **PatchAhead**. Not merged, not applied. Review before approving."
31
+
32
+
28
33
  def _use_color(stream) -> bool:
29
34
  if os.environ.get("NO_COLOR"):
30
35
  return False
@@ -333,7 +338,7 @@ def render_pr_markdown(result: MigrationResult) -> str:
333
338
  lines += [
334
339
  f"# {title}",
335
340
  "",
336
- "> Proposed by **PatchAhead**. Not merged, not applied. Review before approving.",
341
+ PROPOSED_NOTE,
337
342
  "",
338
343
  "## 1. Upstream change",
339
344
  "",