github.com/sudo-bmitch/version-bump@v0.0.0-20240503123857-70b0e3f646dd/internal/source/github-release.go (about)

     1  package source
     2  
     3  import (
     4  	"encoding/json"
     5  	"fmt"
     6  	"io"
     7  	"net/http"
     8  	"net/url"
     9  	"os"
    10  	"strconv"
    11  	"sync"
    12  	"time"
    13  
    14  	"github.com/sudo-bmitch/version-bump/internal/config"
    15  )
    16  
    17  const ()
    18  
    19  type ghRelease struct {
    20  	conf       config.Source
    21  	httpClient *http.Client
    22  }
    23  
    24  func newGHRelease(conf config.Source) Source {
    25  	return ghRelease{conf: conf, httpClient: http.DefaultClient}
    26  }
    27  
    28  func (g ghRelease) Get(data config.SourceTmplData) (string, error) {
    29  	confExp, err := g.conf.ExpandTemplate(data)
    30  	if err != nil {
    31  		return "", fmt.Errorf("failed to expand template: %w", err)
    32  	}
    33  	releases, err := g.getReleaseList(confExp)
    34  	if err != nil {
    35  		return "", err
    36  	}
    37  	if confExp.Args["type"] == "artifact" {
    38  		return g.getArtifact(confExp, releases)
    39  	}
    40  	return g.getReleaseName(confExp, releases)
    41  }
    42  
    43  var (
    44  	ghCache     map[string][]*GHRelease = map[string][]*GHRelease{}
    45  	ghCacheLock sync.Mutex
    46  )
    47  
    48  func (g ghRelease) getReleaseList(confExp config.Source) ([]*GHRelease, error) {
    49  	if _, ok := confExp.Args["repo"]; !ok {
    50  		return nil, fmt.Errorf("repo argument is required")
    51  	}
    52  	if releases, ok := ghCache[confExp.Args["repo"]]; ok {
    53  		return releases, nil
    54  	}
    55  	ghCacheLock.Lock()
    56  	defer ghCacheLock.Unlock()
    57  	u, err := url.Parse("https://api.github.com/repos/" + confExp.Args["repo"] + "/releases")
    58  	if err != nil {
    59  		return nil, fmt.Errorf("failed to parse api url, check repo syntax (%s should be org/proj): %w", confExp.Args["repo"], err)
    60  	}
    61  	req, err := http.NewRequest(http.MethodGet, u.String(), nil)
    62  	if err != nil {
    63  		return nil, fmt.Errorf("failed to create request: %w", err)
    64  	}
    65  	req.Header.Add("Accept", "application/json")
    66  	token := os.Getenv("GH_TOKEN")
    67  	if token == "" {
    68  		token = os.Getenv("GITHUB_TOKEN")
    69  	}
    70  	if token != "" {
    71  		req.SetBasicAuth("git", token)
    72  	}
    73  	resp, err := g.httpClient.Do(req)
    74  	if err != nil {
    75  		return nil, fmt.Errorf("failed to call releases API: %w", err)
    76  	}
    77  	defer resp.Body.Close()
    78  	if resp.StatusCode != http.StatusOK {
    79  		b, _ := io.ReadAll(resp.Body)
    80  		return nil, fmt.Errorf("unexpected status from API, status: %d, body: %s", resp.StatusCode, string(b))
    81  	}
    82  	releases := []*GHRelease{}
    83  	err = json.NewDecoder(resp.Body).Decode(&releases)
    84  	if err != nil {
    85  		return nil, fmt.Errorf("failed to decode release API response: %w", err)
    86  	}
    87  	// cache result for future requests
    88  	ghCache[confExp.Args["repo"]] = releases
    89  	return releases, nil
    90  }
    91  
    92  func (g ghRelease) getReleaseName(confExp config.Source, releases []*GHRelease) (string, error) {
    93  	var err error
    94  	verData := VersionTmplData{
    95  		VerMap:  map[string]string{},
    96  		VerMeta: map[string]interface{}{},
    97  	}
    98  	allowDraft := false
    99  	if val, ok := confExp.Args["allowDraft"]; ok {
   100  		allowDraft, err = strconv.ParseBool(val)
   101  		if err != nil {
   102  			return "", fmt.Errorf("allowDraft must be a bool value: \"%s\": %w", val, err)
   103  		}
   104  	}
   105  	allowPrerelease := false
   106  	if val, ok := confExp.Args["allowPrerelease"]; ok {
   107  		allowPrerelease, err = strconv.ParseBool(val)
   108  		if err != nil {
   109  			return "", fmt.Errorf("allowPrerelease must be a bool value: \"%s\": %w", val, err)
   110  		}
   111  	}
   112  	for _, r := range releases {
   113  		r := r
   114  		if r.Draft && !allowDraft {
   115  			continue
   116  		}
   117  		if r.Prerelease && !allowPrerelease {
   118  			continue
   119  		}
   120  		verData.VerMap[r.TagName] = r.TagName
   121  		verData.VerMeta[r.TagName] = r
   122  	}
   123  	return procResult(confExp, verData)
   124  }
   125  
   126  func (g ghRelease) getArtifact(confExp config.Source, releases []*GHRelease) (string, error) {
   127  	var err error
   128  	verData := VersionTmplData{
   129  		VerMap:  map[string]string{},
   130  		VerMeta: map[string]interface{}{},
   131  	}
   132  	artifactName, ok := confExp.Args["artifact"]
   133  	if !ok {
   134  		return "", fmt.Errorf("missing arg \"artifact\"")
   135  	}
   136  	allowDraft := false
   137  	if val, ok := confExp.Args["allowDraft"]; ok {
   138  		allowDraft, err = strconv.ParseBool(val)
   139  		if err != nil {
   140  			return "", fmt.Errorf("allowDraft must be a bool value: \"%s\": %w", val, err)
   141  		}
   142  	}
   143  	allowPrerelease := false
   144  	if val, ok := confExp.Args["allowPrerelease"]; ok {
   145  		allowPrerelease, err = strconv.ParseBool(val)
   146  		if err != nil {
   147  			return "", fmt.Errorf("allowPrerelease must be a bool value: \"%s\": %w", val, err)
   148  		}
   149  	}
   150  	for _, r := range releases {
   151  		r := r
   152  		if r.Draft && !allowDraft {
   153  			continue
   154  		}
   155  		if r.Prerelease && !allowPrerelease {
   156  			continue
   157  		}
   158  		for _, asset := range r.Assets {
   159  			if asset.Name == artifactName {
   160  				asset := asset
   161  				verData.VerMap[r.TagName] = asset.DownloadURL
   162  				verData.VerMeta[r.TagName] = asset
   163  				break
   164  			}
   165  		}
   166  	}
   167  	if len(verData.VerMap) <= 0 {
   168  		return "", fmt.Errorf("no releases found with artifact \"%s\"", artifactName)
   169  	}
   170  	return procResult(confExp, verData)
   171  }
   172  
   173  func (g ghRelease) Key(data config.SourceTmplData) (string, error) {
   174  	confExp, err := g.conf.ExpandTemplate(data)
   175  	if err != nil {
   176  		return "", fmt.Errorf("failed to expand template: %w", err)
   177  	}
   178  	return confExp.Key, nil
   179  }
   180  
   181  type GHRelease struct {
   182  	URL             string     `json:"url"`
   183  	HTMLURL         string     `json:"html_url"`
   184  	ID              int64      `json:"id"`
   185  	TagName         string     `json:"tag_name"`
   186  	TargetCommitish string     `json:"target_commitish"`
   187  	Name            string     `json:"name"`
   188  	Draft           bool       `json:"draft"`
   189  	Prerelease      bool       `json:"prerelease"`
   190  	CreatedAt       GHTime     `json:"created_at"`
   191  	PublishedAt     GHTime     `json:"published_at"`
   192  	Assets          []*GHAsset `json:"assets"`
   193  }
   194  
   195  type GHAsset struct {
   196  	URL           string `json:"url"`
   197  	ID            int64  `json:"id"`
   198  	Name          string `json:"name"`
   199  	ContentType   string `json:"content_type"`
   200  	State         string `json:"state"`
   201  	Size          uint64 `json:"size"`
   202  	DownloadCount uint64 `json:"download_count"`
   203  	DownloadURL   string `json:"browser_download_url"`
   204  	CreatedAt     GHTime `json:"created_at"`
   205  	UpdatedAt     GHTime `json:"updated_at"`
   206  }
   207  
   208  type GHTime time.Time
   209  
   210  func (t *GHTime) UnmarshalJSON(data []byte) (err error) {
   211  	str := string(data)
   212  	i, err := strconv.ParseInt(str, 10, 64)
   213  	if err == nil {
   214  		*t = GHTime(time.Unix(i, 0))
   215  		if time.Time(*t).Year() > 3000 {
   216  			*t = GHTime(time.Unix(0, i*1e6))
   217  		}
   218  	} else {
   219  		var tt time.Time
   220  		tt, err = time.Parse(`"`+time.RFC3339+`"`, str)
   221  		*t = GHTime(tt)
   222  	}
   223  	return err
   224  }