Skip to content

Commit 00cd9b2

Browse files
authored
Merge pull request #5018 from camilamacedo86/open-pr
✨ (alpha update) Add option to allow open GitHub Issues after updates
2 parents 2a372d8 + d036bdd commit 00cd9b2

File tree

7 files changed

+264
-18
lines changed

7 files changed

+264
-18
lines changed

pkg/cli/alpha/internal/update/prepare.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ import (
2727
"sigs.k8s.io/kubebuilder/v4/pkg/config/store"
2828
)
2929

30+
const defaultBranch = "main"
31+
3032
type releaseResponse struct {
3133
TagName string `json:"tag_name"`
3234
}
@@ -37,7 +39,7 @@ func (opts *Update) Prepare() error {
3739
if opts.FromBranch == "" {
3840
// TODO: Check if is possible to use get to determine the default branch
3941
log.Warn("No --from-branch specified, using 'main' as default")
40-
opts.FromBranch = "main"
42+
opts.FromBranch = defaultBranch
4143
}
4244

4345
path, err := common.GetInputPath("")

pkg/cli/alpha/internal/update/prepare_test.go

Lines changed: 113 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ limitations under the License.
1717
package update
1818

1919
import (
20+
"fmt"
2021
"os"
2122
"path/filepath"
2223

@@ -35,18 +36,30 @@ var _ = Describe("Prepare for internal update", func() {
3536
tmpDir string
3637
workDir string
3738
projectFile string
39+
mockGh string
3840
err error
41+
42+
logFile string
43+
oldPath string
44+
opts Update
3945
)
4046

4147
BeforeEach(func() {
4248
workDir, err = os.Getwd()
4349
Expect(err).ToNot(HaveOccurred())
4450

51+
// 1) Create tmp dir and chdir first
4552
tmpDir, err = os.MkdirTemp("", "kubebuilder-prepare-test")
4653
Expect(err).ToNot(HaveOccurred())
4754
err = os.Chdir(tmpDir)
4855
Expect(err).ToNot(HaveOccurred())
4956

57+
// 2) Now that tmpDir exists, set logFile and PATH
58+
logFile = filepath.Join(tmpDir, "bin.log")
59+
60+
oldPath = os.Getenv("PATH")
61+
Expect(os.Setenv("PATH", tmpDir+string(os.PathListSeparator)+oldPath)).To(Succeed())
62+
5063
projectFile = filepath.Join(tmpDir, yaml.DefaultPath)
5164

5265
config.Register(config.Version{Number: 3}, func() config.Config {
@@ -56,12 +69,25 @@ var _ = Describe("Prepare for internal update", func() {
5669
gock.New("https://api.github.com").
5770
Get("/repos/kubernetes-sigs/kubebuilder/releases/latest").
5871
Reply(200).
59-
JSON(map[string]string{
60-
"tag_name": "v1.1.0",
61-
})
72+
JSON(map[string]string{"tag_name": "v1.1.0"})
73+
74+
// 3) Create the mock gh inside tmpDir (on PATH)
75+
mockGh = filepath.Join(tmpDir, "gh")
76+
ghOK := `#!/bin/bash
77+
echo "$@" >> "` + logFile + `"
78+
if [[ "$1" == "repo" && "$2" == "view" ]]; then
79+
echo "acme/repo"
80+
exit 0
81+
fi
82+
if [[ "$1" == "issue" && "$2" == "create" ]]; then
83+
exit 0
84+
fi
85+
exit 0`
86+
Expect(mockBinResponse(ghOK, mockGh)).To(Succeed())
6287
})
6388

6489
AfterEach(func() {
90+
Expect(os.Setenv("PATH", oldPath)).To(Succeed())
6591
err = os.Chdir(workDir)
6692
Expect(err).ToNot(HaveOccurred())
6793

@@ -102,7 +128,7 @@ var _ = Describe("Prepare for internal update", func() {
102128
const version = ""
103129
Expect(os.WriteFile(projectFile, []byte(version), 0o644)).To(Succeed())
104130

105-
err := options.Prepare()
131+
err = options.Prepare()
106132
Expect(err).To(HaveOccurred())
107133
Expect(err.Error()).Should(ContainSubstring("failed to load PROJECT config"))
108134
},
@@ -129,10 +155,10 @@ var _ = Describe("Prepare for internal update", func() {
129155
const version = `version: "3"`
130156
Expect(os.WriteFile(projectFile, []byte(version), 0o644)).To(Succeed())
131157

132-
config, err := common.LoadProjectConfig(tmpDir)
133-
Expect(err).ToNot(HaveOccurred())
134-
fromVersion, err := options.defineFromVersion(config)
135-
Expect(err).ToNot(HaveOccurred())
158+
config, errLoad := common.LoadProjectConfig(tmpDir)
159+
Expect(errLoad).ToNot(HaveOccurred())
160+
fromVersion, errLoad := options.defineFromVersion(config)
161+
Expect(errLoad).ToNot(HaveOccurred())
136162
Expect(fromVersion).To(BeEquivalentTo("v1.0.0"))
137163
},
138164
Entry("options", &Update{FromVersion: ""}),
@@ -147,11 +173,11 @@ var _ = Describe("Prepare for internal update", func() {
147173
const version = `version: "3"`
148174
Expect(os.WriteFile(projectFile, []byte(version), 0o644)).To(Succeed())
149175

150-
config, err := common.LoadProjectConfig(tmpDir)
151-
Expect(err).NotTo(HaveOccurred())
152-
fromVersion, err := options.defineFromVersion(config)
153-
Expect(err).To(HaveOccurred())
154-
Expect(err.Error()).To(ContainSubstring("no version specified in PROJECT file"))
176+
config, errLoad := common.LoadProjectConfig(tmpDir)
177+
Expect(errLoad).NotTo(HaveOccurred())
178+
fromVersion, errLoad := options.defineFromVersion(config)
179+
Expect(errLoad).To(HaveOccurred())
180+
Expect(errLoad.Error()).To(ContainSubstring("no version specified in PROJECT file"))
155181
Expect(fromVersion).To(Equal(""))
156182
},
157183
Entry("options", &Update{FromVersion: ""}),
@@ -169,4 +195,78 @@ var _ = Describe("Prepare for internal update", func() {
169195
Entry("options", &Update{}),
170196
)
171197
})
198+
199+
Context("OpenGitHubIssue", func() {
200+
It("creates issue without conflicts", func() {
201+
opts.FromBranch = defaultBranch
202+
opts.FromVersion = "v4.5.1"
203+
opts.ToVersion = "v4.8.0"
204+
205+
err = opts.openGitHubIssue(false)
206+
Expect(err).ToNot(HaveOccurred())
207+
208+
logs, readErr := os.ReadFile(logFile)
209+
Expect(readErr).ToNot(HaveOccurred())
210+
s := string(logs)
211+
212+
Expect(s).To(ContainSubstring("repo view --json nameWithOwner --jq .nameWithOwner"))
213+
Expect(s).To(ContainSubstring("issue create"))
214+
215+
expURL := fmt.Sprintf("https://github.com/%s/compare/%s...%s?expand=1",
216+
"acme/repo", opts.FromBranch, opts.getOutputBranchName())
217+
Expect(s).To(ContainSubstring(expURL))
218+
Expect(s).To(ContainSubstring(opts.ToVersion))
219+
Expect(s).To(ContainSubstring(opts.FromVersion))
220+
})
221+
222+
It("creates issue with conflicts template", func() {
223+
opts.FromBranch = defaultBranch
224+
opts.FromVersion = "v4.5.2"
225+
opts.ToVersion = "v4.10.0"
226+
227+
err = opts.openGitHubIssue(true)
228+
Expect(err).ToNot(HaveOccurred())
229+
230+
logs, _ := os.ReadFile(logFile)
231+
s := string(logs)
232+
Expect(s).To(ContainSubstring("Resolve conflicts"))
233+
Expect(s).To(ContainSubstring("make manifests generate fmt vet lint-fix"))
234+
})
235+
236+
It("fails when repo detection fails", func() {
237+
failRepo := `#!/bin/bash
238+
echo "$@" >> "` + logFile + `"
239+
if [[ "$1" == "repo" && "$2" == "view" ]]; then
240+
exit 1
241+
fi
242+
exit 0`
243+
Expect(mockBinResponse(failRepo, mockGh)).To(Succeed())
244+
245+
err = opts.openGitHubIssue(false)
246+
Expect(err).To(HaveOccurred())
247+
Expect(err.Error()).To(ContainSubstring("failed to detect GitHub repository"))
248+
})
249+
250+
It("fails when issue creation fails", func() {
251+
failIssue := `#!/bin/bash
252+
echo "$@" >> "` + logFile + `"
253+
if [[ "$1" == "repo" && "$2" == "view" ]]; then
254+
echo "acme/repo"
255+
exit 0
256+
fi
257+
if [[ "$1" == "issue" && "$2" == "create" ]]; then
258+
exit 1
259+
fi
260+
exit 0`
261+
Expect(mockBinResponse(failIssue, mockGh)).To(Succeed())
262+
263+
opts.FromBranch = defaultBranch
264+
opts.FromVersion = "v4.5.0"
265+
opts.ToVersion = "v4.6.0"
266+
267+
err = opts.openGitHubIssue(false)
268+
Expect(err).To(HaveOccurred())
269+
Expect(err.Error()).To(ContainSubstring("failed to create GitHub Issue"))
270+
})
271+
})
172272
})

pkg/cli/alpha/internal/update/update.go

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,12 @@ type Update struct {
7171
// Push, when true, pushes the OutputBranch to the "origin" remote after the update completes.
7272
Push bool
7373

74+
// OpenGhIssue, when true, automatically creates a GitHub issue after the update
75+
// completes. The issue includes a pre-filled checklist and a compare link from
76+
// the base branch (--from-branch) to the output branch. This requires the GitHub
77+
// CLI (`gh`) to be installed and authenticated in the local environment.
78+
OpenGhIssue bool
79+
7480
// Temporary branches created during the update process. These are internal to the run
7581
// and are surfaced for transparency/debugging:
7682
// - AncestorBranch: clean scaffold generated from FromVersion
@@ -83,6 +89,73 @@ type Update struct {
8389
MergeBranch string
8490
}
8591

92+
const issueTitleTmpl = "[Action Required] Upgrade the Scaffold: %[2]s -> %[1]s"
93+
94+
//nolint:lll
95+
const issueBodyTmpl = `## Description
96+
97+
Upgrade your project to use the latest scaffold changes introduced in Kubebuilder [%[1]s](https://github.com/kubernetes-sigs/kubebuilder/releases/tag/%[1]s).
98+
99+
See the release notes from [%[3]s](https://github.com/kubernetes-sigs/kubebuilder/releases/tag/%[3]s) to [%[1]s](https://github.com/kubernetes-sigs/kubebuilder/releases/tag/%[1]s) for details about the changes included in this upgrade.
100+
101+
## What to do
102+
103+
A scheduled workflow already attempted this upgrade and created the branch %[4]s to help you in this process.
104+
105+
Create a Pull Request using the URL below to review the changes:
106+
%[2]s
107+
108+
## Next steps
109+
110+
Verify the changes
111+
- Build the project
112+
- Run tests
113+
- Confirm everything still works
114+
115+
:book: **More info:** https://kubebuilder.io/reference/commands/alpha_update
116+
`
117+
118+
//nolint:lll
119+
const issueBodyTmplWithConflicts = `## Description
120+
121+
Upgrade your project to use the latest scaffold changes introduced in Kubebuilder [%[1]s](https://github.com/kubernetes-sigs/kubebuilder/releases/tag/%[1]s).
122+
123+
See the release notes from [%[3]s](https://github.com/kubernetes-sigs/kubebuilder/releases/tag/%[3]s) to [%[1]s](https://github.com/kubernetes-sigs/kubebuilder/releases/tag/%[1]s) for details about the changes included in this upgrade.
124+
125+
## What to do
126+
127+
A scheduled workflow already attempted this upgrade and created the branch (%[4]s) to help you in this process.
128+
129+
:warning: **Conflicts were detected during the merge.**
130+
131+
Create a Pull Request using the URL below to review the changes and resolve conflicts manually:
132+
%[2]s
133+
134+
## Next steps
135+
136+
### 1. Resolve conflicts
137+
After fixing conflicts, run:
138+
~~~bash
139+
make manifests generate fmt vet lint-fix
140+
~~~
141+
142+
### 2. Optional: work on a new branch
143+
To apply the update in a clean branch, run:
144+
~~~bash
145+
kubebuilder alpha update --output-branch my-fix-branch
146+
~~~
147+
148+
This will create a new branch (my-fix-branch) with the update applied.
149+
Resolve conflicts there, complete the merge locally, and push the branch.
150+
151+
### 3. Verify the changes
152+
- Build the project
153+
- Run tests
154+
- Confirm everything still works
155+
156+
:book: **More info:** https://kubebuilder.io/reference/commands/alpha_update
157+
`
158+
86159
// Update a project using a default three-way Git merge.
87160
// This helps apply new scaffolding changes while preserving custom code.
88161
func (opts *Update) Update() error {
@@ -170,6 +243,68 @@ func (opts *Update) Update() error {
170243
opts.cleanupTempBranches()
171244
log.Info("Update completed successfully")
172245

246+
if opts.OpenGhIssue {
247+
if err := opts.openGitHubIssue(hasConflicts); err != nil {
248+
return fmt.Errorf("failed to open GitHub issue: %w", err)
249+
}
250+
}
251+
252+
return nil
253+
}
254+
255+
func (opts *Update) openGitHubIssue(hasConflicts bool) error {
256+
log.Info("Creating GitHub Issue to track the need to update the project")
257+
out := opts.getOutputBranchName()
258+
repoCmd := exec.Command("gh", "repo", "view", "--json",
259+
"nameWithOwner", "--jq", ".nameWithOwner")
260+
repoBytes, err := repoCmd.Output()
261+
if err != nil {
262+
return fmt.Errorf("failed to detect GitHub repository via `gh repo view`: %s", err)
263+
}
264+
265+
repo := strings.TrimSpace(string(repoBytes))
266+
createPRURL := fmt.Sprintf("https://github.com/%s/compare/%s...%s?expand=1",
267+
repo, opts.FromBranch, out)
268+
269+
title := fmt.Sprintf(issueTitleTmpl, opts.ToVersion, opts.FromVersion)
270+
271+
// check if an issue with the same title already exists
272+
checkCmd := exec.Command("gh", "issue", "list",
273+
"--search", fmt.Sprintf("in:title \"%s\"", title),
274+
"--json", "title")
275+
checkOut, checkErr := checkCmd.Output()
276+
if checkErr == nil && strings.Contains(string(checkOut), title) {
277+
log.Info("GitHub Issue already exists, skipping creation", "title", title)
278+
return nil
279+
}
280+
281+
var body string
282+
if hasConflicts {
283+
body = fmt.Sprintf(issueBodyTmplWithConflicts,
284+
opts.ToVersion, // %[1]s -> ToVersion
285+
createPRURL, // %[2]s -> PR compare URL
286+
opts.FromVersion, // %[3]s -> FromVersion
287+
out, // %[4]s -> OutputBranch
288+
)
289+
} else {
290+
body = fmt.Sprintf(issueBodyTmpl,
291+
opts.ToVersion, // %[1]s -> ToVersion
292+
createPRURL, // %[2]s -> PR compare URL
293+
opts.FromVersion, // %[3]s -> FromVersion
294+
out, // %[4]s -> OutputBranch
295+
)
296+
}
297+
298+
issueCmd := exec.Command("gh", "issue", "create",
299+
"--title", title,
300+
"--body", body,
301+
)
302+
issueCmd.Stdout = os.Stdout
303+
issueCmd.Stderr = os.Stderr
304+
if err := issueCmd.Run(); err != nil {
305+
return fmt.Errorf("failed to create GitHub Issue: %s", err)
306+
}
307+
log.Info("GitHub Issue created to track the update", "pr", createPRURL)
173308
return nil
174309
}
175310

pkg/cli/alpha/internal/update/update_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ var _ = Describe("Prepare for internal update", func() {
7272
opts = Update{
7373
FromVersion: "v4.5.0",
7474
ToVersion: "v4.6.0",
75-
FromBranch: "main",
75+
FromBranch: defaultBranch,
7676
}
7777

7878
// Create temporary directory to house fake bin executables.
@@ -444,7 +444,7 @@ exit 0`
444444

445445
Context("SquashToOutputBranch", func() {
446446
BeforeEach(func() {
447-
opts.FromBranch = "main"
447+
opts.FromBranch = defaultBranch
448448
opts.FromVersion = "v4.5.0"
449449
opts.ToVersion = "v4.6.0"
450450
if opts.MergeBranch == "" {

pkg/cli/alpha/internal/update/validate.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,14 @@ func (opts *Update) Validate() error {
4949
if err := validateReleaseAvailability(opts.ToVersion); err != nil {
5050
return fmt.Errorf("unable to find release %s: %w", opts.ToVersion, err)
5151
}
52+
53+
if opts.OpenGhIssue {
54+
if err := exec.Command("gh", "--version").Run(); err != nil {
55+
return fmt.Errorf("`gh` CLI not found or not authenticated. "+
56+
"You must have gh instaled to use the --open-gh-issue option: %s", err)
57+
}
58+
}
59+
5260
return nil
5361
}
5462

0 commit comments

Comments
 (0)