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  }