github.com/abayer/test-infra@v0.0.5/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  	"flag"
    28  	"fmt"
    29  	"log"
    30  	"net/url"
    31  	"regexp"
    32  	"strconv"
    33  	"strings"
    34  	"text/template"
    35  	"time"
    36  
    37  	"k8s.io/test-infra/prow/config"
    38  	"k8s.io/test-infra/prow/flagutil"
    39  	"k8s.io/test-infra/prow/github"
    40  )
    41  
    42  const (
    43  	templateHelp = `--comment is a golang text/template if set.
    44  	Valid placeholders:
    45  		.Org - github org
    46  		.Repo - github repo
    47  		.Number - issue number
    48  	Advanced (see kubernetes/test-infra/prow/github/types.go):
    49  		.Issue.User.Login - github account
    50  		.Issue.Title
    51  		.Issue.State
    52  		.Issue.HTMLURL
    53  		.Issue.Assignees - list of assigned .Users
    54  		.Issue.Labels - list of applied labels (.Name)
    55  `
    56  )
    57  
    58  func flagOptions() options {
    59  	o := options{
    60  		endpoint: flagutil.NewStrings("https://api.github.com"),
    61  	}
    62  	flag.StringVar(&o.query, "query", "", "See https://help.github.com/articles/searching-issues-and-pull-requests/")
    63  	flag.DurationVar(&o.updated, "updated", 2*time.Hour, "Filter to issues unmodified for at least this long if set")
    64  	flag.BoolVar(&o.includeClosed, "include-closed", false, "Match closed issues if set")
    65  	flag.BoolVar(&o.confirm, "confirm", false, "Mutate github if set")
    66  	flag.StringVar(&o.comment, "comment", "", "Append the following comment to matching issues")
    67  	flag.BoolVar(&o.useTemplate, "template", false, templateHelp)
    68  	flag.IntVar(&o.ceiling, "ceiling", 3, "Maximum number of issues to modify, 0 for infinite")
    69  	flag.Var(&o.endpoint, "endpoint", "GitHub's API endpoint")
    70  	flag.StringVar(&o.token, "token", "", "Path to github token")
    71  	flag.Parse()
    72  	return o
    73  }
    74  
    75  type meta struct {
    76  	Number int
    77  	Org    string
    78  	Repo   string
    79  	Issue  github.Issue
    80  }
    81  
    82  type options struct {
    83  	asc           bool
    84  	ceiling       int
    85  	comment       string
    86  	includeClosed bool
    87  	useTemplate   bool
    88  	query         string
    89  	sort          string
    90  	endpoint      flagutil.Strings
    91  	token         string
    92  	updated       time.Duration
    93  	confirm       bool
    94  }
    95  
    96  func parseHTMLURL(url string) (string, string, int, error) {
    97  	// Example: https://github.com/batterseapower/pinyin-toolkit/issues/132
    98  	re := regexp.MustCompile(`.+/(.+)/(.+)/(issues|pull)/(\d+)$`)
    99  	mat := re.FindStringSubmatch(url)
   100  	if mat == nil {
   101  		return "", "", 0, fmt.Errorf("failed to parse: %s", url)
   102  	}
   103  	n, err := strconv.Atoi(mat[4])
   104  	if err != nil {
   105  		return "", "", 0, err
   106  	}
   107  	return mat[1], mat[2], n, nil
   108  }
   109  
   110  func makeQuery(query string, includeClosed bool, minUpdated time.Duration) (string, error) {
   111  	parts := []string{query}
   112  	if !includeClosed {
   113  		if strings.Contains(query, "is:closed") {
   114  			return "", fmt.Errorf("--query='%s' containing is:closed requires --include-closed", query)
   115  		}
   116  		parts = append(parts, "is:open")
   117  	} else if strings.Contains(query, "is:open") {
   118  		return "", fmt.Errorf("--query='%s' should not contain is:open when using --include-closed", query)
   119  	}
   120  	if minUpdated != 0 {
   121  		latest := time.Now().Add(-minUpdated)
   122  		parts = append(parts, "updated:<="+latest.Format(time.RFC3339))
   123  	}
   124  	return strings.Join(parts, " "), nil
   125  }
   126  
   127  type client interface {
   128  	CreateComment(owner, repo string, number int, comment string) error
   129  	FindIssues(query, sort string, asc bool) ([]github.Issue, error)
   130  }
   131  
   132  func main() {
   133  	log.SetFlags(log.LstdFlags | log.Lshortfile)
   134  	o := flagOptions()
   135  
   136  	if o.query == "" {
   137  		log.Fatal("empty --query")
   138  	}
   139  	if o.token == "" {
   140  		log.Fatal("empty --token")
   141  	}
   142  	if o.comment == "" {
   143  		log.Fatal("empty --comment")
   144  	}
   145  
   146  	secretAgent := &config.SecretAgent{}
   147  	if err := secretAgent.Start([]string{o.token}); err != nil {
   148  		log.Fatalf("Error starting secrets agent: %v", err)
   149  	}
   150  
   151  	var err error
   152  	for _, ep := range o.endpoint.Strings() {
   153  		_, err = url.ParseRequestURI(ep)
   154  		if err != nil {
   155  			log.Fatalf("Invalid --endpoint URL %q: %v.", ep, err)
   156  		}
   157  	}
   158  
   159  	var c client
   160  	if o.confirm {
   161  		c = github.NewClient(secretAgent.GetTokenGenerator(o.token), o.endpoint.Strings()...)
   162  	} else {
   163  		c = github.NewDryRunClient(secretAgent.GetTokenGenerator(o.token), o.endpoint.Strings()...)
   164  	}
   165  
   166  	query, err := makeQuery(o.query, o.includeClosed, o.updated)
   167  	if err != nil {
   168  		log.Fatalf("Bad query: %v", err)
   169  	}
   170  	sort := ""
   171  	asc := false
   172  	if o.updated > 0 {
   173  		sort = "updated"
   174  		asc = true
   175  	}
   176  	commenter := makeCommenter(o.comment, o.useTemplate)
   177  	if err := run(c, query, sort, asc, commenter, o.ceiling); err != nil {
   178  		log.Fatalf("Failed run: %v", err)
   179  	}
   180  }
   181  
   182  func makeCommenter(comment string, useTemplate bool) func(meta) (string, error) {
   183  	if !useTemplate {
   184  		return func(_ meta) (string, error) {
   185  			return comment, nil
   186  		}
   187  	}
   188  	t := template.Must(template.New("comment").Parse(comment))
   189  	return func(m meta) (string, error) {
   190  		out := bytes.Buffer{}
   191  		err := t.Execute(&out, m)
   192  		return string(out.Bytes()), err
   193  	}
   194  }
   195  
   196  func run(c client, query, sort string, asc bool, commenter func(meta) (string, error), ceiling int) error {
   197  	log.Printf("Searching: %s", query)
   198  	issues, err := c.FindIssues(query, sort, asc)
   199  	if err != nil {
   200  		return fmt.Errorf("search failed: %v", err)
   201  	}
   202  	problems := []string{}
   203  	log.Printf("Found %d matches", len(issues))
   204  	for n, i := range issues {
   205  		if ceiling > 0 && n == ceiling {
   206  			log.Printf("Stopping at --ceiling=%d of %d results", n, len(issues))
   207  			break
   208  		}
   209  		log.Printf("Matched %s (%s)", i.HTMLURL, i.Title)
   210  		org, repo, number, err := parseHTMLURL(i.HTMLURL)
   211  		if err != nil {
   212  			msg := fmt.Sprintf("Failed to parse %s: %v", i.HTMLURL, err)
   213  			log.Print(msg)
   214  			problems = append(problems, msg)
   215  		}
   216  		comment, err := commenter(meta{Number: number, Org: org, Repo: repo, Issue: i})
   217  		if err != nil {
   218  			msg := fmt.Sprintf("Failed to create comment for %s/%s#%d: %v", org, repo, number, err)
   219  			log.Print(msg)
   220  			problems = append(problems, msg)
   221  			continue
   222  		}
   223  		if err := c.CreateComment(org, repo, number, comment); err != nil {
   224  			msg := fmt.Sprintf("Failed to apply comment to %s/%s#%d: %v", org, repo, number, err)
   225  			log.Print(msg)
   226  			problems = append(problems, msg)
   227  			continue
   228  		}
   229  		log.Printf("Commented on %s", i.HTMLURL)
   230  	}
   231  	if len(problems) > 0 {
   232  		return fmt.Errorf("encoutered %d failures: %v", len(problems), problems)
   233  	}
   234  	return nil
   235  }