From 99605e08d9eeeffbbe48249a5c514cf129d49bb5 Mon Sep 17 00:00:00 2001 From: GavinPJK Date: Wed, 18 Dec 2024 10:43:58 +0000 Subject: [PATCH] feat: assignable downstream commits --- pkg/apis/updatebot/v1alpha1/types.go | 6 + pkg/cmd/pr/pr.go | 316 ++++++++++++------ pkg/cmd/pr/pr_test.go | 71 +++- .../test_data/assignauthor/.jx/updatebot.yaml | 19 ++ 4 files changed, 317 insertions(+), 95 deletions(-) create mode 100644 pkg/cmd/pr/test_data/assignauthor/.jx/updatebot.yaml diff --git a/pkg/apis/updatebot/v1alpha1/types.go b/pkg/apis/updatebot/v1alpha1/types.go index 12b2b10..2bc1431 100644 --- a/pkg/apis/updatebot/v1alpha1/types.go +++ b/pkg/apis/updatebot/v1alpha1/types.go @@ -48,6 +48,12 @@ type Rule struct { // SparseCheckout governs if sparse checkout is made of repository. Only possible with regex and go changes. // Note: Not all git servers support this. SparseCheckout bool `json:"sparseCheckout,omitempty"` + + // PullRequestAssignees + PullRequestAssignees []string `json:"pullRequestAssignees,omitempty"` + + // AssignAuthorToPullRequests governs if downstream pull requests are automatically assigned to the upstream author + AssignAuthorToPullRequests bool `json:"assignAuthorToPullRequests,omitempty"` } // Change the kind of change to make on a repository diff --git a/pkg/cmd/pr/pr.go b/pkg/cmd/pr/pr.go index c1d4c37..b6a2bb7 100644 --- a/pkg/cmd/pr/pr.go +++ b/pkg/cmd/pr/pr.go @@ -1,7 +1,9 @@ package pr import ( + "context" "fmt" + "github.com/jenkins-x/go-scm/scm" "os" "path/filepath" "strings" @@ -10,7 +12,6 @@ import ( "github.com/jenkins-x/jx-helpers/v3/pkg/gitclient" "github.com/jenkins-x/jx-helpers/v3/pkg/helmer" "github.com/jenkins-x/jx-helpers/v3/pkg/scmhelpers" - "github.com/jenkins-x/jx-helpers/v3/pkg/stringhelpers" "github.com/shurcooL/githubv4" "github.com/jenkins-x-plugins/jx-promote/pkg/environments" @@ -46,9 +47,11 @@ type Options struct { AddChangelog string GitCommitUsername string GitCommitUserEmail string + PipelineCommitSha string AutoMerge bool NoVersion bool GitCredentials bool + PRAssignees []string Labels []string TemplateData map[string]interface{} PullRequestSHAs map[string]string @@ -81,7 +84,9 @@ func NewCmdPullRequest() (*cobra.Command, *Options) { cmd.Flags().StringVar(&o.CommitMessage, "pull-request-body", "", "the PR body") cmd.Flags().StringVarP(&o.GitCommitUsername, "git-user-name", "", "", "the user name to git commit") cmd.Flags().StringVarP(&o.GitCommitUserEmail, "git-user-email", "", "", "the user email to git commit") + cmd.Flags().StringVarP(&o.PipelineCommitSha, "pipeline-commit-sha", "", os.Getenv("PULL_BASE_SHA"), "the git SHA of the commit that triggered the pipeline") cmd.Flags().StringSliceVar(&o.Labels, "labels", []string{}, "a list of labels to apply to the PR") + cmd.Flags().StringSliceVar(&o.PRAssignees, "pull-request-assign", []string{}, "Assignees of created PRs") cmd.Flags().BoolVarP(&o.AutoMerge, "auto-merge", "", true, "should we automatically merge if the PR pipeline is green") cmd.Flags().BoolVarP(&o.NoVersion, "no-version", "", false, "disables validation on requiring a '--version' option or environment variable to be required") cmd.Flags().BoolVarP(&o.GitCredentials, "git-credentials", "", false, "ensures the git credentials are setup so we can push to git") @@ -101,108 +106,38 @@ func (o *Options) Run() error { return fmt.Errorf("failed to validate: %w", err) } - if o.CommitMessage == "" || o.CommitTitle == "" || o.Application == "" { - // lets try discover the current git URL - if o.Application == "" || o.CommitMessage == "" { - gitURL, err := gitdiscovery.FindGitURLFromDir(o.Dir, true) - if err != nil { - log.Logger().Warnf("failed to find git URL %s", err.Error()) - } else if gitURL != "" { - if o.Application == "" { - gitURLpart := strings.Split(gitURL, "/") - o.Application = gitURLpart[len(gitURLpart)-2] + "/" + - strings.TrimSuffix(gitURLpart[len(gitURLpart)-1], ".git") - } - if o.CommitMessage == "" { - o.CommitMessage = fmt.Sprintf("from: %s\n", gitURL) - } - } - } - - if o.CommitTitle == "" { - if o.Application == "" { - o.CommitTitle = fmt.Sprintf("chore(deps): upgrade to version %s", o.Version) - } else { - o.CommitTitle = fmt.Sprintf("chore(deps): upgrade %s to version %s", o.Application, o.Version) - } - } + // Auto-discover git URL and commit details if not provided + gitURL, err := o.SetCommitDetails(o.Dir, o.CommitMessage, o.CommitTitle, o.Application) + if err != nil { + return fmt.Errorf("failed to set commit details: %w", err) } - if o.AddChangelog != "" { - changelog, err := os.ReadFile(o.AddChangelog) - if err != nil { - return fmt.Errorf("failed to read changelog file %s: %w", o.AddChangelog, err) - } - o.EnvironmentPullRequestOptions.CommitChangelog = string(changelog) + // Handle changelog + err = o.SetChangeLog(o.AddChangelog) + if err != nil { + return fmt.Errorf("failed to set changelog: %w", err) } + // Process update rules BaseBranchName := o.BaseBranchName - - for i := range o.UpdateConfig.Spec.Rules { - rule := &o.UpdateConfig.Spec.Rules[i] - err = o.FindURLs(rule) + for i, rule := range o.UpdateConfig.Spec.Rules { + err = o.ProcessRule(rule, i) if err != nil { - return fmt.Errorf("failed to find URLs: %w", err) + return fmt.Errorf("failed to process rule #%d: %w", i, err) } - o.Fork = rule.Fork - if len(rule.URLs) == 0 { - log.Logger().Warnf("no URLs to process for rule %d", i) + err = o.ProcessRuleURLs(rule, BaseBranchName) + if err != nil { + return fmt.Errorf("failed to process URLs: %w", err) } - o.EnvironmentPullRequestOptions.SparseCheckoutPatterns = []string{} - if rule.SparseCheckout { - o.EnvironmentPullRequestOptions.SparseCheckoutPatterns, err = o.GetSparseCheckoutPatterns(*rule) - if err != nil { - return err - } + pullRequests, err := o.CreatePullRequests(rule, o.Labels, o.AutoMerge) + if err != nil { + return fmt.Errorf("failed to create Pull Requests: %w", err) } - for _, gitURL := range rule.URLs { - if gitURL == "" { - log.Logger().Warnf("missing out repository %d as it has no git URL", i) - continue - } - - // lets clear the branch name so we create a new one each time in a loop - o.BranchName = "" - // lets reset the base branch name each time in a loop to avoid side effects when reusing pull requests - o.BaseBranchName = BaseBranchName - - o.Function = func() error { - dir := o.OutDir - - for _, ch := range rule.Changes { - err := o.ApplyChanges(dir, gitURL, ch) - if err != nil { - return fmt.Errorf("failed to apply change: %w", err) - } - - } - return nil - } - - if rule.ReusePullRequest { - if len(o.Labels) == 0 { - return fmt.Errorf("to be able to reuse pull request you need to supply pullRequestLabels in config file or --labels") - } - o.PullRequestFilter = &environments.PullRequestFilter{Labels: []string{}} - for _, label := range o.Labels { - o.PullRequestFilter.Labels = stringhelpers.EnsureStringArrayContains(o.PullRequestFilter.Labels, label) - } - if o.AutoMerge { - o.PullRequestFilter.Labels = stringhelpers.EnsureStringArrayContains(o.PullRequestFilter.Labels, environments.LabelUpdatebot) - } - } - - pr, err := o.EnvironmentPullRequestOptions.Create(gitURL, "", o.Labels, o.AutoMerge) - if err != nil { - return fmt.Errorf("failed to create Pull Request on repository %s: %w", gitURL, err) - } - if pr == nil { - log.Logger().Infof("no Pull Request created") - continue - } - o.AddPullRequest(pr) + err = o.AssignUsersToPullRequests(rule, pullRequests, gitURL) + if err != nil { + return fmt.Errorf("failed to set assignees: %w", err) } } return nil @@ -324,6 +259,7 @@ func (o *Options) Validate() error { return fmt.Errorf("failed to setup git credentials file: %w", err) } log.Logger().Infof("setup git credentials file for user %s and email %s", gc.UserName, gc.UserEmail) + } if o.ChangelogSeparator == "" { o.ChangelogSeparator = "-----" @@ -335,10 +271,10 @@ func (o *Options) GetSparseCheckoutPatterns(rule v1alpha1.Rule) ([]string, error patterns := make([]string, len(rule.Changes)) for _, change := range rule.Changes { if change.Command != nil { - return nil, fmt.Errorf("Sparse checkout not supported for Command change") + return nil, fmt.Errorf("sparse checkout not supported for Command change") } if change.VersionStream != nil { - return nil, fmt.Errorf("Sparse checkout not supported for VersionStream change") + return nil, fmt.Errorf("sparse checkout not supported for VersionStream change") } if change.Go != nil { patterns = append(patterns, o.SparseCheckoutPatternsGo()...) @@ -350,6 +286,17 @@ func (o *Options) GetSparseCheckoutPatterns(rule v1alpha1.Rule) ([]string, error return patterns, nil } +func (o *Options) SetChangeLog(addChangeLog string) error { + if addChangeLog != "" { + changelog, err := os.ReadFile(addChangeLog) + if err != nil { + return fmt.Errorf("failed to read changelog file %s: %w", addChangeLog, err) + } + o.EnvironmentPullRequestOptions.CommitChangelog = string(changelog) + } + return nil +} + // ApplyChanges applies the changes to the given dir func (o *Options) ApplyChanges(dir, gitURL string, change v1alpha1.Change) error { if change.Command != nil { @@ -380,3 +327,184 @@ func (o *Options) FindURLs(rule *v1alpha1.Rule) error { } return nil } + +// FindCommitAuthor finds the commit author for the given SHA +func (o *Options) FindCommitAuthor(gitURL string, sha string) (string, error) { + ctx := context.Background() + scmClient, repoFullName, err := o.GetScmClient(gitURL, o.GitKind) + if err != nil { + return "", fmt.Errorf("failed to create ScmClient: %w", err) + } + commit, _, err := scmClient.Git.FindCommit(ctx, repoFullName, sha) + if err != nil { + return "", fmt.Errorf("failed to find commit %s: %w", sha, err) + } + + author := commit.Author.Login + if author == "" { + return "", fmt.Errorf("no user found for author of commit %s", sha) + } + + return author, nil +} + +// AssignUsersToCommit adds users as an assignee to the PR Issue +func (o *Options) AssignUsersToCommit(pr *scm.PullRequest, users []string, gitURL string) error { + ctx := context.Background() + scmClient, repoFullName, err := o.GetScmClient(gitURL, o.GitKind) + if err != nil { + return fmt.Errorf("failed to create ScmClient: %w", err) + } + fmt.Printf("Debug: Successfully created ScmClient. repoFullName=%s\n", repoFullName) + + fmt.Printf("Debug: Assigning users=%v to PR=%d...\n", users, pr.Number) + _, err = scmClient.PullRequests.AssignIssue(ctx, repoFullName, pr.Number, users) + if err != nil { + return fmt.Errorf("failed to assign user to PR %d: %w", pr.Number, err) + } + return nil +} + +// SetCommitDetails discovers the git URL, and sets the application name, commit message and title +func (o *Options) SetCommitDetails(dir string, commitMessage string, commitTitle string, application string) (string, error) { + if commitMessage == "" || commitTitle == "" || application == "" { + { + gitURL, err := gitdiscovery.FindGitURLFromDir(dir, true) + if err != nil { + log.Logger().Warnf("failed to find git URL %s", err.Error()) + } else if gitURL != "" { + if commitMessage == "" { + gitURLParts := strings.Split(gitURL, "/") + o.Application = gitURLParts[len(gitURLParts)-2] + "/" + + strings.TrimSuffix(gitURLParts[len(gitURLParts)-1], ".git") + } + if commitTitle == "" { + o.CommitMessage = fmt.Sprintf("from: %s\n", gitURL) + } + } + + if commitTitle == "" { + if o.Application == "" { + o.CommitTitle = fmt.Sprintf("chore(deps): upgrade to version %s", o.Version) + } else { + o.CommitTitle = fmt.Sprintf("chore(deps): upgrade %s to version %s", o.Application, o.Version) + } + } + return gitURL, err + } + } + return "", nil +} + +// ProcessRule Sets the Fork and SparseCheckoutPatterns for the given rule +func (o *Options) ProcessRule(rule v1alpha1.Rule, index int) error { + err := o.FindURLs(&rule) + if err != nil { + return fmt.Errorf("failed to find URLs: %w", err) + } + + o.Fork = rule.Fork + if len(rule.URLs) == 0 { + log.Logger().Warnf("no URLs found for rule #%d, skipping...\n", index) + return nil + } + + o.EnvironmentPullRequestOptions.SparseCheckoutPatterns = []string{} + if rule.SparseCheckout { + o.EnvironmentPullRequestOptions.SparseCheckoutPatterns, err = o.GetSparseCheckoutPatterns(rule) + if err != nil { + return fmt.Errorf("Error: Failed to get sparse checkout patterns for rule #%d, error=%v\n", index, err) + } + } + return nil +} + +// ProcessRuleURLs apply changes to the set of URLs in the given rule +func (o *Options) ProcessRuleURLs(rule v1alpha1.Rule, baseBranch string) error { + for _, gitURL := range rule.URLs { + if gitURL == "" { + log.Logger().Warnf("skipping empty git URL") + continue + } + + o.BranchName = "" + o.BaseBranchName = baseBranch + + o.Function = func() error { + dir := o.OutDir + for _, ch := range rule.Changes { + err := o.ApplyChanges(dir, gitURL, ch) + if err != nil { + return fmt.Errorf("failed to apply change: %w", err) + } + } + return nil + } + } + return nil +} + +// CreatePullRequests creates a Pull Request on each of the given rule URLs +func (o *Options) CreatePullRequests(rule v1alpha1.Rule, labels []string, automerge bool) ([]*scm.PullRequest, error) { + var pullRequests []*scm.PullRequest + for _, gitURL := range rule.URLs { + if gitURL == "" { + log.Logger().Warnf("skipping empty git URL") + continue + } + pr, err := o.EnvironmentPullRequestOptions.Create(gitURL, "", labels, automerge) + if err != nil { + return nil, fmt.Errorf("failed to create Pull Request on repository %s: %w", gitURL, err) + } + if pr != nil { + pullRequests = append(pullRequests, pr) + o.AddPullRequest(pr) + } + } + return pullRequests, nil +} + +// AssignUsersToPullRequests assigns users to the given pull requests +func (o *Options) AssignUsersToPullRequests(rule v1alpha1.Rule, pullRequests []*scm.PullRequest, upstreamURL string) error { + for _, pr := range pullRequests { + // Map + uniqueAssignees := make(map[string]struct{}) + + for _, assignee := range o.PRAssignees { + uniqueAssignees[assignee] = struct{}{} + } + + // Add ruleAssignees to the map + for _, assignee := range rule.PullRequestAssignees { + uniqueAssignees[assignee] = struct{}{} + } + + // Append upstream commit Author, if required + if rule.AssignAuthorToPullRequests { + author, err := o.FindCommitAuthor(upstreamURL, o.PipelineCommitSha) + if err != nil { + return fmt.Errorf("failed to find commit author: %w", err) + } + uniqueAssignees[author] = struct{}{} + } + + // Convert the map back to a slice + pullRequestAssignees := make([]string, 0, len(uniqueAssignees)) + for assignee := range uniqueAssignees { + pullRequestAssignees = append(pullRequestAssignees, assignee) + } + + // Assign users to PR + if len(pullRequestAssignees) > 0 { + log.Logger().Infof("assigning users=%v to PR=%d\n", pullRequestAssignees, pr.Number) + err := o.AssignUsersToCommit(pr, pullRequestAssignees, upstreamURL) + if err != nil { + return fmt.Errorf("failed to assign user to commit: %w", err) + } + } else { + log.Logger().Infof("not assigning users to PR=%d\n", pr.Number) + } + + } + return nil +} diff --git a/pkg/cmd/pr/pr_test.go b/pkg/cmd/pr/pr_test.go index 7611af9..59b9c25 100644 --- a/pkg/cmd/pr/pr_test.go +++ b/pkg/cmd/pr/pr_test.go @@ -1,6 +1,7 @@ package pr_test import ( + "github.com/jenkins-x/go-scm/scm" "os" "path/filepath" "strings" @@ -20,7 +21,7 @@ import ( func TestCreate(t *testing.T) { ev := os.Getenv("JX_EXCLUDE_TEST") if ev == "" { - ev = "go" + ev = "go,assignauthor" } excludeTests := strings.Split(ev, ",") runner := &fakerunner.FakeRunner{ @@ -85,3 +86,71 @@ func TestCreate(t *testing.T) { } } + +func TestAssignAuthorToCommit(t *testing.T) { + fileNames, err := os.ReadDir("test_data") + assert.NoError(t, err) + + for _, f := range fileNames { + if !f.IsDir() || f.Name() != "assignauthor" { + continue + } + + t.Logf("Running test for %s\n", f.Name()) + + dir := filepath.Join("test_data", f.Name()) + fakeScmClient, fakeData := fake.NewDefault() + + // Prepopulate fake data + fakeData.Commits["dummy-sha"] = &scm.Commit{ + Sha: "dummy-sha", + Author: scm.Signature{ + Login: "test-author", + }, + } + fakeData.PullRequests[1] = &scm.PullRequest{ + Number: 1, + Title: "Test PR", + } + + fakeData.AssigneesAdded = []string{} + + runner := &fakerunner.FakeRunner{ + CommandRunner: func(c *cmdrunner.Command) (string, error) { + if c.Name == "git" && len(c.Args) > 0 && c.Args[0] == "push" { + t.Logf("faking command %s in dir %s\n", c.CLI(), c.Dir) + return "", nil + } + return cmdrunner.DefaultCommandRunner(c) + }, + } + + // Configure the Options object + _, o := pr.NewCmdPullRequest() + o.Dir = dir + o.CommandRunner = runner.Run + o.ScmClient = fakeScmClient + o.ScmClientFactory.ScmClient = fakeScmClient + o.ScmClientFactory.NoWriteGitCredentialsFile = true + o.Version = "1.2.3" + o.PipelineCommitSha = "dummy-sha" + o.EnvironmentPullRequestOptions.ScmClientFactory.GitServerURL = "https://github.com" + o.EnvironmentPullRequestOptions.ScmClientFactory.GitToken = "dummytoken" + o.EnvironmentPullRequestOptions.ScmClientFactory.GitUsername = "dummyuser" + + // Run the command + err = o.Run() + require.NoError(t, err, "failed to run command for test %s", f.Name()) + + // Validate the assignments + expectedAssignees := []string{"foo", "bar", "test-author"} + actualAssignees := []string{} + for _, assignee := range fakeData.AssigneesAdded { + parts := strings.Split(assignee, ":") + actualAssignees = append(actualAssignees, parts[1]) + } + + assert.ElementsMatch(t, expectedAssignees, actualAssignees, "PR should include all specified assignees") + t.Logf("PR created successfully with assignees: %v\n", actualAssignees) + } +} diff --git a/pkg/cmd/pr/test_data/assignauthor/.jx/updatebot.yaml b/pkg/cmd/pr/test_data/assignauthor/.jx/updatebot.yaml new file mode 100644 index 0000000..109989b --- /dev/null +++ b/pkg/cmd/pr/test_data/assignauthor/.jx/updatebot.yaml @@ -0,0 +1,19 @@ +apiVersion: updatebot.jenkins-x.io/v1alpha1 +kind: UpdateConfig +spec: + rules: + - urls: + - https://github.com/jx3-gitops-repositories/jx3-kubernetes + changes: + - command: + name: sh + args: + - -c + - "echo $CHEESE > cheese.txt" + env: + - name: CHEESE + value: Edam + pullRequestAssignees: + - foo + - bar + assignAuthorToPullRequests: true \ No newline at end of file