package github import ( "bytes" "encoding/json" "errors" "fmt" "io" "io/ioutil" "net/http" "net/url" "os" "os/exec" "path" "path/filepath" "sort" "strings" "time" "github.com/github/hub/version" ) const ( GitHubHost string = "github.com" OAuthAppURL string = "https://hub.github.com/" ) var UserAgent = "Hub " + version.Version func NewClient(h string) *Client { return NewClientWithHost(&Host{Host: h}) } func NewClientWithHost(host *Host) *Client { return &Client{Host: host} } type Client struct { Host *Host cachedClient *simpleClient } type Gist struct { Files map[string]GistFile `json:"files"` Description string `json:"description,omitempty"` Id string `json:"id,omitempty"` Public bool `json:"public"` HtmlUrl string `json:"html_url"` } type GistFile struct { Type string `json:"type,omitempty"` Language string `json:"language,omitempty"` Content string `json:"content"` RawUrl string `json:"raw_url"` } func (client *Client) FetchPullRequests(project *Project, filterParams map[string]interface{}, limit int, filter func(*PullRequest) bool) (pulls []PullRequest, err error) { api, err := client.simpleApi() if err != nil { return } path := fmt.Sprintf("repos/%s/%s/pulls?per_page=%d", project.Owner, project.Name, perPage(limit, 100)) if filterParams != nil { path = addQuery(path, filterParams) } pulls = []PullRequest{} var res *simpleResponse for path != "" { res, err = api.GetFile(path, draftsType) if err = checkStatus(200, "fetching pull requests", res, err); err != nil { return } path = res.Link("next") pullsPage := []PullRequest{} if err = res.Unmarshal(&pullsPage); err != nil { return } for _, pr := range pullsPage { if filter == nil || filter(&pr) { pulls = append(pulls, pr) if limit > 0 && len(pulls) == limit { path = "" break } } } } return } func (client *Client) PullRequest(project *Project, id string) (pr *PullRequest, err error) { api, err := client.simpleApi() if err != nil { return } res, err := api.Get(fmt.Sprintf("repos/%s/%s/pulls/%s", project.Owner, project.Name, id)) if err = checkStatus(200, "getting pull request", res, err); err != nil { return } pr = &PullRequest{} err = res.Unmarshal(pr) return } func (client *Client) PullRequestPatch(project *Project, id string) (patch io.ReadCloser, err error) { api, err := client.simpleApi() if err != nil { return } res, err := api.GetFile(fmt.Sprintf("repos/%s/%s/pulls/%s", project.Owner, project.Name, id), patchMediaType) if err = checkStatus(200, "getting pull request patch", res, err); err != nil { return } return res.Body, nil } func (client *Client) CreatePullRequest(project *Project, params map[string]interface{}) (pr *PullRequest, err error) { api, err := client.simpleApi() if err != nil { return } res, err := api.PostJSONPreview(fmt.Sprintf("repos/%s/%s/pulls", project.Owner, project.Name), params, draftsType) if err = checkStatus(201, "creating pull request", res, err); err != nil { if res != nil && res.StatusCode == 404 { projectUrl := strings.SplitN(project.WebURL("", "", ""), "://", 2)[1] err = fmt.Errorf("%s\nAre you sure that %s exists?", err, projectUrl) } return } pr = &PullRequest{} err = res.Unmarshal(pr) return } func (client *Client) RequestReview(project *Project, prNumber int, params map[string]interface{}) (err error) { api, err := client.simpleApi() if err != nil { return } res, err := api.PostJSON(fmt.Sprintf("repos/%s/%s/pulls/%d/requested_reviewers", project.Owner, project.Name, prNumber), params) if err = checkStatus(201, "requesting reviewer", res, err); err != nil { return } res.Body.Close() return } func (client *Client) CommitPatch(project *Project, sha string) (patch io.ReadCloser, err error) { api, err := client.simpleApi() if err != nil { return } res, err := api.GetFile(fmt.Sprintf("repos/%s/%s/commits/%s", project.Owner, project.Name, sha), patchMediaType) if err = checkStatus(200, "getting commit patch", res, err); err != nil { return } return res.Body, nil } func (client *Client) GistPatch(id string) (patch io.ReadCloser, err error) { api, err := client.simpleApi() if err != nil { return } res, err := api.Get(fmt.Sprintf("gists/%s", id)) if err = checkStatus(200, "getting gist patch", res, err); err != nil { return } gist := Gist{} if err = res.Unmarshal(&gist); err != nil { return } rawUrl := "" for _, file := range gist.Files { rawUrl = file.RawUrl break } res, err = api.GetFile(rawUrl, textMediaType) if err = checkStatus(200, "getting gist patch", res, err); err != nil { return } return res.Body, nil } func (client *Client) Repository(project *Project) (repo *Repository, err error) { api, err := client.simpleApi() if err != nil { return } res, err := api.Get(fmt.Sprintf("repos/%s/%s", project.Owner, project.Name)) if err = checkStatus(200, "getting repository info", res, err); err != nil { return } repo = &Repository{} err = res.Unmarshal(&repo) return } func (client *Client) CreateRepository(project *Project, description, homepage string, isPrivate bool) (repo *Repository, err error) { repoURL := "user/repos" if project.Owner != client.Host.User { repoURL = fmt.Sprintf("orgs/%s/repos", project.Owner) } params := map[string]interface{}{ "name": project.Name, "description": description, "homepage": homepage, "private": isPrivate, } api, err := client.simpleApi() if err != nil { return } res, err := api.PostJSON(repoURL, params) if err = checkStatus(201, "creating repository", res, err); err != nil { return } repo = &Repository{} err = res.Unmarshal(repo) return } func (client *Client) DeleteRepository(project *Project) error { api, err := client.simpleApi() if err != nil { return err } repoURL := fmt.Sprintf("repos/%s/%s", project.Owner, project.Name) res, err := api.Delete(repoURL) return checkStatus(204, "deleting repository", res, err) } type Release struct { Name string `json:"name"` TagName string `json:"tag_name"` TargetCommitish string `json:"target_commitish"` Body string `json:"body"` Draft bool `json:"draft"` Prerelease bool `json:"prerelease"` Assets []ReleaseAsset `json:"assets"` TarballUrl string `json:"tarball_url"` ZipballUrl string `json:"zipball_url"` HtmlUrl string `json:"html_url"` UploadUrl string `json:"upload_url"` ApiUrl string `json:"url"` CreatedAt time.Time `json:"created_at"` PublishedAt time.Time `json:"published_at"` } type ReleaseAsset struct { Name string `json:"name"` Label string `json:"label"` DownloadUrl string `json:"browser_download_url"` ApiUrl string `json:"url"` } func (client *Client) FetchReleases(project *Project, limit int, filter func(*Release) bool) (releases []Release, err error) { api, err := client.simpleApi() if err != nil { return } path := fmt.Sprintf("repos/%s/%s/releases?per_page=%d", project.Owner, project.Name, perPage(limit, 100)) releases = []Release{} var res *simpleResponse for path != "" { res, err = api.Get(path) if err = checkStatus(200, "fetching releases", res, err); err != nil { return } path = res.Link("next") releasesPage := []Release{} if err = res.Unmarshal(&releasesPage); err != nil { return } for _, release := range releasesPage { if filter == nil || filter(&release) { releases = append(releases, release) if limit > 0 && len(releases) == limit { path = "" break } } } } return } func (client *Client) FetchRelease(project *Project, tagName string) (*Release, error) { releases, err := client.FetchReleases(project, 100, func(release *Release) bool { return release.TagName == tagName }) if err == nil { if len(releases) < 1 { return nil, fmt.Errorf("Unable to find release with tag name `%s'", tagName) } else { return &releases[0], nil } } else { return nil, err } } func (client *Client) CreateRelease(project *Project, releaseParams *Release) (release *Release, err error) { api, err := client.simpleApi() if err != nil { return } res, err := api.PostJSON(fmt.Sprintf("repos/%s/%s/releases", project.Owner, project.Name), releaseParams) if err = checkStatus(201, "creating release", res, err); err != nil { return } release = &Release{} err = res.Unmarshal(release) return } func (client *Client) EditRelease(release *Release, releaseParams map[string]interface{}) (updatedRelease *Release, err error) { api, err := client.simpleApi() if err != nil { return } res, err := api.PatchJSON(release.ApiUrl, releaseParams) if err = checkStatus(200, "editing release", res, err); err != nil { return } updatedRelease = &Release{} err = res.Unmarshal(updatedRelease) return } func (client *Client) DeleteRelease(release *Release) (err error) { api, err := client.simpleApi() if err != nil { return } res, err := api.Delete(release.ApiUrl) if err = checkStatus(204, "deleting release", res, err); err != nil { return } return } type LocalAsset struct { Name string Label string Contents io.Reader Size int64 } func (client *Client) UploadReleaseAssets(release *Release, assets []LocalAsset) (doneAssets []*ReleaseAsset, err error) { api, err := client.simpleApi() if err != nil { return } idx := strings.Index(release.UploadUrl, "{") uploadURL := release.UploadUrl[0:idx] for _, asset := range assets { for _, existingAsset := range release.Assets { if existingAsset.Name == asset.Name { if err = client.DeleteReleaseAsset(&existingAsset); err != nil { return } break } } params := map[string]interface{}{"name": filepath.Base(asset.Name)} if asset.Label != "" { params["label"] = asset.Label } uploadPath := addQuery(uploadURL, params) var res *simpleResponse attempts := 0 maxAttempts := 3 body := asset.Contents for { res, err = api.PostFile(uploadPath, body, asset.Size) if err == nil && res.StatusCode >= 500 && res.StatusCode < 600 && attempts < maxAttempts { attempts++ time.Sleep(time.Second * time.Duration(attempts)) var f *os.File f, err = os.Open(asset.Name) if err != nil { return } defer f.Close() body = f continue } if err = checkStatus(201, "uploading release asset", res, err); err != nil { return } break } newAsset := ReleaseAsset{} err = res.Unmarshal(&newAsset) if err != nil { return } doneAssets = append(doneAssets, &newAsset) } return } func (client *Client) DeleteReleaseAsset(asset *ReleaseAsset) (err error) { api, err := client.simpleApi() if err != nil { return } res, err := api.Delete(asset.ApiUrl) err = checkStatus(204, "deleting release asset", res, err) return } func (client *Client) DownloadReleaseAsset(url string) (asset io.ReadCloser, err error) { api, err := client.simpleApi() if err != nil { return } resp, err := api.GetFile(url, "application/octet-stream") if err = checkStatus(200, "downloading asset", resp, err); err != nil { return } return resp.Body, err } type CIStatusResponse struct { State string `json:"state"` Statuses []CIStatus `json:"statuses"` } type CIStatus struct { State string `json:"state"` Context string `json:"context"` TargetUrl string `json:"target_url"` } type CheckRunsResponse struct { CheckRuns []CheckRun `json:"check_runs"` } type CheckRun struct { Status string `json:"status"` Conclusion string `json:"conclusion"` Name string `json:"name"` HtmlUrl string `json:"html_url"` } func (client *Client) FetchCIStatus(project *Project, sha string) (status *CIStatusResponse, err error) { api, err := client.simpleApi() if err != nil { return } res, err := api.Get(fmt.Sprintf("repos/%s/%s/commits/%s/status", project.Owner, project.Name, sha)) if err = checkStatus(200, "fetching statuses", res, err); err != nil { return } status = &CIStatusResponse{} if err = res.Unmarshal(status); err != nil { return } sortStatuses := func() { sort.Slice(status.Statuses, func(a, b int) bool { sA := status.Statuses[a] sB := status.Statuses[b] cmp := strings.Compare(strings.ToLower(sA.Context), strings.ToLower(sB.Context)) if cmp == 0 { return strings.Compare(sA.TargetUrl, sB.TargetUrl) < 0 } else { return cmp < 0 } }) } sortStatuses() res, err = api.GetFile(fmt.Sprintf("repos/%s/%s/commits/%s/check-runs", project.Owner, project.Name, sha), checksType) if err == nil && (res.StatusCode == 403 || res.StatusCode == 404 || res.StatusCode == 422) { return } if err = checkStatus(200, "fetching checks", res, err); err != nil { return } checks := &CheckRunsResponse{} if err = res.Unmarshal(checks); err != nil { return } for _, checkRun := range checks.CheckRuns { state := "pending" if checkRun.Status == "completed" { state = checkRun.Conclusion } checkStatus := CIStatus{ State: state, Context: checkRun.Name, TargetUrl: checkRun.HtmlUrl, } status.Statuses = append(status.Statuses, checkStatus) } sortStatuses() return } type Repository struct { Name string `json:"name"` FullName string `json:"full_name"` Parent *Repository `json:"parent"` Owner *User `json:"owner"` Private bool `json:"private"` HasWiki bool `json:"has_wiki"` Permissions *RepositoryPermissions `json:"permissions"` HtmlUrl string `json:"html_url"` DefaultBranch string `json:"default_branch"` } type RepositoryPermissions struct { Admin bool `json:"admin"` Push bool `json:"push"` Pull bool `json:"pull"` } func (client *Client) ForkRepository(project *Project, params map[string]interface{}) (repo *Repository, err error) { api, err := client.simpleApi() if err != nil { return } res, err := api.PostJSON(fmt.Sprintf("repos/%s/%s/forks", project.Owner, project.Name), params) if err = checkStatus(202, "creating fork", res, err); err != nil { return } repo = &Repository{} err = res.Unmarshal(repo) return } type Comment struct { Id int `json:"id"` Body string `json:"body"` User *User `json:"user"` CreatedAt time.Time `json:"created_at"` } type Issue struct { Number int `json:"number"` State string `json:"state"` Title string `json:"title"` Body string `json:"body"` User *User `json:"user"` PullRequest *PullRequest `json:"pull_request"` Head *PullRequestSpec `json:"head"` Base *PullRequestSpec `json:"base"` MergeCommitSha string `json:"merge_commit_sha"` MaintainerCanModify bool `json:"maintainer_can_modify"` Draft bool `json:"draft"` Comments int `json:"comments"` Labels []IssueLabel `json:"labels"` Assignees []User `json:"assignees"` Milestone *Milestone `json:"milestone"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` MergedAt time.Time `json:"merged_at"` RequestedReviewers []User `json:"requested_reviewers"` RequestedTeams []Team `json:"requested_teams"` ApiUrl string `json:"url"` HtmlUrl string `json:"html_url"` ClosedBy *User `json:"closed_by"` } type PullRequest Issue type PullRequestSpec struct { Label string `json:"label"` Ref string `json:"ref"` Sha string `json:"sha"` Repo *Repository `json:"repo"` } func (pr *PullRequest) IsSameRepo() bool { return pr.Head != nil && pr.Head.Repo != nil && pr.Head.Repo.Name == pr.Base.Repo.Name && pr.Head.Repo.Owner.Login == pr.Base.Repo.Owner.Login } func (pr *PullRequest) HasRequestedReviewer(name string) bool { for _, user := range pr.RequestedReviewers { if strings.EqualFold(user.Login, name) { return true } } return false } func (pr *PullRequest) HasRequestedTeam(name string) bool { for _, team := range pr.RequestedTeams { if strings.EqualFold(team.Slug, name) { return true } } return false } type IssueLabel struct { Name string `json:"name"` Color string `json:"color"` } type User struct { Login string `json:"login"` } type Team struct { Name string `json:"name"` Slug string `json:"slug"` } type Milestone struct { Number int `json:"number"` Title string `json:"title"` } func (client *Client) FetchIssues(project *Project, filterParams map[string]interface{}, limit int, filter func(*Issue) bool) (issues []Issue, err error) { api, err := client.simpleApi() if err != nil { return } path := fmt.Sprintf("repos/%s/%s/issues?per_page=%d", project.Owner, project.Name, perPage(limit, 100)) if filterParams != nil { path = addQuery(path, filterParams) } issues = []Issue{} var res *simpleResponse for path != "" { res, err = api.Get(path) if err = checkStatus(200, "fetching issues", res, err); err != nil { return } path = res.Link("next") issuesPage := []Issue{} if err = res.Unmarshal(&issuesPage); err != nil { return } for _, issue := range issuesPage { if filter == nil || filter(&issue) { issues = append(issues, issue) if limit > 0 && len(issues) == limit { path = "" break } } } } return } func (client *Client) FetchIssue(project *Project, number string) (issue *Issue, err error) { api, err := client.simpleApi() if err != nil { return } res, err := api.Get(fmt.Sprintf("repos/%s/%s/issues/%s", project.Owner, project.Name, number)) if err = checkStatus(200, "fetching issue", res, err); err != nil { return nil, err } issue = &Issue{} err = res.Unmarshal(issue) return } func (client *Client) FetchComments(project *Project, number string) (comments []Comment, err error) { api, err := client.simpleApi() if err != nil { return } res, err := api.Get(fmt.Sprintf("repos/%s/%s/issues/%s/comments", project.Owner, project.Name, number)) if err = checkStatus(200, "fetching comments for issue", res, err); err != nil { return nil, err } comments = []Comment{} err = res.Unmarshal(&comments) return } func (client *Client) CreateIssue(project *Project, params interface{}) (issue *Issue, err error) { api, err := client.simpleApi() if err != nil { return } res, err := api.PostJSON(fmt.Sprintf("repos/%s/%s/issues", project.Owner, project.Name), params) if err = checkStatus(201, "creating issue", res, err); err != nil { return } issue = &Issue{} err = res.Unmarshal(issue) return } func (client *Client) UpdateIssue(project *Project, issueNumber int, params map[string]interface{}) (err error) { api, err := client.simpleApi() if err != nil { return } res, err := api.PatchJSON(fmt.Sprintf("repos/%s/%s/issues/%d", project.Owner, project.Name, issueNumber), params) if err = checkStatus(200, "updating issue", res, err); err != nil { return } res.Body.Close() return } type sortedLabels []IssueLabel func (s sortedLabels) Len() int { return len(s) } func (s sortedLabels) Swap(i, j int) { s[i], s[j] = s[j], s[i] } func (s sortedLabels) Less(i, j int) bool { return strings.Compare(strings.ToLower(s[i].Name), strings.ToLower(s[j].Name)) < 0 } func (client *Client) FetchLabels(project *Project) (labels []IssueLabel, err error) { api, err := client.simpleApi() if err != nil { return } path := fmt.Sprintf("repos/%s/%s/labels?per_page=100", project.Owner, project.Name) labels = []IssueLabel{} var res *simpleResponse for path != "" { res, err = api.Get(path) if err = checkStatus(200, "fetching labels", res, err); err != nil { return } path = res.Link("next") labelsPage := []IssueLabel{} if err = res.Unmarshal(&labelsPage); err != nil { return } labels = append(labels, labelsPage...) } sort.Sort(sortedLabels(labels)) return } func (client *Client) FetchMilestones(project *Project) (milestones []Milestone, err error) { api, err := client.simpleApi() if err != nil { return } path := fmt.Sprintf("repos/%s/%s/milestones?per_page=100", project.Owner, project.Name) milestones = []Milestone{} var res *simpleResponse for path != "" { res, err = api.Get(path) if err = checkStatus(200, "fetching milestones", res, err); err != nil { return } path = res.Link("next") milestonesPage := []Milestone{} if err = res.Unmarshal(&milestonesPage); err != nil { return } milestones = append(milestones, milestonesPage...) } return } func (client *Client) GenericAPIRequest(method, path string, data interface{}, headers map[string]string, ttl int) (*simpleResponse, error) { api, err := client.simpleApi() if err != nil { return nil, err } api.CacheTTL = ttl var body io.Reader switch d := data.(type) { case map[string]interface{}: if method == "GET" { path = addQuery(path, d) } else if len(d) > 0 { json, err := json.Marshal(d) if err != nil { return nil, err } body = bytes.NewBuffer(json) } case io.Reader: body = d } return api.performRequest(method, path, body, func(req *http.Request) { if body != nil { req.Header.Set("Content-Type", "application/json; charset=utf-8") } for key, value := range headers { req.Header.Set(key, value) } }) } // GraphQL facilitates performing a GraphQL request and parsing the response func (client *Client) GraphQL(query string, variables interface{}, data interface{}) error { api, err := client.simpleApi() if err != nil { return err } payload := map[string]interface{}{ "query": query, "variables": variables, } resp, err := api.PostJSON("graphql", payload) if err = checkStatus(200, "performing GraphQL", resp, err); err != nil { return err } responseData := struct { Data interface{} Errors []struct { Message string } }{ Data: data, } err = resp.Unmarshal(&responseData) if err != nil { return err } if len(responseData.Errors) > 0 { messages := []string{} for _, e := range responseData.Errors { messages = append(messages, e.Message) } return fmt.Errorf("API error: %s", strings.Join(messages, "; ")) } return nil } func (client *Client) CurrentUser() (user *User, err error) { api, err := client.simpleApi() if err != nil { return } res, err := api.Get("user") if err = checkStatus(200, "getting current user", res, err); err != nil { return } user = &User{} err = res.Unmarshal(user) return } type AuthorizationEntry struct { Token string `json:"token"` } func isToken(api *simpleClient, password string) bool { api.PrepareRequest = func(req *http.Request) { req.Header.Set("Authorization", "token "+password) } res, _ := api.Get("user") if res != nil && res.StatusCode == 200 { return true } return false } func (client *Client) FindOrCreateToken(user, password, twoFactorCode string) (token string, err error) { api := client.apiClient() if len(password) >= 40 && isToken(api, password) { return password, nil } params := map[string]interface{}{ "scopes": []string{"repo", "gist"}, "note_url": OAuthAppURL, } api.PrepareRequest = func(req *http.Request) { req.SetBasicAuth(user, password) if twoFactorCode != "" { req.Header.Set("X-GitHub-OTP", twoFactorCode) } } count := 1 maxTries := 9 for { params["note"], err = authTokenNote(count) if err != nil { return } res, postErr := api.PostJSON("authorizations", params) if postErr != nil { err = postErr break } if res.StatusCode == 201 { auth := &AuthorizationEntry{} if err = res.Unmarshal(auth); err != nil { return } token = auth.Token break } else if res.StatusCode == 422 && count < maxTries { count++ } else { errInfo, e := res.ErrorInfo() if e == nil { err = errInfo } else { err = e } return } } return } func (client *Client) ensureAccessToken() error { if client.Host.AccessToken == "" { host, err := CurrentConfig().PromptForHost(client.Host.Host) if err != nil { return err } client.Host = host } return nil } func (client *Client) simpleApi() (c *simpleClient, err error) { err = client.ensureAccessToken() if err != nil { return } if client.cachedClient != nil { c = client.cachedClient return } c = client.apiClient() c.PrepareRequest = func(req *http.Request) { clientDomain := normalizeHost(client.Host.Host) if strings.HasPrefix(clientDomain, "api.github.") { clientDomain = strings.TrimPrefix(clientDomain, "api.") } requestHost := strings.ToLower(req.URL.Host) if requestHost == clientDomain || strings.HasSuffix(requestHost, "."+clientDomain) { req.Header.Set("Authorization", "token "+client.Host.AccessToken) } } client.cachedClient = c return } func (client *Client) apiClient() *simpleClient { unixSocket := os.ExpandEnv(client.Host.UnixSocket) httpClient := newHttpClient(os.Getenv("HUB_TEST_HOST"), os.Getenv("HUB_VERBOSE") != "", unixSocket) apiRoot := client.absolute(normalizeHost(client.Host.Host)) if !strings.HasPrefix(apiRoot.Host, "api.github.") { apiRoot.Path = "/api/v3/" } return &simpleClient{ httpClient: httpClient, rootUrl: apiRoot, } } func (client *Client) absolute(host string) *url.URL { u, err := url.Parse("https://" + host + "/") if err != nil { panic(err) } else if client.Host != nil && client.Host.Protocol != "" { u.Scheme = client.Host.Protocol } return u } func (client *Client) FetchGist(id string) (gist *Gist, err error) { api, err := client.simpleApi() if err != nil { return } response, err := api.Get(fmt.Sprintf("gists/%s", id)) if err = checkStatus(200, "getting gist", response, err); err != nil { return } response.Unmarshal(&gist) return } func (client *Client) CreateGist(filenames []string, public bool) (gist *Gist, err error) { api, err := client.simpleApi() if err != nil { return } files := map[string]GistFile{} var basename string var content []byte var gf GistFile for _, file := range filenames { if file == "-" { content, err = ioutil.ReadAll(os.Stdin) basename = "gistfile1.txt" } else { content, err = ioutil.ReadFile(file) basename = path.Base(file) } if err != nil { return } gf = GistFile{Content: string(content)} files[basename] = gf } g := Gist{ Files: files, Public: public, } res, err := api.PostJSON("gists", &g) if err = checkStatus(201, "creating gist", res, err); err != nil { return } err = res.Unmarshal(&gist) return } func normalizeHost(host string) string { if host == "" { return GitHubHost } else if strings.EqualFold(host, GitHubHost) { return "api.github.com" } else if strings.EqualFold(host, "github.localhost") { return "api.github.localhost" } else { return strings.ToLower(host) } } func reverseNormalizeHost(host string) string { switch host { case "api.github.com": return GitHubHost case "api.github.localhost": return "github.localhost" default: return host } } func checkStatus(expectedStatus int, action string, response *simpleResponse, err error) error { if err != nil { errStr := err.Error() if urlErr, isURLErr := err.(*url.Error); isURLErr { errStr = fmt.Sprintf("%s %s: %s", urlErr.Op, urlErr.URL, urlErr.Err) } return fmt.Errorf("Error %s: %s", action, errStr) } else if response.StatusCode != expectedStatus { errInfo, err := response.ErrorInfo() if err != nil { return fmt.Errorf("Error %s: %s (HTTP %d)", action, err.Error(), response.StatusCode) } return FormatError(action, errInfo) } return nil } // FormatError annotates an HTTP response error with user-friendly messages func FormatError(action string, err error) error { if e, ok := err.(*errorInfo); ok { return formatError(action, e) } return err } func formatError(action string, e *errorInfo) error { var reason string if s := strings.SplitN(e.Response.Status, " ", 2); len(s) >= 2 { reason = strings.TrimSpace(s[1]) } errStr := fmt.Sprintf("Error %s: %s (HTTP %d)", action, reason, e.Response.StatusCode) var errorSentences []string for _, err := range e.Errors { switch err.Code { case "custom": errorSentences = append(errorSentences, err.Message) case "missing_field": errorSentences = append(errorSentences, fmt.Sprintf("Missing field: \"%s\"", err.Field)) case "already_exists": errorSentences = append(errorSentences, fmt.Sprintf("Duplicate value for \"%s\"", err.Field)) case "invalid": errorSentences = append(errorSentences, fmt.Sprintf("Invalid value for \"%s\"", err.Field)) case "unauthorized": errorSentences = append(errorSentences, fmt.Sprintf("Not allowed to change field \"%s\"", err.Field)) } } var errorMessage string if len(errorSentences) > 0 { errorMessage = strings.Join(errorSentences, "\n") } else { errorMessage = e.Message if action == "getting current user" && e.Message == "Resource not accessible by integration" { errorMessage = errorMessage + "\nYou must specify GITHUB_USER via environment variable." } } if errorMessage != "" { errStr = fmt.Sprintf("%s\n%s", errStr, errorMessage) } if ssoErr := ValidateGitHubSSO(e.Response); ssoErr != nil { return fmt.Errorf("%s\n%s", errStr, ssoErr) } if scopeErr := ValidateSufficientOAuthScopes(e.Response); scopeErr != nil { return fmt.Errorf("%s\n%s", errStr, scopeErr) } return errors.New(errStr) } // ValidateGitHubSSO checks for the challenge via `X-Github-Sso` header func ValidateGitHubSSO(res *http.Response) error { if res.StatusCode != 403 { return nil } sso := res.Header.Get("X-Github-Sso") if !strings.HasPrefix(sso, "required; url=") { return nil } url := sso[strings.IndexByte(sso, '=')+1:] return fmt.Errorf("You must authorize your token to access this organization:\n%s", url) } // ValidateSufficientOAuthScopes warns about insufficient OAuth scopes func ValidateSufficientOAuthScopes(res *http.Response) error { if res.StatusCode != 404 && res.StatusCode != 403 { return nil } needScopes := newScopeSet(res.Header.Get("X-Accepted-Oauth-Scopes")) if len(needScopes) == 0 && isGistWrite(res.Request) { // compensate for a GitHub bug: gist APIs omit proper `X-Accepted-Oauth-Scopes` in responses needScopes = newScopeSet("gist") } haveScopes := newScopeSet(res.Header.Get("X-Oauth-Scopes")) if len(needScopes) == 0 || needScopes.Intersects(haveScopes) { return nil } return fmt.Errorf("Your access token may have insufficient scopes. Visit %s://%s/settings/tokens\n"+ "to edit the 'hub' token and enable one of the following scopes: %s", res.Request.URL.Scheme, reverseNormalizeHost(res.Request.Host), needScopes) } func isGistWrite(req *http.Request) bool { if req.Method == "GET" { return false } path := strings.TrimPrefix(req.URL.Path, "/v3") return strings.HasPrefix(path, "/gists") } type scopeSet map[string]struct{} func (s scopeSet) String() string { scopes := make([]string, 0, len(s)) for scope := range s { scopes = append(scopes, scope) } sort.Sort(sort.StringSlice(scopes)) return strings.Join(scopes, ", ") } func (s scopeSet) Intersects(other scopeSet) bool { for scope := range s { if _, found := other[scope]; found { return true } } return false } func newScopeSet(s string) scopeSet { scopes := scopeSet{} for _, s := range strings.SplitN(s, ",", -1) { if s = strings.TrimSpace(s); s != "" { scopes[s] = struct{}{} } } return scopes } func authTokenNote(num int) (string, error) { n := os.Getenv("USER") if n == "" { n = os.Getenv("USERNAME") } if n == "" { whoami := exec.Command("whoami") whoamiOut, err := whoami.Output() if err != nil { return "", err } n = strings.TrimSpace(string(whoamiOut)) } h, err := os.Hostname() if err != nil { return "", err } if num > 1 { return fmt.Sprintf("hub for %s@%s %d", n, h, num), nil } return fmt.Sprintf("hub for %s@%s", n, h), nil } func perPage(limit, max int) int { if limit > 0 { limit = limit + (limit / 2) if limit < max { return limit } } return max } func addQuery(path string, params map[string]interface{}) string { if len(params) == 0 { return path } query := url.Values{} for key, value := range params { switch v := value.(type) { case string: query.Add(key, v) case nil: query.Add(key, "") case int: query.Add(key, fmt.Sprintf("%d", v)) case bool: query.Add(key, fmt.Sprintf("%v", v)) } } sep := "?" if strings.Contains(path, sep) { sep = "&" } return path + sep + query.Encode() }