Skip to content

Commit ee09a0d

Browse files
committed
plugins: add new plugin for linking and unlinking issues to a PR
This PR adds a new plugin issue-management which has commands for linking and unlinking issues to a PR. - The commands can be used to link an issue to a PR in the current repository or in a different repository as well as handle multiple issues - This is done by adding the supported keyword Fixes to the body of the PR if it doesn't already exist or by appending the issue to the existing Fixes line - Supported formats are issue-number and org/repo-name#issue-number Signed-off-by: Amulyam24 <amulmek1@in.ibm.com>
1 parent f49c615 commit ee09a0d

5 files changed

Lines changed: 765 additions & 0 deletions

File tree

cmd/hook/plugin-imports/plugin-imports.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import (
3838
_ "sigs.k8s.io/prow/pkg/plugins/help"
3939
_ "sigs.k8s.io/prow/pkg/plugins/hold"
4040
_ "sigs.k8s.io/prow/pkg/plugins/invalidcommitmsg"
41+
_ "sigs.k8s.io/prow/pkg/plugins/issue-management"
4142
_ "sigs.k8s.io/prow/pkg/plugins/jira"
4243
_ "sigs.k8s.io/prow/pkg/plugins/label"
4344
_ "sigs.k8s.io/prow/pkg/plugins/lgtm"

pkg/hook/plugin-imports/plugin-imports.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import (
3838
_ "sigs.k8s.io/prow/pkg/plugins/help"
3939
_ "sigs.k8s.io/prow/pkg/plugins/hold"
4040
_ "sigs.k8s.io/prow/pkg/plugins/invalidcommitmsg"
41+
_ "sigs.k8s.io/prow/pkg/plugins/issue-management"
4142
_ "sigs.k8s.io/prow/pkg/plugins/jira"
4243
_ "sigs.k8s.io/prow/pkg/plugins/label"
4344
_ "sigs.k8s.io/prow/pkg/plugins/lgtm"
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
/*
2+
Copyright 2025 The Kubernetes Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
// Package issuemanagement implements issue management commands.
18+
package issuemanagement
19+
20+
import (
21+
"regexp"
22+
"strings"
23+
24+
"github.com/sirupsen/logrus"
25+
"sigs.k8s.io/prow/pkg/config"
26+
"sigs.k8s.io/prow/pkg/github"
27+
"sigs.k8s.io/prow/pkg/pluginhelp"
28+
"sigs.k8s.io/prow/pkg/plugins"
29+
)
30+
31+
const pluginName = "issue-management"
32+
33+
var (
34+
linkIssueRegex = regexp.MustCompile(`(?mi)^/link-issue\s+(.+)$`)
35+
unlinkIssueRegex = regexp.MustCompile(`(?mi)^/unlink-issue\s+(.+)$`)
36+
)
37+
38+
type githubClient interface {
39+
CreateComment(org, repo string, number int, comment string) error
40+
GetIssue(org, repo string, number int) (*github.Issue, error)
41+
GetPullRequest(org, repo string, number int) (*github.PullRequest, error)
42+
GetRepo(org, name string) (github.FullRepo, error)
43+
IsMember(org, user string) (bool, error)
44+
UpdatePullRequest(org, repo string, number int, title, body *string, open *bool, branch *string, canModify *bool) error
45+
}
46+
47+
func helpProvider(_ *plugins.Configuration, _ []config.OrgRepo) (*pluginhelp.PluginHelp, error) {
48+
pluginHelp := &pluginhelp.PluginHelp{
49+
Description: "The issue management plugin provides commands for linking and unlinking issues to a PR.",
50+
}
51+
pluginHelp.AddCommand(pluginhelp.Command{
52+
Usage: "/link-issue <issue(s)>",
53+
Description: "Links issue(s) to a PR in the same or different repo.",
54+
WhoCanUse: "Org members",
55+
Examples: []string{"/link-issue 1234", "/link-issue org/repo#789"},
56+
})
57+
pluginHelp.AddCommand(pluginhelp.Command{
58+
Usage: "/unlink-issue <issue(s)>",
59+
Description: "Unlinks issue(s) from a PR in the same or different repo.",
60+
WhoCanUse: "Org members",
61+
Examples: []string{"/unlink-issue 1234", "/unlink-issue org/repo#789"},
62+
})
63+
return pluginHelp, nil
64+
}
65+
66+
func init() {
67+
plugins.RegisterGenericCommentHandler(pluginName, handleGenericComment, helpProvider)
68+
}
69+
70+
func handleGenericComment(pc plugins.Agent, e github.GenericCommentEvent) error {
71+
return handleIssues(pc.GitHubClient, pc.Logger.WithFields(logrus.Fields{
72+
"org": e.Repo.Owner.Login,
73+
"repo": e.Repo.Name,
74+
"number": e.Number,
75+
"user": e.User.Login,
76+
}), e)
77+
}
78+
79+
func handleIssues(gc githubClient, log *logrus.Entry, e github.GenericCommentEvent) error {
80+
toLink, toUnlink, err := parseCommentForLinkCommands(e.Body)
81+
if err != nil {
82+
return err
83+
}
84+
if len(toLink) > 0 || len(toUnlink) > 0 {
85+
return handleLinkIssue(gc, log, e, toLink, toUnlink)
86+
}
87+
88+
return nil
89+
}
90+
91+
func parseCommentForLinkCommands(commentBody string) ([]string, []string, error) {
92+
extractIssues := func(re *regexp.Regexp) []string {
93+
var issues []string
94+
allMatches := re.FindAllStringSubmatch(commentBody, -1)
95+
96+
for _, match := range allMatches {
97+
if len(match) > 1 {
98+
issues = append(issues, strings.Fields(match[1])...)
99+
}
100+
}
101+
return issues
102+
}
103+
104+
return extractIssues(linkIssueRegex), extractIssues(unlinkIssueRegex), nil
105+
}
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
/*
2+
Copyright 2025 The Kubernetes Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
// The `/link-issue` and `/unlink-issue` command allows
18+
// members of the org to link and unlink issues to PRs.
19+
package issuemanagement
20+
21+
import (
22+
"fmt"
23+
"regexp"
24+
"strconv"
25+
"strings"
26+
27+
"github.com/sirupsen/logrus"
28+
"k8s.io/apimachinery/pkg/util/sets"
29+
"sigs.k8s.io/prow/pkg/github"
30+
"sigs.k8s.io/prow/pkg/plugins"
31+
)
32+
33+
var (
34+
fixesRegex = regexp.MustCompile(`(?i)^fixes\s+(.*)$`)
35+
)
36+
37+
type IssueRef struct {
38+
Org string
39+
Repo string
40+
Num int
41+
}
42+
43+
func handleLinkIssue(gc githubClient, log *logrus.Entry, e github.GenericCommentEvent, linkIssues []string, unlinkIssues []string) error {
44+
org := e.Repo.Owner.Login
45+
repo := e.Repo.Name
46+
number := e.Number
47+
user := e.User.Login
48+
49+
var (
50+
errs []error
51+
sb strings.Builder
52+
)
53+
54+
if !e.IsPR || e.Action != github.GenericCommentActionCreated {
55+
return gc.CreateComment(org, repo, number, plugins.FormatResponseRaw(
56+
e.Body, e.HTMLURL, user, "This command can only be used on pull requests."))
57+
}
58+
59+
isMember, err := gc.IsMember(org, user)
60+
if err != nil {
61+
return fmt.Errorf("unable to fetch if %s is an org member of %s: %w", user, org, err)
62+
}
63+
if !isMember {
64+
return gc.CreateComment(org, repo, number, plugins.FormatResponseRaw(
65+
e.Body, e.HTMLURL, user, "You must be an org member to use this command."))
66+
}
67+
68+
// Verify if issues priovided in the comment are valid and format them accoordingly.
69+
validateIssues := func(issues []string) []string {
70+
var issueRefs []string
71+
for _, issue := range issues {
72+
issueRef, err := parseIssueRef(issue, org, repo)
73+
if err != nil {
74+
errs = append(errs, fmt.Errorf("Invalid format for issue **%s**. Supported formats are **issue-number** and **organization/repository#issue-number**", issue))
75+
continue
76+
}
77+
78+
// If repo or org of the issue reference is different from the one in which the PR is created, check if it exists
79+
if org != issueRef.Org || repo != issueRef.Repo {
80+
if _, err := gc.GetRepo(issueRef.Org, issueRef.Repo); err != nil {
81+
errs = append(errs, fmt.Errorf("Failed to get repository details with name **%s**", issueRef.Repo))
82+
continue
83+
}
84+
}
85+
// Verify if the issue exists
86+
fetchedIssue, err := gc.GetIssue(issueRef.Org, issueRef.Repo, issueRef.Num)
87+
if err != nil {
88+
errs = append(errs, fmt.Errorf("Failed to get issue **#%d** from **%s/%s**", issueRef.Num, issueRef.Org, issueRef.Repo))
89+
continue
90+
}
91+
// Skip linking the issue if the provided issue number is a pull request
92+
if fetchedIssue.IsPullRequest() {
93+
errs = append(errs, fmt.Errorf("Skipping #%d of **%s/%s** as it is a ***pull request***.", fetchedIssue.Number, issueRef.Repo, issueRef.Org))
94+
continue
95+
}
96+
issueRefs = append(issueRefs, formatIssueRef(issueRef, org, repo))
97+
}
98+
return issueRefs
99+
}
100+
101+
toLink := validateIssues(linkIssues)
102+
toUnlink := validateIssues(unlinkIssues)
103+
104+
if len(toLink) > 0 || len(toUnlink) > 0 {
105+
pr, err := gc.GetPullRequest(org, repo, number)
106+
if err != nil {
107+
return fmt.Errorf("failed to get pull request: %w", err)
108+
}
109+
110+
newBody := updateFixesLine(pr.Body, toLink, toUnlink)
111+
if newBody != pr.Body {
112+
if err := gc.UpdatePullRequest(org, repo, number, nil, &newBody, nil, nil, nil); err != nil {
113+
return fmt.Errorf("failed to update PR body: %w", err)
114+
}
115+
116+
log.Infof("Successfully updated the PR body by linking issues: %s and unlinking issues: %s", toLink, toUnlink)
117+
sb.WriteString("Updated the `Fixes` line in the pull request body.\nHowever, ")
118+
} else {
119+
log.Debug("PR body is already up-to-date. No changes needed.")
120+
}
121+
}
122+
123+
if len(errs) > 0 {
124+
sb.WriteString("There are one or more errors with issue references provided, please cross check and retry.\n")
125+
126+
for _, err := range errs {
127+
fmt.Fprintf(&sb, "* %v\n", err)
128+
}
129+
return gc.CreateComment(org, repo, number, plugins.FormatResponseRaw(e.Body, e.HTMLURL, user, sb.String()))
130+
}
131+
132+
return nil
133+
}
134+
135+
func parseIssueRef(issue, defaultOrg, defaultRepo string) (*IssueRef, error) {
136+
// Handling single issue references
137+
if num, err := strconv.Atoi(issue); err == nil {
138+
return &IssueRef{Org: defaultOrg, Repo: defaultRepo, Num: num}, nil
139+
}
140+
141+
if !strings.Contains(issue, "/") {
142+
return nil, fmt.Errorf("unrecognized issue reference: %s", issue)
143+
}
144+
145+
// Handling issue references in format org/repo#issue-number
146+
parts := strings.Split(issue, "#")
147+
if len(parts) != 2 {
148+
return nil, fmt.Errorf("invalid issue ref: %s", issue)
149+
}
150+
orgRepo := strings.Split(parts[0], "/")
151+
if len(orgRepo) != 2 {
152+
return nil, fmt.Errorf("invalid org/repo format: %s", issue)
153+
}
154+
num, err := strconv.Atoi(parts[1])
155+
if err != nil {
156+
return nil, fmt.Errorf("invalid issue number: %s", issue)
157+
}
158+
return &IssueRef{Org: orgRepo[0], Repo: orgRepo[1], Num: num}, nil
159+
160+
}
161+
162+
func formatIssueRef(ref *IssueRef, defaultOrg, defaultRepo string) string {
163+
if ref.Org == defaultOrg && ref.Repo == defaultRepo {
164+
return fmt.Sprintf("#%d", ref.Num)
165+
}
166+
return fmt.Sprintf("%s/%s#%d", ref.Org, ref.Repo, ref.Num)
167+
}
168+
169+
func updateFixesLine(body string, toAdd []string, toRemove []string) string {
170+
lines := strings.Split(body, "\n")
171+
var fixesLine string
172+
fixesIndex := -1
173+
issueSet := sets.NewString()
174+
175+
// Find and parse existing Fixes line
176+
for i, line := range lines {
177+
if m := fixesRegex.FindStringSubmatch(line); m != nil {
178+
fixesIndex = i
179+
for _, issue := range strings.Fields(m[1]) {
180+
issueSet.Insert(issue)
181+
}
182+
break
183+
}
184+
}
185+
186+
issueSet = issueSet.Difference(sets.NewString(toRemove...)).Union(sets.NewString(toAdd...))
187+
188+
if issueSet.Len() == 0 {
189+
// If all linked issues have been removed, the fixes line can be deleted from the PR body.
190+
if fixesIndex != -1 {
191+
lines = append(lines[:fixesIndex], lines[fixesIndex+1:]...)
192+
}
193+
return strings.Join(lines, "\n")
194+
}
195+
196+
newIssueRefs := issueSet.List()
197+
fixesLine = "Fixes " + strings.Join(newIssueRefs, " ")
198+
199+
if fixesIndex >= 0 {
200+
lines[fixesIndex] = fixesLine
201+
} else {
202+
if len(lines) > 0 && lines[len(lines)-1] != "" {
203+
lines = append(lines, "")
204+
}
205+
lines = append(lines, fixesLine)
206+
}
207+
208+
return strings.Join(lines, "\n")
209+
}

0 commit comments

Comments
 (0)