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 }