sigs.k8s.io/prow@v0.0.0-20240503223140-c5e374dc7eb1/pkg/plugins/jira/jira.go (about) 1 /* 2 Copyright 2020 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 jira 18 19 import ( 20 "errors" 21 "fmt" 22 "regexp" 23 "strings" 24 "sync" 25 26 "github.com/andygrunwald/go-jira" 27 "github.com/sirupsen/logrus" 28 29 utilerrors "k8s.io/apimachinery/pkg/util/errors" 30 "k8s.io/apimachinery/pkg/util/sets" 31 "sigs.k8s.io/prow/pkg/config" 32 "sigs.k8s.io/prow/pkg/github" 33 jiraclient "sigs.k8s.io/prow/pkg/jira" 34 "sigs.k8s.io/prow/pkg/pluginhelp" 35 "sigs.k8s.io/prow/pkg/plugins" 36 ) 37 38 const ( 39 PluginName = "jira" 40 ) 41 42 var ( 43 issueNameRegex = regexp.MustCompile(`\b([a-zA-Z]+-[0-9]+)(\s|:|$|]|\))`) 44 projectCache = &threadsafeSet{data: sets.Set[string]{}} 45 ) 46 47 func extractCandidatesFromText(t string) []string { 48 matches := issueNameRegex.FindAllStringSubmatch(t, -1) 49 if matches == nil { 50 return nil 51 } 52 var result []string 53 for _, match := range matches { 54 if len(match) < 2 { 55 continue 56 } 57 result = append(result, match[1]) 58 } 59 return result 60 } 61 62 func init() { 63 plugins.RegisterGenericCommentHandler(PluginName, handleGenericComment, helpProvider) 64 } 65 66 func helpProvider(config *plugins.Configuration, _ []config.OrgRepo) (*pluginhelp.PluginHelp, error) { 67 // The Config field is omitted because this plugin is not configurable. 68 pluginHelp := &pluginhelp.PluginHelp{ 69 Description: "The Jira plugin links Pull Requests and Issues to Jira issues", 70 } 71 return pluginHelp, nil 72 } 73 74 type githubClient interface { 75 EditComment(org, repo string, id int, comment string) error 76 GetIssue(org, repo string, number int) (*github.Issue, error) 77 EditIssue(org, repo string, number int, issue *github.Issue) (*github.Issue, error) 78 } 79 80 func handleGenericComment(pc plugins.Agent, e github.GenericCommentEvent) error { 81 return handle(pc.JiraClient, pc.GitHubClient, pc.PluginConfig.Jira, pc.Logger, &e) 82 } 83 84 func handle(jc jiraclient.Client, ghc githubClient, cfg *plugins.Jira, log *logrus.Entry, e *github.GenericCommentEvent) error { 85 if projectCache.entryCount() == 0 { 86 projects, err := jc.ListProjects() 87 if err != nil { 88 return fmt.Errorf("failed to list jira projects: %w", err) 89 } 90 var projectNames []string 91 for _, project := range *projects { 92 projectNames = append(projectNames, strings.ToLower(project.Key)) 93 } 94 projectCache.insert(projectNames...) 95 } 96 97 return handleWithProjectCache(jc, ghc, cfg, log, e, projectCache) 98 } 99 100 func handleWithProjectCache(jc jiraclient.Client, ghc githubClient, cfg *plugins.Jira, log *logrus.Entry, e *github.GenericCommentEvent, projectCache *threadsafeSet) error { 101 // Nothing to do on deletion 102 if e.Action == github.GenericCommentActionDeleted { 103 return nil 104 } 105 106 jc = &projectCachingJiraClient{jc, projectCache} 107 108 issueCandidateNames := extractCandidatesFromText(e.Body) 109 issueCandidateNames = append(issueCandidateNames, extractCandidatesFromText(e.IssueTitle)...) 110 issueCandidateNames = filterOutDisabledJiraProjects(issueCandidateNames, cfg) 111 if len(issueCandidateNames) == 0 { 112 return nil 113 } 114 115 var errs []error 116 referencedIssues := sets.Set[string]{} 117 for _, match := range issueCandidateNames { 118 if referencedIssues.Has(match) { 119 continue 120 } 121 _, err := jc.GetIssue(match) 122 if err != nil { 123 if !jiraclient.IsNotFound(err) { 124 errs = append(errs, fmt.Errorf("failed to get issue %s: %w", match, err)) 125 } 126 continue 127 } 128 referencedIssues.Insert(match) 129 } 130 131 wg := &sync.WaitGroup{} 132 for _, issue := range sets.List(referencedIssues) { 133 wg.Add(1) 134 go func(issue string) { 135 defer wg.Done() 136 if err := upsertGitHubLinkToIssue(log, issue, jc, e); err != nil { 137 log.WithField("Issue", issue).WithError(err).Error("Failed to ensure GitHub link on Jira issue") 138 } 139 }(issue) 140 } 141 142 if err := updateComment(e, referencedIssues.UnsortedList(), jc.JiraURL(), ghc); err != nil { 143 errs = append(errs, fmt.Errorf("failed to update comment: %w", err)) 144 } 145 wg.Wait() 146 147 return utilerrors.NewAggregate(errs) 148 } 149 150 func updateComment(e *github.GenericCommentEvent, validIssues []string, jiraBaseURL string, ghc githubClient) error { 151 withLinks := insertLinksIntoComment(e.Body, validIssues, jiraBaseURL) 152 if withLinks == e.Body { 153 return nil 154 } 155 if e.CommentID != nil { 156 return ghc.EditComment(e.Repo.Owner.Login, e.Repo.Name, *e.CommentID, withLinks) 157 } 158 159 issue, err := ghc.GetIssue(e.Repo.Owner.Login, e.Repo.Name, e.Number) 160 if err != nil { 161 return fmt.Errorf("failed to get issue %s/%s#%d: %w", e.Repo.Owner.Login, e.Repo.Name, e.Number, err) 162 } 163 164 // Check for the diff on the issues body in case the even't didn't have a commentID but did not originate 165 // in issue creation, e.G. PRReviewEvent 166 if withLinks := insertLinksIntoComment(issue.Body, validIssues, jiraBaseURL); withLinks != issue.Body { 167 issue.Body = withLinks 168 _, err := ghc.EditIssue(e.Repo.Owner.Login, e.Repo.Name, e.Number, issue) 169 return err 170 } 171 172 return nil 173 } 174 175 type line struct { 176 content string 177 replacing bool 178 } 179 180 func getLines(text string) []line { 181 var lines []line 182 rawLines := strings.Split(text, "\n") 183 var prefixCount int 184 for _, rawLine := range rawLines { 185 if strings.HasPrefix(rawLine, "```") { 186 prefixCount++ 187 } 188 l := line{content: rawLine, replacing: true} 189 190 // Literal codeblocks 191 if strings.HasPrefix(rawLine, " ") { 192 l.replacing = false 193 } 194 if prefixCount%2 == 1 { 195 l.replacing = false 196 } 197 lines = append(lines, l) 198 } 199 return lines 200 } 201 202 func insertLinksIntoComment(body string, issueNames []string, jiraBaseURL string) string { 203 var linesWithLinks []string 204 lines := getLines(body) 205 for _, line := range lines { 206 if line.replacing { 207 linesWithLinks = append(linesWithLinks, insertLinksIntoLine(line.content, issueNames, jiraBaseURL)) 208 continue 209 } 210 linesWithLinks = append(linesWithLinks, line.content) 211 } 212 return strings.Join(linesWithLinks, "\n") 213 } 214 215 func insertLinksIntoLine(line string, issueNames []string, jiraBaseURL string) string { 216 for _, issue := range issueNames { 217 replacement := fmt.Sprintf("[%s](%s/browse/%s)", issue, jiraBaseURL, issue) 218 line = replaceStringIfNeeded(line, issue, replacement) 219 } 220 return line 221 } 222 223 // replaceStringIfNeeded replaces a string if it is not prefixed by: 224 // * `[` which we use as heuristic for "Already replaced", 225 // * `/` which we use as heuristic for "Part of a link in a previous replacement", 226 // * ``` (backtick) which we use as heuristic for "Inline code", 227 // * `-` (dash) to prevent replacing a substring that accidentally matches a JIRA issue. 228 // If golang would support back-references in regex replacements, this would have been a lot 229 // simpler. 230 func replaceStringIfNeeded(text, old, new string) string { 231 if old == "" { 232 return text 233 } 234 235 var result string 236 237 // Golangs stdlib has no strings.IndexAll, only funcs to get the first 238 // or last index for a substring. Definitions/condition/assignments are not 239 // in the header of the loop because that makes it completely unreadable. 240 var allOldIdx []int 241 var startingIdx int 242 for { 243 idx := strings.Index(text[startingIdx:], old) 244 if idx == -1 { 245 break 246 } 247 idx = startingIdx + idx 248 // Since we always look for a non-empty string, we know that idx++ 249 // can not be out of bounds 250 allOldIdx = append(allOldIdx, idx) 251 startingIdx = idx + 1 252 } 253 254 startingIdx = 0 255 for _, idx := range allOldIdx { 256 result += text[startingIdx:idx] 257 if idx == 0 || !strings.Contains("[/`-", string(text[idx-1])) { 258 result += new 259 } else { 260 result += old 261 } 262 startingIdx = idx + len(old) 263 } 264 result += text[startingIdx:] 265 266 return result 267 } 268 269 func upsertGitHubLinkToIssue(log *logrus.Entry, issueID string, jc jiraclient.Client, e *github.GenericCommentEvent) error { 270 links, err := jc.GetRemoteLinks(issueID) 271 if err != nil { 272 return fmt.Errorf("failed to get remote links: %w", err) 273 } 274 275 url := e.HTMLURL 276 if idx := strings.Index(url, "#"); idx != -1 { 277 url = url[:idx] 278 } 279 280 title := fmt.Sprintf("%s#%d: %s", e.Repo.FullName, e.Number, e.IssueTitle) 281 var existingLink *jira.RemoteLink 282 283 // Check if the same link exists already. We consider two links to be the same if the have the same URL. 284 // Once it is found we have two possibilities: either it is really equal (just skip the upsert) or it 285 // has to be updated (perform an upsert) 286 for _, link := range links { 287 if link.Object.URL == url { 288 if title == link.Object.Title { 289 return nil 290 } 291 link := link 292 existingLink = &link 293 break 294 } 295 } 296 297 link := &jira.RemoteLink{ 298 Object: &jira.RemoteLinkObject{ 299 URL: url, 300 Title: title, 301 Icon: &jira.RemoteLinkIcon{ 302 Url16x16: "https://github.com/favicon.ico", 303 Title: "GitHub", 304 }, 305 }, 306 } 307 308 if existingLink != nil { 309 existingLink.Object = link.Object 310 if err := jc.UpdateRemoteLink(issueID, existingLink); err != nil { 311 return fmt.Errorf("failed to update remote link: %w", err) 312 } 313 log.Info("Updated jira link") 314 } else { 315 if _, err := jc.AddRemoteLink(issueID, link); err != nil { 316 return fmt.Errorf("failed to add remote link: %w", err) 317 } 318 log.Info("Created jira link") 319 } 320 321 return nil 322 } 323 324 func filterOutDisabledJiraProjects(candidateNames []string, cfg *plugins.Jira) []string { 325 if cfg == nil { 326 return candidateNames 327 } 328 329 candidateSet := sets.New[string](candidateNames...) 330 for _, excludedProject := range cfg.DisabledJiraProjects { 331 for _, candidate := range candidateNames { 332 if strings.HasPrefix(strings.ToLower(candidate), strings.ToLower(excludedProject)) { 333 candidateSet.Delete(candidate) 334 } 335 } 336 } 337 338 return candidateSet.UnsortedList() 339 } 340 341 // projectCachingJiraClient caches 404 for projects and uses them to introduce 342 // a fastpath in GetIssue for returning a 404. 343 type projectCachingJiraClient struct { 344 jiraclient.Client 345 cache *threadsafeSet 346 } 347 348 func (c *projectCachingJiraClient) GetIssue(id string) (*jira.Issue, error) { 349 projectName := strings.ToLower(strings.Split(id, "-")[0]) 350 if !c.cache.has(projectName) { 351 return nil, jiraclient.NewNotFoundError(errors.New("404 from cache")) 352 } 353 result, err := c.Client.GetIssue(id) 354 if err != nil { 355 return nil, err 356 } 357 return result, nil 358 } 359 360 type threadsafeSet struct { 361 data sets.Set[string] 362 lock sync.RWMutex 363 } 364 365 func (s *threadsafeSet) has(projectName string) bool { 366 s.lock.RLock() 367 defer s.lock.RUnlock() 368 return s.data.Has(projectName) 369 } 370 371 func (s *threadsafeSet) insert(projectName ...string) { 372 s.lock.Lock() 373 defer s.lock.Unlock() 374 s.data.Insert(projectName...) 375 } 376 377 func (s *threadsafeSet) entryCount() int { 378 s.lock.RLock() 379 defer s.lock.RUnlock() 380 return len(s.data) 381 }