github.com/ungtb10d/cli/v2@v2.0.0-20221110210412-98537dd9d6a1/internal/codespaces/api/api.go (about) 1 package api 2 3 // For descriptions of service interfaces, see: 4 // - https://online.visualstudio.com/api/swagger (for visualstudio.com) 5 // - https://docs.github.com/en/rest/reference/repos (for api.github.com) 6 // - https://github.com/github/github/blob/master/app/api/codespaces.rb (for vscs_internal) 7 // TODO(adonovan): replace the last link with a public doc URL when available. 8 9 // TODO(adonovan): a possible reorganization would be to split this 10 // file into three internal packages, one per backend service, and to 11 // rename api.API to github.Client: 12 // 13 // - github.GetUser(github.Client) 14 // - github.GetRepository(Client) 15 // - github.ReadFile(Client, nwo, branch, path) // was GetCodespaceRepositoryContents 16 // - codespaces.Create(Client, user, repo, sku, branch, location) 17 // - codespaces.Delete(Client, user, token, name) 18 // - codespaces.Get(Client, token, owner, name) 19 // - codespaces.GetMachineTypes(Client, user, repo, branch, location) 20 // - codespaces.GetToken(Client, login, name) 21 // - codespaces.List(Client, user) 22 // - codespaces.Start(Client, token, codespace) 23 // - visualstudio.GetRegionLocation(http.Client) // no dependency on github 24 // 25 // This would make the meaning of each operation clearer. 26 27 import ( 28 "bytes" 29 "context" 30 "encoding/base64" 31 "encoding/json" 32 "errors" 33 "fmt" 34 "io" 35 "net/http" 36 "net/url" 37 "reflect" 38 "regexp" 39 "strconv" 40 "strings" 41 "time" 42 43 "github.com/ungtb10d/cli/v2/api" 44 "github.com/opentracing/opentracing-go" 45 ) 46 47 const ( 48 githubServer = "https://github.com" 49 githubAPI = "https://api.github.com" 50 vscsAPI = "https://online.visualstudio.com" 51 ) 52 53 const ( 54 VSCSTargetLocal = "local" 55 VSCSTargetDevelopment = "development" 56 VSCSTargetPPE = "ppe" 57 VSCSTargetProduction = "production" 58 ) 59 60 // API is the interface to the codespace service. 61 type API struct { 62 client httpClient 63 vscsAPI string 64 githubAPI string 65 githubServer string 66 retryBackoff time.Duration 67 } 68 69 type httpClient interface { 70 Do(req *http.Request) (*http.Response, error) 71 } 72 73 // New creates a new API client connecting to the configured endpoints with the HTTP client. 74 func New(serverURL, apiURL, vscsURL string, httpClient httpClient) *API { 75 if serverURL == "" { 76 serverURL = githubServer 77 } 78 if apiURL == "" { 79 apiURL = githubAPI 80 } 81 if vscsURL == "" { 82 vscsURL = vscsAPI 83 } 84 return &API{ 85 client: httpClient, 86 vscsAPI: strings.TrimSuffix(vscsURL, "/"), 87 githubAPI: strings.TrimSuffix(apiURL, "/"), 88 githubServer: strings.TrimSuffix(serverURL, "/"), 89 retryBackoff: 100 * time.Millisecond, 90 } 91 } 92 93 // User represents a GitHub user. 94 type User struct { 95 Login string `json:"login"` 96 Type string `json:"type"` 97 } 98 99 // GetUser returns the user associated with the given token. 100 func (a *API) GetUser(ctx context.Context) (*User, error) { 101 req, err := http.NewRequest(http.MethodGet, a.githubAPI+"/user", nil) 102 if err != nil { 103 return nil, fmt.Errorf("error creating request: %w", err) 104 } 105 106 a.setHeaders(req) 107 resp, err := a.do(ctx, req, "/user") 108 if err != nil { 109 return nil, fmt.Errorf("error making request: %w", err) 110 } 111 defer resp.Body.Close() 112 113 if resp.StatusCode != http.StatusOK { 114 return nil, api.HandleHTTPError(resp) 115 } 116 117 b, err := io.ReadAll(resp.Body) 118 if err != nil { 119 return nil, fmt.Errorf("error reading response body: %w", err) 120 } 121 122 var response User 123 if err := json.Unmarshal(b, &response); err != nil { 124 return nil, fmt.Errorf("error unmarshaling response: %w", err) 125 } 126 127 return &response, nil 128 } 129 130 // Repository represents a GitHub repository. 131 type Repository struct { 132 ID int `json:"id"` 133 FullName string `json:"full_name"` 134 DefaultBranch string `json:"default_branch"` 135 } 136 137 // GetRepository returns the repository associated with the given owner and name. 138 func (a *API) GetRepository(ctx context.Context, nwo string) (*Repository, error) { 139 req, err := http.NewRequest(http.MethodGet, a.githubAPI+"/repos/"+strings.ToLower(nwo), nil) 140 if err != nil { 141 return nil, fmt.Errorf("error creating request: %w", err) 142 } 143 144 a.setHeaders(req) 145 resp, err := a.do(ctx, req, "/repos/*") 146 if err != nil { 147 return nil, fmt.Errorf("error making request: %w", err) 148 } 149 defer resp.Body.Close() 150 151 if resp.StatusCode != http.StatusOK { 152 return nil, api.HandleHTTPError(resp) 153 } 154 155 b, err := io.ReadAll(resp.Body) 156 if err != nil { 157 return nil, fmt.Errorf("error reading response body: %w", err) 158 } 159 160 var response Repository 161 if err := json.Unmarshal(b, &response); err != nil { 162 return nil, fmt.Errorf("error unmarshaling response: %w", err) 163 } 164 165 return &response, nil 166 } 167 168 // Codespace represents a codespace. 169 // You can see more about the fields in this type in the codespaces api docs: 170 // https://docs.github.com/en/rest/reference/codespaces 171 type Codespace struct { 172 Name string `json:"name"` 173 CreatedAt string `json:"created_at"` 174 DisplayName string `json:"display_name"` 175 LastUsedAt string `json:"last_used_at"` 176 Owner User `json:"owner"` 177 Repository Repository `json:"repository"` 178 State string `json:"state"` 179 GitStatus CodespaceGitStatus `json:"git_status"` 180 Connection CodespaceConnection `json:"connection"` 181 Machine CodespaceMachine `json:"machine"` 182 VSCSTarget string `json:"vscs_target"` 183 PendingOperation bool `json:"pending_operation"` 184 PendingOperationDisabledReason string `json:"pending_operation_disabled_reason"` 185 IdleTimeoutNotice string `json:"idle_timeout_notice"` 186 WebURL string `json:"web_url"` 187 } 188 189 type CodespaceGitStatus struct { 190 Ahead int `json:"ahead"` 191 Behind int `json:"behind"` 192 Ref string `json:"ref"` 193 HasUnpushedChanges bool `json:"has_unpushed_changes"` 194 HasUncommitedChanges bool `json:"has_uncommited_changes"` 195 } 196 197 type CodespaceMachine struct { 198 Name string `json:"name"` 199 DisplayName string `json:"display_name"` 200 OperatingSystem string `json:"operating_system"` 201 StorageInBytes uint64 `json:"storage_in_bytes"` 202 MemoryInBytes uint64 `json:"memory_in_bytes"` 203 CPUCount int `json:"cpus"` 204 } 205 206 const ( 207 // CodespaceStateAvailable is the state for a running codespace environment. 208 CodespaceStateAvailable = "Available" 209 // CodespaceStateShutdown is the state for a shutdown codespace environment. 210 CodespaceStateShutdown = "Shutdown" 211 // CodespaceStateStarting is the state for a starting codespace environment. 212 CodespaceStateStarting = "Starting" 213 // CodespaceStateRebuilding is the state for a rebuilding codespace environment. 214 CodespaceStateRebuilding = "Rebuilding" 215 ) 216 217 type CodespaceConnection struct { 218 SessionID string `json:"sessionId"` 219 SessionToken string `json:"sessionToken"` 220 RelayEndpoint string `json:"relayEndpoint"` 221 RelaySAS string `json:"relaySas"` 222 HostPublicKeys []string `json:"hostPublicKeys"` 223 } 224 225 // CodespaceFields is the list of exportable fields for a codespace. 226 var CodespaceFields = []string{ 227 "displayName", 228 "name", 229 "owner", 230 "repository", 231 "state", 232 "gitStatus", 233 "createdAt", 234 "lastUsedAt", 235 "machineName", 236 "vscsTarget", 237 } 238 239 func (c *Codespace) ExportData(fields []string) map[string]interface{} { 240 v := reflect.ValueOf(c).Elem() 241 data := map[string]interface{}{} 242 243 for _, f := range fields { 244 switch f { 245 case "owner": 246 data[f] = c.Owner.Login 247 case "repository": 248 data[f] = c.Repository.FullName 249 case "machineName": 250 data[f] = c.Machine.Name 251 case "gitStatus": 252 data[f] = map[string]interface{}{ 253 "ref": c.GitStatus.Ref, 254 "hasUnpushedChanges": c.GitStatus.HasUnpushedChanges, 255 "hasUncommitedChanges": c.GitStatus.HasUncommitedChanges, 256 } 257 case "vscsTarget": 258 if c.VSCSTarget != "" && c.VSCSTarget != VSCSTargetProduction { 259 data[f] = c.VSCSTarget 260 } 261 default: 262 sf := v.FieldByNameFunc(func(s string) bool { 263 return strings.EqualFold(f, s) 264 }) 265 data[f] = sf.Interface() 266 } 267 } 268 269 return data 270 } 271 272 type ListCodespacesOptions struct { 273 OrgName string 274 UserName string 275 RepoName string 276 Limit int 277 } 278 279 // ListCodespaces returns a list of codespaces for the user. Pass a negative limit to request all pages from 280 // the API until all codespaces have been fetched. 281 func (a *API) ListCodespaces(ctx context.Context, opts ListCodespacesOptions) (codespaces []*Codespace, err error) { 282 var ( 283 perPage = 100 284 limit = opts.Limit 285 ) 286 287 if limit > 0 && limit < 100 { 288 perPage = limit 289 } 290 291 var ( 292 listURL string 293 spanName string 294 ) 295 296 if opts.RepoName != "" { 297 listURL = fmt.Sprintf("%s/repos/%s/codespaces?per_page=%d", a.githubAPI, opts.RepoName, perPage) 298 spanName = "/repos/*/codespaces" 299 } else if opts.OrgName != "" { 300 // the endpoints below can only be called by the organization admins 301 orgName := opts.OrgName 302 if opts.UserName != "" { 303 userName := opts.UserName 304 listURL = fmt.Sprintf("%s/orgs/%s/members/%s/codespaces?per_page=%d", a.githubAPI, orgName, userName, perPage) 305 spanName = "/orgs/*/members/*/codespaces" 306 } else { 307 listURL = fmt.Sprintf("%s/orgs/%s/codespaces?per_page=%d", a.githubAPI, orgName, perPage) 308 spanName = "/orgs/*/codespaces" 309 } 310 } else { 311 listURL = fmt.Sprintf("%s/user/codespaces?per_page=%d", a.githubAPI, perPage) 312 spanName = "/user/codespaces" 313 } 314 315 for { 316 req, err := http.NewRequest(http.MethodGet, listURL, nil) 317 if err != nil { 318 return nil, fmt.Errorf("error creating request: %w", err) 319 } 320 a.setHeaders(req) 321 322 resp, err := a.do(ctx, req, spanName) 323 if err != nil { 324 return nil, fmt.Errorf("error making request: %w", err) 325 } 326 defer resp.Body.Close() 327 328 if resp.StatusCode != http.StatusOK { 329 return nil, api.HandleHTTPError(resp) 330 } 331 332 var response struct { 333 Codespaces []*Codespace `json:"codespaces"` 334 } 335 336 dec := json.NewDecoder(resp.Body) 337 if err := dec.Decode(&response); err != nil { 338 return nil, fmt.Errorf("error unmarshaling response: %w", err) 339 } 340 341 nextURL := findNextPage(resp.Header.Get("Link")) 342 codespaces = append(codespaces, response.Codespaces...) 343 344 if nextURL == "" || (limit > 0 && len(codespaces) >= limit) { 345 break 346 } 347 348 if newPerPage := limit - len(codespaces); limit > 0 && newPerPage < 100 { 349 u, _ := url.Parse(nextURL) 350 q := u.Query() 351 q.Set("per_page", strconv.Itoa(newPerPage)) 352 u.RawQuery = q.Encode() 353 listURL = u.String() 354 } else { 355 listURL = nextURL 356 } 357 } 358 359 return codespaces, nil 360 } 361 362 var linkRE = regexp.MustCompile(`<([^>]+)>;\s*rel="([^"]+)"`) 363 364 func findNextPage(linkValue string) string { 365 for _, m := range linkRE.FindAllStringSubmatch(linkValue, -1) { 366 if len(m) > 2 && m[2] == "next" { 367 return m[1] 368 } 369 } 370 return "" 371 } 372 373 func (a *API) GetOrgMemberCodespace(ctx context.Context, orgName string, userName string, codespaceName string) (*Codespace, error) { 374 perPage := 100 375 listURL := fmt.Sprintf("%s/orgs/%s/members/%s/codespaces?per_page=%d", a.githubAPI, orgName, userName, perPage) 376 377 for { 378 req, err := http.NewRequest(http.MethodGet, listURL, nil) 379 if err != nil { 380 return nil, fmt.Errorf("error creating request: %w", err) 381 } 382 a.setHeaders(req) 383 384 resp, err := a.do(ctx, req, "/orgs/*/members/*/codespaces") 385 if err != nil { 386 return nil, fmt.Errorf("error making request: %w", err) 387 } 388 defer resp.Body.Close() 389 390 if resp.StatusCode != http.StatusOK { 391 return nil, api.HandleHTTPError(resp) 392 } 393 394 var response struct { 395 Codespaces []*Codespace `json:"codespaces"` 396 } 397 398 dec := json.NewDecoder(resp.Body) 399 if err := dec.Decode(&response); err != nil { 400 return nil, fmt.Errorf("error unmarshaling response: %w", err) 401 } 402 403 for _, cs := range response.Codespaces { 404 if cs.Name == codespaceName { 405 return cs, nil 406 } 407 } 408 409 nextURL := findNextPage(resp.Header.Get("Link")) 410 if nextURL == "" { 411 break 412 } 413 listURL = nextURL 414 } 415 416 return nil, fmt.Errorf("codespace not found for user %s with name %s", userName, codespaceName) 417 } 418 419 // GetCodespace returns the user codespace based on the provided name. 420 // If the codespace is not found, an error is returned. 421 // If includeConnection is true, it will return the connection information for the codespace. 422 func (a *API) GetCodespace(ctx context.Context, codespaceName string, includeConnection bool) (*Codespace, error) { 423 resp, err := a.withRetry(func() (*http.Response, error) { 424 req, err := http.NewRequest( 425 http.MethodGet, 426 a.githubAPI+"/user/codespaces/"+codespaceName, 427 nil, 428 ) 429 if err != nil { 430 return nil, fmt.Errorf("error creating request: %w", err) 431 } 432 if includeConnection { 433 q := req.URL.Query() 434 q.Add("internal", "true") 435 q.Add("refresh", "true") 436 req.URL.RawQuery = q.Encode() 437 } 438 a.setHeaders(req) 439 return a.do(ctx, req, "/user/codespaces/*") 440 }) 441 if err != nil { 442 return nil, fmt.Errorf("error making request: %w", err) 443 } 444 defer resp.Body.Close() 445 446 if resp.StatusCode != http.StatusOK { 447 return nil, api.HandleHTTPError(resp) 448 } 449 450 b, err := io.ReadAll(resp.Body) 451 if err != nil { 452 return nil, fmt.Errorf("error reading response body: %w", err) 453 } 454 455 var response Codespace 456 if err := json.Unmarshal(b, &response); err != nil { 457 return nil, fmt.Errorf("error unmarshaling response: %w", err) 458 } 459 460 return &response, nil 461 } 462 463 // StartCodespace starts a codespace for the user. 464 // If the codespace is already running, the returned error from the API is ignored. 465 func (a *API) StartCodespace(ctx context.Context, codespaceName string) error { 466 resp, err := a.withRetry(func() (*http.Response, error) { 467 req, err := http.NewRequest( 468 http.MethodPost, 469 a.githubAPI+"/user/codespaces/"+codespaceName+"/start", 470 nil, 471 ) 472 if err != nil { 473 return nil, fmt.Errorf("error creating request: %w", err) 474 } 475 a.setHeaders(req) 476 return a.do(ctx, req, "/user/codespaces/*/start") 477 }) 478 if err != nil { 479 return fmt.Errorf("error making request: %w", err) 480 } 481 defer resp.Body.Close() 482 483 if resp.StatusCode != http.StatusOK { 484 if resp.StatusCode == http.StatusConflict { 485 // 409 means the codespace is already running which we can safely ignore 486 return nil 487 } 488 return api.HandleHTTPError(resp) 489 } 490 491 return nil 492 } 493 494 func (a *API) StopCodespace(ctx context.Context, codespaceName string, orgName string, userName string) error { 495 var stopURL string 496 var spanName string 497 498 if orgName != "" { 499 stopURL = fmt.Sprintf("%s/orgs/%s/members/%s/codespaces/%s/stop", a.githubAPI, orgName, userName, codespaceName) 500 spanName = "/orgs/*/members/*/codespaces/*/stop" 501 } else { 502 stopURL = fmt.Sprintf("%s/user/codespaces/%s/stop", a.githubAPI, codespaceName) 503 spanName = "/user/codespaces/*/stop" 504 } 505 506 req, err := http.NewRequest(http.MethodPost, stopURL, nil) 507 if err != nil { 508 return fmt.Errorf("error creating request: %w", err) 509 } 510 511 a.setHeaders(req) 512 resp, err := a.do(ctx, req, spanName) 513 if err != nil { 514 return fmt.Errorf("error making request: %w", err) 515 } 516 defer resp.Body.Close() 517 518 if resp.StatusCode != http.StatusOK { 519 return api.HandleHTTPError(resp) 520 } 521 522 return nil 523 } 524 525 type Machine struct { 526 Name string `json:"name"` 527 DisplayName string `json:"display_name"` 528 PrebuildAvailability string `json:"prebuild_availability"` 529 } 530 531 // GetCodespacesMachines returns the codespaces machines for the given repo, branch and location. 532 func (a *API) GetCodespacesMachines(ctx context.Context, repoID int, branch, location string, devcontainerPath string) ([]*Machine, error) { 533 reqURL := fmt.Sprintf("%s/repositories/%d/codespaces/machines", a.githubAPI, repoID) 534 req, err := http.NewRequest(http.MethodGet, reqURL, nil) 535 if err != nil { 536 return nil, fmt.Errorf("error creating request: %w", err) 537 } 538 539 q := req.URL.Query() 540 q.Add("location", location) 541 q.Add("ref", branch) 542 q.Add("devcontainer_path", devcontainerPath) 543 req.URL.RawQuery = q.Encode() 544 545 a.setHeaders(req) 546 resp, err := a.do(ctx, req, "/repositories/*/codespaces/machines") 547 if err != nil { 548 return nil, fmt.Errorf("error making request: %w", err) 549 } 550 defer resp.Body.Close() 551 552 if resp.StatusCode != http.StatusOK { 553 return nil, api.HandleHTTPError(resp) 554 } 555 556 b, err := io.ReadAll(resp.Body) 557 if err != nil { 558 return nil, fmt.Errorf("error reading response body: %w", err) 559 } 560 561 var response struct { 562 Machines []*Machine `json:"machines"` 563 } 564 if err := json.Unmarshal(b, &response); err != nil { 565 return nil, fmt.Errorf("error unmarshaling response: %w", err) 566 } 567 568 return response.Machines, nil 569 } 570 571 // RepoSearchParameters are the optional parameters for searching for repositories. 572 type RepoSearchParameters struct { 573 // The maximum number of repos to return. At most 100 repos are returned even if this value is greater than 100. 574 MaxRepos int 575 // The sort order for returned repos. Possible values are 'stars', 'forks', 'help-wanted-issues', or 'updated'. If empty the API's default ordering is used. 576 Sort string 577 } 578 579 // GetCodespaceRepoSuggestions searches for and returns repo names based on the provided search text. 580 func (a *API) GetCodespaceRepoSuggestions(ctx context.Context, partialSearch string, parameters RepoSearchParameters) ([]string, error) { 581 reqURL := fmt.Sprintf("%s/search/repositories", a.githubAPI) 582 req, err := http.NewRequest(http.MethodGet, reqURL, nil) 583 if err != nil { 584 return nil, fmt.Errorf("error creating request: %w", err) 585 } 586 587 parts := strings.SplitN(partialSearch, "/", 2) 588 589 var nameSearch string 590 if len(parts) == 2 { 591 user := parts[0] 592 repo := parts[1] 593 nameSearch = fmt.Sprintf("%s user:%s", repo, user) 594 } else { 595 /* 596 * This results in searching for the text within the owner or the name. It's possible to 597 * do an owner search and then look up some repos for those owners, but that adds a 598 * good amount of latency to the fetch which slows down showing the suggestions. 599 */ 600 nameSearch = partialSearch 601 } 602 603 queryStr := fmt.Sprintf("%s in:name", nameSearch) 604 605 q := req.URL.Query() 606 q.Add("q", queryStr) 607 608 if len(parameters.Sort) > 0 { 609 q.Add("sort", parameters.Sort) 610 } 611 612 if parameters.MaxRepos > 0 { 613 q.Add("per_page", strconv.Itoa(parameters.MaxRepos)) 614 } 615 616 req.URL.RawQuery = q.Encode() 617 618 a.setHeaders(req) 619 resp, err := a.do(ctx, req, "/search/repositories/*") 620 if err != nil { 621 return nil, fmt.Errorf("error searching repositories: %w", err) 622 } 623 defer resp.Body.Close() 624 625 if resp.StatusCode != http.StatusOK { 626 return nil, api.HandleHTTPError(resp) 627 } 628 629 b, err := io.ReadAll(resp.Body) 630 if err != nil { 631 return nil, fmt.Errorf("error reading response body: %w", err) 632 } 633 634 var response struct { 635 Items []*Repository `json:"items"` 636 } 637 if err := json.Unmarshal(b, &response); err != nil { 638 return nil, fmt.Errorf("error unmarshaling response: %w", err) 639 } 640 641 repoNames := make([]string, len(response.Items)) 642 for i, repo := range response.Items { 643 repoNames[i] = repo.FullName 644 } 645 646 return repoNames, nil 647 } 648 649 // GetCodespaceBillableOwner returns the billable owner and expected default values for 650 // codespaces created by the user for a given repository. 651 func (a *API) GetCodespaceBillableOwner(ctx context.Context, nwo string) (*User, error) { 652 req, err := http.NewRequest(http.MethodGet, a.githubAPI+"/repos/"+nwo+"/codespaces/new", nil) 653 if err != nil { 654 return nil, fmt.Errorf("error creating request: %w", err) 655 } 656 657 a.setHeaders(req) 658 resp, err := a.do(ctx, req, "/repos/*/codespaces/new") 659 if err != nil { 660 return nil, fmt.Errorf("error making request: %w", err) 661 } 662 defer resp.Body.Close() 663 664 if resp.StatusCode == http.StatusNotFound { 665 return nil, nil 666 } else if resp.StatusCode == http.StatusForbidden { 667 return nil, fmt.Errorf("you cannot create codespaces with that repository") 668 } else if resp.StatusCode != http.StatusOK { 669 return nil, api.HandleHTTPError(resp) 670 } 671 672 b, err := io.ReadAll(resp.Body) 673 if err != nil { 674 return nil, fmt.Errorf("error reading response body: %w", err) 675 } 676 677 var response struct { 678 BillableOwner User `json:"billable_owner"` 679 Defaults struct { 680 DevcontainerPath string `json:"devcontainer_path"` 681 Location string `json:"location"` 682 } 683 } 684 if err := json.Unmarshal(b, &response); err != nil { 685 return nil, fmt.Errorf("error unmarshaling response: %w", err) 686 } 687 688 // While this response contains further helpful information ahead of codespace creation, 689 // we're only referencing the billable owner today. 690 return &response.BillableOwner, nil 691 } 692 693 // CreateCodespaceParams are the required parameters for provisioning a Codespace. 694 type CreateCodespaceParams struct { 695 RepositoryID int 696 IdleTimeoutMinutes int 697 RetentionPeriodMinutes *int 698 Branch string 699 Machine string 700 Location string 701 DevContainerPath string 702 VSCSTarget string 703 VSCSTargetURL string 704 PermissionsOptOut bool 705 } 706 707 // CreateCodespace creates a codespace with the given parameters and returns a non-nil error if it 708 // fails to create. 709 func (a *API) CreateCodespace(ctx context.Context, params *CreateCodespaceParams) (*Codespace, error) { 710 codespace, err := a.startCreate(ctx, params) 711 if !errors.Is(err, errProvisioningInProgress) { 712 return codespace, err 713 } 714 715 // errProvisioningInProgress indicates that codespace creation did not complete 716 // within the GitHub API RPC time limit (10s), so it continues asynchronously. 717 // We must poll the server to discover the outcome. 718 ctx, cancel := context.WithTimeout(ctx, 2*time.Minute) 719 defer cancel() 720 721 ticker := time.NewTicker(1 * time.Second) 722 defer ticker.Stop() 723 724 for { 725 select { 726 case <-ctx.Done(): 727 return nil, ctx.Err() 728 case <-ticker.C: 729 codespace, err = a.GetCodespace(ctx, codespace.Name, false) 730 if err != nil { 731 return nil, fmt.Errorf("failed to get codespace: %w", err) 732 } 733 734 // we continue to poll until the codespace shows as provisioned 735 if codespace.State != CodespaceStateAvailable { 736 continue 737 } 738 739 return codespace, nil 740 } 741 } 742 } 743 744 type startCreateRequest struct { 745 RepositoryID int `json:"repository_id"` 746 IdleTimeoutMinutes int `json:"idle_timeout_minutes,omitempty"` 747 RetentionPeriodMinutes *int `json:"retention_period_minutes,omitempty"` 748 Ref string `json:"ref"` 749 Location string `json:"location"` 750 Machine string `json:"machine"` 751 DevContainerPath string `json:"devcontainer_path,omitempty"` 752 VSCSTarget string `json:"vscs_target,omitempty"` 753 VSCSTargetURL string `json:"vscs_target_url,omitempty"` 754 PermissionsOptOut bool `json:"multi_repo_permissions_opt_out"` 755 } 756 757 var errProvisioningInProgress = errors.New("provisioning in progress") 758 759 type AcceptPermissionsRequiredError struct { 760 Message string `json:"message"` 761 AllowPermissionsURL string `json:"allow_permissions_url"` 762 } 763 764 func (e AcceptPermissionsRequiredError) Error() string { 765 return e.Message 766 } 767 768 // startCreate starts the creation of a codespace. 769 // It may return success or an error, or errProvisioningInProgress indicating that the operation 770 // did not complete before the GitHub API's time limit for RPCs (10s), in which case the caller 771 // must poll the server to learn the outcome. 772 func (a *API) startCreate(ctx context.Context, params *CreateCodespaceParams) (*Codespace, error) { 773 if params == nil { 774 return nil, errors.New("startCreate missing parameters") 775 } 776 777 requestBody, err := json.Marshal(startCreateRequest{ 778 RepositoryID: params.RepositoryID, 779 IdleTimeoutMinutes: params.IdleTimeoutMinutes, 780 RetentionPeriodMinutes: params.RetentionPeriodMinutes, 781 Ref: params.Branch, 782 Location: params.Location, 783 Machine: params.Machine, 784 DevContainerPath: params.DevContainerPath, 785 VSCSTarget: params.VSCSTarget, 786 VSCSTargetURL: params.VSCSTargetURL, 787 PermissionsOptOut: params.PermissionsOptOut, 788 }) 789 790 if err != nil { 791 return nil, fmt.Errorf("error marshaling request: %w", err) 792 } 793 794 req, err := http.NewRequest(http.MethodPost, a.githubAPI+"/user/codespaces", bytes.NewBuffer(requestBody)) 795 if err != nil { 796 return nil, fmt.Errorf("error creating request: %w", err) 797 } 798 799 a.setHeaders(req) 800 resp, err := a.do(ctx, req, "/user/codespaces") 801 if err != nil { 802 return nil, fmt.Errorf("error making request: %w", err) 803 } 804 defer resp.Body.Close() 805 806 if resp.StatusCode == http.StatusAccepted { 807 b, err := io.ReadAll(resp.Body) 808 if err != nil { 809 return nil, fmt.Errorf("error reading response body: %w", err) 810 } 811 812 var response Codespace 813 if err := json.Unmarshal(b, &response); err != nil { 814 return nil, fmt.Errorf("error unmarshaling response: %w", err) 815 } 816 817 return &response, errProvisioningInProgress // RPC finished before result of creation known 818 } else if resp.StatusCode == http.StatusUnauthorized { 819 var ( 820 ue AcceptPermissionsRequiredError 821 bodyCopy = &bytes.Buffer{} 822 r = io.TeeReader(resp.Body, bodyCopy) 823 ) 824 825 b, err := io.ReadAll(r) 826 if err != nil { 827 return nil, fmt.Errorf("error reading response body: %w", err) 828 } 829 if err := json.Unmarshal(b, &ue); err != nil { 830 return nil, fmt.Errorf("error unmarshaling response: %w", err) 831 } 832 833 if ue.AllowPermissionsURL != "" { 834 return nil, ue 835 } 836 837 resp.Body = io.NopCloser(bodyCopy) 838 839 return nil, api.HandleHTTPError(resp) 840 841 } else if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { 842 return nil, api.HandleHTTPError(resp) 843 } 844 845 b, err := io.ReadAll(resp.Body) 846 if err != nil { 847 return nil, fmt.Errorf("error reading response body: %w", err) 848 } 849 850 var response Codespace 851 if err := json.Unmarshal(b, &response); err != nil { 852 return nil, fmt.Errorf("error unmarshaling response: %w", err) 853 } 854 855 return &response, nil 856 } 857 858 // DeleteCodespace deletes the given codespace. 859 func (a *API) DeleteCodespace(ctx context.Context, codespaceName string, orgName string, userName string) error { 860 var deleteURL string 861 var spanName string 862 863 if orgName != "" && userName != "" { 864 deleteURL = fmt.Sprintf("%s/orgs/%s/members/%s/codespaces/%s", a.githubAPI, orgName, userName, codespaceName) 865 spanName = "/orgs/*/members/*/codespaces/*" 866 } else { 867 deleteURL = a.githubAPI + "/user/codespaces/" + codespaceName 868 spanName = "/user/codespaces/*" 869 } 870 871 req, err := http.NewRequest(http.MethodDelete, deleteURL, nil) 872 if err != nil { 873 return fmt.Errorf("error creating request: %w", err) 874 } 875 876 a.setHeaders(req) 877 resp, err := a.do(ctx, req, spanName) 878 if err != nil { 879 return fmt.Errorf("error making request: %w", err) 880 } 881 defer resp.Body.Close() 882 883 if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted { 884 return api.HandleHTTPError(resp) 885 } 886 887 return nil 888 } 889 890 type DevContainerEntry struct { 891 Path string `json:"path"` 892 Name string `json:"name,omitempty"` 893 } 894 895 // ListDevContainers returns a list of valid devcontainer.json files for the repo. Pass a negative limit to request all pages from 896 // the API until all devcontainer.json files have been fetched. 897 func (a *API) ListDevContainers(ctx context.Context, repoID int, branch string, limit int) (devcontainers []DevContainerEntry, err error) { 898 perPage := 100 899 if limit > 0 && limit < 100 { 900 perPage = limit 901 } 902 903 v := url.Values{} 904 v.Set("per_page", strconv.Itoa(perPage)) 905 if branch != "" { 906 v.Set("ref", branch) 907 } 908 listURL := fmt.Sprintf("%s/repositories/%d/codespaces/devcontainers?%s", a.githubAPI, repoID, v.Encode()) 909 910 for { 911 req, err := http.NewRequest(http.MethodGet, listURL, nil) 912 if err != nil { 913 return nil, fmt.Errorf("error creating request: %w", err) 914 } 915 a.setHeaders(req) 916 917 resp, err := a.do(ctx, req, fmt.Sprintf("/repositories/%d/codespaces/devcontainers", repoID)) 918 if err != nil { 919 return nil, fmt.Errorf("error making request: %w", err) 920 } 921 defer resp.Body.Close() 922 923 if resp.StatusCode != http.StatusOK { 924 return nil, api.HandleHTTPError(resp) 925 } 926 927 var response struct { 928 Devcontainers []DevContainerEntry `json:"devcontainers"` 929 } 930 931 dec := json.NewDecoder(resp.Body) 932 if err := dec.Decode(&response); err != nil { 933 return nil, fmt.Errorf("error unmarshaling response: %w", err) 934 } 935 936 nextURL := findNextPage(resp.Header.Get("Link")) 937 devcontainers = append(devcontainers, response.Devcontainers...) 938 939 if nextURL == "" || (limit > 0 && len(devcontainers) >= limit) { 940 break 941 } 942 943 if newPerPage := limit - len(devcontainers); limit > 0 && newPerPage < 100 { 944 u, _ := url.Parse(nextURL) 945 q := u.Query() 946 q.Set("per_page", strconv.Itoa(newPerPage)) 947 u.RawQuery = q.Encode() 948 listURL = u.String() 949 } else { 950 listURL = nextURL 951 } 952 } 953 954 return devcontainers, nil 955 } 956 957 type EditCodespaceParams struct { 958 DisplayName string `json:"display_name,omitempty"` 959 IdleTimeoutMinutes int `json:"idle_timeout_minutes,omitempty"` 960 Machine string `json:"machine,omitempty"` 961 } 962 963 func (a *API) EditCodespace(ctx context.Context, codespaceName string, params *EditCodespaceParams) (*Codespace, error) { 964 requestBody, err := json.Marshal(params) 965 if err != nil { 966 return nil, fmt.Errorf("error marshaling request: %w", err) 967 } 968 969 req, err := http.NewRequest(http.MethodPatch, a.githubAPI+"/user/codespaces/"+codespaceName, bytes.NewBuffer(requestBody)) 970 if err != nil { 971 return nil, fmt.Errorf("error creating request: %w", err) 972 } 973 974 a.setHeaders(req) 975 resp, err := a.do(ctx, req, "/user/codespaces/*") 976 if err != nil { 977 return nil, fmt.Errorf("error making request: %w", err) 978 } 979 defer resp.Body.Close() 980 981 if resp.StatusCode != http.StatusOK { 982 // 422 (unprocessable entity) is likely caused by the codespace having a 983 // pending op, so we'll fetch the codespace to see if that's the case 984 // and return a more understandable error message. 985 if resp.StatusCode == http.StatusUnprocessableEntity { 986 pendingOp, reason, err := a.checkForPendingOperation(ctx, codespaceName) 987 // If there's an error or there's not a pending op, we want to let 988 // this fall through to the normal api.HandleHTTPError flow 989 if err == nil && pendingOp { 990 return nil, fmt.Errorf( 991 "codespace is disabled while it has a pending operation: %s", 992 reason, 993 ) 994 } 995 } 996 return nil, api.HandleHTTPError(resp) 997 } 998 999 b, err := io.ReadAll(resp.Body) 1000 if err != nil { 1001 return nil, fmt.Errorf("error reading response body: %w", err) 1002 } 1003 1004 var response Codespace 1005 if err := json.Unmarshal(b, &response); err != nil { 1006 return nil, fmt.Errorf("error unmarshaling response: %w", err) 1007 } 1008 1009 return &response, nil 1010 } 1011 1012 func (a *API) checkForPendingOperation(ctx context.Context, codespaceName string) (bool, string, error) { 1013 codespace, err := a.GetCodespace(ctx, codespaceName, false) 1014 if err != nil { 1015 return false, "", err 1016 } 1017 return codespace.PendingOperation, codespace.PendingOperationDisabledReason, nil 1018 } 1019 1020 type getCodespaceRepositoryContentsResponse struct { 1021 Content string `json:"content"` 1022 } 1023 1024 func (a *API) GetCodespaceRepositoryContents(ctx context.Context, codespace *Codespace, path string) ([]byte, error) { 1025 req, err := http.NewRequest(http.MethodGet, a.githubAPI+"/repos/"+codespace.Repository.FullName+"/contents/"+path, nil) 1026 if err != nil { 1027 return nil, fmt.Errorf("error creating request: %w", err) 1028 } 1029 1030 q := req.URL.Query() 1031 q.Add("ref", codespace.GitStatus.Ref) 1032 req.URL.RawQuery = q.Encode() 1033 1034 a.setHeaders(req) 1035 resp, err := a.do(ctx, req, "/repos/*/contents/*") 1036 if err != nil { 1037 return nil, fmt.Errorf("error making request: %w", err) 1038 } 1039 defer resp.Body.Close() 1040 1041 if resp.StatusCode == http.StatusNotFound { 1042 return nil, nil 1043 } else if resp.StatusCode != http.StatusOK { 1044 return nil, api.HandleHTTPError(resp) 1045 } 1046 1047 b, err := io.ReadAll(resp.Body) 1048 if err != nil { 1049 return nil, fmt.Errorf("error reading response body: %w", err) 1050 } 1051 1052 var response getCodespaceRepositoryContentsResponse 1053 if err := json.Unmarshal(b, &response); err != nil { 1054 return nil, fmt.Errorf("error unmarshaling response: %w", err) 1055 } 1056 1057 decoded, err := base64.StdEncoding.DecodeString(response.Content) 1058 if err != nil { 1059 return nil, fmt.Errorf("error decoding content: %w", err) 1060 } 1061 1062 return decoded, nil 1063 } 1064 1065 // do executes the given request and returns the response. It creates an 1066 // opentracing span to track the length of the request. 1067 func (a *API) do(ctx context.Context, req *http.Request, spanName string) (*http.Response, error) { 1068 // TODO(adonovan): use NewRequestWithContext(ctx) and drop ctx parameter. 1069 span, ctx := opentracing.StartSpanFromContext(ctx, spanName) 1070 defer span.Finish() 1071 req = req.WithContext(ctx) 1072 return a.client.Do(req) 1073 } 1074 1075 // setHeaders sets the required headers for the API. 1076 func (a *API) setHeaders(req *http.Request) { 1077 req.Header.Set("Accept", "application/vnd.github.v3+json") 1078 } 1079 1080 // withRetry takes a generic function that sends an http request and retries 1081 // only when the returned response has a >=500 status code. 1082 func (a *API) withRetry(f func() (*http.Response, error)) (resp *http.Response, err error) { 1083 for i := 0; i < 5; i++ { 1084 resp, err = f() 1085 if err != nil { 1086 return nil, err 1087 } 1088 if resp.StatusCode < 500 { 1089 break 1090 } 1091 time.Sleep(a.retryBackoff * (time.Duration(i) + 1)) 1092 } 1093 return resp, err 1094 }