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