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 }