cli/pkg/cmd/release/create/http.go
Kynan Ware a4f96d29e3 Refactor workflow scope checking
Refactor the logic for checking `workflow` scope checking in releases to be in the positive - check if the scope is there, not check if it isn't there. Then, when the function is called we invert it.

Also update comments to be more imperative.

This refactor also incorporates @andyfeller's suggestion to use `slices`.

Co-Authored-By: Andy Feller <andyfeller@github.com>
2024-11-23 13:17:09 -07:00

297 lines
7.7 KiB
Go

package create
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"slices"
"strings"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/api"
"github.com/cli/cli/v2/internal/ghinstance"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/pkg/cmd/release/shared"
"github.com/shurcooL/githubv4"
ghauth "github.com/cli/go-gh/v2/pkg/auth"
)
type tag struct {
Name string `json:"name"`
}
type releaseNotes struct {
Name string `json:"name"`
Body string `json:"body"`
}
var notImplementedError = errors.New("not implemented")
func remoteTagExists(httpClient *http.Client, repo ghrepo.Interface, tagName string) (bool, error) {
gql := api.NewClientFromHTTP(httpClient)
qualifiedTagName := fmt.Sprintf("refs/tags/%s", tagName)
var query struct {
Repository struct {
Ref struct {
ID string
} `graphql:"ref(qualifiedName: $tagName)"`
} `graphql:"repository(owner: $owner, name: $name)"`
}
variables := map[string]interface{}{
"owner": githubv4.String(repo.RepoOwner()),
"name": githubv4.String(repo.RepoName()),
"tagName": githubv4.String(qualifiedTagName),
}
err := gql.Query(repo.RepoHost(), "RepositoryFindRef", &query, variables)
return query.Repository.Ref.ID != "", err
}
func getTags(httpClient *http.Client, repo ghrepo.Interface, limit int) ([]tag, error) {
path := fmt.Sprintf("repos/%s/%s/tags?per_page=%d", repo.RepoOwner(), repo.RepoName(), limit)
url := ghinstance.RESTPrefix(repo.RepoHost()) + path
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json; charset=utf-8")
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
success := resp.StatusCode >= 200 && resp.StatusCode < 300
if !success {
return nil, api.HandleHTTPError(resp)
}
b, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var tags []tag
err = json.Unmarshal(b, &tags)
return tags, err
}
func generateReleaseNotes(httpClient *http.Client, repo ghrepo.Interface, tagName, target, previousTagName string) (*releaseNotes, error) {
params := map[string]interface{}{
"tag_name": tagName,
}
if target != "" {
params["target_commitish"] = target
}
if previousTagName != "" {
params["previous_tag_name"] = previousTagName
}
bodyBytes, err := json.Marshal(params)
if err != nil {
return nil, err
}
path := fmt.Sprintf("repos/%s/%s/releases/generate-notes", repo.RepoOwner(), repo.RepoName())
url := ghinstance.RESTPrefix(repo.RepoHost()) + path
req, err := http.NewRequest("POST", url, bytes.NewBuffer(bodyBytes))
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/vnd.github.v3+json")
req.Header.Set("Content-Type", "application/json; charset=utf-8")
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode == 404 {
return nil, notImplementedError
}
success := resp.StatusCode >= 200 && resp.StatusCode < 300
if !success {
return nil, api.HandleHTTPError(resp)
}
b, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var rn releaseNotes
err = json.Unmarshal(b, &rn)
return &rn, err
}
func publishedReleaseExists(httpClient *http.Client, repo ghrepo.Interface, tagName string) (bool, error) {
path := fmt.Sprintf("repos/%s/%s/releases/tags/%s", repo.RepoOwner(), repo.RepoName(), url.PathEscape(tagName))
url := ghinstance.RESTPrefix(repo.RepoHost()) + path
req, err := http.NewRequest("HEAD", url, nil)
if err != nil {
return false, err
}
resp, err := httpClient.Do(req)
if err != nil {
return false, err
}
if resp.Body != nil {
defer resp.Body.Close()
}
if resp.StatusCode == 200 {
return true, nil
} else if resp.StatusCode == 404 {
return false, nil
} else {
return false, api.HandleHTTPError(resp)
}
}
func createRelease(httpClient *http.Client, repo ghrepo.Interface, params map[string]interface{}) (*shared.Release, error) {
bodyBytes, err := json.Marshal(params)
if err != nil {
return nil, err
}
path := fmt.Sprintf("repos/%s/%s/releases", repo.RepoOwner(), repo.RepoName())
url := ghinstance.RESTPrefix(repo.RepoHost()) + path
req, err := http.NewRequest("POST", url, bytes.NewBuffer(bodyBytes))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json; charset=utf-8")
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
// Check if we received a 404 while attempting to create a release without
// the workflow scope, and if so, return an error message that explains a possible
// solution to the user.
//
// If the same file (with both the same path and contents) exists
// on another branch in the repo, releases with workflow file changes can be
// created without the workflow scope. Otherwise, the workflow scope is
// required to create the release, but the API does not indicate this criteria
// beyond returning a 404.
//
// https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/scopes-for-oauth-apps#available-scopes
if resp.StatusCode == http.StatusNotFound && !tokenHasWorkflowScope(resp) {
normalizedHostname := ghauth.NormalizeHostname(resp.Request.URL.Hostname())
errMissingRequiredWorkflowScope := errors.New(heredoc.Docf(`
HTTP 404: Failed to create release, "workflow" scope may be required
To request it, run gh auth refresh -h %[1]s -s workflow
`, normalizedHostname))
return nil, errMissingRequiredWorkflowScope
}
success := resp.StatusCode >= 200 && resp.StatusCode < 300
if !success {
return nil, api.HandleHTTPError(resp)
}
b, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var newRelease shared.Release
err = json.Unmarshal(b, &newRelease)
return &newRelease, err
}
func publishRelease(httpClient *http.Client, releaseURL string, discussionCategory string, isLatest *bool) (*shared.Release, error) {
params := map[string]interface{}{"draft": false}
if discussionCategory != "" {
params["discussion_category_name"] = discussionCategory
}
if isLatest != nil {
params["make_latest"] = fmt.Sprintf("%v", *isLatest)
}
bodyBytes, err := json.Marshal(params)
if err != nil {
return nil, err
}
req, err := http.NewRequest("PATCH", releaseURL, bytes.NewBuffer(bodyBytes))
if err != nil {
return nil, err
}
req.Header.Add("Content-Type", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode > 299 {
return nil, api.HandleHTTPError(resp)
}
b, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var release shared.Release
err = json.Unmarshal(b, &release)
return &release, err
}
func deleteRelease(httpClient *http.Client, release *shared.Release) error {
req, err := http.NewRequest("DELETE", release.APIURL, nil)
if err != nil {
return err
}
resp, err := httpClient.Do(req)
if err != nil {
return err
}
if resp.Body != nil {
defer resp.Body.Close()
}
success := resp.StatusCode >= 200 && resp.StatusCode < 300
if !success {
return api.HandleHTTPError(resp)
}
if resp.StatusCode != 204 {
_, _ = io.Copy(io.Discard, resp.Body)
}
return nil
}
// tokenHasWorkflowScope checks if the given http.Response's token has the workflow scope.
// Tokens that do not have OAuth scopes are assumed to have the workflow scope.
func tokenHasWorkflowScope(resp *http.Response) bool {
scopes := resp.Header.Get("X-Oauth-Scopes")
// Return true when no scopes are present - no scopes in this header
// means that the user is probably authenticating with a token type other
// than an OAuth token, and we don't know what this token's scopes actually are.
if scopes == "" {
return true
}
return slices.Contains(strings.Split(scopes, ","), "workflow")
}