diff --git a/.github/actions/backport-fix-imports/README.md b/.github/actions/backport-fix-imports/README.md
index 506d3c8f..fd95f533 100644
--- a/.github/actions/backport-fix-imports/README.md
+++ b/.github/actions/backport-fix-imports/README.md
@@ -44,6 +44,36 @@ 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.
+## AI pass
+
+Only with `anthropic-api-key` set, and only when `.go` files the backport
+changed still have conflicts after the fix. Non-Go conflicts are never sent.
+
+1. Each conflicted Go file goes, whole, to [`ai-step`](../ai-step/README.md) in
+ one call. The model answers per conflict block: replacement code, or "not
+ sure".
+2. A file is changed only when every block in it has an answer with no conflict
+ markers.
+3. Each touched package must pass `gofmt` and `go vet`, and have no other
+ conflicted file. Packages that pass are committed as
+ `fix(backport): resolve conflicts with ai`. The rest are reverted.
+4. Before that commit is pushed, the backport PR gets the
+ `backport-ai-resolved` label, so auto-approve can keep it for a person. If
+ the PR cannot be labeled, the AI commit is not pushed.
+
+`gofmt` and `go vet` run without the push token in their environment.
+
+So a wrong or partial answer never reaches the branch. It stays a conflict for a
+person, as without the AI pass. The pass is skipped when there are more than 20
+conflict blocks or the input would exceed 300 KB.
+
+## PR comment
+
+When there is something to tell the assignee, the action upserts one sticky
+comment on the backport PR: the fix commit, the AI commit to review closely,
+and each file left for them with the reason. No comment when nothing was
+changed and nothing is left.
+
## Usage
```yaml
@@ -66,19 +96,21 @@ a one-line `reason` and exit 0, and nothing is pushed.
```
The runner needs `python3` and `git`; `gofmt` is optional and only formats the
-import blocks it resolved (it ships on GitHub-hosted Ubuntu runners). Pushing
+import blocks it resolved (it ships on GitHub-hosted Ubuntu runners). The AI
+pass also needs `go`, which GitHub-hosted runners have. Pushing
with a PAT, rather than `GITHUB_TOKEN`, is what makes the fix commit trigger
the backport PR's CI.
-| INPUT | TYPE | REQUIRED | DEFAULT | DESCRIPTION |
-|-----------------|--------|----------|----------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
-| backport-branch | string | true | | Head branch of the backport PR,
e.g. backport/release-4.12/pr-8606. Checked out, fixed and
pushed back. |
-| github-token | string | true | | Token with contents write on the
repository, used to fetch and push
the backport branch. Supplied to git
through the environment, never argv or
.git/config. |
-| push | string | false | `"true"` | false commits the fix locally without
pushing (dry run). |
-| source-branch | string | false | `"main"` | Branch the original change merged into.
Its root go.mod gives the source
module paths. |
-| target-branch | string | true | | Release branch the backport PR targets,
e.g. release-4.12. Its root go.mod gives
the target module paths. |
+| INPUT | TYPE | REQUIRED | DEFAULT | DESCRIPTION |
+|-------------------|--------|----------|----------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| anthropic-api-key | string | false | | Anthropic API key. Set, it turns
on the AI pass for Go
conflicts left after the fix; empty,
the AI pass never runs. Needs
go and gofmt on the runner. |
+| backport-branch | string | true | | Head branch of the backport PR,
e.g. backport/release-4.12/pr-8606. Checked out, fixed and
pushed back. |
+| github-token | string | true | | Token with contents write on the
repository, used to fetch and push
the backport branch. Supplied to git
through the environment, never argv or
.git/config. |
+| push | string | false | `"true"` | false commits the fix locally without
pushing (dry run). |
+| source-branch | string | false | `"main"` | Branch the original change merged into.
Its root go.mod gives the source
module paths. |
+| target-branch | string | true | | Release branch the backport PR targets,
e.g. release-4.12. Its root go.mod gives
the target module paths. |
diff --git a/.github/actions/backport-fix-imports/action.yml b/.github/actions/backport-fix-imports/action.yml
index d3c00c86..ab35c344 100644
--- a/.github/actions/backport-fix-imports/action.yml
+++ b/.github/actions/backport-fix-imports/action.yml
@@ -4,9 +4,11 @@ description: |
an older module major than its source (for example a v5 main backported to a
v4 release branch). Rewrites the import paths of modules the repository owns,
moves files added under a source-only staging directory, resolves conflict
- hunks made only of import lines, then commits and pushes one commit. Conflicts
- in real code are left and counted. Advisory: every failure path warns and
- exits 0, so it never fails the backport that calls it.
+ hunks made only of import lines, then commits and pushes one commit. With an
+ Anthropic key, Go conflicts that remain go to an AI pass whose answers are
+ committed only for packages that pass go vet. Anything else is left for
+ review. Advisory: every failure path warns and exits 0, so it never fails the
+ backport that calls it.
inputs:
target-branch:
description: 'Release branch the backport PR targets, e.g. release-4.12. Its root go.mod gives the target module paths.'
@@ -25,6 +27,10 @@ inputs:
description: 'false commits the fix locally without pushing (dry run).'
required: false
default: 'true'
+ anthropic-api-key:
+ description: 'Anthropic API key. Set, it turns on the AI pass for Go conflicts left after the fix; empty, the AI pass never runs. Needs go and gofmt on the runner.'
+ required: false
+ default: ''
outputs:
conclusion:
@@ -52,3 +58,83 @@ runs:
PUSH: ${{ inputs.push }}
GH_TOKEN: ${{ inputs.github-token }}
run: python3 "${{ github.action_path }}/src/fix.py"
+
+ - name: Collect Go conflicts for the AI pass
+ id: prepare
+ if: inputs.anthropic-api-key != '' && (steps.fix.outputs.conclusion == 'fixed' || steps.fix.outputs.conclusion == 'unchanged')
+ continue-on-error: true
+ shell: bash
+ env:
+ TARGET_BRANCH: ${{ inputs.target-branch }}
+ run: python3 "${{ github.action_path }}/src/ai.py" prepare
+
+ - name: Resolve Go conflicts
+ id: ai
+ if: steps.prepare.outputs.input-file != ''
+ continue-on-error: true
+ # Pinned by SHA like every internal composite (docs/CONVENTIONS.md).
+ uses: loft-sh/github-actions/.github/actions/ai-step@17f1806257f071e23c72c5fae7cfa7af78ab5bae # ai-step/v1
+ with:
+ provider: anthropic
+ # Few calls (only backports with Go conflicts left), and a wrong answer
+ # costs a reviewer more than a stronger model costs us.
+ effort: high
+ max-tokens: '16000'
+ anthropic-api-key: ${{ inputs.anthropic-api-key }}
+ input-file: ${{ steps.prepare.outputs.input-file }}
+ prompt: |
+ A change merged on ${{ inputs.source-branch }} was cherry-picked onto the older release branch ${{ inputs.target-branch }}, and some Go files have git conflicts.
+ The JSON below lists each conflicted file in full. In every conflict block, the part between "<<<<<<< HEAD" and "=======" is the release branch; the part between "=======" and ">>>>>>>" is the backported change. A "|||||||" section, when present, is the common ancestor. The id of a block is the file path, "#", and its 0-based position in that file.
+
+ For every block, return its id and either:
+ - resolvable true and code: the final lines that take the place of the whole block, from its "<<<<<<<" line through its ">>>>>>>" line, with the file's indentation; or
+ - resolvable false, when you are not sure.
+
+ Rules:
+ - Keep what the backported change does, adapted to the release branch. Use only packages, types, functions and fields that the release-branch side of the file shows exist. Import paths of this repository use release_module, never a newer major version.
+ - If the change depends on code the release branch does not have, or you are not certain the result compiles and behaves as intended, return resolvable false and say why in reason. A wrong answer is worse than none: a person reviews what you leave.
+ - code is the resolved source only: it never contains a "<<<<<<<", "|||||||", "=======" or ">>>>>>>" line.
+ - reason is one short sentence.
+ output-schema: |
+ {
+ "type": "object",
+ "required": ["resolutions"],
+ "properties": {
+ "resolutions": {"type": "array", "items": {"type": "object", "required": ["id", "resolvable", "code", "reason"], "properties": {"id": {"type": "string"}, "resolvable": {"type": "boolean"}, "code": {"type": "string"}, "reason": {"type": "string"}}}}
+ }
+ }
+
+ - name: Apply AI resolutions that pass go vet
+ if: steps.ai.outputs.conclusion == 'success'
+ continue-on-error: true
+ shell: bash
+ env:
+ TARGET_BRANCH: ${{ inputs.target-branch }}
+ BACKPORT_BRANCH: ${{ inputs.backport-branch }}
+ PUSH: ${{ inputs.push }}
+ GH_TOKEN: ${{ inputs.github-token }}
+ INPUT_FILE: ${{ steps.prepare.outputs.input-file }}
+ AI_RESULT: ${{ steps.ai.outputs.result }}
+ run: python3 "${{ github.action_path }}/src/ai.py" apply
+
+ - name: Write the backport PR comment
+ id: report
+ if: steps.fix.outputs.conclusion == 'fixed' || steps.fix.outputs.conclusion == 'unchanged'
+ 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: ''
+ body: ${{ steps.report.outputs.body }}
+ pr-number: ${{ steps.report.outputs.pr-number }}
+ github-token: ${{ inputs.github-token }}
+ expected-author: ${{ steps.report.outputs.author }}
diff --git a/.github/actions/backport-fix-imports/src/ai.py b/.github/actions/backport-fix-imports/src/ai.py
new file mode 100644
index 00000000..a451ada2
--- /dev/null
+++ b/.github/actions/backport-fix-imports/src/ai.py
@@ -0,0 +1,190 @@
+#!/usr/bin/env python3
+"""backport-fix-imports AI pass: resolve the Go conflict hunks fix.py left,
+and commit an answer only when the package it lands in passes go vet.
+
+Runs after fix.py in the same checkout, on the backport branch it left.
+
+ prepare write the conflicted .go files the backport changed, whole, to an
+ input file for ai-step; sets input-file in GITHUB_OUTPUT, or
+ nothing when there is no work or too much of it
+ apply splice the model's answers in, then gofmt and go vet each touched
+ package; commit and push the packages that pass, revert the rest
+
+A file is only changed when every hunk in it has an answer, and a package is
+only committed when it compiles and vets, so a wrong or partial answer never
+reaches the branch. Like fix.py this never fails the job: every path exits 0.
+
+Env:
+ TARGET_BRANCH, BACKPORT_BRANCH, REMOTE, PUSH, GH_TOKEN as for fix.py
+ RUNNER_TEMP prepare: where the input file goes
+ INPUT_FILE apply: the file prepare wrote
+ AI_RESULT apply: the ai-step result JSON
+"""
+from __future__ import annotations
+
+import hashlib
+import json
+import os
+import subprocess
+import sys
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+import fix # noqa: E402
+
+MAX_HUNKS = 20
+MAX_INPUT_BYTES = 300_000
+VET_TIMEOUT = 900
+MARKS = (fix.MARK_OURS, fix.MARK_BASE, fix.MARK_THEIRS)
+
+
+def spans(lines: list[str]) -> list[tuple[int, int]]:
+ """(first, last) line index of each complete conflict block."""
+ out, i = [], 0
+ while i < len(lines):
+ if lines[i].startswith(fix.MARK_OURS):
+ sep = next((j for j in range(i + 1, len(lines)) if lines[j] == fix.MARK_SEP), None)
+ end = next((j for j in range(sep + 1, len(lines)) if lines[j].startswith(fix.MARK_THEIRS)), None) if sep else None
+ if end is None:
+ break
+ out.append((i, end))
+ i = end + 1
+ else:
+ i += 1
+ return out
+
+
+def digest(text: str) -> str:
+ return hashlib.sha256(text.encode()).hexdigest()
+
+
+def prepare() -> None:
+ remote = os.environ.get("REMOTE") or "origin"
+ target = os.environ["TARGET_BRANCH"]
+ base = fix.git("merge-base", f"{remote}/{target}", "HEAD").strip()
+ files = []
+ for _, path in fix.changed_files(base):
+ if not path.endswith(".go") or not Path(path).is_file():
+ continue
+ text = Path(path).read_text(encoding="utf-8", errors="replace")
+ hunks = spans(text.split("\n"))
+ if hunks:
+ files.append({"file": path, "sha256": digest(text), "hunks": len(hunks),
+ "ids": [f"{path}#{n}" for n in range(len(hunks))], "content": text})
+ if not files:
+ print("no Go conflicts left; the AI pass has nothing to do")
+ return
+ total = sum(f["hunks"] for f in files)
+ module = fix.GOMOD_MODULE.search(Path("go.mod").read_text(encoding="utf-8")) if Path("go.mod").is_file() else None
+ payload = json.dumps({"release_branch": target, "release_module": module.group(1) if module else "",
+ "files": files}, indent=1)
+ if total > MAX_HUNKS or len(payload) > MAX_INPUT_BYTES:
+ print(f"::notice::{total} Go conflict(s) in {len(files)} file(s) is too much for the AI pass; left for review")
+ return
+ out = Path(os.environ.get("RUNNER_TEMP", ".")) / "backport-ai-input.json"
+ out.write_text(payload, encoding="utf-8")
+ fix.emit("input-file", str(out))
+ print(f"sending {total} Go conflict(s) in {len(files)} file(s) to the AI pass")
+
+
+def resolve_file(text: str, answers: dict[str, dict], path: str) -> str | None:
+ """The file with every hunk replaced, or None unless every hunk has a usable answer."""
+ lines = text.split("\n")
+ blocks = spans(lines)
+ for n, (first, last) in reversed(list(enumerate(blocks))):
+ answer = answers.get(f"{path}#{n}")
+ if not answer or answer.get("resolvable") is not True or not isinstance(answer.get("code"), str):
+ return None
+ if any(l.startswith(MARKS) or l == fix.MARK_SEP for l in answer["code"].split("\n")):
+ return None
+ lines[first:last + 1] = answer["code"].split("\n")
+ return "\n".join(lines)
+
+
+def vet(package: str) -> tuple[bool, str]:
+ try:
+ res = subprocess.run(["go", "vet", package], capture_output=True, text=True, timeout=VET_TIMEOUT,
+ env=fix.tool_env())
+ except (OSError, subprocess.TimeoutExpired) as e:
+ return False, str(e)
+ lines = [l for l in (res.stdout + res.stderr).splitlines() if l and not l.startswith("#")]
+ return res.returncode == 0, lines[0] if lines else ""
+
+
+def apply() -> None:
+ payload = json.loads(Path(os.environ["INPUT_FILE"]).read_text(encoding="utf-8"))
+ result = json.loads(os.environ.get("AI_RESULT") or "{}")
+ answers = {r["id"]: r for r in result.get("resolutions", []) if isinstance(r, dict) and "id" in r}
+
+ written: dict[str, str] = {}
+ declined: dict[str, str] = {}
+ for f in payload["files"]:
+ path = f["file"]
+ current = Path(path).read_text(encoding="utf-8", errors="replace")
+ if digest(current) != f["sha256"]:
+ print(f"::warning::{path} changed since it was sent to the AI pass; left for review")
+ declined[path] = "changed after it was sent to the AI"
+ continue
+ resolved = resolve_file(current, answers, path)
+ if resolved is None:
+ reasons = "; ".join(answers.get(i, {}).get("reason", "no answer") for i in f["ids"])
+ print(f"::warning::{path}: not every conflict was resolved ({reasons}); left for review")
+ declined[path] = f"the AI did not resolve it: {reasons}"
+ continue
+ Path(path).write_text(resolved, encoding="utf-8")
+ written[path] = current
+
+ by_package: dict[str, list[str]] = {}
+ for path in written:
+ by_package.setdefault(str(Path(path).parent), []).append(path)
+
+ keep = []
+ for directory, paths in sorted(by_package.items()):
+ ok, why = True, ""
+ others = [p for p in Path(directory).glob("*.go") if str(p) not in paths]
+ if any(fix.has_markers(p.read_text(encoding="utf-8", errors="replace")) for p in others):
+ ok, why = False, "another file in the package still has conflicts"
+ if ok:
+ res = subprocess.run(["gofmt", "-w", *paths], capture_output=True, text=True, env=fix.tool_env())
+ ok, why = res.returncode == 0, res.stderr.strip().splitlines()[0] if res.returncode else ""
+ if ok:
+ ok, why = vet(f"./{directory}")
+ if ok:
+ keep.extend(paths)
+ print(f"AI resolution for ./{directory} passes go vet")
+ else:
+ for p in paths:
+ Path(p).write_text(written[p], encoding="utf-8")
+ declined[p] = f"the AI answer was rejected: {why}"
+ print(f"::warning::AI resolution for ./{directory} rejected ({why}); left for review")
+
+ fix.note("ai", {"committed": [], "commit": "", "declined": declined})
+ if not keep:
+ return
+ branch = os.environ["BACKPORT_BRANCH"]
+ fix.git("add", "--", *keep)
+ fix.git("-c", f"user.name={fix.BOT_NAME}", "-c", f"user.email={fix.BOT_EMAIL}", "commit", "-q",
+ "-m", "fix(backport): resolve conflicts with ai", "-m",
+ "Resolved by the backport AI pass and checked with go vet:\n" + "\n".join(f"- {p}" for p in keep))
+ if os.environ.get("PUSH", "true") != "false":
+ # Label first: auto-approve must never see the AI commit on an unlabeled PR.
+ pr = fix.backport_pr(branch)
+ if pr is None:
+ raise RuntimeError(f"no open PR for {branch} to label, so the AI commit was not pushed")
+ fix.github("POST", f"/repos/{os.environ['GITHUB_REPOSITORY']}/issues/{pr}/labels", {"labels": [fix.AI_LABEL]})
+ fix.git("push", "-q", os.environ.get("REMOTE") or "origin", f"HEAD:refs/heads/{branch}")
+ fix.note("ai", {"committed": keep, "commit": fix.git("rev-parse", "HEAD").strip(), "declined": declined})
+ print(f"committed AI resolutions for {len(keep)} file(s)")
+
+
+def main() -> int:
+ try:
+ {"prepare": prepare, "apply": apply}[sys.argv[1]]()
+ except Exception as e: # noqa: BLE001 -- advisory: never fail the backport
+ print(f"::warning::backport AI pass failed: {str(e).splitlines()[0] if str(e) else type(e).__name__}; "
+ "conflicts are left for review")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/.github/actions/backport-fix-imports/src/fix.py b/.github/actions/backport-fix-imports/src/fix.py
index 9ebc6262..78e98d83 100644
--- a/.github/actions/backport-fix-imports/src/fix.py
+++ b/.github/actions/backport-fix-imports/src/fix.py
@@ -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
@@ -59,6 +62,9 @@
# 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)
+# Read by auto-approve to keep AI-written backports for a person.
+AI_LABEL = "backport-ai-resolved"
+NOTES_FILE = "backport-fix-imports-notes.json"
BOT_NAME = "github-actions[bot]"
BOT_EMAIL = "41898282+github-actions[bot]@users.noreply.github.com"
@@ -87,6 +93,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 and go vet never need 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)
@@ -299,7 +337,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()}")
@@ -313,6 +351,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 = []
@@ -349,10 +390,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:
@@ -362,11 +405,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:
diff --git a/.github/actions/backport-fix-imports/src/report.py b/.github/actions/backport-fix-imports/src/report.py
new file mode 100644
index 00000000..f313cce8
--- /dev/null
+++ b/.github/actions/backport-fix-imports/src/report.py
@@ -0,0 +1,66 @@
+#!/usr/bin/env python3
+"""backport-fix-imports: turn what the passes did into the backport PR comment.
+
+Reads the notes fix.py and ai.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 body(notes: dict) -> str:
+ script, ai = notes.get("script", {}), notes.get("ai", {})
+ lines = []
+ if script.get("commit"):
+ lines.append(f"- Module paths: fixed in {script['commit'][:10]} ({script.get('rewritten', 0)} file(s) "
+ f"rewritten, {script.get('hunks', 0)} import conflict(s) resolved).")
+ committed = ai.get("committed", [])
+ if committed:
+ files = ", ".join(f"`{p}`" for p in committed)
+ lines.append(f"- AI: resolved the conflicts in {files} in {ai['commit'][:10]}. A model wrote that commit "
+ f"and only `go vet` checked it, so review it closely. The `{fix.AI_LABEL}` label keeps this "
+ "PR from being auto-approved.")
+ left = {p: r for p, r in script.get("left", {}).items() if p not in committed}
+ left.update(ai.get("declined", {}))
+ if left:
+ lines.append("- Left for you:")
+ lines.extend(f" - `{p}`: {r}" for p, r in sorted(left.items()))
+ return "\n".join(["### Backport fix-imports", *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
+ 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))
+ # sticky-pr-comment only updates comments by this author.
+ fix.emit("author", fix.github("GET", "/user")["login"])
+ 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())
diff --git a/.github/actions/backport-fix-imports/test/test_ai.py b/.github/actions/backport-fix-imports/test/test_ai.py
new file mode 100644
index 00000000..d039813b
--- /dev/null
+++ b/.github/actions/backport-fix-imports/test/test_ai.py
@@ -0,0 +1,221 @@
+"""ai.py: hunk parsing, answer splicing, and prepare/apply against a real
+throwaway Go module with a local bare remote. The model is never called: the
+tests hand apply the JSON ai-step would return. apply runs real gofmt and
+go vet, so those tests skip without a Go toolchain."""
+from __future__ import annotations
+
+import importlib.util
+import json
+import shutil
+import subprocess
+import sys
+from pathlib import Path
+
+import pytest
+
+SRC = Path(__file__).resolve().parent.parent / "src"
+sys.path.insert(0, str(SRC))
+spec = importlib.util.spec_from_file_location("backport_ai", SRC / "ai.py")
+ai = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(ai)
+
+from test_fix import API_V4, commit_all, git, push_backport, remote_file, repo, write # noqa: E402,F401
+
+needs_go = pytest.mark.skipif(not (shutil.which("go") and shutil.which("gofmt")), reason="Go toolchain not installed")
+
+CONFLICT = """package peer
+
+func Pick() int {
+<<<<<<< HEAD
+ return 4
+=======
+ return five()
+>>>>>>> abc123 (feat: x (#1))
+}
+"""
+GOOD = {"resolvable": True, "code": "\treturn 4", "reason": "five() does not exist on the release branch"}
+BAD = {"resolvable": True, "code": "\treturn five()", "reason": "kept the backported call"}
+
+
+@pytest.fixture
+def backport(repo, monkeypatch):
+ """The fixture repo as a buildable module, with one Go conflict pushed on the backport branch."""
+ write(repo, f"{API_V4}/go.mod", "module example.com/acme/api/v4\n\ngo 1.22\n")
+ write(repo, "pkg/peer/pick.go", CONFLICT)
+ push_backport(repo)
+ git(repo, "fetch", "-q", "origin")
+ monkeypatch.setenv("RUNNER_TEMP", str(repo.parent))
+ monkeypatch.setenv("GOFLAGS", "-mod=mod")
+ monkeypatch.setenv("GOTOOLCHAIN", "local")
+ monkeypatch.setenv("GITHUB_REPOSITORY", "acme/app")
+ monkeypatch.setenv("GH_TOKEN", "test-token")
+ return repo
+
+
+@pytest.fixture
+def gh(backport, monkeypatch):
+ """Fake GitHub API: records label calls with the remote tip at that moment."""
+ calls = []
+
+ def github(method, path, body=None):
+ tip = git(backport, "ls-remote", "origin", "refs/heads/backport/release-4.12/pr-1").split()[0]
+ calls.append((method, path, body, tip))
+ return {}
+
+ monkeypatch.setattr(ai.fix, "backport_pr", lambda branch: 42)
+ monkeypatch.setattr(ai.fix, "github", github)
+ return calls
+
+
+def prepared(repo: Path) -> Path:
+ ai.prepare()
+ out = dict(l.split("=", 1) for l in (repo.parent / "output").read_text().splitlines())
+ return Path(out["input-file"])
+
+
+def apply(monkeypatch, input_file: Path, answers: dict[str, dict]) -> None:
+ monkeypatch.setenv("INPUT_FILE", str(input_file))
+ monkeypatch.setenv("AI_RESULT", json.dumps({"resolutions": [{"id": k, **v} for k, v in answers.items()]}))
+ ai.apply()
+
+
+# -- parsing and splicing ---------------------------------------------------
+
+def test_spans_find_complete_blocks_only():
+ lines = ["a", "<<<<<<< HEAD", "x", "=======", "y", ">>>>>>> c", "b", "<<<<<<< HEAD", "z"]
+ assert ai.spans(lines) == [(1, 5)]
+
+
+def test_resolve_file_replaces_every_block():
+ text = CONFLICT + CONFLICT.replace("package peer\n", "")
+ out = ai.resolve_file(text, {"f.go#0": GOOD, "f.go#1": GOOD}, "f.go")
+ assert out is not None and out.count("return 4") == 2 and "<<<<<<<" not in out
+
+
+@pytest.mark.parametrize("answers", [
+ {},
+ {"f.go#0": {**GOOD, "resolvable": False}},
+ {"f.go#0": {**GOOD, "code": "<<<<<<< HEAD\n\treturn 4"}},
+ {"f.go#0": {**GOOD, "code": "\treturn 4\n======="}},
+ {"f.go#0": {**GOOD, "code": None}},
+])
+def test_resolve_file_refuses_missing_or_unusable_answers(answers):
+ assert ai.resolve_file(CONFLICT, answers, "f.go") is None
+
+
+def test_partial_answers_leave_the_file():
+ text = CONFLICT + CONFLICT.replace("package peer\n", "")
+ assert ai.resolve_file(text, {"f.go#0": GOOD}, "f.go") is None
+
+
+# -- prepare ----------------------------------------------------------------
+
+def test_prepare_sends_conflicted_go_files_whole(backport):
+ payload = json.loads(prepared(backport).read_text())
+ assert payload["release_module"] == "example.com/acme/app/v4"
+ [f] = payload["files"]
+ assert f["file"] == "pkg/peer/pick.go" and f["ids"] == ["pkg/peer/pick.go#0"]
+ assert f["content"] == CONFLICT
+
+
+def test_prepare_with_no_go_conflicts_sets_nothing(repo):
+ write(repo, "chart/values.yaml", "<<<<<<< HEAD\na: 1\n=======\na: 2\n>>>>>>> c\n")
+ push_backport(repo)
+ git(repo, "fetch", "-q", "origin")
+ ai.prepare()
+ assert not (repo.parent / "output").exists()
+
+
+def test_prepare_over_the_cap_sets_nothing(backport, monkeypatch):
+ monkeypatch.setattr(ai, "MAX_HUNKS", 0)
+ ai.prepare()
+ assert not (backport.parent / "output").exists()
+
+
+# -- apply ------------------------------------------------------------------
+
+@needs_go
+def test_answer_that_vets_is_committed_and_pushed(backport, monkeypatch, gh):
+ before = git(backport, "rev-parse", "origin/backport/release-4.12/pr-1")
+ apply(monkeypatch, prepared(backport), {"pkg/peer/pick.go#0": GOOD})
+ # The label lands while the remote still has the old tip: before the AI commit.
+ assert gh == [("POST", "/repos/acme/app/issues/42/labels", {"labels": ["backport-ai-resolved"]}, before)]
+ notes = json.loads((backport.parent / ai.fix.NOTES_FILE).read_text())
+ assert notes["ai"]["committed"] == ["pkg/peer/pick.go"] and notes["ai"]["commit"]
+ content = remote_file(backport, "pkg/peer/pick.go")
+ assert "return 4" in content and "<<<<<<<" not in content
+ assert git(backport, "log", "-1", "--format=%s", "origin/backport/release-4.12/pr-1") == \
+ "fix(backport): resolve conflicts with ai"
+
+
+@needs_go
+@pytest.mark.parametrize("failure", ["no pr", "label error"])
+def test_ai_commit_is_not_pushed_unless_the_pr_is_labeled(backport, monkeypatch, failure):
+ def refuse(*args, **kwargs):
+ raise OSError("HTTP Error 403: Forbidden")
+
+ if failure == "no pr":
+ monkeypatch.setattr(ai.fix, "backport_pr", lambda branch: None)
+ else:
+ monkeypatch.setattr(ai.fix, "backport_pr", lambda branch: 42)
+ monkeypatch.setattr(ai.fix, "github", refuse)
+ before = git(backport, "rev-parse", "origin/backport/release-4.12/pr-1")
+ input_file = prepared(backport)
+ monkeypatch.setenv("INPUT_FILE", str(input_file))
+ monkeypatch.setenv("AI_RESULT", json.dumps({"resolutions": [{"id": "pkg/peer/pick.go#0", **GOOD}]}))
+ monkeypatch.setattr(sys, "argv", ["ai.py", "apply"])
+ assert ai.main() == 0
+ git(backport, "fetch", "-q", "origin")
+ assert git(backport, "rev-parse", "origin/backport/release-4.12/pr-1") == before
+
+
+def test_go_tools_never_see_the_push_token(backport, monkeypatch):
+ seen = []
+
+ def run(cmd, **kwargs):
+ seen.append((cmd[0], kwargs.get("env")))
+ return subprocess.CompletedProcess(cmd, 1, "", "stop here")
+
+ monkeypatch.setattr(ai.subprocess, "run", run)
+ ai.vet("./pkg/peer")
+ monkeypatch.setattr(ai.fix.subprocess, "run", run)
+ monkeypatch.setattr(ai.fix.shutil, "which", lambda name: "/usr/bin/" + name)
+ with pytest.raises(RuntimeError):
+ ai.fix.gofmt(["pkg/peer/pick.go"])
+ assert [name for name, _ in seen] == ["go", "gofmt"]
+ for _, env in seen:
+ assert env is not None and "GH_TOKEN" not in env
+
+
+@needs_go
+def test_answer_that_does_not_compile_is_reverted(backport, monkeypatch):
+ before = git(backport, "rev-parse", "origin/backport/release-4.12/pr-1")
+ apply(monkeypatch, prepared(backport), {"pkg/peer/pick.go#0": BAD})
+ git(backport, "fetch", "-q", "origin")
+ assert git(backport, "rev-parse", "origin/backport/release-4.12/pr-1") == before
+ assert (backport / "pkg/peer/pick.go").read_text() == CONFLICT
+
+
+@needs_go
+def test_package_with_another_conflicted_file_is_not_committed(backport, monkeypatch):
+ input_file = prepared(backport)
+ write(backport, "pkg/peer/other.go", CONFLICT.replace("Pick", "Other"))
+ apply(monkeypatch, input_file, {"pkg/peer/pick.go#0": GOOD})
+ assert (backport / "pkg/peer/pick.go").read_text() == CONFLICT
+
+
+def test_file_changed_since_prepare_is_left(backport, monkeypatch):
+ input_file = prepared(backport)
+ write(backport, "pkg/peer/pick.go", CONFLICT + "\n// edited\n")
+ apply(monkeypatch, input_file, {"pkg/peer/pick.go#0": GOOD})
+ assert (backport / "pkg/peer/pick.go").read_text() == CONFLICT + "\n// edited\n"
+
+
+@pytest.mark.parametrize("result", ["", "not json", '{"resolutions": "nope"}'])
+def test_garbage_result_changes_nothing_and_never_fails(backport, monkeypatch, result):
+ input_file = prepared(backport)
+ monkeypatch.setenv("INPUT_FILE", str(input_file))
+ monkeypatch.setenv("AI_RESULT", result)
+ monkeypatch.setattr(sys, "argv", ["ai.py", "apply"])
+ assert ai.main() == 0
+ assert (backport / "pkg/peer/pick.go").read_text() == CONFLICT
diff --git a/.github/actions/backport-fix-imports/test/test_fix.py b/.github/actions/backport-fix-imports/test/test_fix.py
index f4283626..580b4101 100644
--- a/.github/actions/backport-fix-imports/test/test_fix.py
+++ b/.github/actions/backport-fix-imports/test/test_fix.py
@@ -3,6 +3,7 @@
from __future__ import annotations
import importlib.util
+import json
import shutil
import subprocess
from pathlib import Path
@@ -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
@@ -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')
@@ -474,9 +486,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"]
diff --git a/.github/actions/backport-fix-imports/test/test_report.py b/.github/actions/backport-fix-imports/test/test_report.py
new file mode 100644
index 00000000..885535cc
--- /dev/null
+++ b/.github/actions/backport-fix-imports/test/test_report.py
@@ -0,0 +1,73 @@
+"""report.py: the comment body from the passes' notes, and the outputs it
+hands sticky-pr-comment. GitHub is faked."""
+from __future__ import annotations
+
+import importlib.util
+import json
+import sys
+from pathlib import Path
+
+import pytest
+
+SRC = Path(__file__).resolve().parent.parent / "src"
+sys.path.insert(0, str(SRC))
+spec = importlib.util.spec_from_file_location("backport_report", SRC / "report.py")
+report = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(report)
+
+SCRIPT = {"rewritten": 22, "hunks": 18, "commit": "b2c250ffb0050cae", "left": {
+ "pkg/a/a_test.go": "conflict markers remain", "pkg/b/b.go": "conflict markers remain"}}
+
+
+def test_nothing_to_tell_means_no_comment():
+ assert report.body({}) == ""
+ assert report.body({"script": {"rewritten": 0, "hunks": 0, "commit": "", "left": {}}}) == ""
+
+
+def test_script_fix_and_leftovers():
+ text = report.body({"script": SCRIPT})
+ assert "fixed in b2c250ffb0 (22 file(s) rewritten, 18 import conflict(s) resolved)" in text
+ assert " - `pkg/a/a_test.go`: conflict markers remain" in text
+
+
+def test_ai_commit_is_flagged_and_its_files_are_not_left():
+ text = report.body({"script": SCRIPT, "ai": {
+ "committed": ["pkg/a/a_test.go"], "commit": "bee5777f0837",
+ "declined": {"pkg/b/b.go": "the AI did not resolve it: needs pkg/tenancy"}}})
+ assert "AI: resolved the conflicts in `pkg/a/a_test.go` in bee5777f08" in text
+ assert "review it closely" in text and "backport-ai-resolved" in text
+ assert "`pkg/a/a_test.go`: conflict" not in text
+ assert " - `pkg/b/b.go`: the AI did not resolve it: needs pkg/tenancy" in text
+
+
+@pytest.fixture
+def env(tmp_path, monkeypatch):
+ monkeypatch.setenv("RUNNER_TEMP", str(tmp_path))
+ monkeypatch.setenv("GITHUB_OUTPUT", str(tmp_path / "output"))
+ monkeypatch.setenv("BACKPORT_BRANCH", "backport/release-4.12/pr-1")
+ monkeypatch.setattr(report.fix, "backport_pr", lambda branch: 42)
+ monkeypatch.setattr(report.fix, "github", lambda method, path, body=None: {"login": "acme-bot"})
+ return tmp_path
+
+
+def test_main_hands_sticky_pr_comment_its_inputs(env):
+ (env / report.fix.NOTES_FILE).write_text(json.dumps({"script": SCRIPT}))
+ assert report.main() == 0
+ out = (env / "output").read_text()
+ assert out.startswith("body<-
+ After the cherry-picks, run backport-fix-imports on each backport
+ branch: rewrite the repository's own Go module paths to the target
+ line's major, resolve import-only conflicts, and push a fix commit.
+ For repositories whose release lines sit on an older module major.
+ With anthropic-api-key set, Go conflicts that remain also go to an AI
+ pass, committed only for packages that pass go vet.
+ type: boolean
+ required: false
+ default: false
secrets:
gh-access-token:
description: 'GitHub PAT for creating backport PRs'
@@ -45,6 +56,9 @@ on:
linear-token:
description: 'Linear API token for linking backport PRs to their matching Linear sub-issue. Optional; linking is skipped when not provided.'
required: false
+ anthropic-api-key:
+ description: 'Anthropic API key for the fix-imports AI pass. Optional; without it only the deterministic fix runs.'
+ required: false
# Serialize runs for the same source PR so two rapid label events can't both race
# past the open-PR check and double-create the same deterministic backport branch
@@ -70,7 +84,16 @@ jobs:
timeout-minutes: 20
permissions:
contents: read
+ outputs:
+ targets: ${{ steps.targets.outputs.targets }}
steps:
+ # First, so fix-imports gets its targets even when a cherry-pick fails.
+ - name: List backport targets
+ id: targets
+ env:
+ LABELS: ${{ toJSON(github.event.pull_request.labels.*.name) }}
+ run: echo "targets=$(jq -c '[.[] | select(startswith("backport-to-")) | ltrimstr("backport-to-")]' <<< "$LABELS")" >> "$GITHUB_OUTPUT"
+
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
@@ -102,6 +125,39 @@ jobs:
if: ${{ failure() }}
run: cat ~/.backport/backport.debug.log
+ # One job per backport-to-* label. A label with no cherry-pick branch (a
+ # legacy line, a failed pick) makes the action skip, so the matrix needs no
+ # filtering here.
+ fix-imports:
+ name: Fix imports for ${{ matrix.target }}
+ needs: backport
+ if: inputs.fix-imports && !cancelled() && needs.backport.outputs.targets != '' && needs.backport.outputs.targets != '[]'
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ permissions:
+ contents: read
+ strategy:
+ fail-fast: false
+ matrix:
+ target: ${{ fromJSON(needs.backport.outputs.targets) }}
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ fetch-depth: 0
+ persist-credentials: false
+
+ # go and gofmt for the AI pass come with the hosted runner; go vet
+ # switches to the toolchain the target's go.mod asks for on its own.
+ - name: Fix imports on backport/${{ matrix.target }}/pr-${{ github.event.pull_request.number }}
+ uses: loft-sh/github-actions/.github/actions/backport-fix-imports@backport-fix-imports/v1 # zizmor: ignore[unpinned-uses] -- internal loft-sh/github-actions ref; reusable workflows check out the CALLER repo, so a local ./.github/actions path is not present here. @backport-fix-imports/v1 is the floating coordination tag kept aligned with backport/v1 (see DEVOPS-923 pin-drift class).
+ with:
+ target-branch: ${{ matrix.target }}
+ backport-branch: backport/${{ matrix.target }}/pr-${{ github.event.pull_request.number }}
+ source-branch: ${{ github.event.pull_request.base.ref }}
+ github-token: ${{ secrets.gh-access-token }} # zizmor: ignore[secrets-outside-env] -- PAT passed through workflow_call; pushes the fix commit so the backport PR's CI runs on it
+ anthropic-api-key: ${{ secrets.anthropic-api-key }} # zizmor: ignore[secrets-outside-env] -- optional key passed through workflow_call
+
# --- pre-monorepo (<= v0.36) split/re-root flow ---------------------------
# sorenlouv (above) only cherry-picks monorepo-era (>= v0.37) targets. Legacy
# lines predate the monorepo layout, so a cherry-pick there would recreate the
diff --git a/README.md b/README.md
index 693e2aab..eb96fe86 100644
--- a/README.md
+++ b/README.md
@@ -169,7 +169,7 @@ See [select-backport-source-prs README](./.github/actions/select-backport-source
### Backport Fix Imports Action
-Repairs Go module paths on a backport branch whose target release line is on an older module major than its source, for example a v5 `main` backported to a v4 release branch. Rewrites the import paths of modules the repository owns, moves files added under a source-only staging directory, resolves conflict hunks made only of import lines, and pushes one fix commit. Conflicts in real code are left and counted; advisory, never fails the job.
+Repairs Go module paths on a backport branch whose target release line is on an older module major than its source, for example a v5 `main` backported to a v4 release branch. Rewrites the import paths of modules the repository owns, moves files added under a source-only staging directory, resolves conflict hunks made only of import lines, and pushes one fix commit. With an Anthropic key, Go conflicts that remain go to an AI pass, committed only for packages that pass `go vet`. Wired into [`backport.yaml`](./docs/workflows/backport.md) behind `fix-imports`; advisory, never fails the job.
**Location:** `.github/actions/backport-fix-imports`
diff --git a/docs/workflows/backport.md b/docs/workflows/backport.md
index cbee38ae..5060d0b2 100644
--- a/docs/workflows/backport.md
+++ b/docs/workflows/backport.md
@@ -6,13 +6,14 @@ Creates backport PRs when a merged PR is labeled with `backport/`.
-| INPUT | TYPE | REQUIRED | DEFAULT | DESCRIPTION |
-|----------------|---------|----------|---------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
-| docs-url | string | false | | Optional link to this repo's backport
documentation. Added to the failure comment
so whoever hits it knows where
the process is written down. Omitted
when empty. An input rather than
a constant because this workflow is
shared with public repositories. |
-| legacy-split | boolean | false | `false` | Enable the pre-monorepo (<= v0.36) split/re-root backport
flow. When true this workflow empties
auto_backport_label_prefix so sorenlouv honors the caller's
.backportrc.json branchLabelMapping regex (which MUST be scoped to the monorepo era, >= v0.37), and a
companion job routes <= v0.36 targets
through backport-legacy-split. Requires subtree-prefix, oss-repo and
pro-repo. |
-| oss-repo | string | false | | OSS repo as owner/repo for legacy
OSS-side backport PRs. Required when legacy-split
is true. |
-| pro-repo | string | false | | Pro repo as owner/repo for the
pro half of mixed legacy backports.
Required when legacy-split is true. |
-| subtree-prefix | string | false | | OSS subtree path in the monorepo,
e.g. staging/github.com/loft-sh/vcluster. Required when legacy-split is
true. |
+| INPUT | TYPE | REQUIRED | DEFAULT | DESCRIPTION |
+|----------------|---------|----------|---------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| docs-url | string | false | | Optional link to this repo's backport
documentation. Added to the failure comment
so whoever hits it knows where
the process is written down. Omitted
when empty. An input rather than
a constant because this workflow is
shared with public repositories. |
+| fix-imports | boolean | false | `false` | After the cherry-picks, run backport-fix-imports on
each backport branch: rewrite the repository's
own Go module paths to the
target line's major, resolve import-only conflicts,
and push a fix commit. For
repositories whose release lines sit on
an older module major. With anthropic-api-key
set, Go conflicts that remain also
go to an AI pass, committed
only for packages that pass go
vet. |
+| legacy-split | boolean | false | `false` | Enable the pre-monorepo (<= v0.36) split/re-root backport
flow. When true this workflow empties
auto_backport_label_prefix so sorenlouv honors the caller's
.backportrc.json branchLabelMapping regex (which MUST be scoped to the monorepo era, >= v0.37), and a
companion job routes <= v0.36 targets
through backport-legacy-split. Requires subtree-prefix, oss-repo and
pro-repo. |
+| oss-repo | string | false | | OSS repo as owner/repo for legacy
OSS-side backport PRs. Required when legacy-split
is true. |
+| pro-repo | string | false | | Pro repo as owner/repo for the
pro half of mixed legacy backports.
Required when legacy-split is true. |
+| subtree-prefix | string | false | | OSS subtree path in the monorepo,
e.g. staging/github.com/loft-sh/vcluster. Required when legacy-split is
true. |
@@ -20,9 +21,10 @@ Creates backport PRs when a merged PR is labeled with `backport/`.
-| SECRET | REQUIRED | DESCRIPTION |
-|-----------------|----------|--------------------------------------------------------------------------------------------------------------------------------------------|
-| gh-access-token | true | GitHub PAT for creating backport PRs |
-| linear-token | false | Linear API token for linking backport
PRs to their matching Linear sub-issue.
Optional; linking is skipped when not
provided. |
+| SECRET | REQUIRED | DESCRIPTION |
+|-------------------|----------|--------------------------------------------------------------------------------------------------------------------------------------------|
+| anthropic-api-key | false | Anthropic API key for the fix-imports
AI pass. Optional; without it only
the deterministic fix runs. |
+| gh-access-token | true | GitHub PAT for creating backport PRs |
+| linear-token | false | Linear API token for linking backport
PRs to their matching Linear sub-issue.
Optional; linking is skipped when not
provided. |