Skip to content

Commit c65a580

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 <[email protected]>
1 parent f49c615 commit c65a580

File tree

5 files changed

+694
-0
lines changed

5 files changed

+694
-0
lines changed

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: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
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+
23+
"github.com/sirupsen/logrus"
24+
"sigs.k8s.io/prow/pkg/config"
25+
"sigs.k8s.io/prow/pkg/github"
26+
"sigs.k8s.io/prow/pkg/pluginhelp"
27+
"sigs.k8s.io/prow/pkg/plugins"
28+
)
29+
30+
const pluginName = "issue-management"
31+
32+
var (
33+
linkIssueRegex = regexp.MustCompile(`(?mi)^/link-issue((?: +(?:\d+|[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+#\d+))+)\b`)
34+
unlinkIssueRegex = regexp.MustCompile(`(?mi)^/unlink-issue((?: +(?:\d+|[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+#\d+))+)\b`)
35+
)
36+
37+
type githubClient interface {
38+
CreateComment(org, repo string, number int, comment string) error
39+
GetIssue(org, repo string, number int) (*github.Issue, error)
40+
GetPullRequest(org, repo string, number int) (*github.PullRequest, error)
41+
GetRepo(org, name string) (github.FullRepo, error)
42+
IsMember(org, user string) (bool, error)
43+
UpdatePullRequest(org, repo string, number int, title, body *string, open *bool, branch *string, canModify *bool) error
44+
}
45+
46+
func helpProvider(_ *plugins.Configuration, _ []config.OrgRepo) (*pluginhelp.PluginHelp, error) {
47+
pluginHelp := &pluginhelp.PluginHelp{
48+
Description: "The issue management plugin provides commands for linking and unlinking issues to a PR.",
49+
}
50+
pluginHelp.AddCommand(pluginhelp.Command{
51+
Usage: "/link-issue <issue(s)>",
52+
Description: "Links issue(s) to a PR in the same or different repo.",
53+
WhoCanUse: "Org members",
54+
Examples: []string{"/link-issue 1234", "/link-issue org/repo#789"},
55+
})
56+
pluginHelp.AddCommand(pluginhelp.Command{
57+
Usage: "/unlink-issue <issue(s)>",
58+
Description: "Unlinks issue(s) from a PR in the same or different repo.",
59+
WhoCanUse: "Org members",
60+
Examples: []string{"/unlink-issue 1234", "/unlink-issue org/repo#789"},
61+
})
62+
return pluginHelp, nil
63+
}
64+
65+
func init() {
66+
plugins.RegisterGenericCommentHandler(pluginName, handleGenericComment, helpProvider)
67+
}
68+
69+
func handleGenericComment(pc plugins.Agent, e github.GenericCommentEvent) error {
70+
return handleIssues(pc.GitHubClient, pc.Logger, e)
71+
}
72+
73+
func handleIssues(gc githubClient, log *logrus.Entry, e github.GenericCommentEvent) error {
74+
75+
switch {
76+
case linkIssueRegex.MatchString(e.Body):
77+
log.WithFields(logrus.Fields{
78+
"org": e.Repo.Owner.Login,
79+
"repo": e.Repo.Name,
80+
"number": e.Number,
81+
"user": e.User.Login,
82+
}).Info("Handling link issue command")
83+
return handleLinkIssue(gc, log, e, true)
84+
case unlinkIssueRegex.MatchString(e.Body):
85+
log.WithFields(logrus.Fields{
86+
"org": e.Repo.Owner.Login,
87+
"repo": e.Repo.Name,
88+
"number": e.Number,
89+
"user": e.User.Login,
90+
}).Info("Handling unlink issue command")
91+
return handleLinkIssue(gc, log, e, false)
92+
default:
93+
return nil
94+
}
95+
}
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
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+
"sort"
25+
"strconv"
26+
"strings"
27+
28+
"github.com/sirupsen/logrus"
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, linkIssue bool) error {
44+
org := e.Repo.Owner.Login
45+
repo := e.Repo.Name
46+
number := e.Number
47+
user := e.User.Login
48+
49+
if !e.IsPR || e.Action != github.GenericCommentActionCreated {
50+
return gc.CreateComment(org, repo, number, plugins.FormatResponseRaw(
51+
e.Body, e.HTMLURL, user, "This command can only be used on pull requests."))
52+
}
53+
54+
isMember, err := gc.IsMember(org, user)
55+
if err != nil {
56+
return fmt.Errorf("unable to fetch if %s is an org member of %s: %w", user, org, err)
57+
}
58+
if !isMember {
59+
return gc.CreateComment(org, repo, number, plugins.FormatResponseRaw(
60+
e.Body, e.HTMLURL, user, "You must be an org member to use this command."))
61+
}
62+
63+
regex := linkIssueRegex
64+
if !linkIssue {
65+
regex = unlinkIssueRegex
66+
}
67+
68+
matches := regex.FindStringSubmatch(e.Body)
69+
if len(matches) == 0 {
70+
return nil
71+
}
72+
73+
issues := strings.Fields(matches[1])
74+
if len(issues) == 0 {
75+
log.Info("No issue references provided in the comment.")
76+
return nil
77+
}
78+
79+
var issueRefs []string
80+
for _, issue := range issues {
81+
issueRef, err := parseIssueRef(issue, org, repo)
82+
if err != nil {
83+
log.Warnf("Skipping invalid issue: %s", issue)
84+
continue
85+
}
86+
87+
// If repo in issue reference is different from the PR, check if it exists
88+
if repo != issueRef.Repo {
89+
if _, err := gc.GetRepo(issueRef.Org, issueRef.Repo); err != nil {
90+
return fmt.Errorf("failed to get repo: %w", err)
91+
}
92+
}
93+
94+
// Verify if the issue exists
95+
fetchedIssue, err := gc.GetIssue(issueRef.Org, issueRef.Repo, issueRef.Num)
96+
if err != nil {
97+
return fmt.Errorf("failed to get issue: %w", err)
98+
}
99+
if fetchedIssue.IsPullRequest() {
100+
response := fmt.Sprintf("Skipping #%d of repo **%s** and org **%s** as it is a *pull request*.", fetchedIssue.Number, issueRef.Repo, issueRef.Org)
101+
if err := gc.CreateComment(org, repo, number, plugins.FormatResponseRaw(e.Body, e.HTMLURL, user, response)); err != nil {
102+
log.WithError(err).Error("Failed to leave comment")
103+
}
104+
continue
105+
}
106+
issueRefs = append(issueRefs, formatIssueRef(issueRef, org, repo))
107+
}
108+
109+
if len(issueRefs) == 0 {
110+
log.Info("No valid issues to process.")
111+
return nil
112+
}
113+
114+
pr, err := gc.GetPullRequest(org, repo, number)
115+
if err != nil {
116+
return fmt.Errorf("failed to get PR: %w", err)
117+
}
118+
119+
newBody := updateFixesLine(pr.Body, issueRefs, linkIssue)
120+
if newBody == pr.Body {
121+
log.Info("PR body is already up-to-date. No changes needed.")
122+
return nil
123+
}
124+
125+
if err := gc.UpdatePullRequest(org, repo, number, nil, &newBody, nil, nil, nil); err != nil {
126+
return fmt.Errorf("failed to update PR body: %w", err)
127+
}
128+
129+
log.Infof("Successfully updated the PR body")
130+
return nil
131+
}
132+
133+
func parseIssueRef(issue, defaultOrg, defaultRepo string) (*IssueRef, error) {
134+
// Handling single issue references
135+
if num, err := strconv.Atoi(issue); err == nil {
136+
return &IssueRef{Org: defaultOrg, Repo: defaultRepo, Num: num}, nil
137+
}
138+
139+
// Handling issue references in format org/repo#issue-number
140+
if strings.Contains(issue, "/") {
141+
parts := strings.Split(issue, "#")
142+
if len(parts) != 2 {
143+
return nil, fmt.Errorf("invalid issue ref: %s", issue)
144+
}
145+
orgRepo := strings.Split(parts[0], "/")
146+
if len(orgRepo) != 2 {
147+
return nil, fmt.Errorf("invalid org/repo format: %s", issue)
148+
}
149+
num, err := strconv.Atoi(parts[1])
150+
if err != nil {
151+
return nil, fmt.Errorf("invalid issue number: %s", issue)
152+
}
153+
return &IssueRef{Org: orgRepo[0], Repo: orgRepo[1], Num: num}, nil
154+
}
155+
return nil, fmt.Errorf("unrecognized issue reference: %s", issue)
156+
}
157+
158+
func formatIssueRef(ref *IssueRef, defaultOrg, defaultRepo string) string {
159+
if ref.Org == defaultOrg && ref.Repo == defaultRepo {
160+
return fmt.Sprintf("#%d", ref.Num)
161+
}
162+
return fmt.Sprintf("%s/%s#%d", ref.Org, ref.Repo, ref.Num)
163+
}
164+
165+
func updateFixesLine(body string, issueRefs []string, add bool) string {
166+
lines := strings.Split(body, "\n")
167+
var fixesLine string
168+
fixesIndex := -1
169+
issueList := make(map[string]bool)
170+
171+
// Find and parse existing Fixes line
172+
for i, line := range lines {
173+
if m := fixesRegex.FindStringSubmatch(line); m != nil {
174+
fixesIndex = i
175+
for _, i := range strings.Fields(m[1]) {
176+
issueList[i] = true
177+
}
178+
break
179+
}
180+
}
181+
182+
for _, ref := range issueRefs {
183+
if add {
184+
issueList[ref] = true
185+
} else {
186+
delete(issueList, ref)
187+
}
188+
}
189+
190+
if len(issueList) == 0 {
191+
// All linked issues have been removed, the fixes line can be deleted from the PR body.
192+
if fixesIndex != -1 {
193+
lines = append(lines[:fixesIndex], lines[fixesIndex+1:]...)
194+
}
195+
return strings.Join(lines, "\n")
196+
}
197+
198+
var newIssueRefs []string
199+
for ref := range issueList {
200+
newIssueRefs = append(newIssueRefs, ref)
201+
}
202+
203+
sort.Strings(newIssueRefs)
204+
fixesLine = "Fixes " + strings.Join(newIssueRefs, " ")
205+
206+
if fixesIndex >= 0 {
207+
lines[fixesIndex] = fixesLine
208+
} else {
209+
if len(lines) > 0 && lines[len(lines)-1] != "" {
210+
lines = append(lines, "")
211+
}
212+
lines = append(lines, fixesLine)
213+
}
214+
215+
return strings.Join(lines, "\n")
216+
}

0 commit comments

Comments
 (0)