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.
- {patchahead-0.3.0/src/patchahead.egg-info → patchahead-0.4.0}/PKG-INFO +10 -8
- {patchahead-0.3.0 → patchahead-0.4.0}/README.md +8 -6
- {patchahead-0.3.0 → patchahead-0.4.0}/pyproject.toml +2 -2
- {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/__init__.py +1 -1
- {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/ci.py +6 -4
- {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/demo/__init__.py +1 -1
- patchahead-0.4.0/src/patchahead/fixpr.py +317 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/reporting.py +6 -1
- {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/web/static/index.html +191 -199
- {patchahead-0.3.0 → patchahead-0.4.0/src/patchahead.egg-info}/PKG-INFO +10 -8
- {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead.egg-info/SOURCES.txt +2 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/tests/test_demo.py +1 -1
- patchahead-0.4.0/tests/test_fixpr.py +234 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/LICENSE +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/setup.cfg +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/analysis/__init__.py +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/analysis/edits.py +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/analysis/index.py +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/analysis/python_ast.py +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/apidiff/__init__.py +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/apidiff/compare.py +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/apidiff/download.py +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/apidiff/surface.py +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/cli.py +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/config.py +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/demo/fixtures/changes/field-rename.md +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/demo/fixtures/changes/invoice-field-rename.md +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/demo/fixtures/changes/kwarg-rename.md +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/demo/fixtures/changes/method-rename.md +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/demo/fixtures/changes/pagination-cursor.json +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/demo/fixtures/changes/pagination-cursor.md +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/demo/fixtures/changes/sdk-v2.md +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/demo/fixtures/orders-service/README.md +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/demo/fixtures/orders-service/app/__init__.py +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/demo/fixtures/orders-service/app/client.py +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/demo/fixtures/orders-service/app/models.py +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/demo/fixtures/orders-service/app/order_report.py +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/demo/fixtures/orders-service/app/order_sync.py +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/demo/fixtures/orders-service/conftest.py +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/demo/fixtures/orders-service/pyproject.toml +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/demo/fixtures/orders-service/tests/test_client.py +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/demo/fixtures/orders-service/tests/test_order_report.py +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/demo/fixtures/orders-service/tests/test_order_sync.py +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/demo/fixtures/orders-service/upstream/__init__.py +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/demo/fixtures/orders-service/upstream/api_v1.py +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/demo/fixtures/orders-service/upstream/api_v2.py +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/demo/serve.py +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/domain/__init__.py +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/domain/change.py +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/domain/completeness.py +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/domain/impact.py +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/domain/patch.py +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/domain/plan.py +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/domain/result.py +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/domain/validation.py +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/engine.py +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/handlers/__init__.py +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/handlers/base.py +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/handlers/field_rename.py +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/handlers/kwarg_rename.py +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/handlers/method_rename.py +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/handlers/pagination.py +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/ingest/__init__.py +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/ingest/base.py +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/ingest/markdown.py +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/ingest/structured.py +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/llm/__init__.py +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/llm/client.py +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/llm/proposer.py +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/observability.py +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/testing/__init__.py +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/testing/discovery.py +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/testing/runner.py +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/validation/__init__.py +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/validation/completeness.py +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/validation/engine.py +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/web/__init__.py +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/web/server.py +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead/workspace.py +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead.egg-info/dependency_links.txt +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead.egg-info/entry_points.txt +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead.egg-info/requires.txt +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/src/patchahead.egg-info/top_level.txt +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/tests/test_analysis.py +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/tests/test_apidiff.py +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/tests/test_ci.py +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/tests/test_cli.py +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/tests/test_completeness.py +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/tests/test_config.py +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/tests/test_engine_e2e.py +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/tests/test_eval_harness.py +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/tests/test_evals.py +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/tests/test_handlers.py +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/tests/test_ingest.py +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/tests/test_llm.py +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/tests/test_migrate_tests.py +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/tests/test_observability.py +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/tests/test_packaging.py +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/tests/test_validation.py +0 -0
- {patchahead-0.3.0 → patchahead-0.4.0}/tests/test_web.py +0 -0
- {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.
|
|
4
|
-
Summary:
|
|
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
|
-

|
|
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.
|
|
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.
|
|
213
|
-
|
|
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
|
-
- **
|
|
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
|
-

|
|
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.
|
|
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.
|
|
162
|
-
|
|
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
|
-
- **
|
|
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.
|
|
8
|
-
description = "
|
|
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" }
|
|
@@ -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:
|
|
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
|
-
|
|
341
|
+
PROPOSED_NOTE,
|
|
337
342
|
"",
|
|
338
343
|
"## 1. Upstream change",
|
|
339
344
|
"",
|