sigs.k8s.io/prow@v0.0.0-20240503223140-c5e374dc7eb1/pkg/prstatus/prstatus.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 prstatus 18 19 import ( 20 "context" 21 "encoding/json" 22 "fmt" 23 "io" 24 "net/http" 25 "strings" 26 "time" 27 28 "github.com/gorilla/sessions" 29 githubql "github.com/shurcooL/githubv4" 30 "github.com/sirupsen/logrus" 31 "golang.org/x/oauth2" 32 33 "sigs.k8s.io/prow/pkg/github" 34 "sigs.k8s.io/prow/pkg/githuboauth" 35 ) 36 37 const ( 38 loginSession = "github_login" 39 tokenSession = "access-token-session" 40 tokenKey = "access-token" 41 loginKey = "login" 42 ) 43 44 // pullRequestQueryHandler defines an interface that query handlers should implement. 45 type pullRequestQueryHandler interface { 46 queryPullRequests(context.Context, githubQuerier, string) ([]PullRequest, error) 47 getHeadContexts(ghc githubStatusFetcher, pr PullRequest) ([]Context, error) 48 } 49 50 // UserData represents data returned to client request to the endpoint. It has a flag that indicates 51 // whether the user has logged in his github or not and list of open pull requests owned by the 52 // user. 53 type UserData struct { 54 Login bool 55 PullRequestsWithContexts []PullRequestWithContexts 56 } 57 58 // PullRequestWithContexts contains a pull request with its latest commit contexts. 59 type PullRequestWithContexts struct { 60 Contexts []Context 61 PullRequest PullRequest 62 } 63 64 // DashboardAgent is responsible for handling request to /pr-status endpoint. 65 // It will serve a list of open pull requests owned by the user. 66 type DashboardAgent struct { 67 repos []string 68 goac *githuboauth.Config 69 70 log *logrus.Entry 71 } 72 73 // Label represents a GitHub label. 74 type Label struct { 75 ID githubql.ID 76 Name githubql.String 77 } 78 79 // Context represent a GitHub status check context. 80 type Context struct { 81 Context string 82 Description string 83 State string 84 } 85 86 // PullRequest holds the GraphQL response data for a GitHub pull request. 87 type PullRequest struct { 88 Number githubql.Int 89 Merged githubql.Boolean 90 Title githubql.String 91 Author struct { 92 Login githubql.String 93 } 94 BaseRef struct { 95 Name githubql.String 96 Prefix githubql.String 97 } 98 HeadRefOID githubql.String `graphql:"headRefOid"` 99 Repository struct { 100 Name githubql.String 101 NameWithOwner githubql.String 102 Owner struct { 103 Login githubql.String 104 } 105 } 106 Labels struct { 107 Nodes []struct { 108 Label Label `graphql:"... on Label"` 109 } 110 } `graphql:"labels(first: 100)"` 111 Milestone struct { 112 Title githubql.String 113 } 114 Mergeable githubql.MergeableState 115 } 116 117 // UserLoginQuery holds the GraphQL query for the currently authenticated user. 118 type UserLoginQuery struct { 119 Viewer struct { 120 Login githubql.String 121 } 122 } 123 124 type searchQuery struct { 125 RateLimit struct { 126 Cost githubql.Int 127 Remaining githubql.Int 128 } 129 Search struct { 130 PageInfo struct { 131 HasNextPage githubql.Boolean 132 EndCursor githubql.String 133 } 134 Nodes []struct { 135 PullRequest PullRequest `graphql:"... on PullRequest"` 136 } 137 } `graphql:"search(type: ISSUE, first: 100, after: $searchCursor, query: $query)"` 138 } 139 140 // NewDashboardAgent creates a new user dashboard agent . 141 func NewDashboardAgent(repos []string, config *githuboauth.Config, log *logrus.Entry) *DashboardAgent { 142 return &DashboardAgent{ 143 repos: repos, 144 goac: config, 145 log: log, 146 } 147 } 148 149 func invalidateGitHubSession(w http.ResponseWriter, r *http.Request, session *sessions.Session) error { 150 // Invalidate github login session 151 http.SetCookie(w, &http.Cookie{ 152 Name: loginSession, 153 Path: "/", 154 Expires: time.Now().Add(-time.Hour * 24), 155 MaxAge: -1, 156 Secure: true, 157 }) 158 159 // Invalidate access token session 160 session.Options.MaxAge = -1 161 return session.Save(r, w) 162 } 163 164 type GitHubClient interface { 165 githubQuerier 166 githubStatusFetcher 167 BotUser() (*github.UserData, error) 168 } 169 170 type githubClientCreator func(accessToken string) (GitHubClient, error) 171 172 // HandlePrStatus returns a http handler function that handles request to /pr-status 173 // endpoint. The handler takes user access token stored in the cookie to query to GitHub on behalf 174 // of the user and serve the data in return. The Query handler is passed to the method so as it 175 // can be mocked in the unit test.. 176 func (da *DashboardAgent) HandlePrStatus(queryHandler pullRequestQueryHandler, createClient githubClientCreator) http.HandlerFunc { 177 return func(w http.ResponseWriter, r *http.Request) { 178 serverError := func(action string, err error) { 179 da.log.WithError(err).Errorf("Error %s.", action) 180 msg := fmt.Sprintf("500 Internal server error %s: %v", action, err) 181 http.Error(w, msg, http.StatusInternalServerError) 182 } 183 184 data := UserData{ 185 Login: false, 186 } 187 188 // Get existing session. Invalidate everything if we fail and continue as 189 // if not logged in. 190 session, err := da.goac.CookieStore.Get(r, tokenSession) 191 if err != nil { 192 da.log.WithError(err).Info("Failed to get existing session, invalidating GitHub login session") 193 if err := invalidateGitHubSession(w, r, session); err != nil { 194 serverError("Failed to invalidate GitHub session", err) 195 return 196 } 197 } 198 199 // If access token exists, get user login using the access token. This is a 200 // chance to validate whether the access token is consumable or not. If 201 // not, we invalidate the sessions and continue as if not logged in. 202 token, ok := session.Values[tokenKey].(*oauth2.Token) // TODO(fejta): client cache 203 var user *github.User 204 var botUser *github.UserData 205 if ok && token.Valid() { 206 githubClient, err := createClient(token.AccessToken) 207 if err != nil { 208 serverError("creating githubClient", err) 209 return 210 } 211 botUser, err = githubClient.BotUser() 212 if err != nil { 213 if strings.Contains(err.Error(), "401") { 214 da.log.Info("Failed to access GitHub with existing access token, invalidating GitHub login session") 215 if err := invalidateGitHubSession(w, r, session); err != nil { 216 serverError("Failed to invalidate GitHub session", err) 217 return 218 } 219 } else { 220 serverError("Error with getting user login", err) 221 return 222 } 223 } 224 user = &github.User{Login: botUser.Login} 225 226 login := user.Login 227 data.Login = true 228 // Saves login. We save the login under 2 cookies. One for the use of client to render the 229 // data and one encoded for server to verify the identity of the authenticated user. 230 http.SetCookie(w, &http.Cookie{ 231 Name: loginSession, 232 Value: login, 233 Path: "/", 234 Expires: time.Now().Add(time.Hour * 24 * 30), 235 Secure: true, 236 }) 237 session.Values[loginKey] = login 238 if err := session.Save(r, w); err != nil { 239 serverError("Save oauth session", err) 240 return 241 } 242 243 query := da.ConstructSearchQuery(login) 244 if err := r.ParseForm(); err == nil { 245 if q := r.Form.Get("query"); q != "" { 246 query = q 247 } 248 } 249 // If neither repo nor org is specified in the search query. We limit the search to repos that 250 // are configured with either Prow or Tide. 251 if !queryConstrainsRepos(query) { 252 for _, v := range da.repos { 253 query += fmt.Sprintf(" repo:\"%s\"", v) 254 } 255 } 256 pullRequests, err := queryHandler.queryPullRequests(context.Background(), githubClient, query) 257 if err != nil { 258 serverError("Error with querying user data.", err) 259 return 260 } 261 var pullRequestWithContexts []PullRequestWithContexts 262 for _, pr := range pullRequests { 263 prcontexts, err := queryHandler.getHeadContexts(githubClient, pr) 264 if err != nil { 265 serverError("Error with getting head context of pr", err) 266 continue 267 } 268 pullRequestWithContexts = append(pullRequestWithContexts, PullRequestWithContexts{ 269 Contexts: prcontexts, 270 PullRequest: pr, 271 }) 272 } 273 274 data.PullRequestsWithContexts = pullRequestWithContexts 275 } 276 277 marshaledData, err := json.Marshal(data) 278 if err != nil { 279 da.log.WithError(err).Error("Error with marshalling user data.") 280 } 281 282 if v := r.URL.Query().Get("var"); v != "" { 283 fmt.Fprintf(w, "var %s = ", v) 284 w.Write(marshaledData) 285 io.WriteString(w, ";") 286 } else { 287 w.Write(marshaledData) 288 } 289 } 290 } 291 292 type githubQuerier interface { 293 QueryWithGitHubAppsSupport(ctx context.Context, q interface{}, vars map[string]interface{}, org string) error 294 } 295 296 // queryPullRequests is a query function that returns a list of open pull requests owned by the user whose access token 297 // is consumed by the github client. 298 func (da *DashboardAgent) queryPullRequests(ctx context.Context, ghc githubQuerier, query string) ([]PullRequest, error) { 299 var prs []PullRequest 300 vars := map[string]interface{}{ 301 "query": (githubql.String)(query), 302 "searchCursor": (*githubql.String)(nil), 303 } 304 var totalCost int 305 var remaining int 306 for { 307 sq := searchQuery{} 308 if err := ghc.QueryWithGitHubAppsSupport(ctx, &sq, vars, ""); err != nil { 309 return nil, err 310 } 311 totalCost += int(sq.RateLimit.Cost) 312 remaining = int(sq.RateLimit.Remaining) 313 for _, n := range sq.Search.Nodes { 314 org := string(n.PullRequest.Repository.Owner.Login) 315 repo := string(n.PullRequest.Repository.Name) 316 ref := string(n.PullRequest.HeadRefOID) 317 if org == "" || repo == "" || ref == "" { 318 da.log.Warningf("Skipped empty pull request returned by query \"%s\": %v", query, n.PullRequest) 319 continue 320 } 321 prs = append(prs, n.PullRequest) 322 } 323 if !sq.Search.PageInfo.HasNextPage { 324 break 325 } 326 vars["searchCursor"] = githubql.NewString(sq.Search.PageInfo.EndCursor) 327 } 328 da.log.Infof("Search for query \"%s\" cost %d point(s). %d remaining.", query, totalCost, remaining) 329 return prs, nil 330 } 331 332 type githubStatusFetcher interface { 333 GetCombinedStatus(org, repo, ref string) (*github.CombinedStatus, error) 334 ListCheckRuns(org, repo, ref string) (*github.CheckRunList, error) 335 } 336 337 // getHeadContexts returns the status checks' contexts of the head commit of the PR. 338 func (da *DashboardAgent) getHeadContexts(ghc githubStatusFetcher, pr PullRequest) ([]Context, error) { 339 org := string(pr.Repository.Owner.Login) 340 repo := string(pr.Repository.Name) 341 combined, err := ghc.GetCombinedStatus(org, repo, string(pr.HeadRefOID)) 342 if err != nil { 343 return nil, fmt.Errorf("failed to get the combined status: %w", err) 344 } 345 checkruns, err := ghc.ListCheckRuns(org, repo, string(pr.HeadRefOID)) 346 if err != nil { 347 return nil, fmt.Errorf("failed to fetch checkruns: %w", err) 348 } 349 contexts := make([]Context, 0, len(combined.Statuses)+len(checkruns.CheckRuns)) 350 for _, status := range combined.Statuses { 351 contexts = append(contexts, Context{ 352 Context: status.Context, 353 Description: status.Description, 354 State: strings.ToUpper(status.State), 355 }) 356 } 357 for _, checkrun := range checkruns.CheckRuns { 358 var state string 359 if checkrun.CompletedAt == "" { 360 state = "PENDING" 361 } else if strings.ToUpper(checkrun.Conclusion) == "NEUTRAL" { 362 state = "SUCCESS" 363 } else { 364 state = strings.ToUpper(checkrun.Conclusion) 365 } 366 contexts = append(contexts, Context{ 367 Context: checkrun.Name, 368 Description: checkrun.DetailsURL, 369 State: state, 370 }) 371 } 372 return contexts, nil 373 } 374 375 // ConstructSearchQuery returns the GitHub search query string for PRs that are open and authored 376 // by the user passed. The search is scoped to repositories that are configured with either Prow or 377 // Tide. 378 func (da *DashboardAgent) ConstructSearchQuery(login string) string { 379 tokens := []string{"is:pr", "state:open", "author:" + login} 380 for i := range da.repos { 381 tokens = append(tokens, fmt.Sprintf("repo:\"%s\"", da.repos[i])) 382 } 383 return strings.Join(tokens, " ") 384 } 385 386 func queryConstrainsRepos(q string) bool { 387 tkns := strings.Split(q, " ") 388 for _, tkn := range tkns { 389 if strings.HasPrefix(tkn, "org:") || strings.HasPrefix(tkn, "repo:") { 390 return true 391 } 392 } 393 return false 394 }