github.com/zppinho/prow@v0.0.0-20240510014325-1738badeb017/pkg/plugins/dco/dco.go (about) 1 /* 2 Copyright 2018 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 dco implements a DCO (https://developercertificate.org/) checker plugin 18 package dco 19 20 import ( 21 "fmt" 22 "regexp" 23 "strings" 24 25 "github.com/sirupsen/logrus" 26 27 "sigs.k8s.io/prow/pkg/config" 28 "sigs.k8s.io/prow/pkg/github" 29 "sigs.k8s.io/prow/pkg/pluginhelp" 30 "sigs.k8s.io/prow/pkg/plugins" 31 "sigs.k8s.io/prow/pkg/plugins/trigger" 32 ) 33 34 const ( 35 pluginName = "dco" 36 dcoContextName = "dco" 37 dcoContextMessageFailed = "Commits in PR missing Signed-off-by" 38 dcoContextMessageSuccess = "All commits have Signed-off-by" 39 40 dcoYesLabel = "dco-signoff: yes" 41 dcoNoLabel = "dco-signoff: no" 42 dcoMsgPruneMatch = "Thanks for your pull request. Before we can look at it, you'll need to add a 'DCO signoff' to your commits." 43 dcoNotFoundMessage = `Thanks for your pull request. Before we can look at it, you'll need to add a 'DCO signoff' to your commits. 44 45 :memo: **Please follow instructions in the [contributing guide](%s) to update your commits with the DCO** 46 47 Full details of the Developer Certificate of Origin can be found at [developercertificate.org](https://developercertificate.org/). 48 49 **The list of commits missing DCO signoff**: 50 51 %s 52 53 <details> 54 55 %s 56 </details> 57 ` 58 ) 59 60 var ( 61 checkDCORe = regexp.MustCompile(`(?mi)^/check-dco\s*$`) 62 testRe = regexp.MustCompile(`(?mi)^signed-off-by:`) 63 ) 64 65 func init() { 66 plugins.RegisterPullRequestHandler(pluginName, handlePullRequestEvent, helpProvider) 67 plugins.RegisterGenericCommentHandler(pluginName, handleCommentEvent, helpProvider) 68 } 69 70 func helpProvider(config *plugins.Configuration, enabledRepos []config.OrgRepo) (*pluginhelp.PluginHelp, error) { 71 configInfo := map[string]string{} 72 for _, repo := range enabledRepos { 73 opts := config.DcoFor(repo.Org, repo.Repo) 74 if opts.SkipDCOCheckForMembers || opts.SkipDCOCheckForCollaborators { 75 configInfo[repo.String()] = fmt.Sprintf("The trusted GitHub organization for this repository is %q.", repo) 76 } 77 } 78 yamlSnippet, err := plugins.CommentMap.GenYaml(&plugins.Configuration{ 79 Dco: map[string]*plugins.Dco{ 80 "org/repo": { 81 SkipDCOCheckForMembers: true, 82 TrustedOrg: "org", 83 SkipDCOCheckForCollaborators: true, 84 ContributingRepo: "other-org/other-repo", 85 ContributingBranch: "main", 86 ContributingPath: "docs/CONTRIBUTING.md", 87 }, 88 }, 89 }) 90 if err != nil { 91 logrus.WithError(err).Warnf("cannot generate comments for %s plugin", pluginName) 92 } 93 pluginHelp := &pluginhelp.PluginHelp{ 94 Description: "The dco plugin checks pull request commits for 'DCO sign off' and maintains the '" + dcoContextName + "' status context, as well as the 'dco' label.", 95 Config: configInfo, 96 Snippet: yamlSnippet, 97 } 98 pluginHelp.AddCommand(pluginhelp.Command{ 99 Usage: "/check-dco", 100 Description: "Forces rechecking of the DCO status.", 101 Featured: true, 102 WhoCanUse: "Anyone", 103 Examples: []string{"/check-dco"}, 104 }) 105 return pluginHelp, nil 106 } 107 108 type gitHubClient interface { 109 IsMember(org, user string) (bool, error) 110 IsCollaborator(org, repo, user string) (bool, error) 111 CreateComment(owner, repo string, number int, comment string) error 112 GetIssueLabels(org, repo string, number int) ([]github.Label, error) 113 AddLabel(owner, repo string, number int, label string) error 114 RemoveLabel(owner, repo string, number int, label string) error 115 CreateStatus(owner, repo, ref string, status github.Status) error 116 ListPullRequestCommits(org, repo string, number int) ([]github.RepositoryCommit, error) 117 GetPullRequest(owner, repo string, number int) (*github.PullRequest, error) 118 GetCombinedStatus(org, repo, ref string) (*github.CombinedStatus, error) 119 BotUserChecker() (func(candidate string) bool, error) 120 } 121 122 type commentPruner interface { 123 PruneComments(shouldPrune func(github.IssueComment) bool) 124 } 125 126 // filterTrustedUsers checks whether the commits are from a trusted user and returns those that are not 127 func filterTrustedUsers(gc gitHubClient, l *logrus.Entry, skipDCOCheckForCollaborators bool, trustedApps []string, trustedOrg, org, repo string, allCommits []github.RepositoryCommit) ([]github.RepositoryCommit, error) { 128 untrustedCommits := make([]github.RepositoryCommit, 0, len(allCommits)) 129 130 for _, commit := range allCommits { 131 trustedResponse, err := trigger.TrustedUser(gc, !skipDCOCheckForCollaborators, trustedApps, trustedOrg, commit.Author.Login, org, repo) 132 if err != nil { 133 return nil, fmt.Errorf("Error checking is member trusted: %w", err) 134 } 135 if !trustedResponse.IsTrusted { 136 l.Debugf("Member %s is not trusted", commit.Author.Login) 137 untrustedCommits = append(untrustedCommits, commit) 138 } 139 } 140 141 l.Debugf("Unsigned commits from untrusted users: %d", len(untrustedCommits)) 142 return untrustedCommits, nil 143 } 144 145 // checkCommitMessages will perform the actual DCO check by retrieving all 146 // commits contained within the PR with the given number. 147 // *All* commits in the pull request *must* match the 'testRe' in order to pass. 148 func checkCommitMessages(gc gitHubClient, l *logrus.Entry, org, repo string, number int) ([]github.RepositoryCommit, error) { 149 allCommits, err := gc.ListPullRequestCommits(org, repo, number) 150 if err != nil { 151 return nil, fmt.Errorf("error listing commits for pull request: %w", err) 152 } 153 l.Debugf("Found %d commits in PR", len(allCommits)) 154 155 var commitsMissingDCO []github.RepositoryCommit 156 for _, commit := range allCommits { 157 if !testRe.MatchString(commit.Commit.Message) { 158 commitsMissingDCO = append(commitsMissingDCO, commit) 159 } 160 } 161 162 l.Debugf("Commits in PR missing DCO signoff: %d", len(commitsMissingDCO)) 163 return commitsMissingDCO, nil 164 } 165 166 // checkExistingStatus will retrieve the current status of the DCO context for 167 // the provided SHA. 168 func checkExistingStatus(gc gitHubClient, l *logrus.Entry, org, repo, sha string) (string, error) { 169 combinedStatus, err := gc.GetCombinedStatus(org, repo, sha) 170 if err != nil { 171 return "", fmt.Errorf("error listing pull request combined statuses: %w", err) 172 } 173 174 existingStatus := "" 175 for _, status := range combinedStatus.Statuses { 176 if status.Context != dcoContextName { 177 continue 178 } 179 existingStatus = status.State 180 break 181 } 182 l.Debugf("Existing DCO status context status is %q", existingStatus) 183 return existingStatus, nil 184 } 185 186 // checkExistingLabels will check the provided PR for the dco sign off labels, 187 // returning bool's indicating whether the 'yes' and the 'no' label are present. 188 func checkExistingLabels(gc gitHubClient, l *logrus.Entry, org, repo string, number int) (hasYesLabel, hasNoLabel bool, err error) { 189 labels, err := gc.GetIssueLabels(org, repo, number) 190 if err != nil { 191 return false, false, fmt.Errorf("error getting pull request labels: %w", err) 192 } 193 194 for _, l := range labels { 195 if l.Name == dcoYesLabel { 196 hasYesLabel = true 197 } 198 if l.Name == dcoNoLabel { 199 hasNoLabel = true 200 } 201 } 202 203 return hasYesLabel, hasNoLabel, nil 204 } 205 206 // takeAction will take appropriate action on the pull request according to its 207 // current state. 208 func takeAction(gc gitHubClient, cp commentPruner, l *logrus.Entry, org, repo string, pr github.PullRequest, commitsMissingDCO []github.RepositoryCommit, existingStatus, contributingUrl string, hasYesLabel, hasNoLabel, addComment bool) error { 209 signedOff := len(commitsMissingDCO) == 0 210 211 // handle the 'all commits signed off' case by adding appropriate labels 212 // TODO: clean-up old comments? 213 if signedOff { 214 if hasNoLabel { 215 l.Debugf("Removing %q label", dcoNoLabel) 216 // remove 'dco-signoff: no' label 217 if err := gc.RemoveLabel(org, repo, pr.Number, dcoNoLabel); err != nil { 218 return fmt.Errorf("error removing label: %w", err) 219 } 220 } 221 if !hasYesLabel { 222 l.Debugf("Adding %q label", dcoYesLabel) 223 // add 'dco-signoff: yes' label 224 if err := gc.AddLabel(org, repo, pr.Number, dcoYesLabel); err != nil { 225 return fmt.Errorf("error adding label: %w", err) 226 } 227 } 228 if existingStatus != github.StatusSuccess { 229 l.Debugf("Setting DCO status context to succeeded") 230 if err := gc.CreateStatus(org, repo, pr.Head.SHA, github.Status{ 231 Context: dcoContextName, 232 State: github.StatusSuccess, 233 TargetURL: contributingUrl, 234 Description: dcoContextMessageSuccess, 235 }); err != nil { 236 return fmt.Errorf("error setting pull request status: %w", err) 237 } 238 } 239 240 cp.PruneComments(shouldPrune(l)) 241 return nil 242 } 243 244 // handle the 'not all commits signed off' case 245 if !hasNoLabel { 246 l.Debugf("Adding %q label", dcoNoLabel) 247 // add 'dco-signoff: no' label 248 if err := gc.AddLabel(org, repo, pr.Number, dcoNoLabel); err != nil { 249 return fmt.Errorf("error adding label: %w", err) 250 } 251 } 252 if hasYesLabel { 253 l.Debugf("Removing %q label", dcoYesLabel) 254 // remove 'dco-signoff: yes' label 255 if err := gc.RemoveLabel(org, repo, pr.Number, dcoYesLabel); err != nil { 256 return fmt.Errorf("error removing label: %w", err) 257 } 258 } 259 if existingStatus != github.StatusFailure { 260 l.Debugf("Setting DCO status context to failed") 261 if err := gc.CreateStatus(org, repo, pr.Head.SHA, github.Status{ 262 Context: dcoContextName, 263 State: github.StatusFailure, 264 TargetURL: contributingUrl, 265 Description: dcoContextMessageFailed, 266 }); err != nil { 267 return fmt.Errorf("error setting pull request status: %w", err) 268 } 269 } 270 271 if addComment { 272 // prune any old comments and add a new one with the latest list of 273 // failing commits 274 cp.PruneComments(shouldPrune(l)) 275 l.Debugf("Commenting on PR to advise users of DCO check") 276 if err := gc.CreateComment(org, repo, pr.Number, fmt.Sprintf(dcoNotFoundMessage, contributingUrl, MarkdownSHAList(org, repo, commitsMissingDCO), plugins.AboutThisBot)); err != nil { 277 l.WithError(err).Warning("Could not create DCO not found comment.") 278 } 279 } 280 281 return nil 282 } 283 284 // 1. Check should commit messages from trusted users be checked 285 // 2. Check commit messages in the pull request for the sign-off string 286 // 3. Check the existing status context value 287 // 4. Check the existing PR labels 288 // 5. If signed off, apply appropriate labels and status context. 289 // 6. If not signed off, apply appropriate labels and status context and add a comment. 290 func handle(config plugins.Dco, gc gitHubClient, cp commentPruner, log *logrus.Entry, org, repo string, pr github.PullRequest, addComment bool) error { 291 l := log.WithField("pr", pr.Number) 292 293 commitsMissingDCO, err := checkCommitMessages(gc, l, org, repo, pr.Number) 294 if err != nil { 295 l.WithError(err).Infof("Error running DCO check against commits in PR") 296 return err 297 } 298 299 if config.SkipDCOCheckForMembers || config.SkipDCOCheckForCollaborators { 300 commitsMissingDCO, err = filterTrustedUsers(gc, l, config.SkipDCOCheckForCollaborators, config.TrustedApps, config.TrustedOrg, org, repo, commitsMissingDCO) 301 if err != nil { 302 l.WithError(err).Infof("Error running trusted org member check against commits in PR") 303 return err 304 } 305 } 306 307 existingStatus, err := checkExistingStatus(gc, l, org, repo, pr.Head.SHA) 308 if err != nil { 309 l.WithError(err).Infof("Error checking existing PR status") 310 return err 311 } 312 313 hasYesLabel, hasNoLabel, err := checkExistingLabels(gc, l, org, repo, pr.Number) 314 if err != nil { 315 l.WithError(err).Infof("Error checking existing PR labels") 316 return err 317 } 318 319 contributingRepo := fmt.Sprintf("%s/%s", org, repo) 320 if config.ContributingRepo != "" { 321 contributingRepo = config.ContributingRepo 322 } 323 324 contributingBranch := "master" 325 if config.ContributingBranch != "" { 326 contributingBranch = config.ContributingBranch 327 } 328 329 contributingPath := "CONTRIBUTING.md" 330 if config.ContributingPath != "" { 331 contributingPath = config.ContributingPath 332 } 333 334 contributingUrl := fmt.Sprintf("https://github.com/%s/blob/%s/%s", contributingRepo, contributingBranch, contributingPath) 335 336 return takeAction(gc, cp, l, org, repo, pr, commitsMissingDCO, existingStatus, contributingUrl, hasYesLabel, hasNoLabel, addComment) 337 } 338 339 // MarkdownSHAList prints the list of commits in a markdown-friendly way. 340 func MarkdownSHAList(org, repo string, list []github.RepositoryCommit) string { 341 lines := make([]string, len(list)) 342 lineFmt := "- [%s](https://github.com/%s/%s/commits/%s) %s" 343 for i, commit := range list { 344 if commit.SHA == "" { 345 continue 346 } 347 // if we somehow encounter a SHA that's less than 7 characters, we will 348 // just use it as is. 349 shortSHA := commit.SHA 350 if len(shortSHA) > 7 { 351 shortSHA = shortSHA[:7] 352 } 353 354 // get the first line of the commit 355 message := strings.Split(commit.Commit.Message, "\n")[0] 356 357 lines[i] = fmt.Sprintf(lineFmt, shortSHA, org, repo, commit.SHA, message) 358 } 359 return strings.Join(lines, "\n") 360 } 361 362 // shouldPrune finds comments left by this plugin. 363 func shouldPrune(log *logrus.Entry) func(github.IssueComment) bool { 364 return func(comment github.IssueComment) bool { 365 return strings.Contains(comment.Body, dcoMsgPruneMatch) 366 } 367 } 368 369 func handlePullRequestEvent(pc plugins.Agent, pe github.PullRequestEvent) error { 370 config := pc.PluginConfig.DcoFor(pe.Repo.Owner.Login, pe.Repo.Name) 371 372 cp, err := pc.CommentPruner() 373 if err != nil { 374 return err 375 } 376 377 return handlePullRequest(*config, pc.GitHubClient, cp, pc.Logger, pe) 378 } 379 380 func handlePullRequest(config plugins.Dco, gc gitHubClient, cp commentPruner, log *logrus.Entry, pe github.PullRequestEvent) error { 381 org := pe.Repo.Owner.Login 382 repo := pe.Repo.Name 383 384 // we only reprocess on label, unlabel, open, reopen and synchronize events 385 // this will reduce our API token usage and save processing of unrelated events 386 switch pe.Action { 387 case github.PullRequestActionOpened, 388 github.PullRequestActionReopened, 389 github.PullRequestActionSynchronize: 390 default: 391 return nil 392 } 393 394 shouldComment := pe.Action == github.PullRequestActionSynchronize || 395 pe.Action == github.PullRequestActionOpened 396 397 return handle(config, gc, cp, log, org, repo, pe.PullRequest, shouldComment) 398 } 399 400 func handleCommentEvent(pc plugins.Agent, ce github.GenericCommentEvent) error { 401 config := pc.PluginConfig.DcoFor(ce.Repo.Owner.Login, ce.Repo.Name) 402 403 cp, err := pc.CommentPruner() 404 if err != nil { 405 return err 406 } 407 408 return handleComment(*config, pc.GitHubClient, cp, pc.Logger, ce) 409 } 410 411 func handleComment(config plugins.Dco, gc gitHubClient, cp commentPruner, log *logrus.Entry, ce github.GenericCommentEvent) error { 412 // Only consider open PRs and new comments. 413 if ce.IssueState != "open" || ce.Action != github.GenericCommentActionCreated || !ce.IsPR { 414 return nil 415 } 416 // Only consider "/check-dco" comments. 417 if !checkDCORe.MatchString(ce.Body) { 418 return nil 419 } 420 421 org := ce.Repo.Owner.Login 422 repo := ce.Repo.Name 423 424 pr, err := gc.GetPullRequest(org, repo, ce.Number) 425 if err != nil { 426 return fmt.Errorf("error getting pull request for comment: %w", err) 427 } 428 429 return handle(config, gc, cp, log, org, repo, *pr, true) 430 }