Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 17 additions & 4 deletions .github/actions/backport-fix-imports/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,19 @@ rewrite and reports `unchanged`, so re-runs are safe.
The action never fails the job: every failure path ends with `conclusion=failed`,
a one-line `reason` and exit 0, and nothing is pushed.

## PR comment

When there is something to tell the assignee, the action upserts one sticky
comment on the backport PR: the fix commit, and each file left for them with
the reason. No comment when nothing was changed and nothing is left.

## In the backport workflow

[`backport.yaml`](../../../docs/workflows/backport.md) runs this action on
every backport branch after the cherry-picks when the caller sets
`fix-imports: true`. Off by default. A label with no backport branch, such as
a legacy line, is skipped.

## Usage

```yaml
Expand Down Expand Up @@ -84,10 +97,10 @@ the backport PR's CI.

<!-- AUTO-DOC-OUTPUT:START - Do not remove or modify this section -->

| OUTPUT | TYPE | DESCRIPTION |
|------------|--------|-------------------------------------------------------------------------------------------------------------------------------------|
| conclusion | string | fixed (a commit was made), unchanged (nothing to rewrite), skipped (same module paths, no go.mod, or missing input) <br>or failed. |
| reason | string | One-line explanation of the conclusion. |
| OUTPUT | TYPE | DESCRIPTION |
|------------|--------|---------------------------------------------------------------------------------------------------------------------------------------------------------|
| conclusion | string | fixed (a commit was made), unchanged (nothing to rewrite), skipped (no backport branch, same module paths, no go.mod, or missing input) <br>or failed. |
| reason | string | One-line explanation of the conclusion. |

<!-- AUTO-DOC-OUTPUT:END -->

Expand Down
25 changes: 24 additions & 1 deletion .github/actions/backport-fix-imports/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ inputs:

outputs:
conclusion:
description: 'fixed (a commit was made), unchanged (nothing to rewrite), skipped (same module paths, no go.mod, or missing input) or failed.'
description: 'fixed (a commit was made), unchanged (nothing to rewrite), skipped (no backport branch, same module paths, no go.mod, or missing input) or failed.'
value: ${{ steps.fix.outputs.conclusion || 'failed' }}
reason:
description: 'One-line explanation of the conclusion.'
Expand All @@ -52,3 +52,26 @@ runs:
PUSH: ${{ inputs.push }}
GH_TOKEN: ${{ inputs.github-token }}
run: python3 "${{ github.action_path }}/src/fix.py"

- name: Write the backport PR comment
id: report
# A dry run pushes nothing, so it must not post a comment naming its commit.
if: (steps.fix.outputs.conclusion == 'fixed' || steps.fix.outputs.conclusion == 'unchanged') && inputs.push != 'false'
continue-on-error: true
shell: bash
env:
BACKPORT_BRANCH: ${{ inputs.backport-branch }}
GH_TOKEN: ${{ inputs.github-token }}
run: python3 "${{ github.action_path }}/src/report.py"

- name: Comment on the backport PR
if: steps.report.outputs.body != ''
continue-on-error: true
# Pinned by SHA like every internal composite (docs/CONVENTIONS.md).
uses: loft-sh/github-actions/.github/actions/sticky-pr-comment@d462e847d4f8f4a139890264a35b68cc30472547 # sticky-pr-comment/v1
with:
marker: '<!-- backport-fix-imports -->'
body: ${{ steps.report.outputs.body }}
pr-number: ${{ steps.report.outputs.pr-number }}
github-token: ${{ inputs.github-token }}
expected-author: ${{ steps.report.outputs.author }}
51 changes: 48 additions & 3 deletions .github/actions/backport-fix-imports/src/fix.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,14 @@

import base64
import functools
import json
import os
import re
import shutil
import subprocess
import sys
import urllib.parse
import urllib.request
from pathlib import Path
from typing import Callable

Expand All @@ -59,6 +62,7 @@
# Comments and string, raw string and rune literals: a name inside them is not a use.
GO_NOISE = re.compile(r"""//[^\n]*|/\*.*?\*/|"(?:\\.|[^"\\\n])*"|`[^`]*`|'(?:\\.|[^'\\\n])*'""", re.S)

NOTES_FILE = "backport-fix-imports-notes.json"
BOT_NAME = "github-actions[bot]"
BOT_EMAIL = "41898282+github-actions[bot]@users.noreply.github.com"

Expand Down Expand Up @@ -87,6 +91,38 @@ def git(*args: str, check: bool = True) -> str:
return res.stdout


def tool_env() -> dict[str, str]:
"""The job environment without the push token: gofmt never needs it."""
return {k: v for k, v in os.environ.items() if k not in ("GH_TOKEN", "GITHUB_TOKEN")}


