sigs.k8s.io/prow@v0.0.0-20240503223140-c5e374dc7eb1/pkg/github/client.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 package github 18 19 import ( 20 "bytes" 21 "context" 22 "crypto/rsa" 23 "crypto/sha256" 24 "encoding/base64" 25 "encoding/json" 26 "errors" 27 "fmt" 28 "io" 29 "net/http" 30 "net/url" 31 "regexp" 32 "strconv" 33 "strings" 34 "sync" 35 "time" 36 37 "github.com/prometheus/client_golang/prometheus" 38 githubql "github.com/shurcooL/githubv4" 39 "github.com/sirupsen/logrus" 40 "golang.org/x/oauth2" 41 utilerrors "k8s.io/apimachinery/pkg/util/errors" 42 "k8s.io/apimachinery/pkg/util/sets" 43 44 "sigs.k8s.io/prow/pkg/ghcache" 45 "sigs.k8s.io/prow/pkg/throttle" 46 "sigs.k8s.io/prow/pkg/version" 47 ) 48 49 type timeClient interface { 50 Sleep(time.Duration) 51 Until(time.Time) time.Duration 52 } 53 54 type standardTime struct{} 55 56 func (s *standardTime) Sleep(d time.Duration) { 57 time.Sleep(d) 58 } 59 func (s *standardTime) Until(t time.Time) time.Duration { 60 return time.Until(t) 61 } 62 63 // OrganizationClient interface for organisation related API actions 64 type OrganizationClient interface { 65 IsMember(org, user string) (bool, error) 66 GetOrg(name string) (*Organization, error) 67 EditOrg(name string, config Organization) (*Organization, error) 68 ListOrgInvitations(org string) ([]OrgInvitation, error) 69 ListOrgMembers(org, role string) ([]TeamMember, error) 70 HasPermission(org, repo, user string, roles ...string) (bool, error) 71 GetUserPermission(org, repo, user string) (string, error) 72 UpdateOrgMembership(org, user string, admin bool) (*OrgMembership, error) 73 RemoveOrgMembership(org, user string) error 74 } 75 76 // HookClient interface for hook related API actions 77 type HookClient interface { 78 ListOrgHooks(org string) ([]Hook, error) 79 ListRepoHooks(org, repo string) ([]Hook, error) 80 EditRepoHook(org, repo string, id int, req HookRequest) error 81 EditOrgHook(org string, id int, req HookRequest) error 82 CreateOrgHook(org string, req HookRequest) (int, error) 83 CreateRepoHook(org, repo string, req HookRequest) (int, error) 84 DeleteOrgHook(org string, id int, req HookRequest) error 85 DeleteRepoHook(org, repo string, id int, req HookRequest) error 86 ListCurrentUserRepoInvitations() ([]UserRepoInvitation, error) 87 AcceptUserRepoInvitation(invitationID int) error 88 ListCurrentUserOrgInvitations() ([]UserOrgInvitation, error) 89 AcceptUserOrgInvitation(org string) error 90 } 91 92 // CommentClient interface for comment related API actions 93 type CommentClient interface { 94 CreateComment(org, repo string, number int, comment string) error 95 CreateCommentWithContext(ctx context.Context, org, repo string, number int, comment string) error 96 DeleteComment(org, repo string, id int) error 97 DeleteCommentWithContext(ctx context.Context, org, repo string, id int) error 98 EditComment(org, repo string, id int, comment string) error 99 EditCommentWithContext(ctx context.Context, org, repo string, id int, comment string) error 100 CreateCommentReaction(org, repo string, id int, reaction string) error 101 DeleteStaleComments(org, repo string, number int, comments []IssueComment, isStale func(IssueComment) bool) error 102 DeleteStaleCommentsWithContext(ctx context.Context, org, repo string, number int, comments []IssueComment, isStale func(IssueComment) bool) error 103 } 104 105 // IssueClient interface for issue related API actions 106 type IssueClient interface { 107 CreateIssue(org, repo, title, body string, milestone int, labels, assignees []string) (int, error) 108 CreateIssueReaction(org, repo string, id int, reaction string) error 109 ListIssueComments(org, repo string, number int) ([]IssueComment, error) 110 ListIssueCommentsWithContext(ctx context.Context, org, repo string, number int) ([]IssueComment, error) 111 GetIssueLabels(org, repo string, number int) ([]Label, error) 112 ListIssueEvents(org, repo string, num int) ([]ListedIssueEvent, error) 113 AssignIssue(org, repo string, number int, logins []string) error 114 UnassignIssue(org, repo string, number int, logins []string) error 115 CloseIssue(org, repo string, number int) error 116 CloseIssueAsNotPlanned(org, repo string, number int) error 117 ReopenIssue(org, repo string, number int) error 118 FindIssues(query, sort string, asc bool) ([]Issue, error) 119 FindIssuesWithOrg(org, query, sort string, asc bool) ([]Issue, error) 120 ListOpenIssues(org, repo string) ([]Issue, error) 121 GetIssue(org, repo string, number int) (*Issue, error) 122 EditIssue(org, repo string, number int, issue *Issue) (*Issue, error) 123 } 124 125 // PullRequestClient interface for pull request related API actions 126 type PullRequestClient interface { 127 GetPullRequests(org, repo string) ([]PullRequest, error) 128 GetPullRequest(org, repo string, number int) (*PullRequest, error) 129 EditPullRequest(org, repo string, number int, pr *PullRequest) (*PullRequest, error) 130 GetPullRequestDiff(org, repo string, number int) ([]byte, error) 131 GetPullRequestPatch(org, repo string, number int) ([]byte, error) 132 CreatePullRequest(org, repo, title, body, head, base string, canModify bool) (int, error) 133 UpdatePullRequest(org, repo string, number int, title, body *string, open *bool, branch *string, canModify *bool) error 134 GetPullRequestChanges(org, repo string, number int) ([]PullRequestChange, error) 135 ListPullRequestComments(org, repo string, number int) ([]ReviewComment, error) 136 CreatePullRequestReviewComment(org, repo string, number int, rc ReviewComment) error 137 ListReviews(org, repo string, number int) ([]Review, error) 138 ClosePullRequest(org, repo string, number int) error 139 ReopenPullRequest(org, repo string, number int) error 140 CreateReview(org, repo string, number int, r DraftReview) error 141 RequestReview(org, repo string, number int, logins []string) error 142 UnrequestReview(org, repo string, number int, logins []string) error 143 Merge(org, repo string, pr int, details MergeDetails) error 144 IsMergeable(org, repo string, number int, SHA string) (bool, error) 145 ListPullRequestCommits(org, repo string, number int) ([]RepositoryCommit, error) 146 UpdatePullRequestBranch(org, repo string, number int, expectedHeadSha *string) error 147 } 148 149 // CommitClient interface for commit related API actions 150 type CommitClient interface { 151 CreateStatus(org, repo, SHA string, s Status) error 152 CreateStatusWithContext(ctx context.Context, org, repo, SHA string, s Status) error 153 ListStatuses(org, repo, ref string) ([]Status, error) 154 GetSingleCommit(org, repo, SHA string) (RepositoryCommit, error) 155 GetCombinedStatus(org, repo, ref string) (*CombinedStatus, error) 156 ListCheckRuns(org, repo, ref string) (*CheckRunList, error) 157 GetRef(org, repo, ref string) (string, error) 158 DeleteRef(org, repo, ref string) error 159 ListFileCommits(org, repo, path string) ([]RepositoryCommit, error) 160 CreateCheckRun(org, repo string, checkRun CheckRun) error 161 } 162 163 // RepositoryClient interface for repository related API actions 164 type RepositoryClient interface { 165 GetRepo(owner, name string) (FullRepo, error) 166 GetRepos(org string, isUser bool) ([]Repo, error) 167 GetBranches(org, repo string, onlyProtected bool) ([]Branch, error) 168 GetBranchProtection(org, repo, branch string) (*BranchProtection, error) 169 RemoveBranchProtection(org, repo, branch string) error 170 UpdateBranchProtection(org, repo, branch string, config BranchProtectionRequest) error 171 AddRepoLabel(org, repo, label, description, color string) error 172 UpdateRepoLabel(org, repo, label, newName, description, color string) error 173 DeleteRepoLabel(org, repo, label string) error 174 GetRepoLabels(org, repo string) ([]Label, error) 175 AddLabel(org, repo string, number int, label string) error 176 AddLabelWithContext(ctx context.Context, org, repo string, number int, label string) error 177 AddLabels(org, repo string, number int, labels ...string) error 178 AddLabelsWithContext(ctx context.Context, org, repo string, number int, labels ...string) error 179 RemoveLabel(org, repo string, number int, label string) error 180 RemoveLabelWithContext(ctx context.Context, org, repo string, number int, label string) error 181 WasLabelAddedByHuman(org, repo string, number int, label string) (bool, error) 182 GetFile(org, repo, filepath, commit string) ([]byte, error) 183 GetDirectory(org, repo, dirpath, commit string) ([]DirectoryContent, error) 184 IsCollaborator(org, repo, user string) (bool, error) 185 ListCollaborators(org, repo string) ([]User, error) 186 CreateFork(owner, repo string) (string, error) 187 EnsureFork(forkingUser, org, repo string) (string, error) 188 ListRepoTeams(org, repo string) ([]Team, error) 189 CreateRepo(owner string, isUser bool, repo RepoCreateRequest) (*FullRepo, error) 190 UpdateRepo(owner, name string, repo RepoUpdateRequest) (*FullRepo, error) 191 } 192 193 // TeamClient interface for team related API actions 194 type TeamClient interface { 195 CreateTeam(org string, team Team) (*Team, error) 196 EditTeam(org string, t Team) (*Team, error) 197 DeleteTeamBySlug(org, teamSlug string) error 198 ListTeams(org string) ([]Team, error) 199 UpdateTeamMembershipBySlug(org, teamSlug, user string, maintainer bool) (*TeamMembership, error) 200 RemoveTeamMembershipBySlug(org, teamSlug, user string) error 201 ListTeamMembers(org string, id int, role string) ([]TeamMember, error) 202 ListTeamMembersBySlug(org, teamSlug, role string) ([]TeamMember, error) 203 ListTeamReposBySlug(org, teamSlug string) ([]Repo, error) 204 UpdateTeamRepoBySlug(org, teamSlug, repo string, permission TeamPermission) error 205 RemoveTeamRepoBySlug(org, teamSlug, repo string) error 206 ListTeamInvitationsBySlug(org, teamSlug string) ([]OrgInvitation, error) 207 TeamHasMember(org string, teamID int, memberLogin string) (bool, error) 208 TeamBySlugHasMember(org string, teamSlug string, memberLogin string) (bool, error) 209 GetTeamBySlug(slug string, org string) (*Team, error) 210 } 211 212 // UserClient interface for user related API actions 213 type UserClient interface { 214 // BotUser will return details about the user the client runs as. Use BotUserChecker() 215 // instead when checking for comment authorship, as the Username in comments might have 216 // a [bot] suffix when using github apps authentication. 217 BotUser() (*UserData, error) 218 // BotUserChecker can be used to check if a comment was authored by the bot user. 219 BotUserChecker() (func(candidate string) bool, error) 220 BotUserCheckerWithContext(ctx context.Context) (func(candidate string) bool, error) 221 Email() (string, error) 222 } 223 224 // ProjectClient interface for project related API actions 225 type ProjectClient interface { 226 GetRepoProjects(owner, repo string) ([]Project, error) 227 GetOrgProjects(org string) ([]Project, error) 228 GetProjectColumns(org string, projectID int) ([]ProjectColumn, error) 229 CreateProjectCard(org string, columnID int, projectCard ProjectCard) (*ProjectCard, error) 230 GetColumnProjectCards(org string, columnID int) ([]ProjectCard, error) 231 GetColumnProjectCard(org string, columnID int, issueURL string) (*ProjectCard, error) 232 MoveProjectCard(org string, projectCardID int, newColumnID int) error 233 DeleteProjectCard(org string, projectCardID int) error 234 } 235 236 // MilestoneClient interface for milestone related API actions 237 type MilestoneClient interface { 238 ClearMilestone(org, repo string, num int) error 239 SetMilestone(org, repo string, issueNum, milestoneNum int) error 240 ListMilestones(org, repo string) ([]Milestone, error) 241 } 242 243 // RerunClient interface for job rerun access check related API actions 244 type RerunClient interface { 245 TeamBySlugHasMember(org string, teamSlug string, memberLogin string) (bool, error) 246 TeamHasMember(org string, teamID int, memberLogin string) (bool, error) 247 IsCollaborator(org, repo, user string) (bool, error) 248 IsMember(org, user string) (bool, error) 249 GetIssueLabels(org, repo string, number int) ([]Label, error) 250 } 251 252 // Client interface for GitHub API 253 type Client interface { 254 PullRequestClient 255 RepositoryClient 256 CommitClient 257 IssueClient 258 CommentClient 259 OrganizationClient 260 TeamClient 261 ProjectClient 262 MilestoneClient 263 UserClient 264 HookClient 265 ListAppInstallations() ([]AppInstallation, error) 266 IsAppInstalled(org, repo string) (bool, error) 267 UsesAppAuth() bool 268 ListAppInstallationsForOrg(org string) ([]AppInstallation, error) 269 GetApp() (*App, error) 270 GetAppWithContext(ctx context.Context) (*App, error) 271 GetFailedActionRunsByHeadBranch(org, repo, branchName, headSHA string) ([]WorkflowRun, error) 272 273 Throttle(hourlyTokens, burst int, org ...string) error 274 QueryWithGitHubAppsSupport(ctx context.Context, q interface{}, vars map[string]interface{}, org string) error 275 MutateWithGitHubAppsSupport(ctx context.Context, m interface{}, input githubql.Input, vars map[string]interface{}, org string) error 276 277 SetMax404Retries(int) 278 279 WithFields(fields logrus.Fields) Client 280 ForPlugin(plugin string) Client 281 ForSubcomponent(subcomponent string) Client 282 Used() bool 283 TriggerGitHubWorkflow(org, repo string, id int) error 284 TriggerFailedGitHubWorkflow(org, repo string, id int) error 285 } 286 287 // client interacts with the github api. It is reconstructed whenever 288 // ForPlugin/ForSubcomment is called to change the Logger and User-Agent 289 // header, whereas delegate will stay the same. 290 type client struct { 291 // If logger is non-nil, log all method calls with it. 292 logger *logrus.Entry 293 // identifier is used to add more identification to the user-agent header 294 identifier string 295 gqlc gqlClient 296 used bool 297 mutUsed sync.Mutex // protects used 298 *delegate 299 } 300 301 // delegate actually does the work to talk to GitHub 302 type delegate struct { 303 time timeClient 304 305 maxRetries int 306 max404Retries int 307 maxSleepTime time.Duration 308 initialDelay time.Duration 309 310 client httpClient 311 bases []string 312 dry bool 313 fake bool 314 usesAppsAuth bool 315 throttle ghThrottler 316 getToken func() []byte 317 censor func([]byte) []byte 318 319 mut sync.Mutex // protects botName and email 320 userData *UserData 321 } 322 323 type UserData struct { 324 Name string 325 Login string 326 Email string 327 } 328 329 // Used determines whether the client has been used 330 func (c *client) Used() bool { 331 return c.used 332 } 333 334 // ForPlugin clones the client, keeping the underlying delegate the same but adding 335 // a plugin identifier and log field 336 func (c *client) ForPlugin(plugin string) Client { 337 return c.forKeyValue("plugin", plugin) 338 } 339 340 // ForSubcomponent clones the client, keeping the underlying delegate the same but adding 341 // an identifier and log field 342 func (c *client) ForSubcomponent(subcomponent string) Client { 343 return c.forKeyValue("subcomponent", subcomponent) 344 } 345 346 func (c *client) forKeyValue(key, value string) Client { 347 newClient := &client{ 348 identifier: value, 349 logger: c.logger.WithField(key, value), 350 delegate: c.delegate, 351 } 352 newClient.gqlc = c.gqlc.forUserAgent(newClient.userAgent()) 353 return newClient 354 } 355 356 func (c *client) userAgent() string { 357 if c.identifier != "" { 358 return version.UserAgentWithIdentifier(c.identifier) 359 } 360 return version.UserAgent() 361 } 362 363 // WithFields clones the client, keeping the underlying delegate the same but adding 364 // fields to the logging context 365 func (c *client) WithFields(fields logrus.Fields) Client { 366 return &client{ 367 logger: c.logger.WithFields(fields), 368 identifier: c.identifier, 369 gqlc: c.gqlc, 370 delegate: c.delegate, 371 } 372 } 373 374 var ( 375 teamRe = regexp.MustCompile(`^(.*)/(.*)$`) 376 ) 377 378 const ( 379 acceptNone = "" 380 githubApiVersion = "2022-11-28" 381 382 // MaxRequestTime aborts requests that don't return in 5 mins. Longest graphql 383 // calls can take up to 2 minutes. This limit should ensure all successful calls 384 // return but will prevent an indefinite stall if GitHub never responds. 385 MaxRequestTime = 5 * time.Minute 386 387 DefaultMaxRetries = 8 388 DefaultMax404Retries = 2 389 DefaultMaxSleepTime = 2 * time.Minute 390 DefaultInitialDelay = 2 * time.Second 391 ) 392 393 // Force the compiler to check if the TokenSource is implementing correctly. 394 // Tokensource is needed to dynamically update the token in the GraphQL client. 395 var _ oauth2.TokenSource = &reloadingTokenSource{} 396 397 type reloadingTokenSource struct { 398 getToken func() []byte 399 } 400 401 // Interface for how prow interacts with the http client, which we may throttle. 402 type httpClient interface { 403 Do(req *http.Request) (*http.Response, error) 404 } 405 406 // Interface for how prow interacts with the graphql client, which we may throttle. 407 type gqlClient interface { 408 QueryWithGitHubAppsSupport(ctx context.Context, q interface{}, vars map[string]interface{}, org string) error 409 MutateWithGitHubAppsSupport(ctx context.Context, m interface{}, input githubql.Input, vars map[string]interface{}, org string) error 410 forUserAgent(userAgent string) gqlClient 411 } 412 413 // ghThrottler sets a ceiling on the rate of GitHub requests. 414 // Configure with Client.Throttle(). 415 // It gets reconstructed whenever forUserAgent() is called, 416 // whereas its *throttle.Throttler remains. 417 type ghThrottler struct { 418 graph gqlClient 419 http httpClient 420 *throttle.Throttler 421 } 422 423 func (t *ghThrottler) Do(req *http.Request) (*http.Response, error) { 424 org := extractOrgFromContext(req.Context()) 425 if err := t.Wait(req.Context(), org); err != nil { 426 return nil, err 427 } 428 resp, err := t.http.Do(req) 429 if err == nil { 430 cacheMode := ghcache.CacheResponseMode(resp.Header.Get(ghcache.CacheModeHeader)) 431 if ghcache.CacheModeIsFree(cacheMode) { 432 // This request was fulfilled by ghcache without using an API token. 433 // Refund the throttling token we preemptively consumed. 434 logrus.WithFields(logrus.Fields{ 435 "client": "github", 436 "throttled": true, 437 "cache-mode": string(cacheMode), 438 }).Debug("Throttler refunding token for free response from ghcache.") 439 t.Refund(org) 440 } else { 441 logrus.WithFields(logrus.Fields{ 442 "client": "github", 443 "throttled": true, 444 "cache-mode": string(cacheMode), 445 "path": req.URL.Path, 446 "method": req.Method, 447 }).Debug("Used token for request") 448 449 } 450 } 451 return resp, err 452 } 453 454 func (t *ghThrottler) QueryWithGitHubAppsSupport(ctx context.Context, q interface{}, vars map[string]interface{}, org string) error { 455 if err := t.Wait(ctx, extractOrgFromContext(ctx)); err != nil { 456 return err 457 } 458 return t.graph.QueryWithGitHubAppsSupport(ctx, q, vars, org) 459 } 460 461 func (t *ghThrottler) MutateWithGitHubAppsSupport(ctx context.Context, m interface{}, input githubql.Input, vars map[string]interface{}, org string) error { 462 if err := t.Wait(ctx, extractOrgFromContext(ctx)); err != nil { 463 return err 464 } 465 return t.graph.MutateWithGitHubAppsSupport(ctx, m, input, vars, org) 466 } 467 468 func (t *ghThrottler) forUserAgent(userAgent string) gqlClient { 469 return &ghThrottler{ 470 graph: t.graph.forUserAgent(userAgent), 471 Throttler: t.Throttler, 472 } 473 } 474 475 // Throttle client to a rate of at most hourlyTokens requests per hour, 476 // allowing burst tokens. 477 func (c *client) Throttle(hourlyTokens, burst int, orgs ...string) error { 478 if len(orgs) > 0 { 479 if !c.usesAppsAuth { 480 return errors.New("passing an org to the throttler is only allowed when using github apps auth") 481 } 482 } 483 c.log("Throttle", hourlyTokens, burst, orgs) 484 return c.throttle.Throttle(hourlyTokens, burst, orgs...) 485 } 486 487 func (c *client) SetMax404Retries(max int) { 488 c.max404Retries = max 489 } 490 491 // ClientOptions holds options for creating a new client 492 type ClientOptions struct { 493 // censor knows how to censor output 494 Censor func([]byte) []byte 495 496 // the following fields handle auth 497 GetToken func() []byte 498 AppID string 499 AppPrivateKey func() *rsa.PrivateKey 500 501 // the following fields determine which server we talk to 502 GraphqlEndpoint string 503 Bases []string 504 505 // the following fields determine client retry behavior 506 MaxRequestTime, InitialDelay, MaxSleepTime time.Duration 507 MaxRetries, Max404Retries int 508 509 DryRun bool 510 // BaseRoundTripper is the last RoundTripper to be called. Used for testing, gets defaulted to http.DefaultTransport 511 BaseRoundTripper http.RoundTripper 512 } 513 514 func (o ClientOptions) Default() ClientOptions { 515 if o.MaxRequestTime == 0 { 516 o.MaxRequestTime = MaxRequestTime 517 } 518 if o.InitialDelay == 0 { 519 o.InitialDelay = DefaultInitialDelay 520 } 521 if o.MaxSleepTime == 0 { 522 o.MaxSleepTime = DefaultMaxSleepTime 523 } 524 if o.MaxRetries == 0 { 525 o.MaxRetries = DefaultMaxRetries 526 } 527 if o.Max404Retries == 0 { 528 o.Max404Retries = DefaultMax404Retries 529 } 530 return o 531 } 532 533 // TokenGenerator knows how to generate a token for use in git client calls 534 type TokenGenerator func(org string) (string, error) 535 536 // UserGenerator knows how to identify this user for use in git client calls 537 type UserGenerator func() (string, error) 538 539 // NewClientWithFields creates a new fully operational GitHub client. With 540 // added logging fields. 541 // 'getToken' is a generator for the GitHub access token to use. 542 // 'bases' is a variadic slice of endpoints to use in order of preference. 543 // 544 // An endpoint is used when all preceding endpoints have returned a conn err. 545 // This should be used when using the ghproxy GitHub proxy cache to allow 546 // this client to bypass the cache if it is temporarily unavailable. 547 func NewClientWithFields(fields logrus.Fields, getToken func() []byte, censor func([]byte) []byte, graphqlEndpoint string, bases ...string) (Client, error) { 548 _, _, client, err := NewClientFromOptions(fields, ClientOptions{ 549 Censor: censor, 550 GetToken: getToken, 551 GraphqlEndpoint: graphqlEndpoint, 552 Bases: bases, 553 DryRun: false, 554 }.Default()) 555 return client, err 556 } 557 558 func NewAppsAuthClientWithFields(fields logrus.Fields, censor func([]byte) []byte, appID string, appPrivateKey func() *rsa.PrivateKey, graphqlEndpoint string, bases ...string) (TokenGenerator, UserGenerator, Client, error) { 559 return NewClientFromOptions(fields, ClientOptions{ 560 Censor: censor, 561 AppID: appID, 562 AppPrivateKey: appPrivateKey, 563 GraphqlEndpoint: graphqlEndpoint, 564 Bases: bases, 565 DryRun: false, 566 }.Default()) 567 } 568 569 // This should only be called once when the client is created. 570 func (c *client) wrapThrottler() { 571 c.throttle.http = c.client 572 c.throttle.graph = c.gqlc 573 c.client = &c.throttle 574 c.gqlc = &c.throttle 575 } 576 577 // NewClientFromOptions creates a new client from the options we expose. This method should be used over the more-specific ones. 578 func NewClientFromOptions(fields logrus.Fields, options ClientOptions) (TokenGenerator, UserGenerator, Client, error) { 579 options = options.Default() 580 581 // Will be nil if github app authentication is used 582 if options.GetToken == nil { 583 options.GetToken = func() []byte { return nil } 584 } 585 if options.BaseRoundTripper == nil { 586 options.BaseRoundTripper = http.DefaultTransport 587 } 588 589 httpClient := &http.Client{ 590 Transport: options.BaseRoundTripper, 591 Timeout: options.MaxRequestTime, 592 } 593 graphQLTransport := newAddHeaderTransport(options.BaseRoundTripper) 594 c := &client{ 595 logger: logrus.WithFields(fields).WithField("client", "github"), 596 gqlc: &graphQLGitHubAppsAuthClientWrapper{Client: githubql.NewEnterpriseClient( 597 options.GraphqlEndpoint, 598 &http.Client{ 599 Timeout: options.MaxRequestTime, 600 Transport: &oauth2.Transport{ 601 Source: newReloadingTokenSource(options.GetToken), 602 Base: graphQLTransport, 603 }, 604 })}, 605 delegate: &delegate{ 606 time: &standardTime{}, 607 client: httpClient, 608 bases: options.Bases, 609 throttle: ghThrottler{Throttler: &throttle.Throttler{}}, 610 getToken: options.GetToken, 611 censor: options.Censor, 612 dry: options.DryRun, 613 usesAppsAuth: options.AppID != "", 614 maxRetries: options.MaxRetries, 615 max404Retries: options.Max404Retries, 616 initialDelay: options.InitialDelay, 617 maxSleepTime: options.MaxSleepTime, 618 }, 619 } 620 c.gqlc = c.gqlc.forUserAgent(c.userAgent()) 621 622 // Wrap clients with the throttler 623 c.wrapThrottler() 624 625 var tokenGenerator func(_ string) (string, error) 626 var userGenerator func() (string, error) 627 if options.AppID != "" { 628 appsTransport, err := newAppsRoundTripper(options.AppID, options.AppPrivateKey, options.BaseRoundTripper, c, options.Bases) 629 if err != nil { 630 return nil, nil, nil, fmt.Errorf("failed to construct apps auth roundtripper: %w", err) 631 } 632 httpClient.Transport = appsTransport 633 graphQLTransport.upstream = appsTransport 634 635 // Use github apps auth for git actions 636 // https://docs.github.com/en/free-pro-team@latest/developers/apps/authenticating-with-github-apps#http-based-git-access-by-an-installation= 637 tokenGenerator = func(org string) (string, error) { 638 res, _, err := appsTransport.installationTokenFor(org) 639 return res, err 640 } 641 userGenerator = func() (string, error) { 642 return "x-access-token", nil 643 } 644 } else { 645 // Use Personal Access token auth for git actions 646 tokenGenerator = func(_ string) (string, error) { 647 return string(options.GetToken()), nil 648 } 649 userGenerator = func() (string, error) { 650 user, err := c.BotUser() 651 if err != nil { 652 return "", err 653 } 654 return user.Login, nil 655 } 656 } 657 658 return tokenGenerator, userGenerator, c, nil 659 } 660 661 type graphQLGitHubAppsAuthClientWrapper struct { 662 *githubql.Client 663 userAgent string 664 } 665 666 type contextKey int 667 668 const ( 669 userAgentContextKey contextKey = iota 670 githubOrgContextKey 671 ) 672 673 func (c *graphQLGitHubAppsAuthClientWrapper) QueryWithGitHubAppsSupport(ctx context.Context, q interface{}, vars map[string]interface{}, org string) error { 674 ctx = context.WithValue(ctx, githubOrgContextKey, org) 675 ctx = context.WithValue(ctx, userAgentContextKey, c.userAgent) 676 return c.Client.Query(ctx, q, vars) 677 } 678 679 func (c *graphQLGitHubAppsAuthClientWrapper) MutateWithGitHubAppsSupport(ctx context.Context, m interface{}, input githubql.Input, vars map[string]interface{}, org string) error { 680 ctx = context.WithValue(ctx, githubOrgContextKey, org) 681 ctx = context.WithValue(ctx, userAgentContextKey, c.userAgent) 682 return c.Client.Mutate(ctx, m, input, vars) 683 } 684 685 func (c *graphQLGitHubAppsAuthClientWrapper) forUserAgent(userAgent string) gqlClient { 686 return &graphQLGitHubAppsAuthClientWrapper{ 687 Client: c.Client, 688 userAgent: userAgent, 689 } 690 } 691 692 // addHeaderTransport implements http.RoundTripper 693 var _ http.RoundTripper = &addHeaderTransport{} 694 695 func newAddHeaderTransport(upstream http.RoundTripper) *addHeaderTransport { 696 return &addHeaderTransport{upstream} 697 } 698 699 type addHeaderTransport struct { 700 upstream http.RoundTripper 701 } 702 703 func (s *addHeaderTransport) RoundTrip(r *http.Request) (*http.Response, error) { 704 // We have to add this header to enable the Checks scheme preview: 705 // https://docs.github.com/en/enterprise-server@2.22/graphql/overview/schema-previews 706 // Any GHE version after 2.22 will enable the Checks types per default 707 r.Header.Add("Accept", "application/vnd.github.antiope-preview+json") 708 709 // We have to add this header to enable the Merge info scheme preview: 710 // https://docs.github.com/en/graphql/overview/schema-previews#merge-info-preview 711 r.Header.Add("Accept", "application/vnd.github.merge-info-preview+json") 712 713 // We use the context to pass the UserAgent through the V4 client we depend on 714 if v := r.Context().Value(userAgentContextKey); v != nil { 715 r.Header.Add("User-Agent", v.(string)) 716 } 717 718 return s.upstream.RoundTrip(r) 719 } 720 721 // NewClient creates a new fully operational GitHub client. 722 func NewClient(getToken func() []byte, censor func([]byte) []byte, graphqlEndpoint string, bases ...string) (Client, error) { 723 return NewClientWithFields(logrus.Fields{}, getToken, censor, graphqlEndpoint, bases...) 724 } 725 726 // NewDryRunClientWithFields creates a new client that will not perform mutating actions 727 // such as setting statuses or commenting, but it will still query GitHub and 728 // use up API tokens. Additional fields are added to the logger. 729 // 'getToken' is a generator the GitHub access token to use. 730 // 'bases' is a variadic slice of endpoints to use in order of preference. 731 // 732 // An endpoint is used when all preceding endpoints have returned a conn err. 733 // This should be used when using the ghproxy GitHub proxy cache to allow 734 // this client to bypass the cache if it is temporarily unavailable. 735 func NewDryRunClientWithFields(fields logrus.Fields, getToken func() []byte, censor func([]byte) []byte, graphqlEndpoint string, bases ...string) (Client, error) { 736 _, _, client, err := NewClientFromOptions(fields, ClientOptions{ 737 Censor: censor, 738 GetToken: getToken, 739 GraphqlEndpoint: graphqlEndpoint, 740 Bases: bases, 741 DryRun: true, 742 }.Default()) 743 return client, err 744 } 745 746 // NewAppsAuthDryRunClientWithFields creates a new client that will not perform mutating actions 747 // such as setting statuses or commenting, but it will still query GitHub and 748 // use up API tokens. Additional fields are added to the logger. 749 func NewAppsAuthDryRunClientWithFields(fields logrus.Fields, censor func([]byte) []byte, appId string, appPrivateKey func() *rsa.PrivateKey, graphqlEndpoint string, bases ...string) (TokenGenerator, UserGenerator, Client, error) { 750 return NewClientFromOptions(fields, ClientOptions{ 751 Censor: censor, 752 AppID: appId, 753 AppPrivateKey: appPrivateKey, 754 GraphqlEndpoint: graphqlEndpoint, 755 Bases: bases, 756 DryRun: false, 757 }.Default()) 758 } 759 760 // NewDryRunClient creates a new client that will not perform mutating actions 761 // such as setting statuses or commenting, but it will still query GitHub and 762 // use up API tokens. 763 // 'getToken' is a generator the GitHub access token to use. 764 // 'bases' is a variadic slice of endpoints to use in order of preference. 765 // 766 // An endpoint is used when all preceding endpoints have returned a conn err. 767 // This should be used when using the ghproxy GitHub proxy cache to allow 768 // this client to bypass the cache if it is temporarily unavailable. 769 func NewDryRunClient(getToken func() []byte, censor func([]byte) []byte, graphqlEndpoint string, bases ...string) (Client, error) { 770 return NewDryRunClientWithFields(logrus.Fields{}, getToken, censor, graphqlEndpoint, bases...) 771 } 772 773 // NewFakeClient creates a new client that will not perform any actions at all. 774 func NewFakeClient() Client { 775 return &client{ 776 logger: logrus.WithField("client", "github"), 777 gqlc: &graphQLGitHubAppsAuthClientWrapper{}, 778 delegate: &delegate{ 779 time: &standardTime{}, 780 fake: true, 781 dry: true, 782 }, 783 } 784 } 785 786 func (c *client) log(methodName string, args ...interface{}) (logDuration func()) { 787 c.mutUsed.Lock() 788 c.used = true 789 c.mutUsed.Unlock() 790 791 if c.logger == nil { 792 return func() {} 793 } 794 var as []string 795 for _, arg := range args { 796 as = append(as, fmt.Sprintf("%v", arg)) 797 } 798 start := time.Now() 799 c.logger.Infof("%s(%s)", methodName, strings.Join(as, ", ")) 800 return func() { 801 c.logger.WithField("duration", time.Since(start).String()).Debugf("%s(%s) finished", methodName, strings.Join(as, ", ")) 802 } 803 } 804 805 type request struct { 806 method string 807 path string 808 accept string 809 org string 810 requestBody interface{} 811 exitCodes []int 812 } 813 814 type requestError struct { 815 StatusCode int 816 ClientError error 817 ErrorString string 818 } 819 820 func (r requestError) Error() string { 821 return r.ErrorString 822 } 823 824 func (r requestError) ErrorMessages() []string { 825 clientErr, isClientError := r.ClientError.(ClientError) 826 if isClientError { 827 errors := []string{} 828 for _, subErr := range clientErr.Errors { 829 errors = append(errors, subErr.Message) 830 } 831 return errors 832 } 833 alternativeClientErr, isAlternativeClientError := r.ClientError.(AlternativeClientError) 834 if isAlternativeClientError { 835 return alternativeClientErr.Errors 836 } 837 return []string{} 838 } 839 840 // NewNotFound returns a NotFound error which may be useful for tests 841 func NewNotFound() error { 842 return requestError{ 843 ClientError: ClientError{ 844 Errors: []clientErrorSubError{{Message: "status code 404"}}, 845 }, 846 } 847 } 848 849 func IsNotFound(err error) bool { 850 if err == nil { 851 return false 852 } 853 854 var requestErr requestError 855 if !errors.As(err, &requestErr) { 856 return false 857 } 858 859 if requestErr.StatusCode == http.StatusNotFound { 860 return true 861 } 862 863 for _, errorMsg := range requestErr.ErrorMessages() { 864 if strings.Contains(errorMsg, "status code 404") { 865 return true 866 } 867 } 868 return false 869 } 870 871 // Make a request with retries. If ret is not nil, unmarshal the response body 872 // into it. Returns an error if the exit code is not one of the provided codes. 873 func (c *client) request(r *request, ret interface{}) (int, error) { 874 return c.requestWithContext(context.Background(), r, ret) 875 } 876 877 func (c *client) requestWithContext(ctx context.Context, r *request, ret interface{}) (int, error) { 878 statusCode, b, err := c.requestRawWithContext(ctx, r) 879 if err != nil { 880 return statusCode, err 881 } 882 if ret != nil { 883 if err := json.Unmarshal(b, ret); err != nil { 884 return statusCode, err 885 } 886 } 887 return statusCode, nil 888 } 889 890 // requestRaw makes a request with retries and returns the response body. 891 // Returns an error if the exit code is not one of the provided codes. 892 func (c *client) requestRaw(r *request) (int, []byte, error) { 893 return c.requestRawWithContext(context.Background(), r) 894 } 895 896 func (c *client) requestRawWithContext(ctx context.Context, r *request) (int, []byte, error) { 897 if c.fake || (c.dry && r.method != http.MethodGet) { 898 return r.exitCodes[0], nil, nil 899 } 900 resp, err := c.requestRetryWithContext(ctx, r.method, r.path, r.accept, r.org, r.requestBody) 901 if err != nil { 902 return 0, nil, err 903 } 904 defer resp.Body.Close() 905 b, err := io.ReadAll(resp.Body) 906 if err != nil { 907 return 0, nil, err 908 } 909 var okCode bool 910 for _, code := range r.exitCodes { 911 if code == resp.StatusCode { 912 okCode = true 913 break 914 } 915 } 916 if !okCode { 917 clientError := unmarshalClientError(b) 918 err = requestError{ 919 StatusCode: resp.StatusCode, 920 ClientError: clientError, 921 ErrorString: fmt.Sprintf("status code %d not one of %v, body: %s", resp.StatusCode, r.exitCodes, string(b)), 922 } 923 } 924 return resp.StatusCode, b, err 925 } 926 927 // Retry on transport failures. Retries on 500s, retries after sleep on 928 // ratelimit exceeded, and retries 404s a couple times. 929 // This function closes the response body iff it also returns an error. 930 func (c *client) requestRetry(method, path, accept, org string, body interface{}) (*http.Response, error) { 931 return c.requestRetryWithContext(context.Background(), method, path, accept, org, body) 932 } 933 934 func (c *client) requestRetryWithContext(ctx context.Context, method, path, accept, org string, body interface{}) (*http.Response, error) { 935 var hostIndex int 936 var resp *http.Response 937 var err error 938 backoff := c.initialDelay 939 for retries := 0; retries < c.maxRetries; retries++ { 940 if retries > 0 && resp != nil { 941 resp.Body.Close() 942 } 943 resp, err = c.doRequest(ctx, method, c.bases[hostIndex]+path, accept, org, body) 944 if err == nil { 945 if resp.StatusCode == 404 && retries < c.max404Retries { 946 // Retry 404s a couple times. Sometimes GitHub is inconsistent in 947 // the sense that they send us an event such as "PR opened" but an 948 // immediate request to GET the PR returns 404. We don't want to 949 // retry more than a couple times in this case, because a 404 may 950 // be caused by a bad API call and we'll just burn through API 951 // tokens. 952 c.logger.WithField("backoff", backoff.String()).Debug("Retrying 404") 953 c.time.Sleep(backoff) 954 backoff *= 2 955 } else if resp.StatusCode == 403 { 956 if resp.Header.Get("X-RateLimit-Remaining") == "0" { 957 // If we are out of API tokens, sleep first. The X-RateLimit-Reset 958 // header tells us the time at which we can request again. 959 var t int 960 if t, err = strconv.Atoi(resp.Header.Get("X-RateLimit-Reset")); err == nil { 961 // Sleep an extra second plus how long GitHub wants us to 962 // sleep. If it's going to take too long, then break. 963 sleepTime := c.time.Until(time.Unix(int64(t), 0)) + time.Second 964 if sleepTime < c.maxSleepTime { 965 c.logger.WithField("backoff", sleepTime.String()).WithField("path", path).Debug("Retrying after token budget reset") 966 c.time.Sleep(sleepTime) 967 } else { 968 err = fmt.Errorf("sleep time for token reset exceeds max sleep time (%v > %v)", sleepTime, c.maxSleepTime) 969 resp.Body.Close() 970 break 971 } 972 } else { 973 err = fmt.Errorf("failed to parse rate limit reset unix time %q: %w", resp.Header.Get("X-RateLimit-Reset"), err) 974 resp.Body.Close() 975 break 976 } 977 } else if rawTime := resp.Header.Get("Retry-After"); rawTime != "" && rawTime != "0" { 978 // If we are getting abuse rate limited, we need to wait or 979 // else we risk continuing to make the situation worse 980 var t int 981 if t, err = strconv.Atoi(rawTime); err == nil { 982 // Sleep an extra second plus how long GitHub wants us to 983 // sleep. If it's going to take too long, then break. 984 sleepTime := time.Duration(t+1) * time.Second 985 if sleepTime < c.maxSleepTime { 986 c.logger.WithField("backoff", sleepTime.String()).WithField("path", path).Debug("Retrying after abuse ratelimit reset") 987 c.time.Sleep(sleepTime) 988 } else { 989 err = fmt.Errorf("sleep time for abuse rate limit exceeds max sleep time (%v > %v)", sleepTime, c.maxSleepTime) 990 resp.Body.Close() 991 break 992 } 993 } else { 994 err = fmt.Errorf("failed to parse abuse rate limit wait time %q: %w", rawTime, err) 995 resp.Body.Close() 996 break 997 } 998 } else { 999 acceptedScopes := resp.Header.Get("X-Accepted-OAuth-Scopes") 1000 authorizedScopes := resp.Header.Get("X-OAuth-Scopes") 1001 if authorizedScopes == "" { 1002 authorizedScopes = "no" 1003 } 1004 1005 want := sets.New[string]() 1006 for _, acceptedScope := range strings.Split(acceptedScopes, ",") { 1007 want.Insert(strings.TrimSpace(acceptedScope)) 1008 } 1009 var got []string 1010 for _, authorizedScope := range strings.Split(authorizedScopes, ",") { 1011 got = append(got, strings.TrimSpace(authorizedScope)) 1012 } 1013 if acceptedScopes != "" && !want.HasAny(got...) { 1014 err = fmt.Errorf("the account is using %s oauth scopes, please make sure you are using at least one of the following oauth scopes: %s", authorizedScopes, acceptedScopes) 1015 } else { 1016 body, _ := io.ReadAll(resp.Body) 1017 err = fmt.Errorf("the GitHub API request returns a 403 error: %s", string(body)) 1018 } 1019 resp.Body.Close() 1020 break 1021 } 1022 } else if resp.StatusCode < 500 { 1023 // Normal, happy case. 1024 break 1025 } else { 1026 // Retry 500 after a break. 1027 c.logger.WithField("backoff", backoff.String()).Debug("Retrying 5XX") 1028 c.time.Sleep(backoff) 1029 backoff *= 2 1030 } 1031 } else if errors.Is(err, &appsAuthError{}) { 1032 c.logger.WithError(err).Error("Stopping retry due to appsAuthError") 1033 return resp, err 1034 } else if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { 1035 return resp, err 1036 } else { 1037 // Connection problem. Try a different host. 1038 oldHostIndex := hostIndex 1039 hostIndex = (hostIndex + 1) % len(c.bases) 1040 c.logger.WithFields(logrus.Fields{ 1041 "err": err, 1042 "backoff": backoff.String(), 1043 "old-endpoint": c.bases[oldHostIndex], 1044 "new-endpoint": c.bases[hostIndex], 1045 }).Debug("Retrying request due to connection problem") 1046 c.time.Sleep(backoff) 1047 backoff *= 2 1048 } 1049 } 1050 return resp, err 1051 } 1052 1053 func (c *client) doRequest(ctx context.Context, method, path, accept, org string, body interface{}) (*http.Response, error) { 1054 var buf io.Reader 1055 if body != nil { 1056 b, err := json.Marshal(body) 1057 if err != nil { 1058 return nil, err 1059 } 1060 b = c.censor(b) 1061 buf = bytes.NewBuffer(b) 1062 } 1063 req, err := http.NewRequestWithContext(ctx, method, path, buf) 1064 if err != nil { 1065 return nil, fmt.Errorf("failed creating new request: %w", err) 1066 } 1067 // We do not make use of the Set() method to set this header because 1068 // the header name `X-GitHub-Api-Version` is non-canonical in nature. 1069 // 1070 // See https://pkg.go.dev/net/http#Header.Set for more info. 1071 req.Header["X-GitHub-Api-Version"] = []string{githubApiVersion} 1072 c.logger.Debugf("Using GitHub REST API Version: %s", githubApiVersion) 1073 if header := c.authHeader(); len(header) > 0 { 1074 req.Header.Set("Authorization", header) 1075 } 1076 if accept == acceptNone { 1077 req.Header.Add("Accept", "application/vnd.github.v3+json") 1078 } else { 1079 req.Header.Add("Accept", accept) 1080 } 1081 if userAgent := c.userAgent(); userAgent != "" { 1082 req.Header.Add("User-Agent", userAgent) 1083 } 1084 if org != "" { 1085 req = req.WithContext(context.WithValue(req.Context(), githubOrgContextKey, org)) 1086 } 1087 // Disable keep-alive so that we don't get flakes when GitHub closes the 1088 // connection prematurely. 1089 // https://go-review.googlesource.com/#/c/3210/ fixed it for GET, but not 1090 // for POST. 1091 req.Close = true 1092 1093 c.logger.WithField("curl", toCurl(req)).Trace("Executing http request") 1094 return c.client.Do(req) 1095 } 1096 1097 // toCurl is a slightly adjusted copy of https://github.com/kubernetes/kubernetes/blob/74053d555d71a14e3853b97e204d7d6415521375/staging/src/k8s.io/client-go/transport/round_trippers.go#L339 1098 func toCurl(r *http.Request) string { 1099 headers := "" 1100 for key, values := range r.Header { 1101 for _, value := range values { 1102 headers += fmt.Sprintf(` -H %q`, fmt.Sprintf("%s: %s", key, maskAuthorizationHeader(key, value))) 1103 } 1104 } 1105 1106 return fmt.Sprintf("curl -k -v -X%s %s '%s'", r.Method, headers, r.URL.String()) 1107 } 1108 1109 var knownAuthTypes = sets.New[string]("bearer", "basic", "negotiate") 1110 1111 // maskAuthorizationHeader masks credential content from authorization headers 1112 // See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Authorization 1113 func maskAuthorizationHeader(key string, value string) string { 1114 if !strings.EqualFold(key, "Authorization") { 1115 return value 1116 } 1117 if len(value) == 0 { 1118 return "" 1119 } 1120 var authType string 1121 if i := strings.Index(value, " "); i > 0 { 1122 authType = value[0:i] 1123 } else { 1124 authType = value 1125 } 1126 if !knownAuthTypes.Has(strings.ToLower(authType)) { 1127 return "<masked>" 1128 } 1129 if len(value) > len(authType)+1 { 1130 value = authType + " <masked>" 1131 } else { 1132 value = authType 1133 } 1134 return value 1135 } 1136 1137 func (c *client) authHeader() string { 1138 if c.getToken == nil { 1139 return "" 1140 } 1141 token := c.getToken() 1142 if len(token) == 0 { 1143 return "" 1144 } 1145 return fmt.Sprintf("Bearer %s", token) 1146 } 1147 1148 // userInfo provides the 'github_user_info' vector that is indexed 1149 // by the user's information. 1150 var userInfo = prometheus.NewGaugeVec( 1151 prometheus.GaugeOpts{ 1152 Name: "github_user_info", 1153 Help: "Metadata about a user, tied to their token hash.", 1154 }, 1155 []string{"token_hash", "login", "email"}, 1156 ) 1157 1158 func init() { 1159 prometheus.MustRegister(userInfo) 1160 } 1161 1162 // Not thread-safe - callers need to hold c.mut. 1163 func (c *client) getUserData(ctx context.Context) error { 1164 if c.delegate.usesAppsAuth { 1165 resp, err := c.GetAppWithContext(ctx) 1166 if err != nil { 1167 return err 1168 } 1169 c.userData = &UserData{ 1170 Name: resp.Name, 1171 Login: resp.Slug, 1172 Email: fmt.Sprintf("%s@users.noreply.github.com", resp.Slug), 1173 } 1174 return nil 1175 } 1176 c.log("User") 1177 var u User 1178 _, err := c.requestWithContext(ctx, &request{ 1179 method: http.MethodGet, 1180 path: "/user", 1181 exitCodes: []int{200}, 1182 }, &u) 1183 if err != nil { 1184 return err 1185 } 1186 c.userData = &UserData{ 1187 Name: u.Name, 1188 Login: u.Login, 1189 Email: u.Email, 1190 } 1191 // email needs to be publicly accessible via the profile 1192 // of the current account. Read below for more info 1193 // https://developer.github.com/v3/users/#get-a-single-user 1194 1195 // record information for the user 1196 authHeaderHash := fmt.Sprintf("%x", sha256.Sum256([]byte(c.authHeader()))) // use %x to make this a utf-8 string for use as a label 1197 userInfo.With(prometheus.Labels{"token_hash": authHeaderHash, "login": c.userData.Login, "email": c.userData.Email}).Set(1) 1198 return nil 1199 } 1200 1201 // BotUser returns the user data of the authenticated identity. 1202 // 1203 // See https://developer.github.com/v3/users/#get-the-authenticated-user 1204 func (c *client) BotUser() (*UserData, error) { 1205 c.mut.Lock() 1206 defer c.mut.Unlock() 1207 if c.userData == nil { 1208 if err := c.getUserData(context.Background()); err != nil { 1209 return nil, fmt.Errorf("fetching bot name from GitHub: %w", err) 1210 } 1211 } 1212 return c.userData, nil 1213 } 1214 1215 func (c *client) BotUserChecker() (func(candidate string) bool, error) { 1216 return c.BotUserCheckerWithContext(context.Background()) 1217 } 1218 1219 func (c *client) BotUserCheckerWithContext(ctx context.Context) (func(candidate string) bool, error) { 1220 c.mut.Lock() 1221 defer c.mut.Unlock() 1222 if c.userData == nil { 1223 if err := c.getUserData(ctx); err != nil { 1224 return nil, fmt.Errorf("fetching userdata from GitHub: %w", err) 1225 } 1226 } 1227 1228 botUser := c.userData.Login 1229 return func(candidate string) bool { 1230 if c.usesAppsAuth { 1231 candidate = strings.TrimSuffix(candidate, "[bot]") 1232 } 1233 return candidate == botUser 1234 }, nil 1235 } 1236 1237 // Email returns the user-configured email for the authenticated identity. 1238 // 1239 // See https://developer.github.com/v3/users/#get-the-authenticated-user 1240 func (c *client) Email() (string, error) { 1241 c.mut.Lock() 1242 defer c.mut.Unlock() 1243 if c.userData == nil { 1244 if err := c.getUserData(context.Background()); err != nil { 1245 return "", fmt.Errorf("fetching e-mail from GitHub: %w", err) 1246 } 1247 } 1248 return c.userData.Email, nil 1249 } 1250 1251 // IsMember returns whether or not the user is a member of the org. 1252 // 1253 // See https://developer.github.com/v3/orgs/members/#check-membership 1254 func (c *client) IsMember(org, user string) (bool, error) { 1255 c.log("IsMember", org, user) 1256 if org == user { 1257 // Make it possible to run a couple of plugins on personal repos. 1258 return true, nil 1259 } 1260 code, err := c.request(&request{ 1261 method: http.MethodGet, 1262 path: fmt.Sprintf("/orgs/%s/members/%s", org, user), 1263 org: org, 1264 exitCodes: []int{204, 404, 302}, 1265 }, nil) 1266 if err != nil { 1267 return false, err 1268 } 1269 if code == 204 { 1270 return true, nil 1271 } else if code == 404 { 1272 return false, nil 1273 } else if code == 302 { 1274 return false, fmt.Errorf("requester is not %s org member", org) 1275 } 1276 // Should be unreachable. 1277 return false, fmt.Errorf("unexpected status: %d", code) 1278 } 1279 1280 func (c *client) listHooks(org string, repo *string) ([]Hook, error) { 1281 var ret []Hook 1282 var path string 1283 if repo != nil { 1284 path = fmt.Sprintf("/repos/%s/%s/hooks", org, *repo) 1285 } else { 1286 path = fmt.Sprintf("/orgs/%s/hooks", org) 1287 } 1288 err := c.readPaginatedResults( 1289 path, 1290 acceptNone, 1291 org, 1292 func() interface{} { 1293 return &[]Hook{} 1294 }, 1295 func(obj interface{}) { 1296 ret = append(ret, *(obj.(*[]Hook))...) 1297 }, 1298 ) 1299 if err != nil { 1300 return nil, err 1301 } 1302 return ret, nil 1303 } 1304 1305 // ListOrgHooks returns a list of hooks for the org. 1306 // https://developer.github.com/v3/orgs/hooks/#list-hooks 1307 func (c *client) ListOrgHooks(org string) ([]Hook, error) { 1308 c.log("ListOrgHooks", org) 1309 return c.listHooks(org, nil) 1310 } 1311 1312 // ListRepoHooks returns a list of hooks for the repo. 1313 // https://developer.github.com/v3/repos/hooks/#list-hooks 1314 func (c *client) ListRepoHooks(org, repo string) ([]Hook, error) { 1315 c.log("ListRepoHooks", org, repo) 1316 return c.listHooks(org, &repo) 1317 } 1318 1319 func (c *client) editHook(org string, repo *string, id int, req HookRequest) error { 1320 if c.dry { 1321 return nil 1322 } 1323 var path string 1324 if repo != nil { 1325 path = fmt.Sprintf("/repos/%s/%s/hooks/%d", org, *repo, id) 1326 } else { 1327 path = fmt.Sprintf("/orgs/%s/hooks/%d", org, id) 1328 } 1329 1330 _, err := c.request(&request{ 1331 method: http.MethodPatch, 1332 path: path, 1333 org: org, 1334 exitCodes: []int{200}, 1335 requestBody: &req, 1336 }, nil) 1337 return err 1338 } 1339 1340 // EditRepoHook updates an existing hook with new info (events/url/secret) 1341 // https://developer.github.com/v3/repos/hooks/#edit-a-hook 1342 func (c *client) EditRepoHook(org, repo string, id int, req HookRequest) error { 1343 c.log("EditRepoHook", org, repo, id) 1344 return c.editHook(org, &repo, id, req) 1345 } 1346 1347 // EditOrgHook updates an existing hook with new info (events/url/secret) 1348 // https://developer.github.com/v3/orgs/hooks/#edit-a-hook 1349 func (c *client) EditOrgHook(org string, id int, req HookRequest) error { 1350 c.log("EditOrgHook", org, id) 1351 return c.editHook(org, nil, id, req) 1352 } 1353 1354 func (c *client) createHook(org string, repo *string, req HookRequest) (int, error) { 1355 if c.dry { 1356 return -1, nil 1357 } 1358 var path string 1359 if repo != nil { 1360 path = fmt.Sprintf("/repos/%s/%s/hooks", org, *repo) 1361 } else { 1362 path = fmt.Sprintf("/orgs/%s/hooks", org) 1363 } 1364 var ret Hook 1365 _, err := c.request(&request{ 1366 method: http.MethodPost, 1367 path: path, 1368 org: org, 1369 exitCodes: []int{201}, 1370 requestBody: &req, 1371 }, &ret) 1372 if err != nil { 1373 return 0, err 1374 } 1375 return ret.ID, nil 1376 } 1377 1378 // CreateOrgHook creates a new hook for the org 1379 // https://developer.github.com/v3/orgs/hooks/#create-a-hook 1380 func (c *client) CreateOrgHook(org string, req HookRequest) (int, error) { 1381 c.log("CreateOrgHook", org) 1382 return c.createHook(org, nil, req) 1383 } 1384 1385 // CreateRepoHook creates a new hook for the repo 1386 // https://developer.github.com/v3/repos/hooks/#create-a-hook 1387 func (c *client) CreateRepoHook(org, repo string, req HookRequest) (int, error) { 1388 c.log("CreateRepoHook", org, repo) 1389 return c.createHook(org, &repo, req) 1390 } 1391 1392 func (c *client) deleteHook(org, path string) error { 1393 if c.dry { 1394 return nil 1395 } 1396 1397 _, err := c.request(&request{ 1398 method: http.MethodDelete, 1399 path: path, 1400 org: org, 1401 exitCodes: []int{204}, 1402 }, nil) 1403 return err 1404 } 1405 1406 // DeleteRepoHook deletes an existing repo level webhook. 1407 // https://developer.github.com/v3/repos/hooks/#delete-a-hook 1408 func (c *client) DeleteRepoHook(org, repo string, id int, req HookRequest) error { 1409 c.log("DeleteRepoHook", org, repo, id) 1410 path := fmt.Sprintf("/repos/%s/%s/hooks/%d", org, repo, id) 1411 return c.deleteHook(org, path) 1412 } 1413 1414 // DeleteOrgHook deletes and existing org level webhook. 1415 // https://developer.github.com/v3/orgs/hooks/#edit-a-hook 1416 func (c *client) DeleteOrgHook(org string, id int, req HookRequest) error { 1417 c.log("DeleteOrgHook", org, id) 1418 path := fmt.Sprintf("/orgs/%s/hooks/%d", org, id) 1419 return c.deleteHook(org, path) 1420 } 1421 1422 // GetOrg returns current metadata for the org 1423 // 1424 // https://developer.github.com/v3/orgs/#get-an-organization 1425 func (c *client) GetOrg(name string) (*Organization, error) { 1426 c.log("GetOrg", name) 1427 var retOrg Organization 1428 _, err := c.request(&request{ 1429 method: http.MethodGet, 1430 path: fmt.Sprintf("/orgs/%s", name), 1431 org: name, 1432 exitCodes: []int{200}, 1433 }, &retOrg) 1434 if err != nil { 1435 return nil, err 1436 } 1437 return &retOrg, nil 1438 } 1439 1440 // EditOrg will update the metadata for this org. 1441 // 1442 // https://developer.github.com/v3/orgs/#edit-an-organization 1443 func (c *client) EditOrg(name string, config Organization) (*Organization, error) { 1444 c.log("EditOrg", name, config) 1445 if c.dry { 1446 return &config, nil 1447 } 1448 var retOrg Organization 1449 _, err := c.request(&request{ 1450 method: http.MethodPatch, 1451 path: fmt.Sprintf("/orgs/%s", name), 1452 org: name, 1453 exitCodes: []int{200}, 1454 requestBody: &config, 1455 }, &retOrg) 1456 if err != nil { 1457 return nil, err 1458 } 1459 return &retOrg, nil 1460 } 1461 1462 // ListOrgInvitations lists pending invitations to th org. 1463 // 1464 // https://developer.github.com/v3/orgs/members/#list-pending-organization-invitations 1465 func (c *client) ListOrgInvitations(org string) ([]OrgInvitation, error) { 1466 c.log("ListOrgInvitations", org) 1467 if c.fake { 1468 return nil, nil 1469 } 1470 path := fmt.Sprintf("/orgs/%s/invitations", org) 1471 var ret []OrgInvitation 1472 err := c.readPaginatedResults( 1473 path, 1474 acceptNone, 1475 org, 1476 func() interface{} { 1477 return &[]OrgInvitation{} 1478 }, 1479 func(obj interface{}) { 1480 ret = append(ret, *(obj.(*[]OrgInvitation))...) 1481 }, 1482 ) 1483 if err != nil { 1484 return nil, err 1485 } 1486 return ret, nil 1487 } 1488 1489 // ListCurrentUserRepoInvitations lists pending invitations for the authenticated user. 1490 // 1491 // https://docs.github.com/en/rest/reference/repos#list-repository-invitations-for-the-authenticated-user 1492 func (c *client) ListCurrentUserRepoInvitations() ([]UserRepoInvitation, error) { 1493 c.log("ListCurrentUserRepoInvitations") 1494 if c.fake { 1495 return nil, nil 1496 } 1497 path := "/user/repository_invitations" 1498 var ret []UserRepoInvitation 1499 err := c.readPaginatedResults( 1500 path, 1501 acceptNone, 1502 "", 1503 func() interface{} { 1504 return &[]UserRepoInvitation{} 1505 }, 1506 func(obj interface{}) { 1507 ret = append(ret, *(obj.(*[]UserRepoInvitation))...) 1508 }, 1509 ) 1510 if err != nil { 1511 return nil, err 1512 } 1513 return ret, nil 1514 } 1515 1516 // AcceptUserRepoInvitation accepts invitation for the authenticated user. 1517 // 1518 // https://docs.github.com/en/rest/reference/repos#accept-a-repository-invitation 1519 func (c *client) AcceptUserRepoInvitation(invitationID int) error { 1520 c.log("AcceptUserRepoInvitation", invitationID) 1521 1522 _, err := c.request(&request{ 1523 method: http.MethodPatch, 1524 path: fmt.Sprintf("/user/repository_invitations/%d", invitationID), 1525 org: "", 1526 exitCodes: []int{204}, 1527 }, nil) 1528 1529 return err 1530 } 1531 1532 // ListCurrentUserOrgInvitations lists org invitation for the authenticated user. 1533 // 1534 // https://docs.github.com/en/rest/reference/orgs#get-organization-membership-for-a-user 1535 func (c *client) ListCurrentUserOrgInvitations() ([]UserOrgInvitation, error) { 1536 c.log("ListCurrentUserOrgInvitations") 1537 if c.fake { 1538 return nil, nil 1539 } 1540 path := "/user/memberships/orgs" 1541 var ret []UserOrgInvitation 1542 err := c.readPaginatedResultsWithValues( 1543 path, 1544 url.Values{ 1545 "per_page": []string{"100"}, 1546 "state": []string{"pending"}, 1547 }, 1548 acceptNone, 1549 "", 1550 func() interface{} { 1551 return &[]UserOrgInvitation{} 1552 }, 1553 func(obj interface{}) { 1554 for _, uoi := range *(obj.(*[]UserOrgInvitation)) { 1555 if uoi.State == "pending" { 1556 ret = append(ret, uoi) 1557 } 1558 } 1559 }, 1560 ) 1561 if err != nil { 1562 return nil, err 1563 } 1564 return ret, nil 1565 } 1566 1567 // AcceptUserOrgInvitation accepts org invitation for the authenticated user. 1568 // 1569 // https://docs.github.com/en/rest/reference/orgs#update-an-organization-membership-for-the-authenticated-user 1570 func (c *client) AcceptUserOrgInvitation(org string) error { 1571 c.log("AcceptUserOrgInvitation", org) 1572 1573 _, err := c.request(&request{ 1574 method: http.MethodPatch, 1575 path: fmt.Sprintf("/user/memberships/orgs/%s", org), 1576 org: org, 1577 requestBody: map[string]string{"state": "active"}, 1578 exitCodes: []int{200}, 1579 }, nil) 1580 1581 return err 1582 } 1583 1584 // ListOrgMembers list all users who are members of an organization. If the authenticated 1585 // user is also a member of this organization then both concealed and public members 1586 // will be returned. 1587 // 1588 // Role options are "all", "admin" and "member" 1589 // 1590 // https://developer.github.com/v3/orgs/members/#members-list 1591 func (c *client) ListOrgMembers(org, role string) ([]TeamMember, error) { 1592 c.log("ListOrgMembers", org, role) 1593 if c.fake { 1594 return nil, nil 1595 } 1596 path := fmt.Sprintf("/orgs/%s/members", org) 1597 var teamMembers []TeamMember 1598 err := c.readPaginatedResultsWithValues( 1599 path, 1600 url.Values{ 1601 "per_page": []string{"100"}, 1602 "role": []string{role}, 1603 }, 1604 acceptNone, 1605 org, 1606 func() interface{} { 1607 return &[]TeamMember{} 1608 }, 1609 func(obj interface{}) { 1610 teamMembers = append(teamMembers, *(obj.(*[]TeamMember))...) 1611 }, 1612 ) 1613 if err != nil { 1614 return nil, err 1615 } 1616 return teamMembers, nil 1617 } 1618 1619 // HasPermission returns true if GetUserPermission() returns any of the roles. 1620 func (c *client) HasPermission(org, repo, user string, roles ...string) (bool, error) { 1621 perm, err := c.GetUserPermission(org, repo, user) 1622 if err != nil { 1623 return false, err 1624 } 1625 for _, r := range roles { 1626 if r == perm { 1627 return true, nil 1628 } 1629 } 1630 return false, nil 1631 } 1632 1633 // GetUserPermission returns the user's permission level for a repo 1634 // 1635 // https://developer.github.com/v3/repos/collaborators/#review-a-users-permission-level 1636 func (c *client) GetUserPermission(org, repo, user string) (string, error) { 1637 c.log("GetUserPermission", org, repo, user) 1638 1639 var perm struct { 1640 Perm string `json:"permission"` 1641 } 1642 _, err := c.request(&request{ 1643 method: http.MethodGet, 1644 path: fmt.Sprintf("/repos/%s/%s/collaborators/%s/permission", org, repo, user), 1645 org: org, 1646 exitCodes: []int{200}, 1647 }, &perm) 1648 if err != nil { 1649 return "", err 1650 } 1651 return perm.Perm, nil 1652 } 1653 1654 // UpdateOrgMembership invites a user to the org and/or updates their permission level. 1655 // 1656 // If the user is not already a member, this will invite them. 1657 // This will also change the role to/from admin, on either the invitation or membership setting. 1658 // 1659 // https://developer.github.com/v3/orgs/members/#add-or-update-organization-membership 1660 func (c *client) UpdateOrgMembership(org, user string, admin bool) (*OrgMembership, error) { 1661 c.log("UpdateOrgMembership", org, user, admin) 1662 om := OrgMembership{} 1663 if admin { 1664 om.Role = RoleAdmin 1665 } else { 1666 om.Role = RoleMember 1667 } 1668 if c.dry { 1669 return &om, nil 1670 } 1671 1672 _, err := c.request(&request{ 1673 method: http.MethodPut, 1674 path: fmt.Sprintf("/orgs/%s/memberships/%s", org, user), 1675 org: org, 1676 requestBody: &om, 1677 exitCodes: []int{200}, 1678 }, &om) 1679 return &om, err 1680 } 1681 1682 // RemoveOrgMembership removes the user from the org. 1683 // 1684 // https://developer.github.com/v3/orgs/members/#remove-organization-membership 1685 func (c *client) RemoveOrgMembership(org, user string) error { 1686 c.log("RemoveOrgMembership", org, user) 1687 _, err := c.request(&request{ 1688 method: http.MethodDelete, 1689 org: org, 1690 path: fmt.Sprintf("/orgs/%s/memberships/%s", org, user), 1691 exitCodes: []int{204}, 1692 }, nil) 1693 return err 1694 } 1695 1696 // CreateComment creates a comment on the issue. 1697 // 1698 // See https://developer.github.com/v3/issues/comments/#create-a-comment 1699 func (c *client) CreateComment(org, repo string, number int, comment string) error { 1700 return c.CreateCommentWithContext(context.Background(), org, repo, number, comment) 1701 } 1702 1703 func (c *client) CreateCommentWithContext(ctx context.Context, org, repo string, number int, comment string) error { 1704 c.log("CreateComment", org, repo, number, comment) 1705 ic := IssueComment{ 1706 Body: comment, 1707 } 1708 _, err := c.requestWithContext(ctx, &request{ 1709 method: http.MethodPost, 1710 path: fmt.Sprintf("/repos/%s/%s/issues/%d/comments", org, repo, number), 1711 org: org, 1712 requestBody: &ic, 1713 exitCodes: []int{201}, 1714 }, nil) 1715 return err 1716 } 1717 1718 // DeleteComment deletes the comment. 1719 // 1720 // See https://developer.github.com/v3/issues/comments/#delete-a-comment 1721 func (c *client) DeleteComment(org, repo string, id int) error { 1722 return c.DeleteCommentWithContext(context.Background(), org, repo, id) 1723 } 1724 1725 func (c *client) DeleteCommentWithContext(ctx context.Context, org, repo string, id int) error { 1726 c.log("DeleteComment", org, repo, id) 1727 _, err := c.requestWithContext(ctx, &request{ 1728 method: http.MethodDelete, 1729 path: fmt.Sprintf("/repos/%s/%s/issues/comments/%d", org, repo, id), 1730 org: org, 1731 exitCodes: []int{204, 404}, 1732 }, nil) 1733 return err 1734 } 1735 1736 // EditComment changes the body of comment id in org/repo. 1737 // 1738 // See https://developer.github.com/v3/issues/comments/#edit-a-comment 1739 func (c *client) EditComment(org, repo string, id int, comment string) error { 1740 return c.EditCommentWithContext(context.Background(), org, repo, id, comment) 1741 } 1742 1743 func (c *client) EditCommentWithContext(ctx context.Context, org, repo string, id int, comment string) error { 1744 c.log("EditComment", org, repo, id, comment) 1745 ic := IssueComment{ 1746 Body: comment, 1747 } 1748 _, err := c.requestWithContext(ctx, &request{ 1749 method: http.MethodPatch, 1750 path: fmt.Sprintf("/repos/%s/%s/issues/comments/%d", org, repo, id), 1751 org: org, 1752 requestBody: &ic, 1753 exitCodes: []int{200}, 1754 }, nil) 1755 return err 1756 } 1757 1758 // CreateCommentReaction responds emotionally to comment id in org/repo. 1759 // 1760 // See https://developer.github.com/v3/reactions/#create-reaction-for-an-issue-comment 1761 func (c *client) CreateCommentReaction(org, repo string, id int, reaction string) error { 1762 c.log("CreateCommentReaction", org, repo, id, reaction) 1763 r := Reaction{Content: reaction} 1764 _, err := c.request(&request{ 1765 method: http.MethodPost, 1766 path: fmt.Sprintf("/repos/%s/%s/issues/comments/%d/reactions", org, repo, id), 1767 accept: "application/vnd.github.squirrel-girl-preview", 1768 org: org, 1769 exitCodes: []int{201}, 1770 requestBody: &r, 1771 }, nil) 1772 return err 1773 } 1774 1775 // CreateIssue creates a new issue and returns its number if 1776 // the creation is successful, otherwise any error that is encountered. 1777 // 1778 // See https://developer.github.com/v3/issues/#create-an-issue 1779 func (c *client) CreateIssue(org, repo, title, body string, milestone int, labels, assignees []string) (int, error) { 1780 durationLogger := c.log("CreateIssue", org, repo, title) 1781 defer durationLogger() 1782 1783 data := struct { 1784 Title string `json:"title,omitempty"` 1785 Body string `json:"body,omitempty"` 1786 Milestone int `json:"milestone,omitempty"` 1787 Labels []string `json:"labels,omitempty"` 1788 Assignees []string `json:"assignees,omitempty"` 1789 }{ 1790 Title: title, 1791 Body: body, 1792 Milestone: milestone, 1793 Labels: labels, 1794 Assignees: assignees, 1795 } 1796 var resp struct { 1797 Num int `json:"number"` 1798 } 1799 _, err := c.request(&request{ 1800 // allow the description and draft fields 1801 // https://developer.github.com/changes/2019-02-14-draft-pull-requests/ 1802 accept: "application/vnd.github+json, application/vnd.github.shadow-cat-preview", 1803 method: http.MethodPost, 1804 path: fmt.Sprintf("/repos/%s/%s/issues", org, repo), 1805 org: org, 1806 requestBody: &data, 1807 exitCodes: []int{201}, 1808 }, &resp) 1809 if err != nil { 1810 return 0, err 1811 } 1812 return resp.Num, nil 1813 } 1814 1815 // CreateIssueReaction responds emotionally to org/repo#id 1816 // 1817 // See https://developer.github.com/v3/reactions/#create-reaction-for-an-issue 1818 func (c *client) CreateIssueReaction(org, repo string, id int, reaction string) error { 1819 c.log("CreateIssueReaction", org, repo, id, reaction) 1820 r := Reaction{Content: reaction} 1821 _, err := c.request(&request{ 1822 method: http.MethodPost, 1823 path: fmt.Sprintf("/repos/%s/%s/issues/%d/reactions", org, repo, id), 1824 accept: "application/vnd.github.squirrel-girl-preview", 1825 org: org, 1826 requestBody: &r, 1827 exitCodes: []int{200, 201}, 1828 }, nil) 1829 return err 1830 } 1831 1832 // DeleteStaleComments iterates over comments on an issue/PR, deleting those which the 'isStale' 1833 // function identifies as stale. If 'comments' is nil, the comments will be fetched from GitHub. 1834 func (c *client) DeleteStaleComments(org, repo string, number int, comments []IssueComment, isStale func(IssueComment) bool) error { 1835 return c.DeleteStaleCommentsWithContext(context.Background(), org, repo, number, comments, isStale) 1836 } 1837 1838 func (c *client) DeleteStaleCommentsWithContext(ctx context.Context, org, repo string, number int, comments []IssueComment, isStale func(IssueComment) bool) error { 1839 var err error 1840 if comments == nil { 1841 comments, err = c.ListIssueCommentsWithContext(ctx, org, repo, number) 1842 if err != nil { 1843 return fmt.Errorf("failed to list comments while deleting stale comments. err: %w", err) 1844 } 1845 } 1846 for _, comment := range comments { 1847 if isStale(comment) { 1848 if err := c.DeleteComment(org, repo, comment.ID); err != nil { 1849 return fmt.Errorf("failed to delete stale comment with ID '%d'", comment.ID) 1850 } 1851 } 1852 } 1853 return nil 1854 } 1855 1856 // readPaginatedResults iterates over all objects in the paginated result indicated by the given url. 1857 // 1858 // newObj() should return a new slice of the expected type 1859 // accumulate() should accept that populated slice for each page of results. 1860 // 1861 // Returns an error any call to GitHub or object marshalling fails. 1862 func (c *client) readPaginatedResults(path, accept, org string, newObj func() interface{}, accumulate func(interface{})) error { 1863 return c.readPaginatedResultsWithContext(context.Background(), path, accept, org, newObj, accumulate) 1864 } 1865 1866 func (c *client) readPaginatedResultsWithContext(ctx context.Context, path, accept, org string, newObj func() interface{}, accumulate func(interface{})) error { 1867 values := url.Values{ 1868 "per_page": []string{"100"}, 1869 } 1870 return c.readPaginatedResultsWithValuesWithContext(ctx, path, values, accept, org, newObj, accumulate) 1871 } 1872 1873 // readPaginatedResultsWithValues is an override that allows control over the query string. 1874 func (c *client) readPaginatedResultsWithValues(path string, values url.Values, accept, org string, newObj func() interface{}, accumulate func(interface{})) error { 1875 return c.readPaginatedResultsWithValuesWithContext(context.Background(), path, values, accept, org, newObj, accumulate) 1876 } 1877 1878 func (c *client) readPaginatedResultsWithValuesWithContext(ctx context.Context, path string, values url.Values, accept, org string, newObj func() interface{}, accumulate func(interface{})) error { 1879 pagedPath := path 1880 if len(values) > 0 { 1881 pagedPath += "?" + values.Encode() 1882 } 1883 for { 1884 resp, err := c.requestRetryWithContext(ctx, http.MethodGet, pagedPath, accept, org, nil) 1885 if err != nil { 1886 return err 1887 } 1888 defer resp.Body.Close() 1889 if resp.StatusCode < 200 || resp.StatusCode > 299 { 1890 return fmt.Errorf("return code not 2XX: %s", resp.Status) 1891 } 1892 1893 b, err := io.ReadAll(resp.Body) 1894 if err != nil { 1895 return err 1896 } 1897 1898 obj := newObj() 1899 if err := json.Unmarshal(b, obj); err != nil { 1900 return err 1901 } 1902 1903 accumulate(obj) 1904 1905 link := parseLinks(resp.Header.Get("Link"))["next"] 1906 if link == "" { 1907 break 1908 } 1909 1910 // Example for github.com: 1911 // * c.bases[0]: api.github.com 1912 // * initial call: api.github.com/repos/kubernetes/kubernetes/pulls?per_page=100 1913 // * next: api.github.com/repositories/22/pulls?per_page=100&page=2 1914 // * in this case prefix will be empty and we're just calling the path returned by next 1915 // Example for github enterprise: 1916 // * c.bases[0]: <ghe-url>/api/v3 1917 // * initial call: <ghe-url>/api/v3/repos/kubernetes/kubernetes/pulls?per_page=100 1918 // * next: <ghe-url>/api/v3/repositories/22/pulls?per_page=100&page=2 1919 // * in this case prefix will be "/api/v3" and we will strip the prefix. If we don't do that, 1920 // the next call will go to <ghe-url>/api/v3/api/v3/repositories/22/pulls?per_page=100&page=2 1921 prefix := strings.TrimSuffix(resp.Request.URL.RequestURI(), pagedPath) 1922 1923 u, err := url.Parse(link) 1924 if err != nil { 1925 return fmt.Errorf("failed to parse 'next' link: %w", err) 1926 } 1927 pagedPath = strings.TrimPrefix(u.RequestURI(), prefix) 1928 } 1929 return nil 1930 } 1931 1932 // ListIssueComments returns all comments on an issue. 1933 // 1934 // Each page of results consumes one API token. 1935 // 1936 // See https://developer.github.com/v3/issues/comments/#list-comments-on-an-issue 1937 func (c *client) ListIssueComments(org, repo string, number int) ([]IssueComment, error) { 1938 return c.ListIssueCommentsWithContext(context.Background(), org, repo, number) 1939 } 1940 1941 func (c *client) ListIssueCommentsWithContext(ctx context.Context, org, repo string, number int) ([]IssueComment, error) { 1942 c.log("ListIssueComments", org, repo, number) 1943 if c.fake { 1944 return nil, nil 1945 } 1946 path := fmt.Sprintf("/repos/%s/%s/issues/%d/comments", org, repo, number) 1947 var comments []IssueComment 1948 err := c.readPaginatedResultsWithContext( 1949 ctx, 1950 path, 1951 acceptNone, 1952 org, 1953 func() interface{} { 1954 return &[]IssueComment{} 1955 }, 1956 func(obj interface{}) { 1957 comments = append(comments, *(obj.(*[]IssueComment))...) 1958 }, 1959 ) 1960 if err != nil { 1961 return nil, err 1962 } 1963 return comments, nil 1964 } 1965 1966 // ListOpenIssues returns all open issues, including pull requests 1967 // 1968 // Each page of results consumes one API token. 1969 // 1970 // See https://developer.github.com/v3/issues/#list-issues-for-a-repository 1971 func (c *client) ListOpenIssues(org, repo string) ([]Issue, error) { 1972 c.log("ListOpenIssues", org, repo) 1973 if c.fake { 1974 return nil, nil 1975 } 1976 path := fmt.Sprintf("/repos/%s/%s/issues", org, repo) 1977 var issues []Issue 1978 err := c.readPaginatedResults( 1979 path, 1980 acceptNone, 1981 org, 1982 func() interface{} { 1983 return &[]Issue{} 1984 }, 1985 func(obj interface{}) { 1986 issues = append(issues, *(obj.(*[]Issue))...) 1987 }, 1988 ) 1989 if err != nil { 1990 return nil, err 1991 } 1992 return issues, nil 1993 } 1994 1995 // GetPullRequests get all open pull requests for a repo. 1996 // 1997 // See https://developer.github.com/v3/pulls/#list-pull-requests 1998 func (c *client) GetPullRequests(org, repo string) ([]PullRequest, error) { 1999 c.log("GetPullRequests", org, repo) 2000 var prs []PullRequest 2001 if c.fake { 2002 return prs, nil 2003 } 2004 path := fmt.Sprintf("/repos/%s/%s/pulls", org, repo) 2005 err := c.readPaginatedResults( 2006 path, 2007 // allow the description and draft fields 2008 // https://developer.github.com/changes/2018-02-22-label-description-search-preview/ 2009 // https://developer.github.com/changes/2019-02-14-draft-pull-requests/ 2010 "application/vnd.github.symmetra-preview+json, application/vnd.github.shadow-cat-preview", 2011 org, 2012 func() interface{} { 2013 return &[]PullRequest{} 2014 }, 2015 func(obj interface{}) { 2016 prs = append(prs, *(obj.(*[]PullRequest))...) 2017 }, 2018 ) 2019 if err != nil { 2020 return nil, err 2021 } 2022 return prs, err 2023 } 2024 2025 // GetPullRequest gets a pull request. 2026 // 2027 // See https://developer.github.com/v3/pulls/#get-a-single-pull-request 2028 func (c *client) GetPullRequest(org, repo string, number int) (*PullRequest, error) { 2029 durationLogger := c.log("GetPullRequest", org, repo, number) 2030 defer durationLogger() 2031 2032 var pr PullRequest 2033 _, err := c.request(&request{ 2034 // allow the description and draft fields 2035 // https://developer.github.com/changes/2018-02-22-label-description-search-preview/ 2036 // https://developer.github.com/changes/2019-02-14-draft-pull-requests/ 2037 accept: "application/vnd.github.symmetra-preview+json, application/vnd.github.shadow-cat-preview", 2038 method: http.MethodGet, 2039 path: fmt.Sprintf("/repos/%s/%s/pulls/%d", org, repo, number), 2040 org: org, 2041 exitCodes: []int{200}, 2042 }, &pr) 2043 return &pr, err 2044 } 2045 2046 func (c *client) GetFailedActionRunsByHeadBranch(org, repo, branchName, headSHA string) ([]WorkflowRun, error) { 2047 durationLogger := c.log("GetJobsByHeadBranch", org, repo) 2048 defer durationLogger() 2049 2050 var runs WorkflowRuns 2051 2052 u := url.URL{ 2053 Path: fmt.Sprintf("/repos/%s/%s/actions/runs", org, repo), 2054 } 2055 query := u.Query() 2056 query.Add("status", "failure") 2057 // setting the OR condition to get both PR and PR target workflows 2058 query.Add("event", "pull_request OR pull_request_target") 2059 query.Add("branch", branchName) 2060 u.RawQuery = query.Encode() 2061 2062 _, err := c.request(&request{ 2063 accept: "application/vnd.github.v3+json", 2064 method: http.MethodGet, 2065 path: u.String(), 2066 org: org, 2067 exitCodes: []int{200}, 2068 }, &runs) 2069 2070 prRuns := []WorkflowRun{} 2071 2072 // keep only the runs matching the current PR headSHA 2073 for _, run := range runs.WorflowRuns { 2074 if run.HeadSha == headSHA { 2075 prRuns = append(prRuns, run) 2076 } 2077 } 2078 2079 return prRuns, err 2080 } 2081 2082 // TriggerGitHubWorkflow will rerun a workflow 2083 // 2084 // See https://docs.github.com/en/rest/actions/workflow-runs#re-run-a-workflow 2085 func (c *client) TriggerGitHubWorkflow(org, repo string, id int) error { 2086 durationLogger := c.log("TriggerGitHubWorkflow", org, repo, id) 2087 defer durationLogger() 2088 _, err := c.request(&request{ 2089 accept: "application/vnd.github.v3+json", 2090 method: http.MethodPost, 2091 path: fmt.Sprintf("/repos/%s/%s/actions/runs/%d/rerun", org, repo, id), 2092 org: org, 2093 exitCodes: []int{201}, 2094 }, nil) 2095 return err 2096 } 2097 2098 // TriggerFailedGitHubWorkflow will rerun the failed jobs and all its dependents 2099 // 2100 // See https://docs.github.com/en/rest/actions/workflow-runs#re-run-failed-jobs-from-a-workflow-run 2101 func (c *client) TriggerFailedGitHubWorkflow(org, repo string, id int) error { 2102 durationLogger := c.log("TriggerFailedGitHubWorkflow", org, repo, id) 2103 defer durationLogger() 2104 _, err := c.request(&request{ 2105 accept: "application/vnd.github.v3+json", 2106 method: http.MethodPost, 2107 path: fmt.Sprintf("/repos/%s/%s/actions/runs/%d/rerun-failed-jobs", org, repo, id), 2108 org: org, 2109 exitCodes: []int{201}, 2110 }, nil) 2111 return err 2112 } 2113 2114 // EditPullRequest will update the pull request. 2115 // 2116 // See https://developer.github.com/v3/pulls/#update-a-pull-request 2117 func (c *client) EditPullRequest(org, repo string, number int, pr *PullRequest) (*PullRequest, error) { 2118 durationLogger := c.log("EditPullRequest", org, repo, number) 2119 defer durationLogger() 2120 2121 if c.dry { 2122 return pr, nil 2123 } 2124 edit := struct { 2125 Title string `json:"title,omitempty"` 2126 Body string `json:"body,omitempty"` 2127 State string `json:"state,omitempty"` 2128 }{ 2129 Title: pr.Title, 2130 Body: pr.Body, 2131 State: pr.State, 2132 } 2133 var ret PullRequest 2134 _, err := c.request(&request{ 2135 method: http.MethodPatch, 2136 path: fmt.Sprintf("/repos/%s/%s/pulls/%d", org, repo, number), 2137 org: org, 2138 exitCodes: []int{200}, 2139 requestBody: &edit, 2140 }, &ret) 2141 if err != nil { 2142 return nil, err 2143 } 2144 return &ret, nil 2145 } 2146 2147 // GetIssue gets an issue. 2148 // 2149 // See https://developer.github.com/v3/issues/#get-a-single-issue 2150 func (c *client) GetIssue(org, repo string, number int) (*Issue, error) { 2151 durationLogger := c.log("GetIssue", org, repo, number) 2152 defer durationLogger() 2153 2154 var i Issue 2155 _, err := c.request(&request{ 2156 // allow emoji 2157 // https://developer.github.com/changes/2018-02-22-label-description-search-preview/ 2158 accept: "application/vnd.github.symmetra-preview+json", 2159 method: http.MethodGet, 2160 path: fmt.Sprintf("/repos/%s/%s/issues/%d", org, repo, number), 2161 org: org, 2162 exitCodes: []int{200}, 2163 }, &i) 2164 return &i, err 2165 } 2166 2167 // EditIssue will update the issue. 2168 // 2169 // See https://developer.github.com/v3/issues/#edit-an-issue 2170 func (c *client) EditIssue(org, repo string, number int, issue *Issue) (*Issue, error) { 2171 durationLogger := c.log("EditIssue", org, repo, number) 2172 defer durationLogger() 2173 2174 if c.dry { 2175 return issue, nil 2176 } 2177 edit := struct { 2178 Title string `json:"title,omitempty"` 2179 Body string `json:"body,omitempty"` 2180 State string `json:"state,omitempty"` 2181 }{ 2182 Title: issue.Title, 2183 Body: issue.Body, 2184 State: issue.State, 2185 } 2186 var ret Issue 2187 _, err := c.request(&request{ 2188 method: http.MethodPatch, 2189 path: fmt.Sprintf("/repos/%s/%s/issues/%d", org, repo, number), 2190 org: org, 2191 exitCodes: []int{200}, 2192 requestBody: &edit, 2193 }, &ret) 2194 if err != nil { 2195 return nil, err 2196 } 2197 return &ret, nil 2198 } 2199 2200 // GetPullRequestDiff gets the diff version of a pull request. 2201 // 2202 // See https://docs.github.com/en/rest/overview/media-types?apiVersion=2022-11-28#commits-commit-comparison-and-pull-requests 2203 func (c *client) GetPullRequestDiff(org, repo string, number int) ([]byte, error) { 2204 durationLogger := c.log("GetPullRequestDiff", org, repo, number) 2205 defer durationLogger() 2206 2207 _, diff, err := c.requestRaw(&request{ 2208 accept: "application/vnd.github.diff", 2209 method: http.MethodGet, 2210 path: fmt.Sprintf("/repos/%s/%s/pulls/%d", org, repo, number), 2211 org: org, 2212 exitCodes: []int{200}, 2213 }) 2214 return diff, err 2215 } 2216 2217 // GetPullRequestPatch gets the patch version of a pull request. 2218 // 2219 // See https://docs.github.com/en/rest/overview/media-types?apiVersion=2022-11-28#commits-commit-comparison-and-pull-requests 2220 func (c *client) GetPullRequestPatch(org, repo string, number int) ([]byte, error) { 2221 durationLogger := c.log("GetPullRequestPatch", org, repo, number) 2222 defer durationLogger() 2223 2224 _, patch, err := c.requestRaw(&request{ 2225 accept: "application/vnd.github.patch", 2226 method: http.MethodGet, 2227 path: fmt.Sprintf("/repos/%s/%s/pulls/%d", org, repo, number), 2228 org: org, 2229 exitCodes: []int{200}, 2230 }) 2231 return patch, err 2232 } 2233 2234 // CreatePullRequest creates a new pull request and returns its number if 2235 // the creation is successful, otherwise any error that is encountered. 2236 // 2237 // See https://developer.github.com/v3/pulls/#create-a-pull-request 2238 func (c *client) CreatePullRequest(org, repo, title, body, head, base string, canModify bool) (int, error) { 2239 durationLogger := c.log("CreatePullRequest", org, repo, title) 2240 defer durationLogger() 2241 2242 data := struct { 2243 Title string `json:"title"` 2244 Body string `json:"body"` 2245 Head string `json:"head"` 2246 Base string `json:"base"` 2247 // MaintainerCanModify allows maintainers of the repo to modify this 2248 // pull request, eg. push changes to it before merging. 2249 MaintainerCanModify bool `json:"maintainer_can_modify"` 2250 }{ 2251 Title: title, 2252 Body: body, 2253 Head: head, 2254 Base: base, 2255 2256 MaintainerCanModify: canModify, 2257 } 2258 var resp struct { 2259 Num int `json:"number"` 2260 } 2261 _, err := c.request(&request{ 2262 // allow the description and draft fields 2263 // https://developer.github.com/changes/2018-02-22-label-description-search-preview/ 2264 // https://developer.github.com/changes/2019-02-14-draft-pull-requests/ 2265 accept: "application/vnd.github.symmetra-preview+json, application/vnd.github.shadow-cat-preview", 2266 method: http.MethodPost, 2267 path: fmt.Sprintf("/repos/%s/%s/pulls", org, repo), 2268 org: org, 2269 requestBody: &data, 2270 exitCodes: []int{201}, 2271 }, &resp) 2272 if err != nil { 2273 return 0, fmt.Errorf("failed to create pull request against %s/%s#%s from head %s: %w", org, repo, base, head, err) 2274 } 2275 return resp.Num, nil 2276 } 2277 2278 // UpdatePullRequest modifies the title, body, open state 2279 func (c *client) UpdatePullRequest(org, repo string, number int, title, body *string, open *bool, branch *string, canModify *bool) error { 2280 durationLogger := c.log("UpdatePullRequest", org, repo, title) 2281 defer durationLogger() 2282 2283 data := struct { 2284 State *string `json:"state,omitempty"` 2285 Title *string `json:"title,omitempty"` 2286 Body *string `json:"body,omitempty"` 2287 Base *string `json:"base,omitempty"` 2288 // MaintainerCanModify allows maintainers of the repo to modify this 2289 // pull request, eg. push changes to it before merging. 2290 MaintainerCanModify *bool `json:"maintainer_can_modify,omitempty"` 2291 }{ 2292 Title: title, 2293 Body: body, 2294 Base: branch, 2295 MaintainerCanModify: canModify, 2296 } 2297 if open != nil && *open { 2298 op := "open" 2299 data.State = &op 2300 } else if open != nil { 2301 cl := "closed" 2302 data.State = &cl 2303 } 2304 _, err := c.request(&request{ 2305 // allow the description and draft fields 2306 // https://developer.github.com/changes/2018-02-22-label-description-search-preview/ 2307 // https://developer.github.com/changes/2019-02-14-draft-pull-requests/ 2308 accept: "application/vnd.github.symmetra-preview+json, application/vnd.github.shadow-cat-preview", 2309 method: http.MethodPatch, 2310 path: fmt.Sprintf("/repos/%s/%s/pulls/%d", org, repo, number), 2311 org: org, 2312 requestBody: &data, 2313 exitCodes: []int{200}, 2314 }, nil) 2315 return err 2316 } 2317 2318 // GetPullRequestChanges gets a list of files modified in a pull request. 2319 // 2320 // See https://developer.github.com/v3/pulls/#list-pull-requests-files 2321 func (c *client) GetPullRequestChanges(org, repo string, number int) ([]PullRequestChange, error) { 2322 durationLogger := c.log("GetPullRequestChanges", org, repo, number) 2323 defer durationLogger() 2324 2325 if c.fake { 2326 return []PullRequestChange{}, nil 2327 } 2328 path := fmt.Sprintf("/repos/%s/%s/pulls/%d/files", org, repo, number) 2329 var changes []PullRequestChange 2330 err := c.readPaginatedResults( 2331 path, 2332 acceptNone, 2333 org, 2334 func() interface{} { 2335 return &[]PullRequestChange{} 2336 }, 2337 func(obj interface{}) { 2338 changes = append(changes, *(obj.(*[]PullRequestChange))...) 2339 }, 2340 ) 2341 if err != nil { 2342 return nil, err 2343 } 2344 return changes, nil 2345 } 2346 2347 // ListPullRequestComments returns all *review* comments on a pull request. 2348 // 2349 // Multiple-pages of comments consumes multiple API tokens. 2350 // 2351 // See https://developer.github.com/v3/pulls/comments/#list-comments-on-a-pull-request 2352 func (c *client) ListPullRequestComments(org, repo string, number int) ([]ReviewComment, error) { 2353 durationLogger := c.log("ListPullRequestComments", org, repo, number) 2354 defer durationLogger() 2355 2356 if c.fake { 2357 return nil, nil 2358 } 2359 path := fmt.Sprintf("/repos/%s/%s/pulls/%d/comments", org, repo, number) 2360 var comments []ReviewComment 2361 err := c.readPaginatedResults( 2362 path, 2363 acceptNone, 2364 org, 2365 func() interface{} { 2366 return &[]ReviewComment{} 2367 }, 2368 func(obj interface{}) { 2369 comments = append(comments, *(obj.(*[]ReviewComment))...) 2370 }, 2371 ) 2372 if err != nil { 2373 return nil, err 2374 } 2375 return comments, nil 2376 } 2377 2378 // ListReviews returns all reviews on a pull request. 2379 // 2380 // Multiple-pages of results consumes multiple API tokens. 2381 // 2382 // See https://developer.github.com/v3/pulls/reviews/#list-reviews-on-a-pull-request 2383 func (c *client) ListReviews(org, repo string, number int) ([]Review, error) { 2384 durationLogger := c.log("ListReviews", org, repo, number) 2385 defer durationLogger() 2386 2387 if c.fake { 2388 return nil, nil 2389 } 2390 path := fmt.Sprintf("/repos/%s/%s/pulls/%d/reviews", org, repo, number) 2391 var reviews []Review 2392 err := c.readPaginatedResults( 2393 path, 2394 acceptNone, 2395 org, 2396 func() interface{} { 2397 return &[]Review{} 2398 }, 2399 func(obj interface{}) { 2400 reviews = append(reviews, *(obj.(*[]Review))...) 2401 }, 2402 ) 2403 if err != nil { 2404 return nil, err 2405 } 2406 return reviews, nil 2407 } 2408 2409 // CreateStatus creates or updates the status of a commit. 2410 // 2411 // See https://docs.github.com/en/rest/reference/commits#create-a-commit-status 2412 func (c *client) CreateStatus(org, repo, SHA string, s Status) error { 2413 return c.CreateStatusWithContext(context.Background(), org, repo, SHA, s) 2414 } 2415 2416 func (c *client) CreateStatusWithContext(ctx context.Context, org, repo, SHA string, s Status) error { 2417 durationLogger := c.log("CreateStatus", org, repo, SHA, s) 2418 defer durationLogger() 2419 _, err := c.requestWithContext(ctx, &request{ 2420 method: http.MethodPost, 2421 path: fmt.Sprintf("/repos/%s/%s/statuses/%s", org, repo, SHA), 2422 org: org, 2423 requestBody: &s, 2424 exitCodes: []int{201}, 2425 }, nil) 2426 return err 2427 } 2428 2429 // ListStatuses gets commit statuses for a given ref. 2430 // 2431 // See https://developer.github.com/v3/repos/statuses/#list-statuses-for-a-specific-ref 2432 func (c *client) ListStatuses(org, repo, ref string) ([]Status, error) { 2433 durationLogger := c.log("ListStatuses", org, repo, ref) 2434 defer durationLogger() 2435 2436 path := fmt.Sprintf("/repos/%s/%s/statuses/%s", org, repo, ref) 2437 var statuses []Status 2438 err := c.readPaginatedResults( 2439 path, 2440 acceptNone, 2441 org, 2442 func() interface{} { 2443 return &[]Status{} 2444 }, 2445 func(obj interface{}) { 2446 statuses = append(statuses, *(obj.(*[]Status))...) 2447 }, 2448 ) 2449 return statuses, err 2450 } 2451 2452 // GetRepo returns the repo for the provided owner/name combination. 2453 // 2454 // See https://developer.github.com/v3/repos/#get 2455 func (c *client) GetRepo(owner, name string) (FullRepo, error) { 2456 durationLogger := c.log("GetRepo", owner, name) 2457 defer durationLogger() 2458 2459 var repo FullRepo 2460 _, err := c.request(&request{ 2461 method: http.MethodGet, 2462 path: fmt.Sprintf("/repos/%s/%s", owner, name), 2463 org: owner, 2464 exitCodes: []int{200}, 2465 }, &repo) 2466 return repo, err 2467 } 2468 2469 // CreateRepo creates a new repository 2470 // See https://developer.github.com/v3/repos/#create 2471 func (c *client) CreateRepo(owner string, isUser bool, repo RepoCreateRequest) (*FullRepo, error) { 2472 durationLogger := c.log("CreateRepo", owner, isUser, repo) 2473 defer durationLogger() 2474 2475 if repo.Name == nil || *repo.Name == "" { 2476 return nil, errors.New("repo.Name must be non-empty") 2477 } 2478 if c.fake { 2479 return nil, nil 2480 } else if c.dry { 2481 return repo.ToRepo(), nil 2482 } 2483 2484 path := "/user/repos" 2485 if !isUser { 2486 path = fmt.Sprintf("/orgs/%s/repos", owner) 2487 } 2488 var retRepo FullRepo 2489 _, err := c.request(&request{ 2490 method: http.MethodPost, 2491 path: path, 2492 org: owner, 2493 requestBody: &repo, 2494 exitCodes: []int{201}, 2495 }, &retRepo) 2496 return &retRepo, err 2497 } 2498 2499 // UpdateRepo edits an existing repository 2500 // See https://developer.github.com/v3/repos/#edit 2501 func (c *client) UpdateRepo(owner, name string, repo RepoUpdateRequest) (*FullRepo, error) { 2502 durationLogger := c.log("UpdateRepo", owner, name, repo) 2503 defer durationLogger() 2504 2505 if c.fake { 2506 return nil, nil 2507 } else if c.dry { 2508 return repo.ToRepo(), nil 2509 } 2510 2511 path := fmt.Sprintf("/repos/%s/%s", owner, name) 2512 var retRepo FullRepo 2513 _, err := c.request(&request{ 2514 method: http.MethodPatch, 2515 path: path, 2516 org: owner, 2517 requestBody: &repo, 2518 exitCodes: []int{200}, 2519 }, &retRepo) 2520 return &retRepo, err 2521 } 2522 2523 // GetRepos returns all repos in an org. 2524 // 2525 // This call uses multiple API tokens when results are paginated. 2526 // 2527 // See https://developer.github.com/v3/repos/#list-organization-repositories 2528 func (c *client) GetRepos(org string, isUser bool) ([]Repo, error) { 2529 durationLogger := c.log("GetRepos", org, isUser) 2530 defer durationLogger() 2531 2532 var ( 2533 repos []Repo 2534 nextURL string 2535 ) 2536 if c.fake { 2537 return repos, nil 2538 } 2539 if isUser { 2540 nextURL = fmt.Sprintf("/users/%s/repos", org) 2541 } else { 2542 nextURL = fmt.Sprintf("/orgs/%s/repos", org) 2543 } 2544 err := c.readPaginatedResults( 2545 nextURL, // path 2546 acceptNone, // accept 2547 org, 2548 func() interface{} { // newObj 2549 return &[]Repo{} 2550 }, 2551 func(obj interface{}) { // accumulate 2552 repos = append(repos, *(obj.(*[]Repo))...) 2553 }, 2554 ) 2555 if err != nil { 2556 return nil, err 2557 } 2558 return repos, nil 2559 } 2560 2561 // GetSingleCommit returns a single commit. 2562 // 2563 // See https://developer.github.com/v3/repos/#get 2564 func (c *client) GetSingleCommit(org, repo, SHA string) (RepositoryCommit, error) { 2565 durationLogger := c.log("GetSingleCommit", org, repo, SHA) 2566 defer durationLogger() 2567 2568 var commit RepositoryCommit 2569 _, err := c.request(&request{ 2570 method: http.MethodGet, 2571 path: fmt.Sprintf("/repos/%s/%s/commits/%s", org, repo, SHA), 2572 org: org, 2573 exitCodes: []int{200}, 2574 }, &commit) 2575 return commit, err 2576 } 2577 2578 // GetBranches returns all branches in the repo. 2579 // 2580 // If onlyProtected is true it will only return repos with protection enabled, 2581 // and branch.Protected will be true. Otherwise Protected is the default value (false). 2582 // 2583 // This call uses multiple API tokens when results are paginated. 2584 // 2585 // See https://developer.github.com/v3/repos/branches/#list-branches 2586 func (c *client) GetBranches(org, repo string, onlyProtected bool) ([]Branch, error) { 2587 durationLogger := c.log("GetBranches", org, repo, onlyProtected) 2588 defer durationLogger() 2589 2590 var branches []Branch 2591 err := c.readPaginatedResultsWithValues( 2592 fmt.Sprintf("/repos/%s/%s/branches", org, repo), 2593 url.Values{ 2594 "protected": []string{strconv.FormatBool(onlyProtected)}, 2595 "per_page": []string{"100"}, 2596 }, 2597 acceptNone, 2598 org, 2599 func() interface{} { // newObj 2600 return &[]Branch{} 2601 }, 2602 func(obj interface{}) { 2603 branches = append(branches, *(obj.(*[]Branch))...) 2604 }, 2605 ) 2606 if err != nil { 2607 return nil, err 2608 } 2609 return branches, nil 2610 } 2611 2612 // GetBranchProtection returns current protection object for the branch 2613 // 2614 // See https://developer.github.com/v3/repos/branches/#get-branch-protection 2615 func (c *client) GetBranchProtection(org, repo, branch string) (*BranchProtection, error) { 2616 durationLogger := c.log("GetBranchProtection", org, repo, branch) 2617 defer durationLogger() 2618 2619 code, body, err := c.requestRaw(&request{ 2620 method: http.MethodGet, 2621 path: fmt.Sprintf("/repos/%s/%s/branches/%s/protection", org, repo, branch), 2622 org: org, 2623 // GitHub returns 404 for this call if either: 2624 // - The branch is not protected 2625 // - The access token used does not have sufficient privileges 2626 // We therefore need to introspect the response body. 2627 exitCodes: []int{200, 404}, 2628 }) 2629 2630 switch { 2631 case err != nil: 2632 return nil, err 2633 case code == 200: 2634 var bp BranchProtection 2635 if err := json.Unmarshal(body, &bp); err != nil { 2636 return nil, err 2637 } 2638 return &bp, nil 2639 case code == 404: 2640 // continue 2641 default: 2642 return nil, fmt.Errorf("unexpected status code: %d", code) 2643 } 2644 2645 var ge githubError 2646 if err := json.Unmarshal(body, &ge); err != nil { 2647 return nil, err 2648 } 2649 2650 // If the error was because the branch is not protected, we return a 2651 // nil pointer to indicate this. 2652 if ge.Message == "Branch not protected" { 2653 return nil, nil 2654 } 2655 2656 // Otherwise we got some other 404 error. 2657 return nil, fmt.Errorf("getting branch protection 404: %s", ge.Message) 2658 } 2659 2660 // RemoveBranchProtection unprotects org/repo=branch. 2661 // 2662 // See https://developer.github.com/v3/repos/branches/#remove-branch-protection 2663 func (c *client) RemoveBranchProtection(org, repo, branch string) error { 2664 durationLogger := c.log("RemoveBranchProtection", org, repo, branch) 2665 defer durationLogger() 2666 2667 _, err := c.request(&request{ 2668 method: http.MethodDelete, 2669 path: fmt.Sprintf("/repos/%s/%s/branches/%s/protection", org, repo, branch), 2670 org: org, 2671 exitCodes: []int{204}, 2672 }, nil) 2673 return err 2674 } 2675 2676 // UpdateBranchProtection configures org/repo=branch. 2677 // 2678 // See https://developer.github.com/v3/repos/branches/#update-branch-protection 2679 func (c *client) UpdateBranchProtection(org, repo, branch string, config BranchProtectionRequest) error { 2680 durationLogger := c.log("UpdateBranchProtection", org, repo, branch, config) 2681 defer durationLogger() 2682 2683 _, err := c.request(&request{ 2684 method: http.MethodPut, 2685 path: fmt.Sprintf("/repos/%s/%s/branches/%s/protection", org, repo, branch), 2686 org: org, 2687 requestBody: config, 2688 exitCodes: []int{200}, 2689 }, nil) 2690 return err 2691 } 2692 2693 // AddRepoLabel adds a defined label given org/repo 2694 // 2695 // See https://developer.github.com/v3/issues/labels/#create-a-label 2696 func (c *client) AddRepoLabel(org, repo, label, description, color string) error { 2697 durationLogger := c.log("AddRepoLabel", org, repo, label, description, color) 2698 defer durationLogger() 2699 2700 _, err := c.request(&request{ 2701 method: http.MethodPost, 2702 path: fmt.Sprintf("/repos/%s/%s/labels", org, repo), 2703 accept: "application/vnd.github.symmetra-preview+json", // allow the description field -- https://developer.github.com/changes/2018-02-22-label-description-search-preview/ 2704 org: org, 2705 requestBody: Label{Name: label, Description: description, Color: color}, 2706 exitCodes: []int{201}, 2707 }, nil) 2708 return err 2709 } 2710 2711 // UpdateRepoLabel updates a org/repo label to new name, description, and color 2712 // 2713 // See https://developer.github.com/v3/issues/labels/#update-a-label 2714 func (c *client) UpdateRepoLabel(org, repo, label, newName, description, color string) error { 2715 durationLogger := c.log("UpdateRepoLabel", org, repo, label, newName, color) 2716 defer durationLogger() 2717 2718 _, err := c.request(&request{ 2719 method: http.MethodPatch, 2720 path: fmt.Sprintf("/repos/%s/%s/labels/%s", org, repo, label), 2721 accept: "application/vnd.github.symmetra-preview+json", // allow the description field -- https://developer.github.com/changes/2018-02-22-label-description-search-preview/ 2722 org: org, 2723 requestBody: Label{Name: newName, Description: description, Color: color}, 2724 exitCodes: []int{200}, 2725 }, nil) 2726 return err 2727 } 2728 2729 // DeleteRepoLabel deletes a label in org/repo 2730 // 2731 // See https://developer.github.com/v3/issues/labels/#delete-a-label 2732 func (c *client) DeleteRepoLabel(org, repo, label string) error { 2733 durationLogger := c.log("DeleteRepoLabel", org, repo, label) 2734 defer durationLogger() 2735 2736 _, err := c.request(&request{ 2737 method: http.MethodDelete, 2738 accept: "application/vnd.github.symmetra-preview+json", // allow the description field -- https://developer.github.com/changes/2018-02-22-label-description-search-preview/ 2739 path: fmt.Sprintf("/repos/%s/%s/labels/%s", org, repo, label), 2740 org: org, 2741 requestBody: Label{Name: label}, 2742 exitCodes: []int{204}, 2743 }, nil) 2744 return err 2745 } 2746 2747 // GetCombinedStatus returns the latest statuses for a given ref. 2748 // 2749 // See https://developer.github.com/v3/repos/statuses/#get-the-combined-status-for-a-specific-ref 2750 func (c *client) GetCombinedStatus(org, repo, ref string) (*CombinedStatus, error) { 2751 durationLogger := c.log("GetCombinedStatus", org, repo, ref) 2752 defer durationLogger() 2753 2754 var combinedStatus CombinedStatus 2755 err := c.readPaginatedResults( 2756 fmt.Sprintf("/repos/%s/%s/commits/%s/status", org, repo, ref), 2757 "", 2758 org, 2759 func() interface{} { 2760 return &CombinedStatus{} 2761 }, 2762 func(obj interface{}) { 2763 cs := *(obj.(*CombinedStatus)) 2764 cs.Statuses = append(combinedStatus.Statuses, cs.Statuses...) 2765 combinedStatus = cs 2766 }, 2767 ) 2768 return &combinedStatus, err 2769 } 2770 2771 // getLabels is a helper function that retrieves a paginated list of labels from a github URI path. 2772 func (c *client) getLabels(path, org string) ([]Label, error) { 2773 var labels []Label 2774 if c.fake { 2775 return labels, nil 2776 } 2777 err := c.readPaginatedResults( 2778 path, 2779 "application/vnd.github.symmetra-preview+json", // allow the description field -- https://developer.github.com/changes/2018-02-22-label-description-search-preview/ 2780 org, 2781 func() interface{} { 2782 return &[]Label{} 2783 }, 2784 func(obj interface{}) { 2785 labels = append(labels, *(obj.(*[]Label))...) 2786 }, 2787 ) 2788 if err != nil { 2789 return nil, err 2790 } 2791 return labels, nil 2792 } 2793 2794 // GetRepoLabels returns the list of labels accessible to org/repo. 2795 // 2796 // See https://developer.github.com/v3/issues/labels/#list-all-labels-for-this-repository 2797 func (c *client) GetRepoLabels(org, repo string) ([]Label, error) { 2798 durationLogger := c.log("GetRepoLabels", org, repo) 2799 defer durationLogger() 2800 2801 return c.getLabels(fmt.Sprintf("/repos/%s/%s/labels", org, repo), org) 2802 } 2803 2804 // GetIssueLabels returns the list of labels currently on issue org/repo#number. 2805 // 2806 // See https://developer.github.com/v3/issues/labels/#list-labels-on-an-issue 2807 func (c *client) GetIssueLabels(org, repo string, number int) ([]Label, error) { 2808 durationLogger := c.log("GetIssueLabels", org, repo, number) 2809 defer durationLogger() 2810 2811 return c.getLabels(fmt.Sprintf("/repos/%s/%s/issues/%d/labels", org, repo, number), org) 2812 } 2813 2814 // AddLabel adds label to org/repo#number, returning an error on a bad response code. 2815 // 2816 // See https://developer.github.com/v3/issues/labels/#add-labels-to-an-issue 2817 func (c *client) AddLabel(org, repo string, number int, label string) error { 2818 return c.AddLabelWithContext(context.Background(), org, repo, number, label) 2819 } 2820 2821 func (c *client) AddLabelWithContext(ctx context.Context, org, repo string, number int, label string) error { 2822 return c.AddLabelsWithContext(ctx, org, repo, number, label) 2823 } 2824 2825 // AddLabels adds one or more labels to org/repo#number, returning an error on a bad response code. 2826 // 2827 // See https://developer.github.com/v3/issues/labels/#add-labels-to-an-issue 2828 func (c *client) AddLabels(org, repo string, number int, labels ...string) error { 2829 return c.AddLabelsWithContext(context.Background(), org, repo, number, labels...) 2830 } 2831 2832 func (c *client) AddLabelsWithContext(ctx context.Context, org, repo string, number int, labels ...string) error { 2833 durationLogger := c.log("AddLabels", org, repo, number, labels) 2834 defer durationLogger() 2835 2836 _, err := c.requestWithContext(ctx, &request{ 2837 method: http.MethodPost, 2838 path: fmt.Sprintf("/repos/%s/%s/issues/%d/labels", org, repo, number), 2839 org: org, 2840 requestBody: labels, 2841 exitCodes: []int{200}, 2842 }, nil) 2843 return err 2844 } 2845 2846 type githubError struct { 2847 Message string `json:"message,omitempty"` 2848 } 2849 2850 // RemoveLabel removes label from org/repo#number, returning an error on any failure. 2851 // 2852 // See https://developer.github.com/v3/issues/labels/#remove-a-label-from-an-issue 2853 func (c *client) RemoveLabel(org, repo string, number int, label string) error { 2854 return c.RemoveLabelWithContext(context.Background(), org, repo, number, label) 2855 } 2856 2857 func (c *client) RemoveLabelWithContext(ctx context.Context, org, repo string, number int, label string) error { 2858 durationLogger := c.log("RemoveLabel", org, repo, number, label) 2859 defer durationLogger() 2860 2861 code, body, err := c.requestRawWithContext(ctx, &request{ 2862 method: http.MethodDelete, 2863 path: fmt.Sprintf("/repos/%s/%s/issues/%d/labels/%s", org, repo, number, label), 2864 org: org, 2865 // GitHub sometimes returns 200 for this call, which is a bug on their end. 2866 // Do not expect a 404 exit code and handle it separately because we need 2867 // to introspect the request's response body. 2868 exitCodes: []int{200, 204}, 2869 }) 2870 2871 switch { 2872 case code == 200 || code == 204: 2873 // If our code was 200 or 204, no error info. 2874 return nil 2875 case code == 404: 2876 // continue 2877 case err != nil: 2878 return err 2879 default: 2880 return fmt.Errorf("unexpected status code: %d", code) 2881 } 2882 2883 ge := &githubError{} 2884 if err := json.Unmarshal(body, ge); err != nil { 2885 return err 2886 } 2887 2888 // If the error was because the label was not found, we don't really 2889 // care since the label won't exist anyway. 2890 if ge.Message == "Label does not exist" { 2891 return nil 2892 } 2893 2894 // Otherwise we got some other 404 error. 2895 return fmt.Errorf("deleting label 404: %s", ge.Message) 2896 } 2897 2898 func (c *client) WasLabelAddedByHuman(org, repo string, number int, label string) (bool, error) { 2899 isBot, err := c.BotUserChecker() 2900 if err != nil { 2901 return false, fmt.Errorf("failed to construct bot user checker: %w", err) 2902 } 2903 2904 events, err := c.ListIssueEvents(org, repo, number) 2905 if err != nil { 2906 return false, fmt.Errorf("failed to list issue events: %w", err) 2907 } 2908 var lastAdded ListedIssueEvent 2909 for _, event := range events { 2910 if event.Event != IssueActionLabeled || event.Label.Name != label { 2911 continue 2912 } 2913 lastAdded = event 2914 } 2915 2916 if lastAdded.Actor.Login == "" || isBot(lastAdded.Actor.Login) { 2917 return false, nil 2918 } 2919 2920 return true, nil 2921 } 2922 2923 // MissingUsers is an error specifying the users that could not be unassigned. 2924 type MissingUsers struct { 2925 Users []string 2926 action string 2927 apiErr error 2928 } 2929 2930 func (m MissingUsers) Error() string { 2931 return fmt.Sprintf("could not %s the following user(s): %s; %v.", m.action, strings.Join(m.Users, ", "), m.apiErr) 2932 } 2933 2934 // AssignIssue adds logins to org/repo#number, returning an error if any login is missing after making the call. 2935 // 2936 // See https://developer.github.com/v3/issues/assignees/#add-assignees-to-an-issue 2937 func (c *client) AssignIssue(org, repo string, number int, logins []string) error { 2938 durationLogger := c.log("AssignIssue", org, repo, number, logins) 2939 defer durationLogger() 2940 2941 assigned := make(map[string]bool) 2942 var i Issue 2943 _, err := c.request(&request{ 2944 method: http.MethodPost, 2945 path: fmt.Sprintf("/repos/%s/%s/issues/%d/assignees", org, repo, number), 2946 org: org, 2947 requestBody: map[string][]string{"assignees": logins}, 2948 exitCodes: []int{201}, 2949 }, &i) 2950 if err != nil { 2951 return err 2952 } 2953 for _, assignee := range i.Assignees { 2954 assigned[NormLogin(assignee.Login)] = true 2955 } 2956 missing := MissingUsers{action: "assign"} 2957 for _, login := range logins { 2958 if !assigned[NormLogin(login)] { 2959 missing.Users = append(missing.Users, login) 2960 } 2961 } 2962 if len(missing.Users) > 0 { 2963 return missing 2964 } 2965 return nil 2966 } 2967 2968 // ExtraUsers is an error specifying the users that could not be unassigned. 2969 type ExtraUsers struct { 2970 Users []string 2971 action string 2972 } 2973 2974 func (e ExtraUsers) Error() string { 2975 return fmt.Sprintf("could not %s the following user(s): %s.", e.action, strings.Join(e.Users, ", ")) 2976 } 2977 2978 // UnassignIssue removes logins from org/repo#number, returns an error if any login remains assigned. 2979 // 2980 // See https://developer.github.com/v3/issues/assignees/#remove-assignees-from-an-issue 2981 func (c *client) UnassignIssue(org, repo string, number int, logins []string) error { 2982 durationLogger := c.log("UnassignIssue", org, repo, number, logins) 2983 defer durationLogger() 2984 2985 assigned := make(map[string]bool) 2986 var i Issue 2987 _, err := c.request(&request{ 2988 method: http.MethodDelete, 2989 path: fmt.Sprintf("/repos/%s/%s/issues/%d/assignees", org, repo, number), 2990 org: org, 2991 requestBody: map[string][]string{"assignees": logins}, 2992 exitCodes: []int{200}, 2993 }, &i) 2994 if err != nil { 2995 return err 2996 } 2997 for _, assignee := range i.Assignees { 2998 assigned[NormLogin(assignee.Login)] = true 2999 } 3000 extra := ExtraUsers{action: "unassign"} 3001 for _, login := range logins { 3002 if assigned[NormLogin(login)] { 3003 extra.Users = append(extra.Users, login) 3004 } 3005 } 3006 if len(extra.Users) > 0 { 3007 return extra 3008 } 3009 return nil 3010 } 3011 3012 // CreateReview creates a review using the draft. 3013 // 3014 // https://developer.github.com/v3/pulls/reviews/#create-a-pull-request-review 3015 func (c *client) CreateReview(org, repo string, number int, r DraftReview) error { 3016 durationLogger := c.log("CreateReview", org, repo, number, r) 3017 defer durationLogger() 3018 3019 _, err := c.request(&request{ 3020 method: http.MethodPost, 3021 path: fmt.Sprintf("/repos/%s/%s/pulls/%d/reviews", org, repo, number), 3022 accept: "application/vnd.github.black-cat-preview+json", 3023 org: org, 3024 requestBody: r, 3025 exitCodes: []int{200}, 3026 }, nil) 3027 return err 3028 } 3029 3030 // prepareReviewersBody separates reviewers from team_reviewers and prepares a map 3031 // 3032 // { 3033 // "reviewers": [ 3034 // "octocat", 3035 // "hubot", 3036 // "other_user" 3037 // ], 3038 // "team_reviewers": [ 3039 // "justice-league" 3040 // ] 3041 // } 3042 // 3043 // https://developer.github.com/v3/pulls/review_requests/#create-a-review-request 3044 func prepareReviewersBody(logins []string, org string) (map[string][]string, error) { 3045 body := map[string][]string{} 3046 var errors []error 3047 for _, login := range logins { 3048 mat := teamRe.FindStringSubmatch(login) 3049 if mat == nil { 3050 if _, exists := body["reviewers"]; !exists { 3051 body["reviewers"] = []string{} 3052 } 3053 body["reviewers"] = append(body["reviewers"], login) 3054 } else if mat[1] == org { 3055 if _, exists := body["team_reviewers"]; !exists { 3056 body["team_reviewers"] = []string{} 3057 } 3058 body["team_reviewers"] = append(body["team_reviewers"], mat[2]) 3059 } else { 3060 errors = append(errors, fmt.Errorf("team %s is not part of %s org", login, org)) 3061 } 3062 } 3063 return body, utilerrors.NewAggregate(errors) 3064 } 3065 3066 func (c *client) tryRequestReview(org, repo string, number int, logins []string) (int, error) { 3067 durationLogger := c.log("RequestReview", org, repo, number, logins) 3068 defer durationLogger() 3069 3070 var pr PullRequest 3071 body, err := prepareReviewersBody(logins, org) 3072 if err != nil { 3073 // At least one team not in org, 3074 // let RequestReview handle retries and alerting for each login. 3075 return http.StatusUnprocessableEntity, err 3076 } 3077 return c.request(&request{ 3078 method: http.MethodPost, 3079 path: fmt.Sprintf("/repos/%s/%s/pulls/%d/requested_reviewers", org, repo, number), 3080 org: org, 3081 accept: "application/vnd.github.symmetra-preview+json", 3082 requestBody: body, 3083 exitCodes: []int{http.StatusCreated /*201*/}, 3084 }, &pr) 3085 } 3086 3087 // RequestReview tries to add the users listed in 'logins' as requested reviewers of the specified PR. 3088 // If any user in the 'logins' slice is not a contributor of the repo, the entire POST will fail 3089 // without adding any reviewers. The GitHub API response does not specify which user(s) were invalid 3090 // so if we fail to request reviews from the members of 'logins' we try to request reviews from 3091 // each member individually. We try first with all users in 'logins' for efficiency in the common case. 3092 // 3093 // See https://developer.github.com/v3/pulls/review_requests/#create-a-review-request 3094 func (c *client) RequestReview(org, repo string, number int, logins []string) error { 3095 statusCode, err := c.tryRequestReview(org, repo, number, logins) 3096 if err != nil && statusCode == http.StatusUnprocessableEntity /*422*/ { 3097 // Failed to set all members of 'logins' as reviewers, try individually. 3098 missing := MissingUsers{action: "request a PR review from", apiErr: err} 3099 for _, user := range logins { 3100 statusCode, err = c.tryRequestReview(org, repo, number, []string{user}) 3101 if err != nil && statusCode == http.StatusUnprocessableEntity /*422*/ { 3102 // User is not a contributor, or team not in org. 3103 missing.Users = append(missing.Users, user) 3104 } else if err != nil { 3105 return fmt.Errorf("failed to add reviewer to PR. Status code: %d, errmsg: %w", statusCode, err) 3106 } 3107 } 3108 if len(missing.Users) > 0 { 3109 return missing 3110 } 3111 return nil 3112 } 3113 return err 3114 } 3115 3116 // UnrequestReview tries to remove the users listed in 'logins' from the requested reviewers of the 3117 // specified PR. The GitHub API treats deletions of review requests differently than creations. Specifically, if 3118 // 'logins' contains a user that isn't a requested reviewer, other users that are valid are still removed. 3119 // Furthermore, the API response lists the set of requested reviewers after the deletion (unlike request creations), 3120 // so we can determine if each deletion was successful. 3121 // The API responds with http status code 200 no matter what the content of 'logins' is. 3122 // 3123 // See https://developer.github.com/v3/pulls/review_requests/#delete-a-review-request 3124 func (c *client) UnrequestReview(org, repo string, number int, logins []string) error { 3125 durationLogger := c.log("UnrequestReview", org, repo, number, logins) 3126 defer durationLogger() 3127 3128 var pr PullRequest 3129 body, err := prepareReviewersBody(logins, org) 3130 if len(body) == 0 { 3131 // No point in doing request for none, 3132 // if some logins didn't make it to body, extras.Users will catch them. 3133 return err 3134 } 3135 _, err = c.request(&request{ 3136 method: http.MethodDelete, 3137 path: fmt.Sprintf("/repos/%s/%s/pulls/%d/requested_reviewers", org, repo, number), 3138 accept: "application/vnd.github.symmetra-preview+json", 3139 org: org, 3140 requestBody: body, 3141 exitCodes: []int{http.StatusOK /*200*/}, 3142 }, &pr) 3143 if err != nil { 3144 return err 3145 } 3146 extras := ExtraUsers{action: "remove the PR review request for"} 3147 for _, user := range pr.RequestedReviewers { 3148 found := false 3149 for _, toDelete := range logins { 3150 if NormLogin(user.Login) == NormLogin(toDelete) { 3151 found = true 3152 break 3153 } 3154 } 3155 if found { 3156 extras.Users = append(extras.Users, user.Login) 3157 } 3158 } 3159 if len(extras.Users) > 0 { 3160 return extras 3161 } 3162 return nil 3163 } 3164 3165 // CloseIssue closes the existing, open issue provided 3166 // CloseIssue also closes the issue with the reason being 3167 // "completed" - default value for the state_reason attribute. 3168 // 3169 // See https://developer.github.com/v3/issues/#edit-an-issue 3170 func (c *client) CloseIssue(org, repo string, number int) error { 3171 durationLogger := c.log("CloseIssue", org, repo, number) 3172 defer durationLogger() 3173 3174 return c.closeIssue(org, repo, number, "completed") 3175 } 3176 3177 // CloseIssueAsNotPlanned closes the existing, open issue provided 3178 // CloseIssueAsNotPlanned also closes the issue with the reason being 3179 // "not_planned" - value passed for the state_reason attribute. 3180 // 3181 // See https://developer.github.com/v3/issues/#edit-an-issue 3182 func (c *client) CloseIssueAsNotPlanned(org, repo string, number int) error { 3183 durationLogger := c.log("CloseIssueAsNotPlanned", org, repo, number) 3184 defer durationLogger() 3185 3186 return c.closeIssue(org, repo, number, "not_planned") 3187 } 3188 3189 func (c *client) closeIssue(org, repo string, number int, reason string) error { 3190 _, err := c.request(&request{ 3191 method: http.MethodPatch, 3192 path: fmt.Sprintf("/repos/%s/%s/issues/%d", org, repo, number), 3193 org: org, 3194 requestBody: map[string]string{"state": "closed", "state_reason": reason}, 3195 exitCodes: []int{200}, 3196 }, nil) 3197 3198 return err 3199 } 3200 3201 // StateCannotBeChanged represents the "custom" GitHub API 3202 // error that occurs when a resource cannot be changed 3203 type StateCannotBeChanged struct { 3204 Message string 3205 } 3206 3207 func (s StateCannotBeChanged) Error() string { 3208 return s.Message 3209 } 3210 3211 // StateCannotBeChanged implements error 3212 var _ error = (*StateCannotBeChanged)(nil) 3213 3214 // convert to a StateCannotBeChanged if appropriate or else return the original error 3215 func stateCannotBeChangedOrOriginalError(err error) error { 3216 requestErr, ok := err.(requestError) 3217 if ok { 3218 for _, errorMsg := range requestErr.ErrorMessages() { 3219 if strings.Contains(errorMsg, stateCannotBeChangedMessagePrefix) { 3220 return StateCannotBeChanged{ 3221 Message: errorMsg, 3222 } 3223 } 3224 } 3225 } 3226 return err 3227 } 3228 3229 // ReopenIssue re-opens the existing, closed issue provided 3230 // 3231 // See https://developer.github.com/v3/issues/#edit-an-issue 3232 func (c *client) ReopenIssue(org, repo string, number int) error { 3233 durationLogger := c.log("ReopenIssue", org, repo, number) 3234 defer durationLogger() 3235 3236 _, err := c.request(&request{ 3237 method: http.MethodPatch, 3238 path: fmt.Sprintf("/repos/%s/%s/issues/%d", org, repo, number), 3239 org: org, 3240 requestBody: map[string]string{"state": "open"}, 3241 exitCodes: []int{200}, 3242 }, nil) 3243 return stateCannotBeChangedOrOriginalError(err) 3244 } 3245 3246 // ClosePullRequest closes the existing, open PR provided 3247 // 3248 // See https://developer.github.com/v3/pulls/#update-a-pull-request 3249 func (c *client) ClosePullRequest(org, repo string, number int) error { 3250 durationLogger := c.log("ClosePullRequest", org, repo, number) 3251 defer durationLogger() 3252 3253 _, err := c.request(&request{ 3254 method: http.MethodPatch, 3255 path: fmt.Sprintf("/repos/%s/%s/pulls/%d", org, repo, number), 3256 org: org, 3257 requestBody: map[string]string{"state": "closed"}, 3258 exitCodes: []int{200}, 3259 }, nil) 3260 return err 3261 } 3262 3263 // ReopenPullRequest re-opens the existing, closed PR provided 3264 // 3265 // See https://developer.github.com/v3/pulls/#update-a-pull-request 3266 func (c *client) ReopenPullRequest(org, repo string, number int) error { 3267 durationLogger := c.log("ReopenPullRequest", org, repo, number) 3268 defer durationLogger() 3269 3270 _, err := c.request(&request{ 3271 method: http.MethodPatch, 3272 path: fmt.Sprintf("/repos/%s/%s/pulls/%d", org, repo, number), 3273 org: org, 3274 requestBody: map[string]string{"state": "open"}, 3275 exitCodes: []int{200}, 3276 }, nil) 3277 return stateCannotBeChangedOrOriginalError(err) 3278 } 3279 3280 // GetRef returns the SHA of the given ref, such as "heads/master". 3281 // 3282 // See https://developer.github.com/v3/git/refs/#get-a-reference 3283 // The gitbub api does prefix matching and might return multiple results, 3284 // in which case we will return a GetRefTooManyResultsError 3285 func (c *client) GetRef(org, repo, ref string) (string, error) { 3286 durationLogger := c.log("GetRef", org, repo, ref) 3287 defer durationLogger() 3288 3289 res := GetRefResponse{} 3290 _, err := c.request(&request{ 3291 method: http.MethodGet, 3292 path: fmt.Sprintf("/repos/%s/%s/git/refs/%s", org, repo, ref), 3293 org: org, 3294 exitCodes: []int{200}, 3295 }, &res) 3296 if err != nil { 3297 return "", err 3298 } 3299 3300 if n := len(res); n > 1 { 3301 wantRef := "refs/" + ref 3302 for _, r := range res { 3303 if r.Ref == wantRef { 3304 return r.Object.SHA, nil 3305 } 3306 } 3307 return "", GetRefTooManyResultsError{org: org, repo: repo, ref: ref, resultsRefs: res.RefNames()} 3308 } 3309 return res[0].Object.SHA, nil 3310 } 3311 3312 type GetRefTooManyResultsError struct { 3313 org, repo, ref string 3314 resultsRefs []string 3315 } 3316 3317 func (GetRefTooManyResultsError) Is(err error) bool { 3318 _, ok := err.(GetRefTooManyResultsError) 3319 return ok 3320 } 3321 3322 func (e GetRefTooManyResultsError) Error() string { 3323 return fmt.Sprintf("query for %s/%s ref %q didn't match one but multiple refs: %v", e.org, e.repo, e.ref, e.resultsRefs) 3324 } 3325 3326 type GetRefResponse []GetRefResult 3327 3328 // We need a custom unmarshaler because the GetRefResult may either be a 3329 // single GetRefResponse or multiple 3330 func (grr *GetRefResponse) UnmarshalJSON(data []byte) error { 3331 result := &GetRefResult{} 3332 if err := json.Unmarshal(data, result); err == nil { 3333 *(grr) = GetRefResponse{*result} 3334 return nil 3335 } 3336 var response []GetRefResult 3337 if err := json.Unmarshal(data, &response); err != nil { 3338 return fmt.Errorf("failed to unmarshal response %s: %w", string(data), err) 3339 } 3340 *grr = GetRefResponse(response) 3341 return nil 3342 } 3343 3344 func (grr *GetRefResponse) RefNames() []string { 3345 var result []string 3346 for _, item := range *grr { 3347 result = append(result, item.Ref) 3348 } 3349 return result 3350 } 3351 3352 type GetRefResult struct { 3353 Ref string `json:"ref,omitempty"` 3354 NodeID string `json:"node_id,omitempty"` 3355 URL string `json:"url,omitempty"` 3356 Object struct { 3357 Type string `json:"type,omitempty"` 3358 SHA string `json:"sha,omitempty"` 3359 URL string `json:"url,omitempty"` 3360 } `json:"object,omitempty"` 3361 } 3362 3363 // DeleteRef deletes the given ref 3364 // 3365 // See https://developer.github.com/v3/git/refs/#delete-a-reference 3366 func (c *client) DeleteRef(org, repo, ref string) error { 3367 durationLogger := c.log("DeleteRef", org, repo, ref) 3368 defer durationLogger() 3369 3370 _, err := c.request(&request{ 3371 method: http.MethodDelete, 3372 path: fmt.Sprintf("/repos/%s/%s/git/refs/%s", org, repo, ref), 3373 org: org, 3374 exitCodes: []int{204}, 3375 }, nil) 3376 return err 3377 } 3378 3379 // ListFileCommits returns the commits for this file path. 3380 // 3381 // See https://developer.github.com/v3/repos/#list-commits 3382 func (c *client) ListFileCommits(org, repo, filePath string) ([]RepositoryCommit, error) { 3383 durationLogger := c.log("ListFileCommits", org, repo, filePath) 3384 defer durationLogger() 3385 3386 var commits []RepositoryCommit 3387 err := c.readPaginatedResultsWithValues( 3388 fmt.Sprintf("/repos/%s/%s/commits", org, repo), 3389 url.Values{ 3390 "path": []string{filePath}, 3391 "per_page": []string{"100"}, 3392 }, 3393 acceptNone, 3394 org, 3395 func() interface{} { // newObj 3396 return &[]RepositoryCommit{} 3397 }, 3398 func(obj interface{}) { 3399 commits = append(commits, *(obj.(*[]RepositoryCommit))...) 3400 }, 3401 ) 3402 if err != nil { 3403 return nil, err 3404 } 3405 return commits, nil 3406 } 3407 3408 // FindIssues uses the GitHub search API to find issues which match a particular query. 3409 // 3410 // Input query the same way you would into the website. 3411 // Order returned results with sort (usually "updated"). 3412 // Control whether oldest/newest is first with asc. 3413 // This method is not working in contexts where "github-app-id" is set. Please use FindIssuesWithOrg() in those cases. 3414 // 3415 // See https://help.github.com/articles/searching-issues-and-pull-requests/ for details. 3416 func (c *client) FindIssues(query, sort string, asc bool) ([]Issue, error) { 3417 return c.FindIssuesWithOrg("", query, sort, asc) 3418 } 3419 3420 // FindIssuesWithOrg uses the GitHub search API to find issues which match a particular query. 3421 // 3422 // Input query the same way you would into the website. 3423 // Order returned results with sort (usually "updated"). 3424 // Control whether oldest/newest is first with asc. 3425 // This method is supposed to be used in contexts where "github-app-id" is set. 3426 // 3427 // See https://help.github.com/articles/searching-issues-and-pull-requests/ for details. 3428 func (c *client) FindIssuesWithOrg(org, query, sort string, asc bool) ([]Issue, error) { 3429 loggerName := "FindIssuesWithOrg" 3430 if org == "" { 3431 loggerName = "FindIssues" 3432 } 3433 durationLogger := c.log(loggerName, query) 3434 defer durationLogger() 3435 3436 values := url.Values{ 3437 "per_page": []string{"100"}, 3438 "q": []string{query}, 3439 } 3440 var issues []Issue 3441 3442 if sort != "" { 3443 values["sort"] = []string{sort} 3444 if asc { 3445 values["order"] = []string{"asc"} 3446 } 3447 } 3448 err := c.readPaginatedResultsWithValues( 3449 "/search/issues", 3450 values, 3451 acceptNone, 3452 org, 3453 func() interface{} { // newObj 3454 return &IssuesSearchResult{} 3455 }, 3456 func(obj interface{}) { 3457 issues = append(issues, obj.(*IssuesSearchResult).Issues...) 3458 }, 3459 ) 3460 if err != nil { 3461 return nil, err 3462 } 3463 return issues, err 3464 } 3465 3466 // FileNotFound happens when github cannot find the file requested by GetFile(). 3467 type FileNotFound struct { 3468 org, repo, path, commit string 3469 } 3470 3471 func (e *FileNotFound) Error() string { 3472 return fmt.Sprintf("%s/%s/%s @ %s not found", e.org, e.repo, e.path, e.commit) 3473 } 3474 3475 // GetFile uses GitHub repo contents API to retrieve the content of a file with commit SHA. 3476 // If commit is empty, it will grab content from repo's default branch, usually master. 3477 // Use GetDirectory() method to retrieve a directory. 3478 // 3479 // See https://developer.github.com/v3/repos/contents/#get-contents 3480 func (c *client) GetFile(org, repo, filepath, commit string) ([]byte, error) { 3481 durationLogger := c.log("GetFile", org, repo, filepath, commit) 3482 defer durationLogger() 3483 3484 path := fmt.Sprintf("/repos/%s/%s/contents/%s", org, repo, filepath) 3485 if commit != "" { 3486 path = fmt.Sprintf("%s?ref=%s", path, url.QueryEscape(commit)) 3487 } 3488 3489 var res Content 3490 code, err := c.request(&request{ 3491 method: http.MethodGet, 3492 path: path, 3493 org: org, 3494 exitCodes: []int{200, 404}, 3495 }, &res) 3496 3497 if err != nil { 3498 return nil, err 3499 } 3500 3501 if code == 404 { 3502 return nil, &FileNotFound{ 3503 org: org, 3504 repo: repo, 3505 path: filepath, 3506 commit: commit, 3507 } 3508 } 3509 3510 decoded, err := base64.StdEncoding.DecodeString(res.Content) 3511 if err != nil { 3512 return nil, fmt.Errorf("error decoding %s : %w", res.Content, err) 3513 } 3514 3515 return decoded, nil 3516 } 3517 3518 // QueryWithGitHubAppsSupport runs a GraphQL query using shurcooL/githubql's client. 3519 func (c *client) QueryWithGitHubAppsSupport(ctx context.Context, q interface{}, vars map[string]interface{}, org string) error { 3520 // Don't log query here because Query is typically called multiple times to get all pages. 3521 // Instead log once per search and include total search cost. 3522 return c.gqlc.QueryWithGitHubAppsSupport(ctx, q, vars, org) 3523 } 3524 3525 // MutateWithGitHubAppsSupport runs a GraphQL mutation using shurcooL/githubql's client. 3526 func (c *client) MutateWithGitHubAppsSupport(ctx context.Context, m interface{}, input githubql.Input, vars map[string]interface{}, org string) error { 3527 return c.gqlc.MutateWithGitHubAppsSupport(ctx, m, input, vars, org) 3528 } 3529 3530 // CreateTeam adds a team with name to the org, returning a struct with the new ID. 3531 // 3532 // See https://developer.github.com/v3/teams/#create-team 3533 func (c *client) CreateTeam(org string, team Team) (*Team, error) { 3534 durationLogger := c.log("CreateTeam", org, team) 3535 defer durationLogger() 3536 3537 if team.Name == "" { 3538 return nil, errors.New("team.Name must be non-empty") 3539 } 3540 if c.fake { 3541 return nil, nil 3542 } else if c.dry { 3543 // When in dry mode we need a believable slug to call corresponding methods for this team 3544 team.Slug = strings.ToLower(strings.ReplaceAll(team.Name, " ", "-")) 3545 return &team, nil 3546 } 3547 path := fmt.Sprintf("/orgs/%s/teams", org) 3548 var retTeam Team 3549 _, err := c.request(&request{ 3550 method: http.MethodPost, 3551 path: path, 3552 accept: "application/vnd.github+json", 3553 org: org, 3554 requestBody: &team, 3555 exitCodes: []int{201}, 3556 }, &retTeam) 3557 return &retTeam, err 3558 } 3559 3560 // EditTeam patches team.Slug to contain the specified: 3561 // name, description, privacy, permission, and parentTeamId values. 3562 // 3563 // See https://docs.github.com/en/rest/reference/teams#update-a-team 3564 func (c *client) EditTeam(org string, t Team) (*Team, error) { 3565 durationLogger := c.log("EditTeam", t) 3566 defer durationLogger() 3567 3568 if t.Slug == "" { 3569 return nil, errors.New("team.Slug must be populated") 3570 } 3571 if c.dry { 3572 return &t, nil 3573 } 3574 t.ID = 0 3575 // Need to send parent_team_id: null 3576 team := struct { 3577 Team 3578 ParentTeamID *int `json:"parent_team_id"` 3579 }{ 3580 Team: t, 3581 ParentTeamID: t.ParentTeamID, 3582 } 3583 var retTeam Team 3584 path := fmt.Sprintf("/orgs/%s/teams/%s", org, t.Slug) 3585 _, err := c.request(&request{ 3586 method: http.MethodPatch, 3587 path: path, 3588 accept: "application/vnd.github+json", 3589 org: org, 3590 requestBody: &team, 3591 exitCodes: []int{200, 201}, 3592 }, &retTeam) 3593 return &retTeam, err 3594 } 3595 3596 // DeleteTeamBySlug removes team.Slug from GitHub. 3597 // 3598 // See https://docs.github.com/en/rest/reference/teams#delete-a-team 3599 func (c *client) DeleteTeamBySlug(org, teamSlug string) error { 3600 durationLogger := c.log("DeleteTeamBySlug", org, teamSlug) 3601 defer durationLogger() 3602 path := fmt.Sprintf("/orgs/%s/teams/%s", org, teamSlug) 3603 _, err := c.request(&request{ 3604 method: http.MethodDelete, 3605 path: path, 3606 org: org, 3607 exitCodes: []int{204}, 3608 }, nil) 3609 return err 3610 } 3611 3612 // ListTeams gets a list of teams for the given org 3613 // 3614 // See https://developer.github.com/v3/teams/#list-teams 3615 func (c *client) ListTeams(org string) ([]Team, error) { 3616 durationLogger := c.log("ListTeams", org) 3617 defer durationLogger() 3618 3619 if c.fake { 3620 return nil, nil 3621 } 3622 path := fmt.Sprintf("/orgs/%s/teams", org) 3623 var teams []Team 3624 err := c.readPaginatedResults( 3625 path, 3626 "application/vnd.github+json", 3627 org, 3628 func() interface{} { 3629 return &[]Team{} 3630 }, 3631 func(obj interface{}) { 3632 teams = append(teams, *(obj.(*[]Team))...) 3633 }, 3634 ) 3635 if err != nil { 3636 return nil, err 3637 } 3638 return teams, nil 3639 } 3640 3641 // UpdateTeamMembershipBySlug adds the user to the team and/or updates their role in that team. 3642 // 3643 // If the user is not a member of the org, GitHub will invite them to become an outside collaborator, setting their status to pending. 3644 // 3645 // https://docs.github.com/en/rest/reference/teams#add-or-update-team-membership-for-a-user 3646 func (c *client) UpdateTeamMembershipBySlug(org, teamSlug, user string, maintainer bool) (*TeamMembership, error) { 3647 durationLogger := c.log("UpdateTeamMembershipBySlug", org, teamSlug, user, maintainer) 3648 defer durationLogger() 3649 3650 if c.fake { 3651 return nil, nil 3652 } 3653 tm := TeamMembership{} 3654 if maintainer { 3655 tm.Role = RoleMaintainer 3656 } else { 3657 tm.Role = RoleMember 3658 } 3659 3660 if c.dry { 3661 return &tm, nil 3662 } 3663 3664 _, err := c.request(&request{ 3665 method: http.MethodPut, 3666 path: fmt.Sprintf("/orgs/%s/teams/%s/memberships/%s", org, teamSlug, user), 3667 org: org, 3668 requestBody: &tm, 3669 exitCodes: []int{200}, 3670 }, &tm) 3671 return &tm, err 3672 } 3673 3674 // RemoveTeamMembershipBySlug removes the user from the team (but not the org). 3675 // 3676 // https://docs.github.com/en/rest/reference/teams#remove-team-membership-for-a-user 3677 func (c *client) RemoveTeamMembershipBySlug(org, teamSlug, user string) error { 3678 durationLogger := c.log("RemoveTeamMembershipBySlug", org, teamSlug, user) 3679 defer durationLogger() 3680 3681 if c.fake { 3682 return nil 3683 } 3684 _, err := c.request(&request{ 3685 method: http.MethodDelete, 3686 path: fmt.Sprintf("/orgs/%s/teams/%s/memberships/%s", org, teamSlug, user), 3687 org: org, 3688 exitCodes: []int{204}, 3689 }, nil) 3690 return err 3691 } 3692 3693 // ListTeamMembers gets a list of team members for the given team id 3694 // 3695 // Role options are "all", "maintainer" and "member" 3696 // 3697 // https://developer.github.com/v3/teams/members/#list-team-members 3698 // Deprecated: please use ListTeamMembersBySlug 3699 func (c *client) ListTeamMembers(org string, id int, role string) ([]TeamMember, error) { 3700 c.logger.WithField("methodName", "ListTeamMembers"). 3701 Warn("method is deprecated, please use ListTeamMembersBySlug") 3702 durationLogger := c.log("ListTeamMembers", id, role) 3703 defer durationLogger() 3704 3705 if c.fake { 3706 return nil, nil 3707 } 3708 path := fmt.Sprintf("/teams/%d/members", id) 3709 var teamMembers []TeamMember 3710 err := c.readPaginatedResultsWithValues( 3711 path, 3712 url.Values{ 3713 "per_page": []string{"100"}, 3714 "role": []string{role}, 3715 }, 3716 "application/vnd.github+json", 3717 org, 3718 func() interface{} { 3719 return &[]TeamMember{} 3720 }, 3721 func(obj interface{}) { 3722 teamMembers = append(teamMembers, *(obj.(*[]TeamMember))...) 3723 }, 3724 ) 3725 if err != nil { 3726 return nil, err 3727 } 3728 return teamMembers, nil 3729 } 3730 3731 // ListTeamMembersBySlug gets a list of team members for the given team slug 3732 // 3733 // Role options are "all", "maintainer" and "member" 3734 // 3735 // https://docs.github.com/en/rest/reference/teams#list-team-members 3736 func (c *client) ListTeamMembersBySlug(org, teamSlug, role string) ([]TeamMember, error) { 3737 durationLogger := c.log("ListTeamMembersBySlug", org, teamSlug, role) 3738 defer durationLogger() 3739 3740 if c.fake { 3741 return nil, nil 3742 } 3743 path := fmt.Sprintf("/orgs/%s/teams/%s/members", org, teamSlug) 3744 var teamMembers []TeamMember 3745 err := c.readPaginatedResultsWithValues( 3746 path, 3747 url.Values{ 3748 "per_page": []string{"100"}, 3749 "role": []string{role}, 3750 }, 3751 "application/vnd.github.v3+json", 3752 org, 3753 func() interface{} { 3754 return &[]TeamMember{} 3755 }, 3756 func(obj interface{}) { 3757 teamMembers = append(teamMembers, *(obj.(*[]TeamMember))...) 3758 }, 3759 ) 3760 if err != nil { 3761 return nil, err 3762 } 3763 return teamMembers, nil 3764 } 3765 3766 // ListTeamReposBySlug gets a list of team repos for the given team slug 3767 // 3768 // https://docs.github.com/en/rest/reference/teams#list-team-repositories 3769 func (c *client) ListTeamReposBySlug(org, teamSlug string) ([]Repo, error) { 3770 durationLogger := c.log("ListTeamReposBySlug", org, teamSlug) 3771 defer durationLogger() 3772 3773 if c.fake { 3774 return nil, nil 3775 } 3776 path := fmt.Sprintf("/orgs/%s/teams/%s/repos", org, teamSlug) 3777 var repos []Repo 3778 err := c.readPaginatedResultsWithValues( 3779 path, 3780 url.Values{ 3781 "per_page": []string{"100"}, 3782 }, 3783 "application/vnd.github.v3+json", 3784 org, 3785 func() interface{} { 3786 return &[]Repo{} 3787 }, 3788 func(obj interface{}) { 3789 for _, repo := range *obj.(*[]Repo) { 3790 // Currently, GitHub API returns false for all permission levels 3791 // for a repo on which the team has 'Maintain' or 'Triage' role. 3792 // This check is to avoid listing a repo under the team but 3793 // showing the permission level as none. 3794 if LevelFromPermissions(repo.Permissions) != None { 3795 repos = append(repos, repo) 3796 } 3797 } 3798 }, 3799 ) 3800 if err != nil { 3801 return nil, err 3802 } 3803 return repos, nil 3804 } 3805 3806 // UpdateTeamRepoBySlug adds the repo to the team with the provided role. 3807 // 3808 // https://docs.github.com/en/rest/reference/teams#add-or-update-team-repository-permissions 3809 func (c *client) UpdateTeamRepoBySlug(org, teamSlug, repo string, permission TeamPermission) error { 3810 durationLogger := c.log("UpdateTeamRepoBySlug", org, teamSlug, repo, permission) 3811 defer durationLogger() 3812 3813 if c.fake || c.dry { 3814 return nil 3815 } 3816 3817 data := struct { 3818 Permission string `json:"permission"` 3819 }{ 3820 Permission: string(permission), 3821 } 3822 3823 _, err := c.request(&request{ 3824 method: http.MethodPut, 3825 path: fmt.Sprintf("/orgs/%s/teams/%s/repos/%s/%s", org, teamSlug, org, repo), 3826 org: org, 3827 requestBody: &data, 3828 exitCodes: []int{204}, 3829 }, nil) 3830 return err 3831 } 3832 3833 // RemoveTeamRepoBySlug removes the team from the repo. 3834 // 3835 // https://docs.github.com/en/rest/reference/teams#remove-a-repository-from-a-team 3836 func (c *client) RemoveTeamRepoBySlug(org, teamSlug, repo string) error { 3837 durationLogger := c.log("RemoveTeamRepoBySlug", org, teamSlug, repo) 3838 defer durationLogger() 3839 3840 if c.fake || c.dry { 3841 return nil 3842 } 3843 3844 _, err := c.request(&request{ 3845 method: http.MethodDelete, 3846 path: fmt.Sprintf("/orgs/%s/teams/%s/repos/%s/%s", org, teamSlug, org, repo), 3847 org: org, 3848 exitCodes: []int{204}, 3849 }, nil) 3850 return err 3851 } 3852 3853 // ListTeamInvitationsBySlug gets a list of team members with pending invitations for the given team slug 3854 // 3855 // https://docs.github.com/en/rest/reference/teams#list-pending-team-invitations 3856 func (c *client) ListTeamInvitationsBySlug(org, teamSlug string) ([]OrgInvitation, error) { 3857 durationLogger := c.log("ListTeamInvitationsBySlug", org, teamSlug) 3858 defer durationLogger() 3859 3860 if c.fake { 3861 return nil, nil 3862 } 3863 3864 path := fmt.Sprintf("/orgs/%s/teams/%s/invitations", org, teamSlug) 3865 var ret []OrgInvitation 3866 err := c.readPaginatedResults( 3867 path, 3868 "application/vnd.github.v3+json", 3869 org, 3870 func() interface{} { 3871 return &[]OrgInvitation{} 3872 }, 3873 func(obj interface{}) { 3874 ret = append(ret, *(obj.(*[]OrgInvitation))...) 3875 }, 3876 ) 3877 if err != nil { 3878 return nil, err 3879 } 3880 return ret, nil 3881 } 3882 3883 // MergeDetails contains desired properties of the merge. 3884 // 3885 // See https://developer.github.com/v3/pulls/#merge-a-pull-request-merge-button 3886 type MergeDetails struct { 3887 // CommitTitle defaults to the automatic message. 3888 CommitTitle string `json:"commit_title,omitempty"` 3889 // CommitMessage defaults to the automatic message. 3890 CommitMessage string `json:"commit_message,omitempty"` 3891 // The PR HEAD must match this to prevent races. 3892 SHA string `json:"sha,omitempty"` 3893 // Can be "merge", "squash", or "rebase". Defaults to merge. 3894 MergeMethod string `json:"merge_method,omitempty"` 3895 } 3896 3897 // ModifiedHeadError happens when github refuses to merge a PR because the PR changed. 3898 type ModifiedHeadError string 3899 3900 func (e ModifiedHeadError) Error() string { return string(e) } 3901 3902 // UnmergablePRError happens when github refuses to merge a PR for other reasons (merge confclit). 3903 type UnmergablePRError string 3904 3905 func (e UnmergablePRError) Error() string { return string(e) } 3906 3907 // UnmergablePRBaseChangedError happens when github refuses merging a PR because the base changed. 3908 type UnmergablePRBaseChangedError string 3909 3910 func (e UnmergablePRBaseChangedError) Error() string { return string(e) } 3911 3912 // UnauthorizedToPushError happens when client is not allowed to push to github. 3913 type UnauthorizedToPushError string 3914 3915 func (e UnauthorizedToPushError) Error() string { return string(e) } 3916 3917 // MergeCommitsForbiddenError happens when the repo disallows the merge strategy configured for the repo in Tide. 3918 type MergeCommitsForbiddenError string 3919 3920 func (e MergeCommitsForbiddenError) Error() string { return string(e) } 3921 3922 // Merge merges a PR. 3923 // 3924 // See https://developer.github.com/v3/pulls/#merge-a-pull-request-merge-button 3925 func (c *client) Merge(org, repo string, pr int, details MergeDetails) error { 3926 durationLogger := c.log("Merge", org, repo, pr, details) 3927 defer durationLogger() 3928 3929 ge := githubError{} 3930 ec, err := c.request(&request{ 3931 method: http.MethodPut, 3932 path: fmt.Sprintf("/repos/%s/%s/pulls/%d/merge", org, repo, pr), 3933 org: org, 3934 requestBody: &details, 3935 exitCodes: []int{200, 405, 409}, 3936 }, &ge) 3937 if err != nil { 3938 return err 3939 } 3940 if ec == 405 { 3941 if strings.Contains(ge.Message, "Base branch was modified") { 3942 return UnmergablePRBaseChangedError(ge.Message) 3943 } 3944 if strings.Contains(ge.Message, "You're not authorized to push to this branch") { 3945 return UnauthorizedToPushError(ge.Message) 3946 } 3947 if strings.Contains(ge.Message, "Merge commits are not allowed on this repository") { 3948 return MergeCommitsForbiddenError(ge.Message) 3949 } 3950 return UnmergablePRError(ge.Message) 3951 } else if ec == 409 { 3952 return ModifiedHeadError(ge.Message) 3953 } 3954 3955 return nil 3956 } 3957 3958 // IsCollaborator returns whether or not the user is a collaborator of the repo. 3959 // From GitHub's API reference: 3960 // For organization-owned repositories, the list of collaborators includes 3961 // outside collaborators, organization members that are direct collaborators, 3962 // organization members with access through team memberships, organization 3963 // members with access through default organization permissions, and 3964 // organization owners. 3965 // 3966 // See https://developer.github.com/v3/repos/collaborators/ 3967 func (c *client) IsCollaborator(org, repo, user string) (bool, error) { 3968 // This call does not support etags and is therefore not cacheable today 3969 // by ghproxy. If we can detect that we're using ghproxy, however, we can 3970 // make a more expensive but cache-able call instead. Detecting that we 3971 // are pointed at a ghproxy instance is not high fidelity, but a best-effort 3972 // approach here is guaranteed to make a positive impact and no negative one. 3973 if strings.Contains(c.bases[0], "ghproxy") { 3974 users, err := c.ListCollaborators(org, repo) 3975 if err != nil { 3976 return false, err 3977 } 3978 for _, u := range users { 3979 if NormLogin(u.Login) == NormLogin(user) { 3980 return true, nil 3981 } 3982 } 3983 return false, nil 3984 } 3985 durationLogger := c.log("IsCollaborator", org, user) 3986 defer durationLogger() 3987 3988 if org == user { 3989 // Make it possible to run a couple of plugins on personal repos. 3990 return true, nil 3991 } 3992 code, err := c.request(&request{ 3993 method: http.MethodGet, 3994 accept: "application/vnd.github+json", 3995 path: fmt.Sprintf("/repos/%s/%s/collaborators/%s", org, repo, user), 3996 org: org, 3997 exitCodes: []int{204, 404, 302}, 3998 }, nil) 3999 if err != nil { 4000 return false, err 4001 } 4002 if code == 204 { 4003 return true, nil 4004 } else if code == 404 { 4005 return false, nil 4006 } 4007 return false, fmt.Errorf("unexpected status: %d", code) 4008 } 4009 4010 // ListCollaborators gets a list of all users who have access to a repo (and 4011 // can become assignees or requested reviewers). 4012 // 4013 // See 'IsCollaborator' for more details. 4014 // See https://developer.github.com/v3/repos/collaborators/ 4015 func (c *client) ListCollaborators(org, repo string) ([]User, error) { 4016 durationLogger := c.log("ListCollaborators", org, repo) 4017 defer durationLogger() 4018 4019 if c.fake { 4020 return nil, nil 4021 } 4022 path := fmt.Sprintf("/repos/%s/%s/collaborators", org, repo) 4023 var users []User 4024 err := c.readPaginatedResults( 4025 path, 4026 "application/vnd.github+json", 4027 org, 4028 func() interface{} { 4029 return &[]User{} 4030 }, 4031 func(obj interface{}) { 4032 users = append(users, *(obj.(*[]User))...) 4033 }, 4034 ) 4035 if err != nil { 4036 return nil, err 4037 } 4038 return users, nil 4039 } 4040 4041 // CreateFork creates a fork for the authenticated user. Forking a repository 4042 // happens asynchronously. Therefore, we may have to wait a short period before 4043 // accessing the git objects. If this takes longer than 5 minutes, GitHub 4044 // recommends contacting their support. 4045 // 4046 // See https://developer.github.com/v3/repos/forks/#create-a-fork 4047 func (c *client) CreateFork(owner, repo string) (string, error) { 4048 durationLogger := c.log("CreateFork", owner, repo) 4049 defer durationLogger() 4050 4051 resp := struct { 4052 Name string `json:"name"` 4053 }{} 4054 4055 _, err := c.request(&request{ 4056 method: http.MethodPost, 4057 path: fmt.Sprintf("/repos/%s/%s/forks", owner, repo), 4058 org: owner, 4059 exitCodes: []int{202}, 4060 }, &resp) 4061 4062 // there are many reasons why GitHub may end up forking the 4063 // repo under a different name -- the repo got re-named, the 4064 // bot account already has a fork with that name, etc 4065 return resp.Name, err 4066 } 4067 4068 // EnsureFork checks to see that there is a fork of org/repo in the forkedUsers repositories. 4069 // If there is not, it makes one, and waits for the fork to be created before returning. 4070 // The return value is the name of the repo that was created 4071 // (This may be different then the one that is forked due to naming conflict) 4072 func (c *client) EnsureFork(forkingUser, org, repo string) (string, error) { 4073 // Fork repo if it doesn't exist. 4074 fork := forkingUser + "/" + repo 4075 repos, err := c.GetRepos(forkingUser, true) 4076 if err != nil { 4077 return repo, fmt.Errorf("could not fetch all existing repos: %w", err) 4078 } 4079 // if the repo does not exist, or it does, but is not a fork of the repo we want 4080 if forkedRepo := getFork(fork, repos); forkedRepo == nil || forkedRepo.Parent.FullName != fmt.Sprintf("%s/%s", org, repo) { 4081 if name, err := c.CreateFork(org, repo); err != nil { 4082 return repo, fmt.Errorf("cannot fork %s/%s: %w", org, repo, err) 4083 } else { 4084 // we got a fork but it may be named differently 4085 repo = name 4086 } 4087 if err := c.waitForRepo(forkingUser, repo); err != nil { 4088 return repo, fmt.Errorf("fork of %s/%s cannot show up on GitHub: %w", org, repo, err) 4089 } 4090 } 4091 return repo, nil 4092 4093 } 4094 4095 func (c *client) waitForRepo(owner, name string) error { 4096 // Wait for at most 5 minutes for the fork to appear on GitHub. 4097 // The documentation instructs us to contact support if this 4098 // takes longer than five minutes. 4099 after := time.After(6 * time.Minute) 4100 tick := time.NewTicker(30 * time.Second) 4101 4102 var ghErr string 4103 for { 4104 select { 4105 case <-tick.C: 4106 repo, err := c.GetRepo(owner, name) 4107 if err != nil { 4108 ghErr = fmt.Sprintf(": %v", err) 4109 logrus.WithError(err).Warn("Error getting bot repository.") 4110 continue 4111 } 4112 ghErr = "" 4113 if forkedRepo := getFork(owner+"/"+name, []Repo{repo.Repo}); forkedRepo != nil { 4114 return nil 4115 } 4116 case <-after: 4117 return fmt.Errorf("timed out waiting for %s to appear on GitHub%s", owner+"/"+name, ghErr) 4118 } 4119 } 4120 } 4121 4122 func getFork(repo string, repos []Repo) *Repo { 4123 for _, r := range repos { 4124 if !r.Fork { 4125 continue 4126 } 4127 if r.FullName == repo { 4128 return &r 4129 } 4130 } 4131 return nil 4132 } 4133 4134 // ListRepoTeams gets a list of all the teams with access to a repository 4135 // See https://developer.github.com/v3/repos/#list-teams 4136 func (c *client) ListRepoTeams(org, repo string) ([]Team, error) { 4137 durationLogger := c.log("ListRepoTeams", org, repo) 4138 defer durationLogger() 4139 4140 if c.fake { 4141 return nil, nil 4142 } 4143 path := fmt.Sprintf("/repos/%s/%s/teams", org, repo) 4144 var teams []Team 4145 err := c.readPaginatedResults( 4146 path, 4147 acceptNone, 4148 org, 4149 func() interface{} { 4150 return &[]Team{} 4151 }, 4152 func(obj interface{}) { 4153 teams = append(teams, *(obj.(*[]Team))...) 4154 }, 4155 ) 4156 if err != nil { 4157 return nil, err 4158 } 4159 return teams, nil 4160 } 4161 4162 // ListIssueEvents gets a list events from GitHub's events API that pertain to the specified issue. 4163 // The events that are returned have a different format than webhook events and certain event types 4164 // are excluded. 4165 // 4166 // See https://developer.github.com/v3/issues/events/ 4167 func (c *client) ListIssueEvents(org, repo string, num int) ([]ListedIssueEvent, error) { 4168 durationLogger := c.log("ListIssueEvents", org, repo, num) 4169 defer durationLogger() 4170 4171 if c.fake { 4172 return nil, nil 4173 } 4174 path := fmt.Sprintf("/repos/%s/%s/issues/%d/events", org, repo, num) 4175 var events []ListedIssueEvent 4176 err := c.readPaginatedResults( 4177 path, 4178 acceptNone, 4179 org, 4180 func() interface{} { 4181 return &[]ListedIssueEvent{} 4182 }, 4183 func(obj interface{}) { 4184 events = append(events, *(obj.(*[]ListedIssueEvent))...) 4185 }, 4186 ) 4187 if err != nil { 4188 return nil, err 4189 } 4190 return events, nil 4191 } 4192 4193 // IsMergeable determines if a PR can be merged. 4194 // Mergeability is calculated by a background job on GitHub and is not immediately available when 4195 // new commits are added so the PR must be polled until the background job completes. 4196 func (c *client) IsMergeable(org, repo string, number int, SHA string) (bool, error) { 4197 backoff := time.Second * 3 4198 maxTries := 3 4199 for try := 0; try < maxTries; try++ { 4200 pr, err := c.GetPullRequest(org, repo, number) 4201 if err != nil { 4202 return false, err 4203 } 4204 if pr.Head.SHA != SHA { 4205 return false, fmt.Errorf("pull request head changed while checking mergeability (%s -> %s)", SHA, pr.Head.SHA) 4206 } 4207 if pr.Merged { 4208 return false, errors.New("pull request was merged while checking mergeability") 4209 } 4210 if pr.Mergable != nil { 4211 return *pr.Mergable, nil 4212 } 4213 if try+1 < maxTries { 4214 c.time.Sleep(backoff) 4215 backoff *= 2 4216 } 4217 } 4218 return false, fmt.Errorf("reached maximum number of retries (%d) checking mergeability", maxTries) 4219 } 4220 4221 // ClearMilestone clears the milestone from the specified issue 4222 // 4223 // See https://developer.github.com/v3/issues/#edit-an-issue 4224 func (c *client) ClearMilestone(org, repo string, num int) error { 4225 durationLogger := c.log("ClearMilestone", org, repo, num) 4226 defer durationLogger() 4227 4228 issue := &struct { 4229 // Clearing the milestone requires providing a null value, and 4230 // interface{} will serialize to null. 4231 Milestone interface{} `json:"milestone"` 4232 }{} 4233 _, err := c.request(&request{ 4234 method: http.MethodPatch, 4235 path: fmt.Sprintf("/repos/%v/%v/issues/%d", org, repo, num), 4236 org: org, 4237 requestBody: &issue, 4238 exitCodes: []int{200}, 4239 }, nil) 4240 return err 4241 } 4242 4243 // SetMilestone sets the milestone from the specified issue (if it is a valid milestone) 4244 // 4245 // See https://developer.github.com/v3/issues/#edit-an-issue 4246 func (c *client) SetMilestone(org, repo string, issueNum, milestoneNum int) error { 4247 durationLogger := c.log("SetMilestone", org, repo, issueNum, milestoneNum) 4248 defer durationLogger() 4249 4250 issue := &struct { 4251 Milestone int `json:"milestone"` 4252 }{Milestone: milestoneNum} 4253 4254 _, err := c.request(&request{ 4255 method: http.MethodPatch, 4256 path: fmt.Sprintf("/repos/%v/%v/issues/%d", org, repo, issueNum), 4257 org: org, 4258 requestBody: &issue, 4259 exitCodes: []int{200}, 4260 }, nil) 4261 return err 4262 } 4263 4264 // ListMilestones list all milestones in a repo 4265 // 4266 // See https://developer.github.com/v3/issues/milestones/#list-milestones-for-a-repository/ 4267 func (c *client) ListMilestones(org, repo string) ([]Milestone, error) { 4268 durationLogger := c.log("ListMilestones", org) 4269 defer durationLogger() 4270 4271 if c.fake { 4272 return nil, nil 4273 } 4274 path := fmt.Sprintf("/repos/%s/%s/milestones", org, repo) 4275 var milestones []Milestone 4276 err := c.readPaginatedResults( 4277 path, 4278 acceptNone, 4279 org, 4280 func() interface{} { 4281 return &[]Milestone{} 4282 }, 4283 func(obj interface{}) { 4284 milestones = append(milestones, *(obj.(*[]Milestone))...) 4285 }, 4286 ) 4287 if err != nil { 4288 return nil, err 4289 } 4290 return milestones, nil 4291 } 4292 4293 // ListPullRequestCommits lists the commits in a pull request. 4294 // 4295 // GitHub API docs: https://developer.github.com/v3/pulls/#list-commits-on-a-pull-request 4296 func (c *client) ListPullRequestCommits(org, repo string, number int) ([]RepositoryCommit, error) { 4297 durationLogger := c.log("ListPullRequestCommits", org, repo, number) 4298 defer durationLogger() 4299 4300 if c.fake { 4301 return nil, nil 4302 } 4303 var commits []RepositoryCommit 4304 err := c.readPaginatedResults( 4305 fmt.Sprintf("/repos/%v/%v/pulls/%d/commits", org, repo, number), 4306 acceptNone, 4307 org, 4308 func() interface{} { // newObj returns a pointer to the type of object to create 4309 return &[]RepositoryCommit{} 4310 }, 4311 func(obj interface{}) { // accumulate is the accumulation function for paginated results 4312 commits = append(commits, *(obj.(*[]RepositoryCommit))...) 4313 }, 4314 ) 4315 if err != nil { 4316 return nil, err 4317 } 4318 return commits, nil 4319 } 4320 4321 // UpdatePullRequestBranch updates the pull request branch with the latest upstream changes by merging HEAD from the base branch into the pull request branch. 4322 // 4323 // GitHub API docs: https://developer.github.com/v3/pulls#update-a-pull-request-branch 4324 func (c *client) UpdatePullRequestBranch(org, repo string, number int, expectedHeadSha *string) error { 4325 durationLogger := c.log("UpdatePullRequestBranch", org, repo) 4326 defer durationLogger() 4327 4328 data := struct { 4329 // The expected SHA of the pull request's HEAD ref. This is the most recent commit on the pull request's branch. 4330 // If the expected SHA does not match the pull request's HEAD, you will receive a 422 Unprocessable Entity status. 4331 // You can use the "List commits" endpoint to find the most recent commit SHA. Default: SHA of the pull request's current HEAD ref. 4332 ExpectedHeadSha *string `json:"expected_head_sha,omitempty"` 4333 }{ 4334 ExpectedHeadSha: expectedHeadSha, 4335 } 4336 4337 code, err := c.request(&request{ 4338 method: http.MethodPut, 4339 path: fmt.Sprintf("/repos/%s/%s/pulls/%d/update-branch", org, repo, number), 4340 accept: "application/vnd.github.lydian-preview+json", 4341 org: org, 4342 requestBody: &data, 4343 exitCodes: []int{202, 422}, 4344 }, nil) 4345 if err != nil { 4346 return err 4347 } 4348 4349 if code == http.StatusUnprocessableEntity { 4350 msg := "mismatch expected head sha" 4351 if expectedHeadSha != nil { 4352 msg = fmt.Sprintf("%s: %s", msg, *expectedHeadSha) 4353 } 4354 return errors.New(msg) 4355 } 4356 4357 return nil 4358 } 4359 4360 // newReloadingTokenSource creates a reloadingTokenSource. 4361 func newReloadingTokenSource(getToken func() []byte) *reloadingTokenSource { 4362 return &reloadingTokenSource{ 4363 getToken: getToken, 4364 } 4365 } 4366 4367 // Token is an implementation for oauth2.TokenSource interface. 4368 func (s *reloadingTokenSource) Token() (*oauth2.Token, error) { 4369 return &oauth2.Token{ 4370 AccessToken: string(s.getToken()), 4371 }, nil 4372 } 4373 4374 // GetRepoProjects returns the list of projects in this repo. 4375 // 4376 // See https://developer.github.com/v3/projects/#list-repository-projects 4377 func (c *client) GetRepoProjects(owner, repo string) ([]Project, error) { 4378 durationLogger := c.log("GetOrgProjects", owner, repo) 4379 defer durationLogger() 4380 4381 path := fmt.Sprintf("/repos/%s/%s/projects", owner, repo) 4382 var projects []Project 4383 err := c.readPaginatedResults( 4384 path, 4385 "application/vnd.github.inertia-preview+json", 4386 owner, 4387 func() interface{} { 4388 return &[]Project{} 4389 }, 4390 func(obj interface{}) { 4391 projects = append(projects, *(obj.(*[]Project))...) 4392 }, 4393 ) 4394 if err != nil { 4395 return nil, err 4396 } 4397 return projects, nil 4398 } 4399 4400 // GetOrgProjects returns the list of projects in this org. 4401 // 4402 // See https://developer.github.com/v3/projects/#list-organization-projects 4403 func (c *client) GetOrgProjects(org string) ([]Project, error) { 4404 durationLogger := c.log("GetOrgProjects", org) 4405 defer durationLogger() 4406 4407 path := fmt.Sprintf("/orgs/%s/projects", org) 4408 var projects []Project 4409 err := c.readPaginatedResults( 4410 path, 4411 "application/vnd.github.inertia-preview+json", 4412 org, 4413 func() interface{} { 4414 return &[]Project{} 4415 }, 4416 func(obj interface{}) { 4417 projects = append(projects, *(obj.(*[]Project))...) 4418 }, 4419 ) 4420 if err != nil { 4421 return nil, err 4422 } 4423 return projects, nil 4424 } 4425 4426 // GetProjectColumns returns the list of columns in a project. 4427 // 4428 // See https://developer.github.com/v3/projects/columns/#list-project-columns 4429 func (c *client) GetProjectColumns(org string, projectID int) ([]ProjectColumn, error) { 4430 durationLogger := c.log("GetProjectColumns", projectID) 4431 defer durationLogger() 4432 4433 path := fmt.Sprintf("/projects/%d/columns", projectID) 4434 var projectColumns []ProjectColumn 4435 err := c.readPaginatedResults( 4436 path, 4437 "application/vnd.github.inertia-preview+json", 4438 org, 4439 func() interface{} { 4440 return &[]ProjectColumn{} 4441 }, 4442 func(obj interface{}) { 4443 projectColumns = append(projectColumns, *(obj.(*[]ProjectColumn))...) 4444 }, 4445 ) 4446 if err != nil { 4447 return nil, err 4448 } 4449 return projectColumns, nil 4450 } 4451 4452 // CreateProjectCard adds a project card to the specified project column. 4453 // 4454 // See https://developer.github.com/v3/projects/cards/#create-a-project-card 4455 func (c *client) CreateProjectCard(org string, columnID int, projectCard ProjectCard) (*ProjectCard, error) { 4456 durationLogger := c.log("CreateProjectCard", columnID, projectCard) 4457 defer durationLogger() 4458 4459 if (projectCard.ContentType != "Issue") && (projectCard.ContentType != "PullRequest") { 4460 return nil, errors.New("projectCard.ContentType must be either Issue or PullRequest") 4461 } 4462 if c.dry { 4463 return &projectCard, nil 4464 } 4465 path := fmt.Sprintf("/projects/columns/%d/cards", columnID) 4466 var retProjectCard ProjectCard 4467 _, err := c.request(&request{ 4468 method: http.MethodPost, 4469 path: path, 4470 accept: "application/vnd.github.inertia-preview+json", 4471 org: org, 4472 requestBody: &projectCard, 4473 exitCodes: []int{200}, 4474 }, &retProjectCard) 4475 return &retProjectCard, err 4476 } 4477 4478 // GetProjectColumnCards get all project cards in a column. This helps in iterating all 4479 // issues and PRs that are under a column 4480 func (c *client) GetColumnProjectCards(org string, columnID int) ([]ProjectCard, error) { 4481 durationLogger := c.log("GetColumnProjectCards", columnID) 4482 defer durationLogger() 4483 4484 if c.fake { 4485 return nil, nil 4486 } 4487 path := fmt.Sprintf("/projects/columns/%d/cards", columnID) 4488 var cards []ProjectCard 4489 err := c.readPaginatedResults( 4490 path, 4491 // projects api requies the accept header to be set this way 4492 "application/vnd.github.inertia-preview+json", 4493 org, 4494 func() interface{} { 4495 return &[]ProjectCard{} 4496 }, 4497 func(obj interface{}) { 4498 cards = append(cards, *(obj.(*[]ProjectCard))...) 4499 }, 4500 ) 4501 return cards, err 4502 } 4503 4504 // GetColumnProjectCard of a specific issue or PR for a specific column in a board/project 4505 // This method requires the URL of the issue/pr to compare the issue with the content_url 4506 // field of the card. See https://developer.github.com/v3/projects/cards/#list-project-cards 4507 func (c *client) GetColumnProjectCard(org string, columnID int, issueURL string) (*ProjectCard, error) { 4508 cards, err := c.GetColumnProjectCards(org, columnID) 4509 if err != nil { 4510 return nil, err 4511 } 4512 4513 for _, card := range cards { 4514 if card.ContentURL == issueURL { 4515 return &card, nil 4516 } 4517 } 4518 return nil, nil 4519 } 4520 4521 // MoveProjectCard moves a specific project card to a specified column in the same project 4522 // 4523 // See https://developer.github.com/v3/projects/cards/#move-a-project-card 4524 func (c *client) MoveProjectCard(org string, projectCardID int, newColumnID int) error { 4525 durationLogger := c.log("MoveProjectCard", projectCardID, newColumnID) 4526 defer durationLogger() 4527 4528 reqParams := struct { 4529 Position string `json:"position"` 4530 ColumnID int `json:"column_id"` 4531 }{"top", newColumnID} 4532 4533 _, err := c.request(&request{ 4534 method: http.MethodPost, 4535 path: fmt.Sprintf("/projects/columns/cards/%d/moves", projectCardID), 4536 accept: "application/vnd.github.inertia-preview+json", 4537 org: org, 4538 requestBody: reqParams, 4539 exitCodes: []int{201}, 4540 }, nil) 4541 return err 4542 } 4543 4544 // DeleteProjectCard deletes the project card of a specific issue or PR 4545 // 4546 // See https://developer.github.com/v3/projects/cards/#delete-a-project-card 4547 func (c *client) DeleteProjectCard(org string, projectCardID int) error { 4548 durationLogger := c.log("DeleteProjectCard", projectCardID) 4549 defer durationLogger() 4550 4551 _, err := c.request(&request{ 4552 method: http.MethodDelete, 4553 accept: "application/vnd.github+json", 4554 path: fmt.Sprintf("/projects/columns/cards/:%d", projectCardID), 4555 org: org, 4556 exitCodes: []int{204}, 4557 }, nil) 4558 return err 4559 } 4560 4561 // TeamHasMember checks if a user belongs to a team 4562 // Deprecated: use TeamBySlugHasMember 4563 func (c *client) TeamHasMember(org string, teamID int, memberLogin string) (bool, error) { 4564 durationLogger := c.log("TeamHasMember", teamID, memberLogin) 4565 defer durationLogger() 4566 4567 projectMaintainers, err := c.ListTeamMembers(org, teamID, RoleAll) 4568 if err != nil { 4569 return false, err 4570 } 4571 for _, person := range projectMaintainers { 4572 if NormLogin(person.Login) == NormLogin(memberLogin) { 4573 return true, nil 4574 } 4575 } 4576 return false, nil 4577 } 4578 4579 func (c *client) TeamBySlugHasMember(org string, teamSlug string, memberLogin string) (bool, error) { 4580 durationLogger := c.log("TeamBySlugHasMember", teamSlug, org) 4581 defer durationLogger() 4582 4583 if c.fake { 4584 return false, nil 4585 } 4586 exitCode, err := c.request(&request{ 4587 method: http.MethodGet, 4588 path: fmt.Sprintf("/orgs/%s/teams/%s/memberships/%s", org, teamSlug, memberLogin), 4589 org: org, 4590 exitCodes: []int{200, 404}, 4591 }, nil) 4592 return exitCode == 200, err 4593 } 4594 4595 // GetTeamBySlug returns information about that team 4596 // 4597 // See https://developer.github.com/v3/teams/#get-team-by-name 4598 func (c *client) GetTeamBySlug(slug string, org string) (*Team, error) { 4599 durationLogger := c.log("GetTeamBySlug", slug, org) 4600 defer durationLogger() 4601 4602 if c.fake { 4603 return &Team{}, nil 4604 } 4605 var team Team 4606 _, err := c.request(&request{ 4607 method: http.MethodGet, 4608 path: fmt.Sprintf("/orgs/%s/teams/%s", org, slug), 4609 org: org, 4610 exitCodes: []int{200}, 4611 }, &team) 4612 if err != nil { 4613 return nil, err 4614 } 4615 return &team, err 4616 } 4617 4618 // ListCheckRuns lists all checkruns for the given ref 4619 // 4620 // See https://docs.github.com/en/rest/checks/runs#list-check-runs-for-a-git-reference 4621 func (c *client) ListCheckRuns(org, repo, ref string) (*CheckRunList, error) { 4622 durationLogger := c.log("ListCheckRuns", org, repo, ref) 4623 defer durationLogger() 4624 4625 var checkRunList CheckRunList 4626 if err := c.readPaginatedResults( 4627 fmt.Sprintf("/repos/%s/%s/commits/%s/check-runs", org, repo, ref), 4628 "", 4629 org, 4630 func() interface{} { 4631 return &CheckRunList{} 4632 }, 4633 func(obj interface{}) { 4634 cr := *(obj.(*CheckRunList)) 4635 cr.CheckRuns = append(checkRunList.CheckRuns, cr.CheckRuns...) 4636 checkRunList = cr 4637 }, 4638 ); err != nil { 4639 return nil, err 4640 } 4641 return &checkRunList, nil 4642 } 4643 4644 // CreateCheckRun Creates a new check run for a specific commit in a repository. 4645 // 4646 // See https://docs.github.com/en/rest/checks/runs#create-a-check-run 4647 func (c *client) CreateCheckRun(org, repo string, checkRun CheckRun) error { 4648 durationLogger := c.log("CreateCheckRun", org, repo, checkRun) 4649 defer durationLogger() 4650 _, err := c.request(&request{ 4651 method: http.MethodPost, 4652 path: fmt.Sprintf("/repos/%s/%s/check-runs", org, repo), 4653 org: org, 4654 requestBody: &checkRun, 4655 exitCodes: []int{201}, 4656 }, nil) 4657 if err != nil { 4658 return err 4659 } 4660 return nil 4661 } 4662 4663 // Simple function to check if GitHub App Authentication is being used 4664 func (c *client) UsesAppAuth() bool { 4665 return c.delegate.usesAppsAuth 4666 } 4667 4668 // ListAppInstallations lists the installations for the current app. Will not work with 4669 // a Personal Access Token. 4670 // 4671 // See https://docs.github.com/en/free-pro-team@latest/rest/reference/apps#list-installations-for-the-authenticated-app 4672 func (c *client) ListAppInstallations() ([]AppInstallation, error) { 4673 durationLogger := c.log("AppInstallation") 4674 defer durationLogger() 4675 4676 var ais []AppInstallation 4677 if err := c.readPaginatedResults( 4678 "/app/installations", 4679 acceptNone, 4680 "", 4681 func() interface{} { 4682 return &[]AppInstallation{} 4683 }, 4684 func(obj interface{}) { 4685 ais = append(ais, *(obj.(*[]AppInstallation))...) 4686 }, 4687 ); err != nil { 4688 return nil, err 4689 } 4690 return ais, nil 4691 } 4692 4693 // IsAppInstalled returns true if there is an app installation for the provided org and repo 4694 // Will not work with a Personal Access Token. 4695 // 4696 // See https://docs.github.com/en/rest/apps/apps#get-a-repository-installation-for-the-authenticated-app 4697 func (c *client) IsAppInstalled(org, repo string) (bool, error) { 4698 durationLogger := c.log("IsAppInstalled", org, repo) 4699 defer durationLogger() 4700 4701 if c.dry { 4702 return false, fmt.Errorf("not getting AppInstallation in dry-run mode") 4703 } 4704 if !c.usesAppsAuth { 4705 return false, fmt.Errorf("IsAppInstalled was called when not using appsAuth") 4706 } 4707 4708 code, err := c.request(&request{ 4709 method: http.MethodGet, 4710 path: fmt.Sprintf("/repos/%s/%s/installation", org, repo), 4711 org: org, 4712 exitCodes: []int{200, 404}, 4713 }, nil) 4714 if err != nil { 4715 return false, err 4716 } 4717 4718 return code == 200, nil 4719 } 4720 4721 // ListAppInstallationsForOrg lists the installations for an organisation. 4722 // The requestor must be an organization owner with admin:read scope 4723 // 4724 // See https://docs.github.com/en/rest/orgs/orgs#list-app-installations-for-an-organization 4725 func (c *client) ListAppInstallationsForOrg(org string) ([]AppInstallation, error) { 4726 durationLogger := c.log("AppInstallationForOrg") 4727 defer durationLogger() 4728 4729 var ais []AppInstallation 4730 if err := c.readPaginatedResults( 4731 fmt.Sprintf("/orgs/%s/installations", org), 4732 acceptNone, 4733 org, 4734 func() interface{} { 4735 return &AppInstallationList{} 4736 }, 4737 func(obj interface{}) { 4738 ais = append(ais, obj.(*AppInstallationList).Installations...) 4739 }, 4740 ); err != nil { 4741 return nil, err 4742 } 4743 return ais, nil 4744 } 4745 4746 func (c *client) getAppInstallationToken(installationId int64) (*AppInstallationToken, error) { 4747 durationLogger := c.log("AppInstallationToken") 4748 defer durationLogger() 4749 4750 if c.dry { 4751 return nil, fmt.Errorf("not requesting GitHub App access_token in dry-run mode") 4752 } 4753 4754 var token AppInstallationToken 4755 if _, err := c.request(&request{ 4756 method: http.MethodPost, 4757 path: fmt.Sprintf("/app/installations/%d/access_tokens", installationId), 4758 exitCodes: []int{201}, 4759 }, &token); err != nil { 4760 return nil, err 4761 } 4762 4763 return &token, nil 4764 } 4765 4766 // GetApp gets the current app. Will not work with a Personal Access Token. 4767 func (c *client) GetApp() (*App, error) { 4768 return c.GetAppWithContext(context.Background()) 4769 } 4770 4771 func (c *client) GetAppWithContext(ctx context.Context) (*App, error) { 4772 durationLogger := c.log("App") 4773 defer durationLogger() 4774 4775 var app App 4776 if _, err := c.requestWithContext(ctx, &request{ 4777 method: http.MethodGet, 4778 path: "/app", 4779 exitCodes: []int{200}, 4780 }, &app); err != nil { 4781 return nil, err 4782 } 4783 4784 return &app, nil 4785 } 4786 4787 // GetDirectory uses GitHub repo contents API to retrieve the content of a directory with commit SHA. 4788 // If commit is empty, it will grab content from repo's default branch, usually master. 4789 // 4790 // See https://developer.github.com/v3/repos/contents/#get-contents 4791 func (c *client) GetDirectory(org, repo, dirpath, commit string) ([]DirectoryContent, error) { 4792 durationLogger := c.log("GetDirectory", org, repo, dirpath, commit) 4793 defer durationLogger() 4794 4795 path := fmt.Sprintf("/repos/%s/%s/contents/%s", org, repo, dirpath) 4796 if commit != "" { 4797 path = fmt.Sprintf("%s?ref=%s", path, url.QueryEscape(commit)) 4798 } 4799 4800 var res []DirectoryContent 4801 code, err := c.request(&request{ 4802 method: http.MethodGet, 4803 path: path, 4804 org: org, 4805 exitCodes: []int{200, 404}, 4806 }, &res) 4807 4808 if err != nil { 4809 return nil, err 4810 } 4811 4812 if code == 404 { 4813 return nil, &FileNotFound{ 4814 org: org, 4815 repo: repo, 4816 path: dirpath, 4817 commit: commit, 4818 } 4819 } 4820 4821 return res, nil 4822 } 4823 4824 // CreatePullRequestReviewComment creates a review comment on a PR. 4825 // 4826 // See also: https://docs.github.com/en/rest/reference/pulls#create-a-review-comment-for-a-pull-request 4827 func (c *client) CreatePullRequestReviewComment(org, repo string, number int, rc ReviewComment) error { 4828 c.log("CreatePullRequestReviewComment", org, repo, number, rc) 4829 4830 // TODO: remove custom Accept headers when their respective API fully launches. 4831 acceptHeaders := []string{ 4832 // https://developer.github.com/changes/2016-05-12-reactions-api-preview/ 4833 "application/vnd.github.squirrel-girl-preview", 4834 // https://developer.github.com/changes/2019-10-03-multi-line-comments/ 4835 "application/vnd.github.comfort-fade-preview+json", 4836 } 4837 4838 _, err := c.request(&request{ 4839 method: http.MethodPost, 4840 accept: strings.Join(acceptHeaders, ", "), 4841 path: fmt.Sprintf("/repos/%s/%s/pulls/%d/comments", org, repo, number), 4842 org: org, 4843 requestBody: &rc, 4844 exitCodes: []int{201}, 4845 }, nil) 4846 return err 4847 }