diff --git a/cmd/hook/plugin-imports/plugin-imports.go b/cmd/hook/plugin-imports/plugin-imports.go index c84cae2f30..ecdf5fe31f 100644 --- a/cmd/hook/plugin-imports/plugin-imports.go +++ b/cmd/hook/plugin-imports/plugin-imports.go @@ -38,6 +38,7 @@ import ( _ "sigs.k8s.io/prow/pkg/plugins/help" _ "sigs.k8s.io/prow/pkg/plugins/hold" _ "sigs.k8s.io/prow/pkg/plugins/invalidcommitmsg" + _ "sigs.k8s.io/prow/pkg/plugins/issue-management" _ "sigs.k8s.io/prow/pkg/plugins/jira" _ "sigs.k8s.io/prow/pkg/plugins/label" _ "sigs.k8s.io/prow/pkg/plugins/lgtm" diff --git a/pkg/hook/plugin-imports/plugin-imports.go b/pkg/hook/plugin-imports/plugin-imports.go index c84cae2f30..ecdf5fe31f 100644 --- a/pkg/hook/plugin-imports/plugin-imports.go +++ b/pkg/hook/plugin-imports/plugin-imports.go @@ -38,6 +38,7 @@ import ( _ "sigs.k8s.io/prow/pkg/plugins/help" _ "sigs.k8s.io/prow/pkg/plugins/hold" _ "sigs.k8s.io/prow/pkg/plugins/invalidcommitmsg" + _ "sigs.k8s.io/prow/pkg/plugins/issue-management" _ "sigs.k8s.io/prow/pkg/plugins/jira" _ "sigs.k8s.io/prow/pkg/plugins/label" _ "sigs.k8s.io/prow/pkg/plugins/lgtm" diff --git a/pkg/plugins/issue-management/issue_management.go b/pkg/plugins/issue-management/issue_management.go new file mode 100644 index 0000000000..11a1c211a9 --- /dev/null +++ b/pkg/plugins/issue-management/issue_management.go @@ -0,0 +1,90 @@ +/* +Copyright 2025 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package issuemanagement implements issue management commands. +package issuemanagement + +import ( + "regexp" + + "github.com/sirupsen/logrus" + "sigs.k8s.io/prow/pkg/config" + "sigs.k8s.io/prow/pkg/github" + "sigs.k8s.io/prow/pkg/pluginhelp" + "sigs.k8s.io/prow/pkg/plugins" +) + +const pluginName = "issue-management" + +var ( + linkIssueRegex = regexp.MustCompile(`(?mi)^/link-issue((?: +(?:\d+|[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+#\d+))+)\b`) + unlinkIssueRegex = regexp.MustCompile(`(?mi)^/unlink-issue((?: +(?:\d+|[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+#\d+))+)\b`) +) + +type githubClient interface { + CreateComment(org, repo string, number int, comment string) error + GetIssue(org, repo string, number int) (*github.Issue, error) + GetPullRequest(org, repo string, number int) (*github.PullRequest, error) + GetRepo(org, name string) (github.FullRepo, error) + IsMember(org, user string) (bool, error) + UpdatePullRequest(org, repo string, number int, title, body *string, open *bool, branch *string, canModify *bool) error +} + +func helpProvider(_ *plugins.Configuration, _ []config.OrgRepo) (*pluginhelp.PluginHelp, error) { + pluginHelp := &pluginhelp.PluginHelp{ + Description: "The issue management plugin provides commands for linking and unlinking issues to a PR.", + } + pluginHelp.AddCommand(pluginhelp.Command{ + Usage: "/link-issue ", + Description: "Links issue(s) to a PR in the same or different repo.", + WhoCanUse: "Org members", + Examples: []string{"/link-issue 1234", "/link-issue org/repo#789"}, + }) + pluginHelp.AddCommand(pluginhelp.Command{ + Usage: "/unlink-issue ", + Description: "Unlinks issue(s) from a PR in the same or different repo.", + WhoCanUse: "Org members", + Examples: []string{"/unlink-issue 1234", "/unlink-issue org/repo#789"}, + }) + return pluginHelp, nil +} + +func init() { + plugins.RegisterGenericCommentHandler(pluginName, handleGenericComment, helpProvider) +} + +func handleGenericComment(pc plugins.Agent, e github.GenericCommentEvent) error { + return handleIssues(pc.GitHubClient, pc.Logger.WithFields(logrus.Fields{ + "org": e.Repo.Owner.Login, + "repo": e.Repo.Name, + "number": e.Number, + "user": e.User.Login, + }), e) +} + +func handleIssues(gc githubClient, log *logrus.Entry, e github.GenericCommentEvent) error { + + switch { + case linkIssueRegex.MatchString(e.Body): + log.Info("Handling link issue command") + return handleLinkIssue(gc, log, e, true) + case unlinkIssueRegex.MatchString(e.Body): + log.Info("Handling unlink issue command") + return handleLinkIssue(gc, log, e, false) + default: + return nil + } +} diff --git a/pkg/plugins/issue-management/link-issue.go b/pkg/plugins/issue-management/link-issue.go new file mode 100644 index 0000000000..3b14038de2 --- /dev/null +++ b/pkg/plugins/issue-management/link-issue.go @@ -0,0 +1,218 @@ +/* +Copyright 2025 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// The `/link-issue` and `/unlink-issue` command allows +// members of the org to link and unlink issues to PRs. +package issuemanagement + +import ( + "fmt" + "regexp" + "sort" + "strconv" + "strings" + + "github.com/sirupsen/logrus" + "sigs.k8s.io/prow/pkg/github" + "sigs.k8s.io/prow/pkg/plugins" +) + +var ( + fixesRegex = regexp.MustCompile(`(?i)^fixes\s+(.*)$`) +) + +type IssueRef struct { + Org string + Repo string + Num int +} + +func handleLinkIssue(gc githubClient, log *logrus.Entry, e github.GenericCommentEvent, linkIssue bool) error { + org := e.Repo.Owner.Login + repo := e.Repo.Name + number := e.Number + user := e.User.Login + + if !e.IsPR || e.Action != github.GenericCommentActionCreated { + return gc.CreateComment(org, repo, number, plugins.FormatResponseRaw( + e.Body, e.HTMLURL, user, "This command can only be used on pull requests.")) + } + + isMember, err := gc.IsMember(org, user) + if err != nil { + return fmt.Errorf("unable to fetch if %s is an org member of %s: %w", user, org, err) + } + if !isMember { + return gc.CreateComment(org, repo, number, plugins.FormatResponseRaw( + e.Body, e.HTMLURL, user, "You must be an org member to use this command.")) + } + + regex := linkIssueRegex + if !linkIssue { + regex = unlinkIssueRegex + } + + matches := regex.FindStringSubmatch(e.Body) + if len(matches) == 0 { + return nil + } + + issues := strings.Fields(matches[1]) + if len(issues) == 0 { + log.Info("No issue references provided in the comment.") + return nil + } + + var issueRefs []string + for _, issue := range issues { + issueRef, err := parseIssueRef(issue, org, repo) + if err != nil { + log.Debugf("Skipping invalid issue: %s", issue) + continue + } + + // If repo or org of the issue reference is different from the one in which the PR is created, check if it exists + if org != issueRef.Org || repo != issueRef.Repo { + if _, err := gc.GetRepo(org, repo); err != nil { + return fmt.Errorf("failed to get repo: %w", err) + } + } + + // Verify if the issue exists + fetchedIssue, err := gc.GetIssue(issueRef.Org, issueRef.Repo, issueRef.Num) + if err != nil { + return fmt.Errorf("failed to get issue: %w", err) + } + if fetchedIssue.IsPullRequest() { + response := fmt.Sprintf("Skipping #%d of repo **%s** and org **%s** as it is a *pull request*.", fetchedIssue.Number, issueRef.Repo, issueRef.Org) + if err := gc.CreateComment(org, repo, number, plugins.FormatResponseRaw(e.Body, e.HTMLURL, user, response)); err != nil { + log.WithError(err).Error("Failed to leave comment") + } + continue + } + issueRefs = append(issueRefs, formatIssueRef(issueRef, org, repo)) + } + + if len(issueRefs) == 0 { + log.Info("No valid issues to process.") + return nil + } + + pr, err := gc.GetPullRequest(org, repo, number) + if err != nil { + return fmt.Errorf("failed to get PR: %w", err) + } + + newBody := updateFixesLine(pr.Body, issueRefs, linkIssue) + if newBody == pr.Body { + log.Debug("PR body is already up-to-date. No changes needed.") + return nil + } + + if err := gc.UpdatePullRequest(org, repo, number, nil, &newBody, nil, nil, nil); err != nil { + return fmt.Errorf("failed to update PR body: %w", err) + } + + log.Infof("Successfully updated the PR body") + return nil +} + +func parseIssueRef(issue, defaultOrg, defaultRepo string) (*IssueRef, error) { + // Handling single issue references + if num, err := strconv.Atoi(issue); err == nil { + return &IssueRef{Org: defaultOrg, Repo: defaultRepo, Num: num}, nil + } + + // Handling issue references in format org/repo#issue-number + if !strings.Contains(issue, "/") { + return nil, fmt.Errorf("unrecognized issue reference: %s", issue) + } + + parts := strings.Split(issue, "#") + if len(parts) != 2 { + return nil, fmt.Errorf("invalid issue ref: %s", issue) + } + orgRepo := strings.Split(parts[0], "/") + if len(orgRepo) != 2 { + return nil, fmt.Errorf("invalid org/repo format: %s", issue) + } + num, err := strconv.Atoi(parts[1]) + if err != nil { + return nil, fmt.Errorf("invalid issue number: %s", issue) + } + return &IssueRef{Org: orgRepo[0], Repo: orgRepo[1], Num: num}, nil + +} + +func formatIssueRef(ref *IssueRef, defaultOrg, defaultRepo string) string { + if ref.Org == defaultOrg && ref.Repo == defaultRepo { + return fmt.Sprintf("#%d", ref.Num) + } + return fmt.Sprintf("%s/%s#%d", ref.Org, ref.Repo, ref.Num) +} + +func updateFixesLine(body string, issueRefs []string, add bool) string { + lines := strings.Split(body, "\n") + var fixesLine string + fixesIndex := -1 + issueList := make(map[string]bool) + + // Find and parse existing Fixes line + for i, line := range lines { + if m := fixesRegex.FindStringSubmatch(line); m != nil { + fixesIndex = i + for _, i := range strings.Fields(m[1]) { + issueList[i] = true + } + break + } + } + + for _, ref := range issueRefs { + if add { + issueList[ref] = true + } else { + delete(issueList, ref) + } + } + + if len(issueList) == 0 { + // All linked issues have been removed, the fixes line can be deleted from the PR body. + if fixesIndex != -1 { + lines = append(lines[:fixesIndex], lines[fixesIndex+1:]...) + } + return strings.Join(lines, "\n") + } + + var newIssueRefs []string + for ref := range issueList { + newIssueRefs = append(newIssueRefs, ref) + } + + sort.Strings(newIssueRefs) + fixesLine = "Fixes " + strings.Join(newIssueRefs, " ") + + if fixesIndex >= 0 { + lines[fixesIndex] = fixesLine + } else { + if len(lines) > 0 && lines[len(lines)-1] != "" { + lines = append(lines, "") + } + lines = append(lines, fixesLine) + } + + return strings.Join(lines, "\n") +} diff --git a/pkg/plugins/issue-management/link-issue_test.go b/pkg/plugins/issue-management/link-issue_test.go new file mode 100644 index 0000000000..32d41643f0 --- /dev/null +++ b/pkg/plugins/issue-management/link-issue_test.go @@ -0,0 +1,381 @@ +/* +Copyright 2025 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package issuemanagement + +import ( + "errors" + "strings" + "testing" + + "github.com/sirupsen/logrus" + "sigs.k8s.io/prow/pkg/github" + "sigs.k8s.io/prow/pkg/github/fakegithub" +) + +func Test_handleLinkIssue(t *testing.T) { + tests := []struct { + name string + event github.GenericCommentEvent + add bool + expectError bool + errorMessage string + expectedComment string + fc func(fc *fakegithub.FakeClient) + }{ + { + name: "should return if the plugin is triggered on an issue", + event: github.GenericCommentEvent{ + IsPR: false, + Action: github.GenericCommentActionCreated, + }, + expectedComment: "This command can only be used on pull requests.", + }, + { + name: "should return with comment when action is not comment created on a PR", + event: github.GenericCommentEvent{ + IsPR: true, + Action: github.GenericCommentActionEdited, + }, + expectedComment: "This command can only be used on pull requests.", + }, + { + name: "should deny request if user who is not a part of the org", + event: github.GenericCommentEvent{ + IsPR: true, + Action: github.GenericCommentActionCreated, + Body: "/link-issue 1", + User: github.User{Login: "user"}, + Repo: github.Repo{Owner: github.User{Login: "kubernetes"}, Name: "repo"}, + }, + expectedComment: "You must be an org member", + fc: func(fc *fakegithub.FakeClient) { + fc.OrgMembers["kubernetes"] = []string{} + }, + }, + { + name: "comment without issue numbers has no action", + event: github.GenericCommentEvent{ + IsPR: true, + Action: github.GenericCommentActionCreated, + Body: "/link-issue", + User: github.User{Login: "user"}, + Repo: github.Repo{Owner: github.User{Login: "kubernetes"}, Name: "repo"}, + }, + fc: func(fc *fakegithub.FakeClient) { + fc.OrgMembers["kubernetes"] = []string{"user"} + }, + }, + { + name: "should error when linking an issue from a different repo which does not exist", + event: github.GenericCommentEvent{ + IsPR: true, + Action: github.GenericCommentActionCreated, + Body: "/link-issue other/repo#12", + User: github.User{Login: "user"}, + Repo: github.Repo{Owner: github.User{Login: "kubernetes"}, Name: "repo1"}, + }, + add: true, + expectError: true, + errorMessage: "failed to get repo", + fc: func(fc *fakegithub.FakeClient) { + fc.GetRepoError = errors.New("error") + fc.OrgMembers["kubernetes"] = []string{"user"} + }, + }, + { + name: "should fail if issue does not exist", + event: github.GenericCommentEvent{ + IsPR: true, + Action: github.GenericCommentActionCreated, + Body: "/link-issue 99", + User: github.User{Login: "user"}, + Repo: github.Repo{Owner: github.User{Login: "kubernetes"}, Name: "repo"}, + }, + add: true, + expectError: true, + errorMessage: "failed to get issue", + fc: func(fc *fakegithub.FakeClient) { + fc.OrgMembers["kubernetes"] = []string{"user"} + }, + }, + { + name: "link issue should update the PR body successfully", + event: github.GenericCommentEvent{ + IsPR: true, + Action: github.GenericCommentActionCreated, + Body: "/link-issue 10", + User: github.User{Login: "user"}, + Number: 9, + Repo: github.Repo{Owner: github.User{Login: "kubernetes"}, Name: "repo"}, + }, + add: true, + fc: func(fc *fakegithub.FakeClient) { + fc.OrgMembers["kubernetes"] = []string{"user"} + fc.Issues[10] = &github.Issue{Number: 10} + fc.PullRequests[9] = &github.PullRequest{Number: 9, Body: "Initial body"} + }, + }, + { + name: "Unlink issue should update the PR body successfully", + add: false, + event: github.GenericCommentEvent{ + IsPR: true, + Action: github.GenericCommentActionCreated, + Body: "/unlink-issue 11", + Number: 10, + User: github.User{Login: "user"}, + Repo: github.Repo{Owner: github.User{Login: "kubernetes"}, Name: "repo"}, + }, + fc: func(fc *fakegithub.FakeClient) { + fc.OrgMembers["kubernetes"] = []string{"user"} + fc.Issues[11] = &github.Issue{Number: 11} + fc.PullRequests[10] = &github.PullRequest{ + Number: 10, + Body: "Fixes #11", + } + }, + }, + { + name: "should not update the PR body when provided issue is already linked ", + event: github.GenericCommentEvent{ + IsPR: true, + Action: github.GenericCommentActionCreated, + Body: "/link-issue 12", + Number: 11, + User: github.User{Login: "user"}, + Repo: github.Repo{Owner: github.User{Login: "kubernetes"}, Name: "repo"}, + }, + add: true, + fc: func(fc *fakegithub.FakeClient) { + fc.OrgMembers["kubernetes"] = []string{"user"} + fc.Issues[12] = &github.Issue{Number: 12} + fc.PullRequests[11] = &github.PullRequest{ + Number: 11, + Body: "Fixes #12", + } + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + fc := fakegithub.NewFakeClient() + + if tc.fc != nil { + tc.fc(fc) + } + + log := logrus.WithField("plugin", pluginName) + err := handleLinkIssue(fc, log, tc.event, tc.add) + + if tc.expectError { + if err == nil { + t.Fatalf("expected error but got none") + } + if tc.errorMessage != "" && !strings.Contains(err.Error(), tc.errorMessage) { + t.Fatalf("expected error to contain %q, got: %v", tc.errorMessage, err) + } + } else if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if tc.expectedComment != "" { + cmts := fc.IssueComments[tc.event.Number] + if len(cmts) == 0 { + t.Fatalf("expected a comment containing %q but none posted", tc.expectedComment) + } + if !strings.Contains(cmts[0].Body, tc.expectedComment) { + t.Fatalf("expected comment %q but got %q", tc.expectedComment, cmts[0].Body) + } + } + }) + } +} + +func TestParseIssueRef(t *testing.T) { + tests := []struct { + name string + issue string + defOrg string + defRepo string + expectedOrg string + expectedRepo string + expectedIssue int + expectedError bool + }{ + { + name: "User provided an issue number", + issue: "42", + defOrg: "kubernetes", + defRepo: "test-infra", + expectedOrg: "kubernetes", + expectedRepo: "test-infra", + expectedIssue: 42, + }, + { + name: "User provided an issue in format org/repo#num", + issue: "foo/bar#77", + defOrg: "org", + defRepo: "repo", + expectedOrg: "foo", + expectedRepo: "bar", + expectedIssue: 77, + }, + { + name: "User provided an invalid issue number", + issue: "x42", + defOrg: "org", + defRepo: "repo", + expectedError: true, + }, + { + name: "Invalid issue format with slash but missing #", + issue: "foo/bar", + expectedError: true, + }, + { + name: "Invalid org repo format", + issue: "foo/bar/baz#1", + expectedError: true, + }, + { + name: "Invalid issue number after #", + issue: "foo/bar#x", + expectedError: true, + }, + { + name: "Invalid issue reference", + issue: "abc", + expectedError: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ref, err := parseIssueRef(tc.issue, tc.defOrg, tc.defRepo) + if tc.expectedError { + if err == nil { + t.Fatalf("expected error but got none") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if ref.Org != tc.expectedOrg || ref.Repo != tc.expectedRepo || ref.Num != tc.expectedIssue { + t.Fatalf("got %+v, want org=%s repo=%s issue=%d", ref, tc.expectedOrg, tc.expectedRepo, tc.expectedIssue) + } + }) + } +} + +func TestFormatIssueRef(t *testing.T) { + tests := []struct { + name string + ref IssueRef + defOrg string + defRepo string + expectedIssueRef string + }{ + { + name: "Issue within the same repo", + ref: IssueRef{Org: "kubernetes", Repo: "test-infra", Num: 12}, + defOrg: "kubernetes", + defRepo: "test-infra", + expectedIssueRef: "#12", + }, + { + name: "Issue in a different repo", + ref: IssueRef{Org: "foo", Repo: "bar", Num: 33}, + defOrg: "kubernetes", + defRepo: "test-infra", + expectedIssueRef: "foo/bar#33", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + issue := formatIssueRef(&tc.ref, tc.defOrg, tc.defRepo) + if issue != tc.expectedIssueRef { + t.Fatalf("got %s, want %s", issue, tc.expectedIssueRef) + } + }) + } +} + +func TestUpdateFixesLine(t *testing.T) { + tests := []struct { + name string + body string + issues []string + addLink bool + expectedBody string + }{ + { + name: "should add fixes line when it doesn't exist", + body: "This is a PR body.", + issues: []string{"#12"}, + addLink: true, + expectedBody: "This is a PR body.\n\nFixes #12", + }, + { + name: "should unlink an issue but keep fixes line", + body: "Fixes #1 #2", + issues: []string{"#2"}, + addLink: false, + expectedBody: "Fixes #1", + }, + { + name: "should remove last issue and delete fixes line", + body: "line1\nFixes #99\nline2", + issues: []string{"#99"}, + addLink: false, + expectedBody: "line1\nline2", + }, + { + name: "should do not add duplicate issue if it is already present in the PR body", + body: "Fixes #7", + issues: []string{"#7"}, + addLink: true, + expectedBody: "Fixes #7", + }, + { + name: "should ensure the fixes line is added at end of the body", + body: "line1\nline2", + issues: []string{"foo/bar#10"}, + addLink: true, + expectedBody: "line1\nline2\n\nFixes foo/bar#10", + }, + { + name: "should append issue to existing fixes line", + body: "line1\nFixes #1\nline3", + issues: []string{"#2", "foo/bar#10"}, + addLink: true, + expectedBody: "line1\nFixes #1 #2 foo/bar#10\nline3", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + returnedBody := updateFixesLine(tc.body, tc.issues, tc.addLink) + if returnedBody != tc.expectedBody { + t.Fatalf("got:\n%s\nwant:\n%s", returnedBody, tc.expectedBody) + } + }) + } +}