def note(section: str, data: dict) -> None:
"""Record what a pass did, for the PR comment. A no-op outside Actions."""
if not os.environ.get("RUNNER_TEMP"):
return
path = Path(os.environ["RUNNER_TEMP"]) / NOTES_FILE
notes = json.loads(path.read_text()) if path.is_file() else {}
notes[section] = data
path.write_text(json.dumps(notes))


def github(method: str, path: str, body: dict | None = None):
req = urllib.request.Request(
f"https://api.github.com{path}", method=method,
data=json.dumps(body).encode() if body is not None else None,
headers={"Authorization": f"Bearer {os.environ['GH_TOKEN']}",
"Accept": "application/vnd.github+json"})
with urllib.request.urlopen(req, timeout=30) as res:
return json.load(res)


def backport_pr(branch: str) -> int | None:
repo = os.environ["GITHUB_REPOSITORY"]
head = urllib.parse.quote(f"{repo.split('/')[0]}:{branch}")
prs = github("GET", f"/repos/{repo}/pulls?state=open&head={head}")
return prs[0]["number"] if prs else None


def strip_major(path: str) -> str:
return MAJOR_SUFFIX.sub("", path)

Expand Down Expand Up @@ -299,7 +335,7 @@ def gofmt(files: list[str]) -> None:
if not shutil.which("gofmt"):
print("::warning::gofmt not on PATH; resolved import blocks are left unformatted")
return
res = subprocess.run(["gofmt", "-w", *files], capture_output=True, text=True)
res = subprocess.run(["gofmt", "-w", *files], capture_output=True, text=True, env=tool_env())
if res.returncode != 0:
raise RuntimeError(f"gofmt rejected the result, nothing committed: {res.stderr.strip()}")

Expand All @@ -313,6 +349,9 @@ def run() -> dict[str, str]:
if not target or not branch:
raise Skip("target-branch and backport-branch are required; pass both from the backport job")

# A label with no cherry-pick branch (a legacy line, a failed pick) is not ours to fix.
if not git("ls-remote", "--heads", remote, f"refs/heads/{branch}").strip():
raise Skip(f"no {branch} branch on {remote}; nothing to fix")
git("fetch", "-q", "--no-tags", remote,
*(f"+refs/heads/{b}:refs/remotes/{remote}/{b}" for b in (target, source, branch)))
gomods = []
Expand Down Expand Up @@ -349,10 +388,12 @@ def run() -> dict[str, str]:
marked = [p for p in changed if Path(p).is_file() and has_markers(Path(p).read_text(encoding="utf-8", errors="replace"))]
for p in marked:
print(f"::warning::{p}: conflict markers remain; resolve them by hand")
left = len(set(marked) | set(blocked))
left_note = f"; {left} file(s) left for review" if left else ""
left = {p: "conflict markers remain" for p in marked}
left.update({p: "added under the source path, but that path exists on the target" for p in blocked})
left_note = f"; {len(left)} file(s) left for review" if left else ""

if not rewritten and not moved:
note("script", {"rewritten": 0, "hunks": 0, "left": left, "commit": ""})
return {"conclusion": "unchanged", "reason": f"no module paths to rewrite{left_note}"}

if rewritten:
Expand All @@ -362,11 +403,15 @@ def run() -> dict[str, str]:
"-m", f"fix(backport): use {target} module paths", "-m", f"Rewrote {mapping}.")
if push:
git("push", "-q", remote, f"HEAD:refs/heads/{branch}")
note("script", {"rewritten": len(rewritten), "hunks": hunks, "left": left,
"commit": git("rev-parse", "HEAD").strip()})
reason = f"rewrote {len(rewritten)} file(s), resolved {hunks} import conflict(s){left_note}"
return {"conclusion": "fixed", "reason": reason}


def main() -> int:
if os.environ.get("RUNNER_TEMP"):
(Path(os.environ["RUNNER_TEMP"]) / NOTES_FILE).unlink(missing_ok=True)
try:
result = run()
except Skip as e:
Expand Down
65 changes: 65 additions & 0 deletions .github/actions/backport-fix-imports/src/report.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
#!/usr/bin/env python3
"""backport-fix-imports: turn what the fix did into the backport PR comment.

Reads the notes fix.py left in RUNNER_TEMP and writes body, pr-number and
author to GITHUB_OUTPUT for sticky-pr-comment. Writes nothing when there is
nothing to tell the assignee. Never fails the job.

Env:
BACKPORT_BRANCH, GH_TOKEN as for fix.py
RUNNER_TEMP where the notes are
"""
from __future__ import annotations

import json
import os
import secrets
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))
import fix # noqa: E402


def count(n: int, noun: str) -> str:
return f"{n} {noun}" if n == 1 else f"{n} {noun}s"


def body(notes: dict) -> str:
script = notes.get("script", {})
lines = []
if script.get("commit"):
lines.append(f"- Module paths: fixed in {script['commit'][:10]} ({count(script.get('rewritten', 0), 'file')} "
f"rewritten, {count(script.get('hunks', 0), 'import conflict')} resolved).")
left = script.get("left", {})
if left:
lines.append("- Left for you:")
lines.extend(f" - `{p}`: {r}" for p, r in sorted(left.items()))
return "\n".join(["### Import fixes for this backport", *lines]) if lines else ""


