Skip to content

Commit 2dc443d

Browse files
committed
plugins: add new plugin for linking and unlinking issues to a PR
Signed-off-by: Amulyam24 <[email protected]>
1 parent f49c615 commit 2dc443d

File tree

4 files changed

+310
-0
lines changed

4 files changed

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

0 commit comments

Comments
 (0)