k8s.io/test-infra@v0.0.0-20240520184403-27c6b4c223d8/robots/commenter/main.go (about)

     1  /*
     2  Copyright 2017 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  // Commenter provides a way to --query for issues and append a --comment to matches.
    18  //
    19  // The --token determines who interacts with github.
    20  // By default commenter runs in dry mode, add --confirm to make it leave comments.
    21  // The --updated, --include-closed, --ceiling options provide minor safeguards
    22  // around leaving excessive comments.
    23  package main
    24  
    25  import (
    26  	"bytes"
    27  	"errors"
    28  	"flag"
    29  	"fmt"
    30  	"log"
    31  	"math/rand"
    32  	"net/url"
    33  	"regexp"
    34  	"strconv"
    35  	"strings"
    36  	"text/template"
    37  	"time"
    38  
    39  	"sigs.k8s.io/prow/pkg/config/secret"
    40  	"sigs.k8s.io/prow/pkg/flagutil"
    41  	"sigs.k8s.io/prow/pkg/github"
    42  )
    43  
    44  const (
    45  	templateHelp = `--comment is a golang text/template if set.
    46  	Valid placeholders:
    47  		.Org - github org
    48  		.Repo - github repo
    49  		.Number - issue number
    50  	Advanced (see kubernetes/test-infra/prow/github/types.go):
    51  		.Issue.User.Login - github account
    52  		.Issue.Title
    53  		.Issue.State
    54  		.Issue.HTMLURL
    55  		.Issue.Assignees - list of assigned .Users
    56  		.Issue.Labels - list of applied labels (.Name)
    57  `
    58  )
    59  
    60  func flagOptions() options {
    61  	o := options{
    62  		endpoint: flagutil.NewStrings(github.DefaultAPIEndpoint),
    63  	}
    64  	flag.StringVar(&o.query, "query", "", "See https://help.github.com/articles/searching-issues-and-pull-requests/")
    65  	flag.DurationVar(&o.updated, "updated", 2*time.Hour, "Filter to issues unmodified for at least this long if set")
    66  	flag.BoolVar(&o.includeArchived, "include-archived", false, "Match archived issues if set")
    67  	flag.BoolVar(&o.includeClosed, "include-closed", false, "Match closed issues if set")
    68  	flag.BoolVar(&o.includeLocked, "include-locked", false, "Match locked issues if set")
    69  	flag.BoolVar(&o.confirm, "confirm", false, "Mutate github if set")
    70  	flag.StringVar(&o.comment, "comment", "", "Append the following comment to matching issues")
    71  	flag.BoolVar(&o.useTemplate, "template", false, templateHelp)
    72  	flag.IntVar(&o.ceiling, "ceiling", 3, "Maximum number of issues to modify, 0 for infinite")
    73  	flag.Var(&o.endpoint, "endpoint", "GitHub's API endpoint")
    74  	flag.StringVar(&o.graphqlEndpoint, "graphql-endpoint", github.DefaultGraphQLEndpoint, "GitHub's GraphQL API Endpoint")
    75  	flag.StringVar(&o.token, "token", "", "Path to github token")
    76  	flag.BoolVar(&o.random, "random", false, "Choose random issues to comment on from the query")
    77  	flag.Parse()
    78  	return o
    79  }
    80  
    81  type meta struct {
    82  	Number int
    83  	Org    string
    84  	Repo   string
    85  	Issue  github.Issue
    86  }
    87  
    88  type options struct {
    89  	ceiling         int
    90  	comment         string
    91  	includeArchived bool
    92  	includeClosed   bool
    93  	includeLocked   bool
    94  	useTemplate     bool
    95  	query           string
    96  	endpoint        flagutil.Strings
    97  	graphqlEndpoint string
    98  	token           string
    99  	updated         time.Duration
   100  	confirm         bool
   101  	random          bool
   102  }
   103  
   104  func parseHTMLURL(url string) (string, string, int, error) {
   105  	// Example: https://github.com/batterseapower/pinyin-toolkit/issues/132
   106  	re := regexp.MustCompile(`.+/(.+)/(.+)/(issues|pull)/(\d+)$`)
   107  	mat := re.FindStringSubmatch(url)
   108  	if mat == nil {
   109  		return "", "", 0, fmt.Errorf("failed to parse: %s", url)
   110  	}
   111  	n, err := strconv.Atoi(mat[4])
   112  	if err != nil {
   113  		return "", "", 0, err
   114  	}
   115  	return mat[1], mat[2], n, nil
   116  }
   117  
   118  func makeQuery(query string, includeArchived, includeClosed, includeLocked bool, minUpdated time.Duration) (string, error) {
   119  	// GitHub used to allow \n but changed it at some point to result in no results at all
   120  	query = strings.ReplaceAll(query, "\n", " ")
   121  	parts := []string{query}
   122  	if !includeArchived {
   123  		if strings.Contains(query, "archived:true") {
   124  			return "", errors.New("archived:true requires --include-archived")
   125  		}
   126  		parts = append(parts, "archived:false")
   127  	} else if strings.Contains(query, "archived:false") {
   128  		return "", errors.New("archived:false conflicts with --include-archived")
   129  	}
   130  	if !includeClosed {
   131  		if strings.Contains(query, "is:closed") {
   132  			return "", errors.New("is:closed requires --include-closed")
   133  		}
   134  		parts = append(parts, "is:open")
   135  	} else if strings.Contains(query, "is:open") {
   136  		return "", errors.New("is:open conflicts with --include-closed")
   137  	}
   138  	if !includeLocked {
   139  		if strings.Contains(query, "is:locked") {
   140  			return "", errors.New("is:locked requires --include-locked")
   141  		}
   142  		parts = append(parts, "is:unlocked")
   143  	} else if strings.Contains(query, "is:unlocked") {
   144  		return "", errors.New("is:unlocked conflicts with --include-locked")
   145  	}
   146  	if minUpdated != 0 {
   147  		latest := time.Now().Add(-minUpdated)
   148  		parts = append(parts, "updated:<="+latest.Format(time.RFC3339))
   149  	}
   150  	return strings.Join(parts, " "), nil
   151  }
   152  
   153  type client interface {
   154  	CreateComment(owner, repo string, number int, comment string) error
   155  	FindIssues(query, sort string, asc bool) ([]github.Issue, error)
   156  }
   157  
   158  func main() {
   159  	log.SetFlags(log.LstdFlags | log.Lshortfile)
   160  	o := flagOptions()
   161  
   162  	if o.query == "" {
   163  		log.Fatal("empty --query")
   164  	}
   165  	if o.token == "" {
   166  		log.Fatal("empty --token")
   167  	}
   168  	if o.comment == "" {
   169  		log.Fatal("empty --comment")
   170  	}
   171  
   172  	if err := secret.Add(o.token); err != nil {
   173  		log.Fatalf("Error starting secrets agent: %v", err)
   174  	}
   175  
   176  	var err error
   177  	for _, ep := range o.endpoint.Strings() {
   178  		_, err = url.ParseRequestURI(ep)
   179  		if err != nil {
   180  			log.Fatalf("Invalid --endpoint URL %q: %v.", ep, err)
   181  		}
   182  	}
   183  
   184  	var c client
   185  	if o.confirm {
   186  		c, err = github.NewClient(secret.GetTokenGenerator(o.token), secret.Censor, o.graphqlEndpoint, o.endpoint.Strings()...)
   187  	} else {
   188  		c, err = github.NewDryRunClient(secret.GetTokenGenerator(o.token), secret.Censor, o.graphqlEndpoint, o.endpoint.Strings()...)
   189  	}
   190  	if err != nil {
   191  		log.Fatalf("Failed to construct GitHub client: %v", err)
   192  	}
   193  
   194  	query, err := makeQuery(o.query, o.includeArchived, o.includeClosed, o.includeLocked, o.updated)
   195  	if err != nil {
   196  		log.Fatalf("Bad query %q: %v", o.query, err)
   197  	}
   198  	sort := ""
   199  	asc := false
   200  	if o.updated > 0 {
   201  		sort = "updated"
   202  		asc = true
   203  	}
   204  	commenter := makeCommenter(o.comment, o.useTemplate)
   205  	if err := run(c, query, sort, asc, o.random, commenter, o.ceiling); err != nil {
   206  		log.Fatalf("Failed run: %v", err)
   207  	}
   208  }
   209  
   210  func makeCommenter(comment string, useTemplate bool) func(meta) (string, error) {
   211  	if !useTemplate {
   212  		return func(_ meta) (string, error) {
   213  			return comment, nil
   214  		}
   215  	}
   216  	t := template.Must(template.New("comment").Parse(comment))
   217  	return func(m meta) (string, error) {
   218  		out := bytes.Buffer{}
   219  		err := t.Execute(&out, m)
   220  		return out.String(), err
   221  	}
   222  }
   223  
   224  func run(c client, query, sort string, asc, random bool, commenter func(meta) (string, error), ceiling int) error {
   225  	log.Printf("Searching: %s", query)
   226  	issues, err := c.FindIssues(query, sort, asc)
   227  	if err != nil {
   228  		return fmt.Errorf("search failed: %w", err)
   229  	}
   230  	problems := []string{}
   231  	log.Printf("Found %d matches", len(issues))
   232  	if random {
   233  		rand.Shuffle(len(issues), func(i, j int) {
   234  			issues[i], issues[j] = issues[j], issues[i]
   235  		})
   236  
   237  	}
   238  	for n, i := range issues {
   239  		if ceiling > 0 && n == ceiling {
   240  			log.Printf("Stopping at --ceiling=%d of %d results", n, len(issues))
   241  			break
   242  		}
   243  		log.Printf("Matched %s (%s)", i.HTMLURL, i.Title)
   244  		org, repo, number, err := parseHTMLURL(i.HTMLURL)
   245  		if err != nil {
   246  			msg := fmt.Sprintf("Failed to parse %s: %v", i.HTMLURL, err)
   247  			log.Print(msg)
   248  			problems = append(problems, msg)
   249  		}
   250  		comment, err := commenter(meta{Number: number, Org: org, Repo: repo, Issue: i})
   251  		if err != nil {
   252  			msg := fmt.Sprintf("Failed to create comment for %s/%s#%d: %v", org, repo, number, err)
   253  			log.Print(msg)
   254  			problems = append(problems, msg)
   255  			continue
   256  		}
   257  		if err := c.CreateComment(org, repo, number, comment); err != nil {
   258  			msg := fmt.Sprintf("Failed to apply comment to %s/%s#%d: %v", org, repo, number, err)
   259  			log.Print(msg)
   260  			problems = append(problems, msg)
   261  			continue
   262  		}
   263  		log.Printf("Commented on %s", i.HTMLURL)
   264  	}
   265  	if len(problems) > 0 {
   266  		return fmt.Errorf("encoutered %d failures: %v", len(problems), problems)
   267  	}
   268  	return nil
   269  }