def main() -> int:
try:
path = Path(os.environ.get("RUNNER_TEMP", ".")) / fix.NOTES_FILE
text = body(json.loads(path.read_text())) if path.is_file() else ""
if not text:
return 0
pr = fix.backport_pr(os.environ["BACKPORT_BRANCH"])
if pr is None:
print("::warning::no open backport PR to comment on")
return 0
# sticky-pr-comment only updates comments by this author. Read it
# first, so a failed lookup writes no outputs and no comment step runs.
author = fix.github("GET", "/user")["login"]
delim = f"EOF_{secrets.token_hex(8)}"
with open(os.environ.get("GITHUB_OUTPUT", os.devnull), "a", encoding="utf-8") as f:
f.write(f"body<<{delim}\n{text}\n{delim}\n")
fix.emit("pr-number", str(pr))
fix.emit("author", author)
except Exception as e: # noqa: BLE001 -- advisory: never fail the backport
print(f"::warning::backport PR comment skipped: {str(e).splitlines()[0] if str(e) else type(e).__name__}")
return 0


if __name__ == "__main__":
sys.exit(main())
29 changes: 26 additions & 3 deletions .github/actions/backport-fix-imports/test/test_fix.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import importlib.util
import json
import shutil
import subprocess
from pathlib import Path
Expand All @@ -23,7 +24,7 @@ def load():

V4_GOMOD = """module example.com/acme/app/v4

go 1.26
go 1.22

require example.com/acme/api/v4 v4.0.0

Expand Down Expand Up @@ -366,6 +367,17 @@ def test_clean_pick_with_source_imports_is_rewritten_and_pushed(repo):
assert git(repo, "log", "-1", "--format=%s", "origin/backport/release-4.12/pr-1") == "fix(backport): use release-4.12 module paths"


def test_script_notes_what_it_fixed_and_left(repo, monkeypatch):
monkeypatch.setenv("RUNNER_TEMP", str(repo.parent))
write(repo, "pkg/peer/new.go", 'package peer\n\nimport "example.com/acme/app/v5/pkg/util"\n\nvar _ = util.X\n')
write(repo, "pkg/peer/code.go", "package peer\n\nfunc B() {\n<<<<<<< HEAD\n\ta()\n=======\n\tb()\n>>>>>>> abc123\n}\n")
push_backport(repo)
fix.main()
notes = json.loads((repo.parent / fix.NOTES_FILE).read_text())["script"]
assert notes["rewritten"] == 1 and notes["commit"]
assert notes["left"] == {"pkg/peer/code.go": "conflict markers remain"}


def test_untouched_files_are_not_rewritten(repo):
# A v5 path in a file the backport did not change is not ours to touch.
write(repo, "pkg/other/other.go", 'package other\n\nimport _ "example.com/acme/app/v5/pkg/x"\n')
Expand All @@ -392,10 +404,13 @@ def test_existing_file_under_source_dir_is_not_moved(repo):
assert f"{API_V4}/types/kept.go" not in tree


def test_move_blocked_by_existing_target_is_left_for_review(repo):
def test_move_blocked_by_existing_target_is_left_for_review(repo, monkeypatch):
monkeypatch.setenv("RUNNER_TEMP", str(repo.parent))
write(repo, f"{API_V5}/types/types.go", 'package types\n\nimport "example.com/acme/api/v5/pkg/x"\n\nvar _ = x.Y\n')
push_backport(repo)
fix.main()
left = json.loads((repo.parent / fix.NOTES_FILE).read_text())["script"]["left"]
assert left == {f"{API_V5}/types/types.go": "added under the source path, but that path exists on the target"}
out = outputs(repo)
assert out["conclusion"] == "unchanged"
assert "1 file(s) left for review" in out["reason"]
Expand Down Expand Up @@ -474,9 +489,17 @@ def test_push_false_leaves_the_remote_alone(repo, monkeypatch):
assert git(repo, "rev-parse", "origin/backport/release-4.12/pr-1") == before


def test_missing_branch_fails_soft(repo, monkeypatch):
def test_missing_backport_branch_skips(repo, monkeypatch):
monkeypatch.setenv("BACKPORT_BRANCH", "backport/release-4.12/pr-404")
assert fix.main() == 0
assert outputs(repo)["conclusion"] == "skipped"


def test_missing_target_branch_fails_soft(repo, monkeypatch):
write(repo, "pkg/peer/new.go", "package peer\n")
push_backport(repo)
monkeypatch.setenv("TARGET_BRANCH", "release-9.9")
assert fix.main() == 0
out = outputs(repo)
assert out["conclusion"] == "failed"
assert "git fetch failed" in out["reason"]
Expand Down
Loading