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  }