github.com/munnerz/test-infra@v0.0.0-20190108210205-ce3d181dc989/prow/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 gogithub "github.com/google/go-github/github" 29 "github.com/gorilla/sessions" 30 githubql "github.com/shurcooL/githubv4" 31 "github.com/sirupsen/logrus" 32 "golang.org/x/oauth2" 33 34 "k8s.io/test-infra/pkg/ghclient" 35 "k8s.io/test-infra/prow/config" 36 "k8s.io/test-infra/prow/github" 37 ) 38 39 const ( 40 loginSession = "github_login" 41 githubEndpoint = "https://api.github.com" 42 tokenSession = "access-token-session" 43 tokenKey = "access-token" 44 loginKey = "login" 45 ) 46 47 type githubClient interface { 48 Query(context.Context, interface{}, map[string]interface{}) error 49 GetCombinedStatus(org, repo, ref string) (*github.CombinedStatus, error) 50 } 51 52 // PullRequestQueryHandler defines an interface that query handlers should implement. 53 type PullRequestQueryHandler interface { 54 QueryPullRequests(context.Context, githubClient, string) ([]PullRequest, error) 55 GetHeadContexts(ghc githubClient, pr PullRequest) ([]Context, error) 56 GetUser(*ghclient.Client) (*gogithub.User, error) 57 } 58 59 // UserData represents data returned to client request to the endpoint. It has a flag that indicates 60 // whether the user has logged in his github or not and list of open pull requests owned by the 61 // user. 62 type UserData struct { 63 Login bool 64 PullRequestsWithContexts []PullRequestWithContexts 65 } 66 67 // PullRequestWithContexts contains a pull request with its latest commit contexts. 68 type PullRequestWithContexts struct { 69 Contexts []Context 70 PullRequest PullRequest 71 } 72 73 // DashboardAgent is responsible for handling request to /pr-status endpoint. 74 // It will serve a list of open pull requests owned by the user. 75 type DashboardAgent struct { 76 repos []string 77 goac *config.GithubOAuthConfig 78 79 log *logrus.Entry 80 } 81 82 // Label represents a Github label. 83 type Label struct { 84 ID githubql.ID 85 Name githubql.String 86 } 87 88 // Context represent a Github status check context. 89 type Context struct { 90 Context string 91 Description string 92 State string 93 } 94 95 // PullRequest holds the GraphQL response data for a Github pull request. 96 type PullRequest struct { 97 Number githubql.Int 98 Merged githubql.Boolean 99 Title githubql.String 100 Author struct { 101 Login githubql.String 102 } 103 BaseRef struct { 104 Name githubql.String 105 Prefix githubql.String 106 } 107 HeadRefOID githubql.String `graphql:"headRefOid"` 108 Repository struct { 109 Name githubql.String 110 NameWithOwner githubql.String 111 Owner struct { 112 Login githubql.String 113 } 114 } 115 Labels struct { 116 Nodes []struct { 117 Label Label `graphql:"... on Label"` 118 } 119 } `graphql:"labels(first: 100)"` 120 Milestone struct { 121 Title githubql.String 122 } 123 Mergeable githubql.MergeableState 124 } 125 126 // UserLoginQuery holds the GraphQL query for the currently authenticated user. 127 type UserLoginQuery struct { 128 Viewer struct { 129 Login githubql.String 130 } 131 } 132 133 type searchQuery struct { 134 RateLimit struct { 135 Cost githubql.Int 136 Remaining githubql.Int 137 } 138 Search struct { 139 PageInfo struct { 140 HasNextPage githubql.Boolean 141 EndCursor githubql.String 142 } 143 Nodes []struct { 144 PullRequest PullRequest `graphql:"... on PullRequest"` 145 } 146 } `graphql:"search(type: ISSUE, first: 100, after: $searchCursor, query: $query)"` 147 } 148 149 // NewDashboardAgent creates a new user dashboard agent . 150 func NewDashboardAgent(repos []string, config *config.GithubOAuthConfig, log *logrus.Entry) *DashboardAgent { 151 return &DashboardAgent{ 152 repos: repos, 153 goac: config, 154 log: log, 155 } 156 } 157 158 func invalidateGitHubSession(w http.ResponseWriter, r *http.Request, session *sessions.Session) error { 159 // Invalidate github login session 160 http.SetCookie(w, &http.Cookie{ 161 Name: loginSession, 162 Path: "/", 163 Expires: time.Now().Add(-time.Hour * 24), 164 MaxAge: -1, 165 Secure: true, 166 }) 167 168 // Invalidate access token session 169 session.Options.MaxAge = -1 170 return session.Save(r, w) 171 } 172 173 // HandlePrStatus returns a http handler function that handles request to /pr-status 174 // endpoint. The handler takes user access token stored in the cookie to query to Github on behalf 175 // of the user and serve the data in return. The Query handler is passed to the method so as it 176 // can be mocked in the unit test.. 177 func (da *DashboardAgent) HandlePrStatus(queryHandler PullRequestQueryHandler) http.HandlerFunc { 178 return func(w http.ResponseWriter, r *http.Request) { 179 serverError := func(action string, err error) { 180 da.log.WithError(err).Errorf("Error %s.", action) 181 msg := fmt.Sprintf("500 Internal server error %s: %v", action, err) 182 http.Error(w, msg, http.StatusInternalServerError) 183 } 184 185 data := UserData{ 186 Login: false, 187 } 188 189 // Get existing session. Invalidate everything if we fail and continue as 190 // if not logged in. 191 session, err := da.goac.CookieStore.Get(r, tokenSession) 192 if err != nil { 193 da.log.WithError(err).Info("Failed to get existing session, invalidating GitHub login session") 194 if err := invalidateGitHubSession(w, r, session); err != nil { 195 serverError("Failed to invalidate GitHub session", err) 196 return 197 } 198 } 199 200 // If access token exists, get user login using the access token. This is a 201 // chance to validate whether the access token is consumable or not. If 202 // not, we invalidate the sessions and continue as if not logged in. 203 token, ok := session.Values[tokenKey].(*oauth2.Token) 204 var user *gogithub.User 205 if ok && token.Valid() { 206 goGithubClient := ghclient.NewClient(token.AccessToken, false) 207 var err error 208 user, err = queryHandler.GetUser(goGithubClient) 209 if err != nil { 210 if strings.Contains(err.Error(), "401") { 211 da.log.Info("Failed to access GitHub with existing access token, invalidating GitHub login session") 212 if err := invalidateGitHubSession(w, r, session); err != nil { 213 serverError("Failed to invalidate GitHub session", err) 214 return 215 } 216 } else { 217 serverError("Error with getting user login", err) 218 return 219 } 220 } 221 } 222 223 if user != nil { 224 login := *user.Login 225 data.Login = true 226 // Saves login. We save the login under 2 cookies. One for the use of client to render the 227 // data and one encoded for server to verify the identity of the authenticated user. 228 http.SetCookie(w, &http.Cookie{ 229 Name: loginSession, 230 Value: login, 231 Path: "/", 232 Expires: time.Now().Add(time.Hour * 24 * 30), 233 Secure: true, 234 }) 235 session.Values[loginKey] = login 236 if err := session.Save(r, w); err != nil { 237 serverError("Save oauth session", err) 238 return 239 } 240 241 // Construct query 242 ghc := github.NewClient(func() []byte { return []byte(token.AccessToken) }, githubEndpoint) 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(), ghc, 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(ghc, 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 // QueryPullRequests is a query function that returns a list of open pull requests owned by the user whose access token 293 // is consumed by the github client. 294 func (da *DashboardAgent) QueryPullRequests(ctx context.Context, ghc githubClient, query string) ([]PullRequest, error) { 295 var prs []PullRequest 296 vars := map[string]interface{}{ 297 "query": (githubql.String)(query), 298 "searchCursor": (*githubql.String)(nil), 299 } 300 var totalCost int 301 var remaining int 302 for { 303 sq := searchQuery{} 304 if err := ghc.Query(ctx, &sq, vars); err != nil { 305 return nil, err 306 } 307 totalCost += int(sq.RateLimit.Cost) 308 remaining = int(sq.RateLimit.Remaining) 309 for _, n := range sq.Search.Nodes { 310 prs = append(prs, n.PullRequest) 311 } 312 if !sq.Search.PageInfo.HasNextPage { 313 break 314 } 315 vars["searchCursor"] = githubql.NewString(sq.Search.PageInfo.EndCursor) 316 } 317 da.log.Infof("Search for query \"%s\" cost %d point(s). %d remaining.", query, totalCost, remaining) 318 return prs, nil 319 } 320 321 // GetHeadContexts returns the status checks' contexts of the head commit of the PR. 322 func (da *DashboardAgent) GetHeadContexts(ghc githubClient, pr PullRequest) ([]Context, error) { 323 org := string(pr.Repository.Owner.Login) 324 repo := string(pr.Repository.Name) 325 combined, err := ghc.GetCombinedStatus(org, repo, string(pr.HeadRefOID)) 326 if err != nil { 327 return nil, fmt.Errorf("failed to get the combined status: %v", err) 328 } 329 contexts := make([]Context, 0, len(combined.Statuses)) 330 for _, status := range combined.Statuses { 331 contexts = append( 332 contexts, 333 Context{ 334 Context: status.Context, 335 Description: status.Description, 336 State: strings.ToUpper(status.State), 337 }, 338 ) 339 } 340 return contexts, nil 341 } 342 343 // GetUser attempts to get the currently authenticated Github user. 344 func (da *DashboardAgent) GetUser(client *ghclient.Client) (*gogithub.User, error) { 345 return client.GetUser("") 346 } 347 348 // ConstructSearchQuery returns the Github search query string for PRs that are open and authored 349 // by the user passed. The search is scoped to repositories that are configured with either Prow or 350 // Tide. 351 func (da *DashboardAgent) ConstructSearchQuery(login string) string { 352 tokens := []string{"is:pr", "state:open", "author:" + login} 353 for i := range da.repos { 354 tokens = append(tokens, fmt.Sprintf("repo:\"%s\"", da.repos[i])) 355 } 356 return strings.Join(tokens, " ") 357 } 358 359 func queryConstrainsRepos(q string) bool { 360 tkns := strings.Split(q, " ") 361 for _, tkn := range tkns { 362 if strings.HasPrefix(tkn, "org:") || strings.HasPrefix(tkn, "repo:") { 363 return true 364 } 365 } 366 return false 367 }