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
128 changes: 128 additions & 0 deletions .github/scripts/backport-pending.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import json
import os
import re
import sys
import urllib.error
import urllib.request
from dataclasses import dataclass
from typing import List

VERSION_LABEL_RE = re.compile(r"^v\d{1,2}$|(^v\d{1,2}\.\d{1,2}$)")
PENDING_LABEL = "backport pending"
PENDING_LABEL_COLOR = "fff2bf"


@dataclass
class PRInfo:
number: int
labels: List[str]


def load_event() -> dict:
path = os.environ.get("GITHUB_EVENT_PATH")
if not path or not os.path.exists(path):
print("::warning::GITHUB_EVENT_PATH not set or file missing; nothing to do", file=sys.stderr)
return {}
with open(path, "r", encoding="utf-8") as f:
return json.load(f)


def extract_pr(event: dict) -> PRInfo | None:
pr = event.get("pull_request")
if not pr:
return None
labels = [lbl.get("name", "") for lbl in pr.get("labels", [])]
return PRInfo(number=pr["number"], labels=labels)


def needs_pending_label(info: PRInfo) -> bool:
has_version_label = any(VERSION_LABEL_RE.match(l) for l in info.labels)
has_pending = PENDING_LABEL in info.labels
return not (has_version_label and has_pending)


def add_label(pr_number: int, label: str) -> None:
repo = os.environ.get("GITHUB_REPOSITORY")
token = os.environ.get("BACKPORT_TOKEN")
if not repo or not token:
print("::error::Missing GITHUB_REPOSITORY or BACKPORT_TOKEN", file=sys.stderr)
sys.exit(1)
owner, repo_name = repo.split("/", 1)
# First ensure the label exists (create or update color/description)
ensure_label(owner, repo_name, token)
url = f"https://api.github.com/repos/{owner}/{repo_name}/issues/{pr_number}/labels"
body = json.dumps({"labels": [label]}).encode()
# POST adds label(s) keeping old ones
req = urllib.request.Request(url, data=body, method="POST")
req.add_header("Authorization", f"Bearer {token}")
req.add_header("Accept", "application/vnd.github+json")
try:
with urllib.request.urlopen(req) as resp:
if resp.status not in (200, 201):
print(f"::error::Failed to add label: HTTP {resp.status}", file=sys.stderr)
sys.exit(1)
print(f"Added label '{label}' to PR #{pr_number}")
except urllib.error.HTTPError as e:
print(f"::error::HTTP error adding label: {e.code} {e.reason}", file=sys.stderr)
sys.exit(1)
except Exception as e:
print(f"::error::Unexpected error adding label: {e}", file=sys.stderr)
sys.exit(1)


def ensure_label(owner: str, repo_name: str, token: str) -> None:
"""Create the Backport Pending label if it does not already exist."""
label_api = f"https://api.github.com/repos/{owner}/{repo_name}/labels/{PENDING_LABEL.replace(' ', '%20')}"
get_req = urllib.request.Request(label_api, method="GET")
get_req.add_header("Authorization", f"Bearer {token}")
get_req.add_header("Accept", "application/vnd.github+json")
try:
with urllib.request.urlopen(get_req) as resp:
if resp.status == 200:
return
except urllib.error.HTTPError as e:
print(f"::warning::Failed to check label existence ({e.code})")
return
create_api = f"https://api.github.com/repos/{owner}/{repo_name}/labels"
body = json.dumps({"name": PENDING_LABEL, "color": PENDING_LABEL_COLOR}).encode()
req = urllib.request.Request(create_api, data=body, method="POST")
req.add_header("Authorization", f"Bearer {token}")
req.add_header("Accept", "application/vnd.github+json")
try:
with urllib.request.urlopen(req) as resp:
if resp.status not in (200, 201):
print(f"::warning::Failed to create label (status {resp.status})")
except Exception as e:
print(f"::warning::Error creating label: {e}")


"""
Label a PR with 'Backport pending' if it has no version label.

Expected environment:
GITHUB_EVENT_PATH: Path to the event JSON (GitHub sets this automatically)
GITHUB_REPOSITORY: owner/repo
BACKPORT_TOKEN: token with repo:issues scope (use BACKPORT_TOKEN or a PAT)

This script is idempotent: if the PR already has a version label (vX.Y) or already
has the 'Backport Pending' label, it exits without error.
"""


def main() -> int:
event = load_event()
if not event:
return 0
info = extract_pr(event)
if not info:
print("No pull_request object in event; skipping")
return 0
if needs_pending_label(info):
add_label(info.number, PENDING_LABEL)
else:
print("No label needed (either PR has version label or already pending)")
return 0


if __name__ == "__main__":
raise SystemExit(main())
9 changes: 8 additions & 1 deletion .github/workflows/backport.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ on:
branches: ['master']
types: ["labeled", "closed"]

env:
BACKPORT_TOKEN: ${{ secrets.BACKPORT_TOKEN }}

jobs:
backport:
name: Backport PR
Expand All @@ -17,7 +20,11 @@ jobs:
uses: sorenlouv/backport-github-action@ad888e978060bc1b2798690dd9d03c4036560947 # v9.5.1
continue-on-error: true
with:
github_token: ${{ secrets.BACKPORT_TOKEN }}
github_token: $BACKPORT_TOKEN

- uses: actions/checkout@v4
- name: Label PR if needed
run: python .github/scripts/backport-pending.py

- name: Info log
if: ${{ success() }}
Expand Down
Loading