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 }