diff --git a/.github/actions/link-backport-prs/README.md b/.github/actions/link-backport-prs/README.md new file mode 100644 index 0000000..b67d128 --- /dev/null +++ b/.github/actions/link-backport-prs/README.md @@ -0,0 +1,78 @@ +# Link Backport PRs to Linear + +A GitHub Action that links sorenlouv-created backport pull requests to the matching Linear sub-issue, so each backport closes the right per-release-line issue when it merges. + +## What it does + +When a merged source PR carries `backport-to-` labels, the [sorenlouv backport action](https://github.com/sorenlouv/backport-github-action) opens one backport PR per label. This action runs right after and, for each backport target: + +1. Resolves the source PR's Linear issue (the parent) via Linear's `attachmentsForURL` reverse lookup, falling back to a `TEAM-123` identifier parsed from the branch name or body. +2. Finds the sub-issue whose title carries the release-line prefix for that target, e.g. `[0.34] Copy of ENGCP-906` for a backport to `v0.34` (a leading `v`, as in `[v0.34]`, is also accepted). +3. Appends `Fixes ` to that backport PR's body, unless it already references the issue. + +The match is by title prefix, not milestone: the `[X.Y] Copy of ...` sub-issues created for a backport family do not reliably carry a patch milestone, so the title is the dependable key. + +It is advisory and idempotent: it never fails the backport job (every error is a warning and it exits 0), it skips entirely when no `linear-token` is provided, and re-runs do not add duplicate `Fixes` lines. + +## Usage + +This action is wired into the shared [`backport.yaml`](../../workflows/backport.yaml) reusable workflow. A caller enables linking by passing a Linear token: + +```yaml +jobs: + backport: + uses: loft-sh/github-actions/.github/workflows/backport.yaml@backport/v1 + secrets: + gh-access-token: ${{ secrets.GH_ACCESS_TOKEN }} + linear-token: ${{ secrets.LINEAR_API_TOKEN }} +``` + +To run it directly: + +```yaml +- uses: loft-sh/github-actions/.github/actions/link-backport-prs@link-backport-prs/v1 + with: + source-pr: ${{ github.event.pull_request.number }} + repo-owner: ${{ github.repository_owner }} + repo-name: ${{ github.event.repository.name }} + github-token: ${{ secrets.GH_ACCESS_TOKEN }} + linear-token: ${{ secrets.LINEAR_API_TOKEN }} +``` + +The `github-token` must be the same PAT that created the backport PRs (a PAT, not the default `GITHUB_TOKEN`, so the backport PRs exist and are editable). + +## Inputs + + + +| INPUT | TYPE | REQUIRED | DEFAULT | DESCRIPTION | +|--------------|--------|----------|------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| dry-run | string | false | `"false"` | Log intended edits without applying them | +| github-token | string | true | | GitHub token with permission to read
and edit pull requests (must be the same PAT that created the backport PRs) | +| label-prefix | string | false | `"backport-to-"` | Prefix of the backport labels on
the source PR | +| linear-token | string | false | | Linear API token for resolving the
issue family. Optional by design: this
is an advisory step, so when
empty the action no-ops and exits
0 instead of failing, letting callers
adopt the shared backport workflow before
a Linear token is wired up. | +| repo-name | string | true | | The name of the repository | +| repo-owner | string | true | | The owner of the repository | +| source-pr | string | true | | The merged source pull request number
that was backported | + + + +## Outputs + + +No outputs. + + +## Development + +### Testing + +Run the unit tests: + +```bash +./test.sh +# or +make test-link-backport-prs +``` + +The tests cover the pure matching logic: release-line extraction from a target branch, title-prefix matching (`[0.34]` and `[v0.34]`), sub-issue selection within an issue family, idempotency of the `Fixes` line, and identifier extraction fallback. diff --git a/.github/actions/link-backport-prs/action.yml b/.github/actions/link-backport-prs/action.yml new file mode 100644 index 0000000..1137bde --- /dev/null +++ b/.github/actions/link-backport-prs/action.yml @@ -0,0 +1,61 @@ +name: 'Link Backport PRs to Linear' +description: 'Links sorenlouv-created backport PRs to the matching Linear sub-issue by adding "Fixes " to the backport PR body' + +inputs: + source-pr: + description: 'The merged source pull request number that was backported' + required: true + repo-owner: + description: 'The owner of the repository' + required: true + repo-name: + description: 'The name of the repository' + required: true + github-token: + description: 'GitHub token with permission to read and edit pull requests (must be the same PAT that created the backport PRs)' + required: true + linear-token: + description: 'Linear API token for resolving the issue family. Optional by design: this is an advisory step, so when empty the action no-ops and exits 0 instead of failing, letting callers adopt the shared backport workflow before a Linear token is wired up.' + required: false + default: '' + label-prefix: + description: 'Prefix of the backport labels on the source PR' + required: false + default: 'backport-to-' + dry-run: + description: 'Log intended edits without applying them' + required: false + default: 'false' + +runs: + using: 'composite' + steps: + - name: Set up Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version-file: ${{ github.action_path }}/src/go.mod + + - name: Build and run Link Backport PRs + shell: bash + env: + GITHUB_TOKEN: ${{ inputs.github-token }} + LINEAR_TOKEN: ${{ inputs.linear-token }} + ACTION_PATH: ${{ github.action_path }} + INPUT_SOURCE_PR: ${{ inputs.source-pr }} + INPUT_REPO_OWNER: ${{ inputs.repo-owner }} + INPUT_REPO_NAME: ${{ inputs.repo-name }} + INPUT_LABEL_PREFIX: ${{ inputs.label-prefix }} + INPUT_DRY_RUN: ${{ inputs.dry-run }} + run: | + cd "$ACTION_PATH/src" + go build -o link-backport-prs . + ./link-backport-prs \ + -source-pr="$INPUT_SOURCE_PR" \ + -repo-owner="$INPUT_REPO_OWNER" \ + -repo-name="$INPUT_REPO_NAME" \ + -label-prefix="$INPUT_LABEL_PREFIX" \ + -dry-run="$INPUT_DRY_RUN" + +branding: + icon: 'link' + color: 'orange' diff --git a/.github/actions/link-backport-prs/src/go.mod b/.github/actions/link-backport-prs/src/go.mod new file mode 100644 index 0000000..c4767d0 --- /dev/null +++ b/.github/actions/link-backport-prs/src/go.mod @@ -0,0 +1,7 @@ +module github.com/loft-sh/github-actions/link-backport-prs + +go 1.26.4 + +require github.com/google/go-github/v88 v88.0.0 + +require github.com/google/go-querystring v1.2.0 // indirect diff --git a/.github/actions/link-backport-prs/src/go.sum b/.github/actions/link-backport-prs/src/go.sum new file mode 100644 index 0000000..06a97fa --- /dev/null +++ b/.github/actions/link-backport-prs/src/go.sum @@ -0,0 +1,7 @@ +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-github/v88 v88.0.0 h1:dZA9IKkPK1eXZj4ypngnpRj5FwdpTv4whix2PrQMP7M= +github.com/google/go-github/v88 v88.0.0/go.mod h1:rufTDgn2N45wjhukLTyxmvc9nilSp3mr3Rgtt6b1MPw= +github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= +github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= diff --git a/.github/actions/link-backport-prs/src/main.go b/.github/actions/link-backport-prs/src/main.go new file mode 100644 index 0000000..d6a1041 --- /dev/null +++ b/.github/actions/link-backport-prs/src/main.go @@ -0,0 +1,413 @@ +// Command link-backport-prs links sorenlouv-created backport pull requests to +// the matching Linear sub-issue. +// +// On the shared backport workflow, after sorenlouv opens the backport PRs for a +// merged source PR, this tool resolves the source PR's Linear issue (the +// parent), finds the sub-issue for each backported release line by its title +// prefix (for example "[0.34] Copy of ENGCP-906"), and appends "Fixes " to +// that backport PR's body so the sub-issue is closed when the backport merges. +// +// It is advisory: any failure is logged as a warning and the process still +// exits 0 so it never blocks the backport workflow. +package main + +import ( + "bytes" + "context" + "encoding/json" + "flag" + "fmt" + "io" + "log" + "net/http" + "os" + "regexp" + "strings" + + "github.com/google/go-github/v88/github" +) + +const linearAPIURL = "https://api.linear.app/graphql" + +// issueRef is a minimal Linear issue. +type issueRef struct { + ID string `json:"id"` + Identifier string `json:"identifier"` + Title string `json:"title"` +} + +// issueWithChildren is an issue plus its direct sub-issues. +type issueWithChildren struct { + issueRef + Children struct { + Nodes []issueRef `json:"nodes"` + } `json:"children"` +} + +// issueFamily is the issue attached to a PR, its sub-issues, and its parent +// (with the parent's sub-issues, i.e. the attached issue's siblings). +type issueFamily struct { + issueWithChildren + Parent *issueWithChildren `json:"parent"` +} + +func main() { + if err := run(); err != nil { + // Advisory tool: surface the problem but never fail the backport job. + warnf("link-backport-prs: %v", err) + } +} + +func run() error { + sourcePR := flag.Int("source-pr", 0, "merged source pull request number") + repoOwner := flag.String("repo-owner", "", "repository owner") + repoName := flag.String("repo-name", "", "repository name") + labelPrefix := flag.String("label-prefix", "backport-to-", "prefix of backport labels") + dryRun := flag.Bool("dry-run", false, "log intended edits without applying them") + flag.Parse() + + if *sourcePR == 0 || *repoOwner == "" || *repoName == "" { + return fmt.Errorf("source-pr, repo-owner and repo-name are required") + } + + githubToken := os.Getenv("GITHUB_TOKEN") + if githubToken == "" { + return fmt.Errorf("GITHUB_TOKEN is required") + } + + linearToken := os.Getenv("LINEAR_TOKEN") + if linearToken == "" { + // No Linear token wired up by the caller: nothing to do, not an error. + noticef("LINEAR_TOKEN not provided, skipping Linear sub-issue linking") + return nil + } + + ctx := context.Background() + gh, err := github.NewClient(github.WithAuthToken(githubToken)) + if err != nil { + return fmt.Errorf("create github client: %w", err) + } + + src, _, err := gh.PullRequests.Get(ctx, *repoOwner, *repoName, *sourcePR) + if err != nil { + return fmt.Errorf("get source PR #%d: %w", *sourcePR, err) + } + + targets := backportTargets(src.Labels, *labelPrefix) + if len(targets) == 0 { + noticef("source PR #%d has no %s* labels, nothing to link", *sourcePR, *labelPrefix) + return nil + } + + family := resolveFamily(linearToken, src) + if family == nil { + warnf("could not resolve a Linear issue for source PR #%d (no attachment, no identifier in branch/body)", *sourcePR) + return nil + } + candidates := familyCandidates(*family) + noticef("resolved Linear family from %s: %d candidate sub-issues", family.Identifier, len(candidates)) + + linked := 0 + for _, target := range targets { + version := versionFromBranch(target) + if version == "" { + noticef("label target %q has no X.Y version (e.g. main), skipping", target) + continue + } + + sub, ok := matchSubIssue(candidates, version) + if !ok { + warnf("no sub-issue with a [%s] title prefix found under %s for backport to %s", version, family.Identifier, target) + continue + } + + headBranch := backportHeadBranch(target, *sourcePR) + bp, err := findBackportPR(ctx, gh, *repoOwner, *repoName, headBranch, target) + if err != nil { + warnf("looking up backport PR %s: %v", headBranch, err) + continue + } + if bp == nil { + warnf("no backport PR found for %s (base %s); sorenlouv may have skipped it (conflict/no commits)", headBranch, target) + continue + } + + if bodyReferencesIssue(bp.GetBody(), sub.Identifier) { + noticef("backport PR #%d already references %s, skipping", bp.GetNumber(), sub.Identifier) + continue + } + + newBody := appendFixes(bp.GetBody(), sub.Identifier) + if *dryRun { + noticef("[dry-run] would add 'Fixes %s' to backport PR #%d (%s)", sub.Identifier, bp.GetNumber(), target) + linked++ + continue + } + + if _, _, err := gh.PullRequests.Edit(ctx, *repoOwner, *repoName, bp.GetNumber(), &github.PullRequest{Body: &newBody}); err != nil { + warnf("updating backport PR #%d body: %v", bp.GetNumber(), err) + continue + } + noticef("linked backport PR #%d (%s) to %s via 'Fixes %s'", bp.GetNumber(), target, sub.Identifier, sub.Identifier) + linked++ + } + + noticef("link-backport-prs: linked %d of %d backport target(s)", linked, len(targets)) + if out := os.Getenv("GITHUB_OUTPUT"); out != "" { + if f, err := os.OpenFile(out, os.O_APPEND|os.O_WRONLY, 0o644); err == nil { + fmt.Fprintf(f, "linked-count=%d\n", linked) + f.Close() + } + } + return nil +} + +// backportTargets returns the target branch for each backport-to-* label, +// i.e. the label with the prefix stripped ("backport-to-v0.34" -> "v0.34"). +func backportTargets(labels []*github.Label, prefix string) []string { + names := make([]string, 0, len(labels)) + for _, l := range labels { + names = append(names, l.GetName()) + } + return backportTargetsFromNames(names, prefix) +} + +// backportTargetsFromNames is the pure core of backportTargets. +func backportTargetsFromNames(names []string, prefix string) []string { + var targets []string + for _, name := range names { + if strings.HasPrefix(name, prefix) { + targets = append(targets, strings.TrimPrefix(name, prefix)) + } + } + return targets +} + +var versionInBranchRe = regexp.MustCompile(`(\d+)\.(\d+)`) + +// versionFromBranch extracts the X.Y release line from a target branch. +// "v0.34" -> "0.34", "release-4.2" -> "4.2", "main" -> "". +func versionFromBranch(branch string) string { + m := versionInBranchRe.FindStringSubmatch(branch) + if len(m) < 3 { + return "" + } + return m[1] + "." + m[2] +} + +// backportHeadBranch is the branch sorenlouv creates for a backport: +// backport//pr-. +func backportHeadBranch(targetBranch string, sourcePR int) string { + return fmt.Sprintf("backport/%s/pr-%d", targetBranch, sourcePR) +} + +// titleMatchesVersion reports whether a sub-issue title carries the release-line +// prefix for version, e.g. "[0.34] Copy of ENGCP-906" or "[v0.34] ...". +func titleMatchesVersion(title, version string) bool { + if version == "" { + return false + } + re := regexp.MustCompile(`^\s*\[v?` + regexp.QuoteMeta(version) + `\]`) + return re.MatchString(title) +} + +// matchSubIssue returns the first candidate whose title matches the version. +func matchSubIssue(candidates []issueRef, version string) (issueRef, bool) { + for _, c := range candidates { + if titleMatchesVersion(c.Title, version) { + return c, true + } + } + return issueRef{}, false +} + +// familyCandidates flattens an issue family into the set of issues that may +// carry a release-line prefix: the family root plus its direct children. When +// the attached issue has a parent, the root is that parent and the children are +// the attached issue's siblings (which is where the [X.Y] copies live). +func familyCandidates(f issueFamily) []issueRef { + if f.Parent != nil { + out := []issueRef{f.Parent.issueRef} + return append(out, f.Parent.Children.Nodes...) + } + out := []issueRef{f.issueRef} + return append(out, f.Children.Nodes...) +} + +var fixesRe = regexp.MustCompile(`(?i)\b(fix(es|ed)?|close[sd]?|resolve[sd]?)\s+#?`) + +// bodyReferencesIssue reports whether a PR body already closes/fixes/resolves +// the given Linear identifier, so the tool stays idempotent across re-runs. +func bodyReferencesIssue(body, identifier string) bool { + re := regexp.MustCompile(fixesRe.String() + regexp.QuoteMeta(identifier) + `\b`) + return re.MatchString(body) +} + +// appendFixes adds a "Fixes " line to a PR body. +func appendFixes(body, identifier string) string { + trimmed := strings.TrimRight(body, "\n") + if trimmed == "" { + return "Fixes " + identifier + } + return trimmed + "\n\nFixes " + identifier +} + +// findBackportPR returns the backport PR with the expected head branch, or nil +// if none exists yet. +func findBackportPR(ctx context.Context, gh *github.Client, owner, repo, headBranch, baseBranch string) (*github.PullRequest, error) { + opts := &github.PullRequestListOptions{ + State: "all", + Head: owner + ":" + headBranch, + Base: baseBranch, + ListOptions: github.ListOptions{PerPage: 20}, + } + prs, _, err := gh.PullRequests.List(ctx, owner, repo, opts) + if err != nil { + return nil, err + } + for _, pr := range prs { + if pr.GetHead().GetRef() == headBranch { + return pr, nil + } + } + return nil, nil +} + +var identifierRe = regexp.MustCompile(`(?i)\b([A-Z][A-Z0-9]+)-(\d+)\b`) + +// extractIdentifier returns the first Linear-looking identifier (TEAM-123) found +// across the given strings, uppercased. +func extractIdentifier(strs ...string) string { + for _, s := range strs { + if m := identifierRe.FindStringSubmatch(s); len(m) == 3 { + return strings.ToUpper(m[1]) + "-" + m[2] + } + } + return "" +} + +// resolveFamily finds the Linear issue family for a source PR: first via +// Linear's reverse attachment lookup on the PR URL, then by parsing an +// identifier from the branch name or body as a fallback. +func resolveFamily(token string, src *github.PullRequest) *issueFamily { + if url := src.GetHTMLURL(); url != "" { + if f, err := getFamilyByURL(token, url); err != nil { + warnf("attachmentsForURL lookup failed: %v", err) + } else if f != nil { + return f + } + } + id := extractIdentifier(src.GetHead().GetRef(), src.GetBody()) + if id == "" { + return nil + } + noticef("falling back to identifier %s parsed from branch/body", id) + f, err := getFamilyByID(token, id) + if err != nil { + warnf("issue lookup for %s failed: %v", id, err) + return nil + } + return f +} + +func getFamilyByURL(token, url string) (*issueFamily, error) { + const q = `query($url: String!) { + attachmentsForURL(url: $url) { + nodes { issue { + id identifier title + children { nodes { id identifier title } } + parent { id identifier title children { nodes { id identifier title } } } + } } + } +}` + var resp struct { + Data struct { + AttachmentsForURL struct { + Nodes []struct { + Issue issueFamily `json:"issue"` + } `json:"nodes"` + } `json:"attachmentsForURL"` + } `json:"data"` + Errors []struct { + Message string `json:"message"` + } `json:"errors"` + } + if err := linearGraphQL(token, q, map[string]any{"url": url}, &resp); err != nil { + return nil, err + } + if len(resp.Errors) > 0 { + return nil, fmt.Errorf("linear: %s", resp.Errors[0].Message) + } + for _, n := range resp.Data.AttachmentsForURL.Nodes { + if n.Issue.Identifier != "" { + f := n.Issue + return &f, nil + } + } + return nil, nil +} + +func getFamilyByID(token, id string) (*issueFamily, error) { + const q = `query($id: String!) { + issue(id: $id) { + id identifier title + children { nodes { id identifier title } } + parent { id identifier title children { nodes { id identifier title } } } + } +}` + var resp struct { + Data struct { + Issue issueFamily `json:"issue"` + } `json:"data"` + Errors []struct { + Message string `json:"message"` + } `json:"errors"` + } + if err := linearGraphQL(token, q, map[string]any{"id": id}, &resp); err != nil { + return nil, err + } + if len(resp.Errors) > 0 { + return nil, fmt.Errorf("linear: %s", resp.Errors[0].Message) + } + if resp.Data.Issue.Identifier == "" { + return nil, nil + } + f := resp.Data.Issue + return &f, nil +} + +func linearGraphQL(token, query string, variables map[string]any, out any) error { + payload, err := json.Marshal(map[string]any{"query": query, "variables": variables}) + if err != nil { + return err + } + req, err := http.NewRequest("POST", linearAPIURL, bytes.NewReader(payload)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", token) + + resp, err := (&http.Client{}).Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("linear status %d: %s", resp.StatusCode, string(body)) + } + return json.Unmarshal(body, out) +} + +func noticef(format string, args ...any) { + log.Printf("::notice::"+format, args...) +} + +func warnf(format string, args ...any) { + log.Printf("::warning::"+format, args...) +} diff --git a/.github/actions/link-backport-prs/src/main_test.go b/.github/actions/link-backport-prs/src/main_test.go new file mode 100644 index 0000000..861b433 --- /dev/null +++ b/.github/actions/link-backport-prs/src/main_test.go @@ -0,0 +1,167 @@ +package main + +import "testing" + +func TestVersionFromBranch(t *testing.T) { + cases := map[string]string{ + "v0.34": "0.34", + "v0.35": "0.35", + "0.34": "0.34", + "release-4.2": "4.2", + "release-v4.2": "4.2", + "main": "", + "": "", + "v1": "", + } + for in, want := range cases { + if got := versionFromBranch(in); got != want { + t.Errorf("versionFromBranch(%q) = %q, want %q", in, got, want) + } + } +} + +func TestBackportHeadBranch(t *testing.T) { + if got := backportHeadBranch("v0.34", 3993); got != "backport/v0.34/pr-3993" { + t.Errorf("got %q", got) + } + if got := backportHeadBranch("release-4.2", 12); got != "backport/release-4.2/pr-12" { + t.Errorf("got %q", got) + } +} + +func TestTitleMatchesVersion(t *testing.T) { + cases := []struct { + title, version string + want bool + }{ + {"[0.34] Copy of ENGCP-906", "0.34", true}, // real-world format, no v + {"[v0.34] Copy of ENGCP-906", "0.34", true}, // spec format, with v + {" [0.34] padded", "0.34", true}, + {"[0.35] Copy of ENGCP-906", "0.34", false}, + {"[0.3] something", "0.34", false}, // prefix must not partial-match + {"[0.34] x", "0.3", false}, // 0.3 must not match [0.34] + {"Copy of ENGCP-906 [0.34]", "0.34", false}, // prefix must be at start + {"QA for ENGCP-906", "0.34", false}, + {"[0.34] x", "", false}, + } + for _, c := range cases { + if got := titleMatchesVersion(c.title, c.version); got != c.want { + t.Errorf("titleMatchesVersion(%q, %q) = %v, want %v", c.title, c.version, got, c.want) + } + } +} + +func TestMatchSubIssue(t *testing.T) { + candidates := []issueRef{ + {Identifier: "ENGCP-906", Title: "[Bug] HA vCluster ExternalSecrets CRD sync race"}, + {Identifier: "ENGCP-913", Title: "[0.34] Copy of ENGCP-906"}, + {Identifier: "ENGCP-943", Title: "[0.35] Copy of ENGCP-906"}, + {Identifier: "ENGQA-1102", Title: "QA for ENGCP-906"}, + } + got, ok := matchSubIssue(candidates, "0.34") + if !ok || got.Identifier != "ENGCP-913" { + t.Fatalf("0.34 -> %+v, ok=%v; want ENGCP-913", got, ok) + } + got, ok = matchSubIssue(candidates, "0.35") + if !ok || got.Identifier != "ENGCP-943" { + t.Fatalf("0.35 -> %+v, ok=%v; want ENGCP-943", got, ok) + } + if _, ok := matchSubIssue(candidates, "0.33"); ok { + t.Errorf("0.33 should not match any candidate") + } +} + +func TestFamilyCandidates(t *testing.T) { + // Attached issue is the parent: its own children are the [X.Y] copies. + parentAttached := issueFamily{ + issueWithChildren: issueWithChildren{ + issueRef: issueRef{Identifier: "ENGCP-906", Title: "[Bug] ..."}, + }, + } + parentAttached.Children.Nodes = []issueRef{ + {Identifier: "ENGCP-913", Title: "[0.34] Copy of ENGCP-906"}, + } + cands := familyCandidates(parentAttached) + if len(cands) != 2 || cands[0].Identifier != "ENGCP-906" || cands[1].Identifier != "ENGCP-913" { + t.Fatalf("parent-attached candidates = %+v", cands) + } + + // Attached issue is a sub-issue: the [X.Y] copies are siblings under parent. + child := issueWithChildren{issueRef: issueRef{Identifier: "ENGCP-940", Title: "[main] Copy of ENGCP-906"}} + parent := &issueWithChildren{issueRef: issueRef{Identifier: "ENGCP-906", Title: "[Bug] ..."}} + parent.Children.Nodes = []issueRef{ + {Identifier: "ENGCP-913", Title: "[0.34] Copy of ENGCP-906"}, + {Identifier: "ENGCP-940", Title: "[main] Copy of ENGCP-906"}, + } + subAttached := issueFamily{issueWithChildren: child, Parent: parent} + cands = familyCandidates(subAttached) + if got, ok := matchSubIssue(cands, "0.34"); !ok || got.Identifier != "ENGCP-913" { + t.Fatalf("sibling match = %+v ok=%v; want ENGCP-913", got, ok) + } +} + +func TestBodyReferencesIssue(t *testing.T) { + cases := []struct { + body, id string + want bool + }{ + {"Fixes ENGCP-913", "ENGCP-913", true}, + {"fixes ENGCP-913", "ENGCP-913", true}, + {"Closes ENGCP-913", "ENGCP-913", true}, + {"resolved ENGCP-913", "ENGCP-913", true}, + {"Fixes #ENGCP-913", "ENGCP-913", true}, + {"some text\nFixes ENGCP-913\nmore", "ENGCP-913", true}, + {"Fixes ENGCP-9131", "ENGCP-913", false}, // word boundary + {"mentions ENGCP-913 without keyword", "ENGCP-913", false}, + {"", "ENGCP-913", false}, + {"Fixes ENGCP-913", "ENGCP-943", false}, + } + for _, c := range cases { + if got := bodyReferencesIssue(c.body, c.id); got != c.want { + t.Errorf("bodyReferencesIssue(%q, %q) = %v, want %v", c.body, c.id, got, c.want) + } + } +} + +func TestAppendFixes(t *testing.T) { + if got := appendFixes("", "ENGCP-913"); got != "Fixes ENGCP-913" { + t.Errorf("empty body -> %q", got) + } + if got := appendFixes("Backport of #3993", "ENGCP-913"); got != "Backport of #3993\n\nFixes ENGCP-913" { + t.Errorf("got %q", got) + } + if got := appendFixes("Backport of #3993\n\n", "ENGCP-913"); got != "Backport of #3993\n\nFixes ENGCP-913" { + t.Errorf("trailing newlines -> %q", got) + } +} + +func TestExtractIdentifier(t *testing.T) { + if got := extractIdentifier("ENGCP-906"); got != "ENGCP-906" { + t.Errorf("branch-as-id -> %q", got) + } + if got := extractIdentifier("eng-906/some-slug"); got != "ENG-906" { + t.Errorf("lowercase branch -> %q", got) + } + if got := extractIdentifier("no ref here", "Closes DEVOPS-12 in body"); got != "DEVOPS-12" { + t.Errorf("body fallback -> %q", got) + } + if got := extractIdentifier("gwapi-p0s-v2", ""); got != "" { + t.Errorf("no identifier -> %q", got) + } +} + +func TestBackportTargetsFromNames(t *testing.T) { + got := backportTargetsFromNames( + []string{"backport-to-v0.34", "area/ci", "backport-to-v0.35", "backport-to-main"}, + "backport-to-", + ) + want := []string{"v0.34", "v0.35", "main"} + if len(got) != len(want) { + t.Fatalf("got %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("got %v, want %v", got, want) + } + } +} diff --git a/.github/actions/link-backport-prs/test.sh b/.github/actions/link-backport-prs/test.sh new file mode 100755 index 0000000..a9f6a1c --- /dev/null +++ b/.github/actions/link-backport-prs/test.sh @@ -0,0 +1,10 @@ +#!/bin/bash +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR/src" + +echo "Running tests..." +go test -v + +exit $? diff --git a/.github/workflows/backport.yaml b/.github/workflows/backport.yaml index 305a1d3..fe8ece6 100644 --- a/.github/workflows/backport.yaml +++ b/.github/workflows/backport.yaml @@ -6,6 +6,9 @@ on: gh-access-token: description: 'GitHub PAT for creating backport PRs' required: true + 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 jobs: backport: @@ -38,6 +41,21 @@ jobs: github_token: ${{ secrets.gh-access-token }} # zizmor: ignore[secrets-outside-env] -- PAT passed via workflow_call, not a repo secret auto_backport_label_prefix: backport-to- + # Link each backport PR to the matching Linear sub-issue ([X.Y] Copy of ...) + # so it closes on merge. Advisory: never fails the job, and is skipped when + # no linear-token is configured by the caller. Runs even if some backports + # hit merge conflicts (sorenlouv ignores those by default). + - name: Link backport PRs to Linear sub-issues + if: ${{ !cancelled() }} + continue-on-error: true + uses: loft-sh/github-actions/.github/actions/link-backport-prs@link-backport-prs/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. @link-backport-prs/v1 is the floating coordination tag kept aligned with backport/v1 (see DEVOPS-923 pin-drift class). + with: + source-pr: ${{ github.event.pull_request.number }} + repo-owner: ${{ github.repository_owner }} + repo-name: ${{ github.event.repository.name }} + github-token: ${{ secrets.gh-access-token }} + linear-token: ${{ secrets.linear-token }} + - name: Info log if: ${{ success() }} run: cat ~/.backport/backport.info.log diff --git a/.github/workflows/test-link-backport-prs.yaml b/.github/workflows/test-link-backport-prs.yaml new file mode 100644 index 0000000..689dae1 --- /dev/null +++ b/.github/workflows/test-link-backport-prs.yaml @@ -0,0 +1,28 @@ +name: Test link-backport-prs + +on: + push: + branches: [main] + paths: + - '.github/actions/link-backport-prs/**' + pull_request: + paths: + - '.github/actions/link-backport-prs/**' + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version-file: .github/actions/link-backport-prs/src/go.mod + - run: go test ./... + working-directory: .github/actions/link-backport-prs/src diff --git a/Makefile b/Makefile index e5e0aff..6fa043f 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: test test-semver-validation test-linear-pr-commenter test-release-notification test-linear-release-sync test-aws-test-infra test-cleanup-head-charts test-ci-test-notify test-auto-approve-bot-prs test-ai-pr-review test-ai-step test-publish-helm-chart test-govulncheck test-go-licenses test-run-ginkgo test-sticky-pr-comment test-repository-dispatch test-parse-label-filter build-linear-release-sync lint install-auto-doc generate-docs check-docs help +.PHONY: test test-semver-validation test-linear-pr-commenter test-link-backport-prs test-release-notification test-linear-release-sync test-aws-test-infra test-cleanup-head-charts test-ci-test-notify test-auto-approve-bot-prs test-ai-pr-review test-ai-step test-publish-helm-chart test-govulncheck test-go-licenses test-run-ginkgo test-sticky-pr-comment test-repository-dispatch test-parse-label-filter build-linear-release-sync lint install-auto-doc generate-docs check-docs help ACTIONS_DIR := .github/actions WORKFLOWS_DIR := .github/workflows @@ -93,7 +93,7 @@ check-docs: generate-docs ## verify docs are up to date (fails if drift detected help: ## show this help @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " %-30s %s\n", $$1, $$2}' -test: test-semver-validation test-linear-pr-commenter test-release-notification test-linear-release-sync test-aws-test-infra test-cleanup-head-charts test-auto-approve-bot-prs test-ai-pr-review test-ai-step test-ci-test-notify test-go-licenses test-publish-helm-chart test-govulncheck test-run-ginkgo test-sticky-pr-comment test-repository-dispatch test-parse-label-filter ## run all action tests +test: test-semver-validation test-linear-pr-commenter test-link-backport-prs test-release-notification test-linear-release-sync test-aws-test-infra test-cleanup-head-charts test-auto-approve-bot-prs test-ai-pr-review test-ai-step test-ci-test-notify test-go-licenses test-publish-helm-chart test-govulncheck test-run-ginkgo test-sticky-pr-comment test-repository-dispatch test-parse-label-filter ## run all action tests test-semver-validation: ## run semver-validation unit tests cd $(ACTIONS_DIR)/semver-validation && npm ci --silent && NODE_OPTIONS=--experimental-vm-modules npx jest --ci --coverage --watchAll=false @@ -101,6 +101,9 @@ test-semver-validation: ## run semver-validation unit tests test-linear-pr-commenter: ## run linear-pr-commenter unit tests cd $(ACTIONS_DIR)/linear-pr-commenter/src && go test -v ./... +test-link-backport-prs: ## run link-backport-prs unit tests + cd $(ACTIONS_DIR)/link-backport-prs/src && go test -v ./... + test-release-notification: ## run release-notification detect-branch tests bats $(ACTIONS_DIR)/release-notification/test/detect-branch.bats diff --git a/README.md b/README.md index 7f7086a..d2a800b 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,26 @@ Syncs Linear issues to the "Released" state when a GitHub release is published. See [linear-release-sync README](./.github/actions/linear-release-sync/README.md) for detailed documentation. +### Link Backport PRs Action + +Links sorenlouv-created backport PRs to the matching Linear sub-issue (`[X.Y] Copy of ...`) by adding `Fixes ` to the backport PR body, so the per-release-line issue closes when the backport merges. Wired into the [`backport.yaml`](./docs/workflows/backport.md) reusable workflow and runs automatically after backports are created; advisory and skipped when no `linear-token` is configured. + +**Location:** `.github/actions/link-backport-prs` + +**Usage:** + +```yaml +- uses: loft-sh/github-actions/.github/actions/link-backport-prs@link-backport-prs/v1 + with: + source-pr: ${{ github.event.pull_request.number }} + repo-owner: ${{ github.repository_owner }} + repo-name: ${{ github.event.repository.name }} + github-token: ${{ secrets.GH_ACCESS_TOKEN }} + linear-token: ${{ secrets.LINEAR_API_TOKEN }} +``` + +See [link-backport-prs README](./.github/actions/link-backport-prs/README.md) for detailed documentation. + ### Run Ginkgo Tests Runs Ginkgo tests with directory or label-based filtering and generates a @@ -627,6 +647,7 @@ the action's files change: - `test-semver-validation.yaml` - triggers on `.github/actions/semver-validation/**` - `test-linear-pr-commenter.yaml` - triggers on `.github/actions/linear-pr-commenter/**` +- `test-link-backport-prs.yaml` - triggers on `.github/actions/link-backport-prs/**` - `test-linear-release-sync.yaml` - triggers on `.github/actions/linear-release-sync/**` - `test-sticky-pr-comment.yaml` - triggers on `.github/actions/sticky-pr-comment/**` - `release-linear-release-sync.yaml` - builds and publishes the binary on tag push or `workflow_dispatch` diff --git a/docs/workflows/backport.md b/docs/workflows/backport.md index 1c88120..079cf72 100644 --- a/docs/workflows/backport.md +++ b/docs/workflows/backport.md @@ -12,8 +12,9 @@ No inputs. -| SECRET | REQUIRED | DESCRIPTION | -|-----------------|----------|--------------------------------------| -| gh-access-token | true | GitHub PAT for creating backport PRs | +| 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. | diff --git a/renovate.json b/renovate.json index f544481..9282d89 100644 --- a/renovate.json +++ b/renovate.json @@ -23,6 +23,12 @@ "matchManagers": ["gomod"], "matchFileNames": [".github/actions/linear-release-sync/**"], "groupName": "linear-release-sync" + }, + { + "description": "Group link-backport-prs Go dependencies", + "matchManagers": ["gomod"], + "matchFileNames": [".github/actions/link-backport-prs/**"], + "groupName": "link-backport-prs" } ], "customManagers": [