github.com/zppinho/prow@v0.0.0-20240510014325-1738badeb017/pkg/plugins/releasenote/releasenote.go (about)

     1  /*
     2  Copyright 2016 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 releasenote
    18  
    19  import (
    20  	"fmt"
    21  	"regexp"
    22  	"strconv"
    23  	"strings"
    24  
    25  	"sigs.k8s.io/prow/pkg/labels"
    26  
    27  	"github.com/sirupsen/logrus"
    28  
    29  	"k8s.io/apimachinery/pkg/util/sets"
    30  	"sigs.k8s.io/prow/pkg/config"
    31  	"sigs.k8s.io/prow/pkg/github"
    32  	"sigs.k8s.io/prow/pkg/pluginhelp"
    33  	"sigs.k8s.io/prow/pkg/plugins"
    34  )
    35  
    36  const (
    37  	// PluginName defines this plugin's registered name.
    38  	PluginName = "release-note"
    39  )
    40  const (
    41  	releaseNoteFormat            = `Adding the "%s" label because no release-note block was detected, please follow our [release note process](https://git.k8s.io/community/contributors/guide/release-notes.md) to remove it.`
    42  	parentReleaseNoteFormat      = `All 'parent' PRs of a cherry-pick PR must have one of the %q or %q labels, or this PR must follow the standard/parent release note labeling requirement.`
    43  	releaseNoteDeprecationFormat = `Adding the "%s" label and removing any existing "%s" label because there is a "%s" label on the PR.`
    44  
    45  	actionRequiredNote = "action required"
    46  )
    47  
    48  var (
    49  	releaseNoteBody            = fmt.Sprintf(releaseNoteFormat, labels.ReleaseNoteLabelNeeded)
    50  	parentReleaseNoteBody      = fmt.Sprintf(parentReleaseNoteFormat, labels.ReleaseNote, labels.ReleaseNoteActionRequired)
    51  	releaseNoteDeprecationBody = fmt.Sprintf(releaseNoteDeprecationFormat, labels.ReleaseNoteLabelNeeded, labels.ReleaseNoteNone, labels.DeprecationLabel)
    52  
    53  	noteMatcherRE = regexp.MustCompile(`(?s)(?:Release note\*\*:\s*(?:<!--[^<>]*-->\s*)?` + "```(?:release-note)?|```release-note)(.+?)```")
    54  	cpRe          = regexp.MustCompile(`Cherry pick of #([[:digit:]]+) on release-([[:digit:]]+\.[[:digit:]]+).`)
    55  	noneRe        = regexp.MustCompile(`(?i)^\W*(NONE|NO)\W*$`)
    56  
    57  	allRNLabels = []string{
    58  		labels.ReleaseNoteNone,
    59  		labels.ReleaseNoteActionRequired,
    60  		labels.ReleaseNoteLabelNeeded,
    61  		labels.ReleaseNote,
    62  	}
    63  
    64  	releaseNoteRe               = regexp.MustCompile(`(?mi)^/release-note\s*$`)
    65  	releaseNoteEditRe           = regexp.MustCompile(`(?mi)^/release-note-edit\s*$`)
    66  	releaseNoteNoneRe           = regexp.MustCompile(`(?mi)^/release-note-none\s*$`)
    67  	releaseNoteActionRequiredRe = regexp.MustCompile(`(?mi)^/release-note-action-required\s*$`)
    68  )
    69  
    70  func init() {
    71  	plugins.RegisterIssueCommentHandler(PluginName, handleIssueComment, helpProvider)
    72  	plugins.RegisterPullRequestHandler(PluginName, handlePullRequest, helpProvider)
    73  }
    74  
    75  func helpProvider(_ *plugins.Configuration, _ []config.OrgRepo) (*pluginhelp.PluginHelp, error) {
    76  	pluginHelp := &pluginhelp.PluginHelp{
    77  		Description: `The releasenote plugin implements a release note process that uses a markdown 'release-note' code block to associate a release note with a pull request. Until the 'release-note' block in the pull request body is populated the PR will be assigned the '` + labels.ReleaseNoteLabelNeeded + `' label.
    78  <br>There are three valid types of release notes that can replace this label:
    79  <ol><li>PRs with a normal release note in the 'release-note' block are given the label '` + labels.ReleaseNote + `'.</li>
    80  <li>PRs that have a release note of 'none' in the block are given the label '` + labels.ReleaseNoteNone + `' to indicate that the PR does not warrant a release note.</li>
    81  <li>PRs that contain 'action required' in their 'release-note' block are given the label '` + labels.ReleaseNoteActionRequired + `' to indicate that the PR introduces potentially breaking changes that necessitate user action before upgrading to the release.</li></ol>
    82  ` + "To use the plugin, in the pull request body text:\n\n```release-note\n<release note content>\n```",
    83  	}
    84  	// NOTE: the other two commands re deprecated, so we're not documenting them
    85  	pluginHelp.AddCommand(pluginhelp.Command{
    86  		Usage:       "/release-note-none",
    87  		Description: "Adds the '" + labels.ReleaseNoteNone + `' label to indicate that the PR does not warrant a release note. This is deprecated and ideally <a href="https://git.k8s.io/community/contributors/guide/release-notes.md">the release note process</a> should be followed in the PR body instead.`,
    88  		WhoCanUse:   "PR Authors and Org Members.",
    89  		Examples:    []string{"/release-note-none"},
    90  	})
    91  	pluginHelp.AddCommand(pluginhelp.Command{
    92  		Usage:       "/release-note-edit",
    93  		Description: "Replaces the release note block in the top level comment with the provided one.",
    94  		WhoCanUse:   "Org Members.",
    95  		Examples:    []string{"/release-note-edit\r\n```release-note\r\nThe new release note\r\n```"},
    96  	})
    97  	return pluginHelp, nil
    98  }
    99  
   100  type githubClient interface {
   101  	IsMember(org, user string) (bool, error)
   102  	CreateComment(owner, repo string, number int, comment string) error
   103  	AddLabel(owner, repo string, number int, label string) error
   104  	RemoveLabel(owner, repo string, number int, label string) error
   105  	GetIssueLabels(org, repo string, number int) ([]github.Label, error)
   106  	ListIssueComments(org, repo string, number int) ([]github.IssueComment, error)
   107  	DeleteStaleComments(org, repo string, number int, comments []github.IssueComment, isStale func(github.IssueComment) bool) error
   108  	BotUserChecker() (func(candidate string) bool, error)
   109  	EditIssue(org, repo string, number int, issue *github.Issue) (*github.Issue, error)
   110  }
   111  
   112  func handleIssueComment(pc plugins.Agent, ic github.IssueCommentEvent) error {
   113  	return handleComment(pc.GitHubClient, pc.Logger, ic)
   114  }
   115  
   116  func handleComment(gc githubClient, log *logrus.Entry, ic github.IssueCommentEvent) error {
   117  	// Only consider PRs and new comments.
   118  	if !ic.Issue.IsPullRequest() || ic.Action != github.IssueCommentActionCreated {
   119  		return nil
   120  	}
   121  
   122  	org := ic.Repo.Owner.Login
   123  	repo := ic.Repo.Name
   124  	number := ic.Issue.Number
   125  
   126  	if releaseNoteEditRe.MatchString(ic.Comment.Body) {
   127  		return editReleaseNote(gc, log, ic)
   128  	}
   129  
   130  	// Which label does the comment want us to add?
   131  	var nl string
   132  	switch {
   133  	case releaseNoteRe.MatchString(ic.Comment.Body):
   134  		nl = labels.ReleaseNote
   135  	case releaseNoteNoneRe.MatchString(ic.Comment.Body):
   136  		nl = labels.ReleaseNoteNone
   137  	case releaseNoteActionRequiredRe.MatchString(ic.Comment.Body):
   138  		nl = labels.ReleaseNoteActionRequired
   139  	default:
   140  		return nil
   141  	}
   142  
   143  	// Emit deprecation warning for /release-note and /release-note-action-required.
   144  	if nl == labels.ReleaseNote || nl == labels.ReleaseNoteActionRequired {
   145  		format := "the `/%s` and `/%s` commands have been deprecated.\nPlease edit the `release-note` block in the PR body text to include the release note. If the release note requires additional action include the string `action required` in the release note. For example:\n````\n```release-note\nSome release note with action required.\n```\n````"
   146  		resp := fmt.Sprintf(format, labels.ReleaseNote, labels.ReleaseNoteActionRequired)
   147  		return gc.CreateComment(org, repo, number, plugins.FormatICResponse(ic.Comment, resp))
   148  	}
   149  
   150  	// Only allow authors and org members to add currentLabels.
   151  	isMember, err := gc.IsMember(ic.Repo.Owner.Login, ic.Comment.User.Login)
   152  	if err != nil {
   153  		return err
   154  	}
   155  
   156  	isAuthor := ic.Issue.IsAuthor(ic.Comment.User.Login)
   157  
   158  	if !isMember && !isAuthor {
   159  		format := "you can only set the release note label to %s if you are the PR author or an org member."
   160  		resp := fmt.Sprintf(format, labels.ReleaseNoteNone)
   161  		return gc.CreateComment(org, repo, number, plugins.FormatICResponse(ic.Comment, resp))
   162  	}
   163  
   164  	// Don't allow the /release-note-none command if the release-note block contains a valid release note.
   165  	blockNL := determineReleaseNoteLabel(ic.Issue.Body, labelsSet(ic.Issue.Labels))
   166  	if blockNL == labels.ReleaseNote || blockNL == labels.ReleaseNoteActionRequired {
   167  		format := "you can only set the release note label to %s if the release-note block in the PR body text is empty or \"none\"."
   168  		resp := fmt.Sprintf(format, labels.ReleaseNoteNone)
   169  		return gc.CreateComment(org, repo, number, plugins.FormatICResponse(ic.Comment, resp))
   170  	}
   171  
   172  	// Don't allow /release-note-none command if the PR has a 'kind/deprecation'
   173  	// label.
   174  	if ic.Issue.HasLabel(labels.DeprecationLabel) {
   175  		format := "you can not set the release note label to \"%s\" because the PR has the label \"%s\"."
   176  		resp := fmt.Sprintf(format, labels.ReleaseNoteNone, labels.DeprecationLabel)
   177  		return gc.CreateComment(org, repo, number, plugins.FormatICResponse(ic.Comment, resp))
   178  	}
   179  
   180  	if !ic.Issue.HasLabel(labels.ReleaseNoteNone) {
   181  		if err := gc.AddLabel(org, repo, number, labels.ReleaseNoteNone); err != nil {
   182  			return err
   183  		}
   184  	}
   185  
   186  	currentLabels := sets.Set[string]{}
   187  	for _, label := range ic.Issue.Labels {
   188  		currentLabels.Insert(label.Name)
   189  	}
   190  	// Remove all other release-note-* currentLabels if necessary.
   191  	return removeOtherLabels(
   192  		func(l string) error {
   193  			return gc.RemoveLabel(org, repo, number, l)
   194  		},
   195  		labels.ReleaseNoteNone,
   196  		allRNLabels,
   197  		currentLabels,
   198  	)
   199  }
   200  
   201  func removeOtherLabels(remover func(string) error, label string, labelSet []string, currentLabels sets.Set[string]) error {
   202  	var errs []error
   203  	for _, elem := range labelSet {
   204  		if elem != label && currentLabels.Has(elem) {
   205  			if err := remover(elem); err != nil {
   206  				errs = append(errs, err)
   207  			}
   208  			currentLabels.Delete(elem)
   209  		}
   210  	}
   211  	if len(errs) > 0 {
   212  		return fmt.Errorf("encountered %d errors setting labels: %v", len(errs), errs)
   213  	}
   214  	return nil
   215  }
   216  
   217  func handlePullRequest(pc plugins.Agent, pr github.PullRequestEvent) error {
   218  	return handlePR(pc.GitHubClient, pc.Logger, &pr)
   219  }
   220  
   221  func shouldHandlePR(pr *github.PullRequestEvent) bool {
   222  	// Only consider events that edit the PR body or add a label
   223  	if pr.Action != github.PullRequestActionOpened &&
   224  		pr.Action != github.PullRequestActionEdited &&
   225  		pr.Action != github.PullRequestActionLabeled {
   226  		return false
   227  	}
   228  
   229  	// Ignoring unrelated PR labels prevents duplicate release note messages
   230  	if pr.Action == github.PullRequestActionLabeled {
   231  		for _, rnLabel := range allRNLabels {
   232  			if pr.Label.Name == rnLabel {
   233  				return true
   234  			}
   235  		}
   236  		return false
   237  	}
   238  
   239  	return true
   240  }
   241  
   242  func handlePR(gc githubClient, log *logrus.Entry, pr *github.PullRequestEvent) error {
   243  	if !shouldHandlePR(pr) {
   244  		return nil
   245  	}
   246  	org := pr.Repo.Owner.Login
   247  	repo := pr.Repo.Name
   248  
   249  	prInitLabels, err := gc.GetIssueLabels(org, repo, pr.Number)
   250  	if err != nil {
   251  		return fmt.Errorf("failed to list labels on PR #%d. err: %w", pr.Number, err)
   252  	}
   253  	prLabels := labelsSet(prInitLabels)
   254  
   255  	var comments []github.IssueComment
   256  	labelToAdd := determineReleaseNoteLabel(pr.PullRequest.Body, prLabels)
   257  
   258  	if labelToAdd == labels.ReleaseNoteLabelNeeded {
   259  		//Do not add do not merge label when the PR is merged
   260  		if pr.PullRequest.Merged {
   261  			return nil
   262  		}
   263  		if !prMustFollowRelNoteProcess(gc, log, pr, prLabels, true) {
   264  			ensureNoRelNoteNeededLabel(gc, log, pr, prLabels)
   265  			return clearStaleComments(gc, log, pr, prLabels, nil)
   266  		}
   267  
   268  		if prLabels.Has(labels.DeprecationLabel) {
   269  			if !prLabels.Has(labels.ReleaseNoteLabelNeeded) {
   270  				comment := plugins.FormatSimpleResponse(releaseNoteDeprecationBody)
   271  				if err := gc.CreateComment(org, repo, pr.Number, comment); err != nil {
   272  					log.WithError(err).Errorf("Failed to comment on %s/%s#%d with comment %q.", org, repo, pr.Number, comment)
   273  				}
   274  			}
   275  		} else {
   276  			comments, err = gc.ListIssueComments(org, repo, pr.Number)
   277  			if err != nil {
   278  				return fmt.Errorf("failed to list comments on %s/%s#%d. err: %w", org, repo, pr.Number, err)
   279  			}
   280  			if containsNoneCommand(comments) {
   281  				labelToAdd = labels.ReleaseNoteNone
   282  			} else if !prLabels.Has(labels.ReleaseNoteLabelNeeded) {
   283  				comment := plugins.FormatSimpleResponse(releaseNoteBody)
   284  				if err := gc.CreateComment(org, repo, pr.Number, comment); err != nil {
   285  					log.WithError(err).Errorf("Failed to comment on %s/%s#%d with comment %q.", org, repo, pr.Number, comment)
   286  				}
   287  			}
   288  		}
   289  	}
   290  
   291  	// Add the label if needed
   292  	if !prLabels.Has(labelToAdd) {
   293  		if err = gc.AddLabel(org, repo, pr.Number, labelToAdd); err != nil {
   294  			return err
   295  		}
   296  		prLabels.Insert(labelToAdd)
   297  	}
   298  
   299  	err = removeOtherLabels(
   300  		func(l string) error {
   301  			return gc.RemoveLabel(org, repo, pr.Number, l)
   302  		},
   303  		labelToAdd,
   304  		allRNLabels,
   305  		prLabels,
   306  	)
   307  	if err != nil {
   308  		log.Error(err)
   309  	}
   310  
   311  	return clearStaleComments(gc, log, pr, prLabels, comments)
   312  }
   313  
   314  // clearStaleComments deletes old comments that are no longer applicable.
   315  func clearStaleComments(gc githubClient, log *logrus.Entry, pr *github.PullRequestEvent, prLabels sets.Set[string], comments []github.IssueComment) error {
   316  	// If the PR must follow the process and hasn't yet completed the process, don't remove comments.
   317  	if prMustFollowRelNoteProcess(gc, log, pr, prLabels, false) && !releaseNoteAlreadyAdded(prLabels) {
   318  		return nil
   319  	}
   320  	botUserChecker, err := gc.BotUserChecker()
   321  	if err != nil {
   322  		return err
   323  	}
   324  	return gc.DeleteStaleComments(
   325  		pr.Repo.Owner.Login,
   326  		pr.Repo.Name,
   327  		pr.Number,
   328  		comments,
   329  		func(c github.IssueComment) bool { // isStale function
   330  			return botUserChecker(c.User.Login) &&
   331  				(strings.Contains(c.Body, releaseNoteBody) ||
   332  					strings.Contains(c.Body, parentReleaseNoteBody))
   333  		},
   334  	)
   335  }
   336  
   337  func containsNoneCommand(comments []github.IssueComment) bool {
   338  	for _, c := range comments {
   339  		if releaseNoteNoneRe.MatchString(c.Body) {
   340  			return true
   341  		}
   342  	}
   343  	return false
   344  }
   345  
   346  func ensureNoRelNoteNeededLabel(gc githubClient, log *logrus.Entry, pr *github.PullRequestEvent, prLabels sets.Set[string]) {
   347  	org := pr.Repo.Owner.Login
   348  	repo := pr.Repo.Name
   349  	format := "Failed to remove the label %q from %s/%s#%d."
   350  	if prLabels.Has(labels.ReleaseNoteLabelNeeded) {
   351  		if err := gc.RemoveLabel(org, repo, pr.Number, labels.ReleaseNoteLabelNeeded); err != nil {
   352  			log.WithError(err).Errorf(format, labels.ReleaseNoteLabelNeeded, org, repo, pr.Number)
   353  		}
   354  	}
   355  }
   356  
   357  // determineReleaseNoteLabel returns the label to be added based on the contents of the 'release-note'
   358  // section of a PR's body text, as well as the set of PR's labels.
   359  func determineReleaseNoteLabel(body string, prLabels sets.Set[string]) string {
   360  	composedReleaseNote := strings.ToLower(strings.TrimSpace(getReleaseNote(body)))
   361  	hasNoneNoteInPRBody := noneRe.MatchString(composedReleaseNote)
   362  	hasDeprecationLabel := prLabels.Has(labels.DeprecationLabel)
   363  
   364  	switch {
   365  	case composedReleaseNote == "" && hasDeprecationLabel:
   366  		return labels.ReleaseNoteLabelNeeded
   367  	case composedReleaseNote == "" && prLabels.Has(labels.ReleaseNoteNone):
   368  		return labels.ReleaseNoteNone
   369  	case composedReleaseNote == "":
   370  		return labels.ReleaseNoteLabelNeeded
   371  	case hasNoneNoteInPRBody && hasDeprecationLabel:
   372  		return labels.ReleaseNoteLabelNeeded
   373  	case hasNoneNoteInPRBody:
   374  		return labels.ReleaseNoteNone
   375  	case strings.Contains(composedReleaseNote, actionRequiredNote):
   376  		return labels.ReleaseNoteActionRequired
   377  	default:
   378  		return labels.ReleaseNote
   379  	}
   380  }
   381  
   382  // getReleaseNote returns the release note from a PR body
   383  // assumes that the PR body followed the PR template
   384  func getReleaseNote(body string) string {
   385  	potentialMatch := noteMatcherRE.FindStringSubmatch(body)
   386  	if potentialMatch == nil {
   387  		return ""
   388  	}
   389  	return strings.TrimSpace(potentialMatch[1])
   390  }
   391  
   392  func releaseNoteAlreadyAdded(prLabels sets.Set[string]) bool {
   393  	return prLabels.HasAny(labels.ReleaseNote, labels.ReleaseNoteActionRequired, labels.ReleaseNoteNone)
   394  }
   395  
   396  func prMustFollowRelNoteProcess(gc githubClient, log *logrus.Entry, pr *github.PullRequestEvent, prLabels sets.Set[string], comment bool) bool {
   397  	if pr.PullRequest.Base.Ref == "master" {
   398  		return true
   399  	}
   400  
   401  	parents := getCherrypickParentPRNums(pr.PullRequest.Body)
   402  	// if it has no parents it needs to follow the release note process
   403  	if len(parents) == 0 {
   404  		return true
   405  	}
   406  
   407  	org := pr.Repo.Owner.Login
   408  	repo := pr.Repo.Name
   409  
   410  	var notelessParents []string
   411  	for _, parent := range parents {
   412  		// If the parent didn't set a release note, the CP must
   413  		parentLabels, err := gc.GetIssueLabels(org, repo, parent)
   414  		if err != nil {
   415  			log.WithError(err).Errorf("Failed to list labels on PR #%d (parent of #%d).", parent, pr.Number)
   416  			continue
   417  		}
   418  		if !github.HasLabel(labels.ReleaseNote, parentLabels) &&
   419  			!github.HasLabel(labels.ReleaseNoteActionRequired, parentLabels) {
   420  			notelessParents = append(notelessParents, "#"+strconv.Itoa(parent))
   421  		}
   422  	}
   423  	if len(notelessParents) == 0 {
   424  		// All of the parents set the releaseNote or releaseNoteActionRequired label,
   425  		// so this cherrypick PR needs to do nothing.
   426  		return false
   427  	}
   428  
   429  	if comment && !prLabels.Has(labels.ReleaseNoteLabelNeeded) {
   430  		comment := plugins.FormatResponse(
   431  			pr.PullRequest.User.Login,
   432  			parentReleaseNoteBody,
   433  			fmt.Sprintf("The following parent PRs have neither the %q nor the %q labels: %s.",
   434  				labels.ReleaseNote,
   435  				labels.ReleaseNoteActionRequired,
   436  				strings.Join(notelessParents, ", "),
   437  			),
   438  		)
   439  		if err := gc.CreateComment(org, repo, pr.Number, comment); err != nil {
   440  			log.WithError(err).Errorf("Error creating comment on %s/%s#%d with comment %q.", org, repo, pr.Number, comment)
   441  		}
   442  	}
   443  	return true
   444  }
   445  
   446  func getCherrypickParentPRNums(body string) []int {
   447  	lines := strings.Split(body, "\n")
   448  
   449  	var out []int
   450  	for _, line := range lines {
   451  		matches := cpRe.FindStringSubmatch(line)
   452  		if len(matches) != 3 {
   453  			continue
   454  		}
   455  		parentNum, err := strconv.Atoi(matches[1])
   456  		if err != nil {
   457  			continue
   458  		}
   459  		out = append(out, parentNum)
   460  	}
   461  	return out
   462  }
   463  
   464  func labelsSet(labels []github.Label) sets.Set[string] {
   465  	prLabels := sets.Set[string]{}
   466  	for _, label := range labels {
   467  		prLabels.Insert(label.Name)
   468  	}
   469  	return prLabels
   470  }
   471  
   472  // editReleaseNote is used to edit the top level release note.
   473  // Since the edit itself triggers an event we don't need to worry
   474  // about labels because the plugin will run again and handle them.
   475  func editReleaseNote(gc githubClient, log *logrus.Entry, ic github.IssueCommentEvent) error {
   476  	org := ic.Repo.Owner.Login
   477  	repo := ic.Repo.Name
   478  	user := ic.Comment.User.Login
   479  
   480  	isMember, err := gc.IsMember(org, user)
   481  	if err != nil {
   482  		return fmt.Errorf("unable to fetch if %s is an org member of %s: %w", user, org, err)
   483  	}
   484  	if !isMember {
   485  		return gc.CreateComment(
   486  			org, repo, ic.Issue.Number,
   487  			plugins.FormatResponseRaw(ic.Comment.Body, ic.Issue.HTMLURL, user, "You must be an org member to edit the release note."),
   488  		)
   489  	}
   490  
   491  	newNote := getReleaseNote(ic.Comment.Body)
   492  	if newNote == "" {
   493  		return gc.CreateComment(
   494  			org, repo, ic.Issue.Number,
   495  			plugins.FormatResponseRaw(ic.Comment.Body, ic.Comment.HTMLURL, user, "/release-note-edit must be used with a release note block."),
   496  		)
   497  	}
   498  
   499  	// 0: start of release note block
   500  	// 1: end of release note block
   501  	// 2: start of release note content
   502  	// 3: end of release note content
   503  	i := noteMatcherRE.FindStringSubmatchIndex(ic.Issue.Body)
   504  	if len(i) != 4 {
   505  		return gc.CreateComment(
   506  			org, repo, ic.Issue.Number,
   507  			plugins.FormatResponseRaw(ic.Comment.Body, ic.Comment.HTMLURL, user, "/release-note-edit must be used with a single release note block."),
   508  		)
   509  	}
   510  	// Splice in the contents of the new release note block to the top level comment
   511  	// This accounts for all older regex matches
   512  	b := []byte(ic.Issue.Body)
   513  	replaced := append(b[:i[2]], append([]byte("\r\n"+strings.TrimSpace(newNote)+"\r\n"), b[i[3]:]...)...)
   514  	ic.Issue.Body = string(replaced)
   515  
   516  	_, err = gc.EditIssue(ic.Repo.Owner.Login, ic.Repo.Name, ic.Issue.Number, &ic.Issue)
   517  	if err != nil {
   518  		return fmt.Errorf("unable to edit issue: %w", err)
   519  	}
   520  	log.WithFields(logrus.Fields{
   521  		"user":        user,
   522  		"org":         org,
   523  		"repo":        repo,
   524  		"issueNumber": ic.Issue.Number,
   525  	}).Info("edited release note")
   526  	return nil
   527  }