Skip to content

Commit 71aedb2

Browse files
mergify[bot]mrodmclaude
authored
[sync-changelog] Assign sync PRs and always run from main (#21243) (#21388)
[sync-changelog] Assign sync PR to the backport PR author or merger When creating the changelog sync PR, resolve the assignee from the originating backport PR: use the PR author if they are an elastic org member, otherwise fall back to whoever merged the PR. If the lookup fails for any reason, the --assignee flag is omitted so PR creation is not blocked. [sync-changelog] Always load action and binary from main Replace the local ./.github/actions/sync-backport-changelog reference with elastic/integrations/.github/actions/sync-backport-changelog@main in both the push and issue_comment jobs, so the action definition is always fetched from main regardless of which backport branch triggered the run. Add an explicit checkout of main inside the action so the Go binary is also always built from main's cmd/backport, not from a potentially older backport branch. [sync-changelog] Use bot flag and write access to resolve sync PR assignee Replace the elastic org membership check (which required read:org scope not available on GITHUB_TOKEN) with a collaborator permission check via GET /repos/{repo}/collaborators/{login}/permission, which works with the existing contents:write + pull-requests:write permissions. Introduce prActor{Login, IsBot} parsed directly from the gh pr view JSON so bot detection uses the API's own is_bot flag rather than string matching. pickAssignee now guards both the author and mergedBy paths: - Use author if not a bot and has write/maintain/admin access. - Fall back to mergedBy if not a bot. - Return empty string (skip --assignee) if neither qualifies. --------- (cherry picked from commit 6bba11e) Co-authored-by: Mario Rodriguez Molins <mario.rodriguez@elastic.co> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 5418dd4 commit 71aedb2

4 files changed

Lines changed: 99 additions & 15 deletions

File tree

‎.github/actions/sync-backport-changelog/action.yml‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,14 @@ inputs:
2525
runs:
2626
using: composite
2727
steps:
28+
- uses: actions/checkout@v7
29+
with:
30+
ref: main
31+
fetch-depth: 0
32+
2833
- name: Set up Go
2934
uses: actions/setup-go@v7
3035
with:
31-
# Use the backport tool's own Go version (pinned to main's version)
32-
# so it stays consistent regardless of which backport branch this runs on.
3336
go-version-file: cmd/backport/.go-version
3437

3538
- name: Build backport tool

‎.github/workflows/sync-backport-changelog.yml‎

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,7 @@ jobs:
2828
pull-requests: write # create sync PR and comment on backport PR
2929

3030
steps:
31-
- uses: actions/checkout@v7
32-
with:
33-
fetch-depth: 0
34-
35-
- uses: ./.github/actions/sync-backport-changelog
31+
- uses: elastic/integrations/.github/actions/sync-backport-changelog@main
3632
with:
3733
before: ${{ github.event.before }}
3834
after: ${{ github.event.after }}
@@ -112,12 +108,7 @@ jobs:
112108
echo "backport_branch=$BASE" >> "$GITHUB_OUTPUT"
113109
echo "pr_number=$ISSUE_NUMBER" >> "$GITHUB_OUTPUT"
114110
115-
- uses: actions/checkout@v7
116-
if: steps.resolve.outputs.pr_number != ''
117-
with:
118-
fetch-depth: 0
119-
120-
- uses: ./.github/actions/sync-backport-changelog
111+
- uses: elastic/integrations/.github/actions/sync-backport-changelog@main
121112
if: steps.resolve.outputs.pr_number != ''
122113
with:
123114
before: ${{ steps.resolve.outputs.before }}

‎cmd/backport/backports/changelog/sync.go‎

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ package changelog
66

77
import (
88
"bufio"
9+
"encoding/json"
910
"fmt"
1011
"os"
1112
"path/filepath"
@@ -95,15 +96,20 @@ func CreateSyncPR(workDir, entriesTSV, workingBranch, backportPRNumber, backport
9596

9697
body := fmt.Sprintf("Automated changelog sync from backport PR #%s.\n\nGenerated by the sync-backport-changelog workflow.", backportPRNumber)
9798

98-
_, _, err = gh.Exec("pr", "create",
99+
prArgs := []string{
100+
"pr", "create",
99101
"--base", "main",
100102
"--head", workingBranch,
101103
"--title", prTitle,
102104
"--label", "backport:sync-changelog",
103105
"--label", "changelog-link-check:skip",
104106
"--reviewer", "elastic/ecosystem",
105107
"--body", body,
106-
)
108+
}
109+
if assignee := resolveAssignee(backportPRNumber, repository); assignee != "" {
110+
prArgs = append(prArgs, "--assignee", assignee)
111+
}
112+
_, _, err = gh.Exec(prArgs...)
107113
if err != nil {
108114
return nil, fmt.Errorf("creating PR: %w", err)
109115
}
@@ -112,6 +118,55 @@ func CreateSyncPR(workDir, entriesTSV, workingBranch, backportPRNumber, backport
112118
return &SyncResult{NotFoundPackages: notFound, Outcome: "success"}, nil
113119
}
114120

121+
type prActor struct {
122+
Login string `json:"login"`
123+
IsBot bool `json:"is_bot"`
124+
}
125+
126+
// resolveAssignee returns the login that should be set as assignee on the sync
127+
// PR. It prefers the backport PR author when they are not a bot and have repo
128+
// write access, and falls back to whoever merged the PR when they are not a
129+
// bot. Returns an empty string on any lookup failure so the caller can skip
130+
// the --assignee flag gracefully.
131+
func resolveAssignee(prNumber, repository string) string {
132+
stdout, _, err := gh.Exec("pr", "view", prNumber, "--repo", repository,
133+
"--json", "author,mergedBy")
134+
if err != nil {
135+
return ""
136+
}
137+
var data struct {
138+
Author prActor `json:"author"`
139+
MergedBy prActor `json:"mergedBy"`
140+
}
141+
if err := json.Unmarshal(stdout.Bytes(), &data); err != nil {
142+
return ""
143+
}
144+
hasWriteAccess := func(login string) bool {
145+
out, _, err := gh.Exec("api",
146+
fmt.Sprintf("repos/%s/collaborators/%s/permission", repository, login),
147+
"--jq", ".permission")
148+
if err != nil {
149+
return false
150+
}
151+
perm := strings.TrimSpace(out.String())
152+
return perm == "write" || perm == "maintain" || perm == "admin"
153+
}
154+
return pickAssignee(data.Author, data.MergedBy, hasWriteAccess)
155+
}
156+
157+
// pickAssignee returns the author login when the author is not a bot and has
158+
// repo write access. Falls back to mergedBy when the author check fails, as
159+
// long as mergedBy is not a bot. Returns empty string if neither qualifies.
160+
func pickAssignee(author, mergedBy prActor, hasWriteAccess func(string) bool) string {
161+
if !author.IsBot && author.Login != "" && hasWriteAccess(author.Login) {
162+
return author.Login
163+
}
164+
if !mergedBy.IsBot && mergedBy.Login != "" {
165+
return mergedBy.Login
166+
}
167+
return ""
168+
}
169+
115170
type tsvEntry struct{ pkg, version, entryFile string }
116171

117172
func readEntriesTSV(path string) ([]tsvEntry, error) {

‎cmd/backport/backports/changelog/sync_test.go‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,3 +84,38 @@ func TestBuildPRTitle(t *testing.T) {
8484
entries := []tsvEntry{{pkg: "gcp", version: "3.1.0", entryFile: "/tmp/e.yml"}}
8585
assert.Equal(t, buildCommitMessage(entries, "99"), buildPRTitle(entries, "99"))
8686
}
87+
88+
func TestPickAssignee(t *testing.T) {
89+
yes := func(string) bool { return true }
90+
no := func(string) bool { return false }
91+
92+
t.Run("author is not bot and has write access — use author", func(t *testing.T) {
93+
assert.Equal(t, "author", pickAssignee(
94+
prActor{Login: "author", IsBot: false},
95+
prActor{Login: "merger", IsBot: false}, yes))
96+
})
97+
98+
t.Run("author is bot — fall back to mergedBy", func(t *testing.T) {
99+
assert.Equal(t, "merger", pickAssignee(
100+
prActor{Login: "app/some-bot", IsBot: true},
101+
prActor{Login: "merger", IsBot: false}, yes))
102+
})
103+
104+
t.Run("author has no write access (external contributor) — fall back to mergedBy", func(t *testing.T) {
105+
assert.Equal(t, "merger", pickAssignee(
106+
prActor{Login: "external-contributor", IsBot: false},
107+
prActor{Login: "merger", IsBot: false}, no))
108+
})
109+
110+
t.Run("author is bot and mergedBy is bot — return empty string", func(t *testing.T) {
111+
assert.Equal(t, "", pickAssignee(
112+
prActor{Login: "app/bot-a", IsBot: true},
113+
prActor{Login: "app/bot-b", IsBot: true}, yes))
114+
})
115+
116+
t.Run("author is bot and mergedBy is empty — return empty string", func(t *testing.T) {
117+
assert.Equal(t, "", pickAssignee(
118+
prActor{Login: "app/bot", IsBot: true},
119+
prActor{}, yes))
120+
})
121+
}

0 commit comments

Comments
 (0)