diff --git a/.github/codecov.yml b/.github/codecov.yml index 6b77c4a6..60703b4d 100644 --- a/.github/codecov.yml +++ b/.github/codecov.yml @@ -7,3 +7,4 @@ coverage: - "internal/mockable" - "internal/packages" - "internal/typepatch" + - "internal/github" diff --git a/internal/github/commit.go b/internal/github/commit.go new file mode 100644 index 00000000..3aa3a9cd --- /dev/null +++ b/internal/github/commit.go @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2025 The GoPlus Authors (goplus.org). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package github + +import ( + "encoding/json" + "net/http" + "strings" +) + +// Author represents a github user or bot. +type Author struct { + Login string `json:"login"` // github-actions[bot] + ID int `json:"id"` // 41898282 + NodeID string `json:"node_id"` // MDM6Qm90NDE4OTgyODI= + AvatarURL string `json:"avatar_url"` // https://avatars.githubusercontent.com/in/15368?v=4 + URL string `json:"url"` // https://api.github.com/users/github-actions%5Bbot%5D + HtmlURL string `json:"html_url"` // https://github.com/apps/github-actions + Type string `json:"type"` // Bot + SiteAdmin bool `json:"site_admin"` // false +} + +// CommitAuthor represents the author of a GitHub commit. +type CommitAuthor struct { + Name string `json:"name"` // xushiwei + Email string `json:"email"` // x@goplus.org + Date string `json:"date"` // 2025-04-21T14:13:29Z +} + +// CommitSummary represents the summary of a GitHub commit. +type CommitSummary struct { + Author CommitAuthor `json:"author"` + Message string `json:"message"` // Merge pull request #2296 from goplus/main\n\nv1.4.0 +} + +// CommitDetail represents the details of a GitHub commit. +type CommitDetail struct { + NodeID string `json:"node_id"` // C_kwDOAtpGOtoAKDE2OGEwODlmOWY5ZTNhNDdhMTliMTRjZDczODQ4N2M2ZTJkMTMxYmE + Commit CommitSummary `json:"commit"` + Author Author `json:"author"` +} + +func commitURL(pkgPath, sha string) string { + return "https://api.github.com/repos/" + pkgPath + "/commits/" + sha +} + +// GetCommit retrieves the details of a specific commit from a GitHub repository. +func GetCommit(pkgPath, shaOrURL string) (ret *CommitDetail, err error) { + url := shaOrURL + if !strings.HasPrefix(shaOrURL, "https://") { + url = commitURL(pkgPath, shaOrURL) + } + resp, err := http.Get(url) + if err != nil { + return + } + defer resp.Body.Close() + + ret = new(CommitDetail) + err = json.NewDecoder(resp.Body).Decode(ret) + return +} diff --git a/internal/github/release.go b/internal/github/release.go new file mode 100644 index 00000000..a7ee56fa --- /dev/null +++ b/internal/github/release.go @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2025 The GoPlus Authors (goplus.org). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package github + +import ( + "encoding/json" + "net/http" +) + +// ReleaseAsset represents a GitHub release asset. +type ReleaseAsset struct { + URL string `json:"url"` // https://api.github.com/repos/flintlib/flint/releases/assets/242245930 + ID int `json:"id"` // 242245930 + NodeID string `json:"node_id"` // RA_kwDOAC8YHs4OcGEq + Name string `json:"name"` // flint-3.2.2.tar.gz + ContentType string `json:"content_type"` // application/x-gtar + State string `json:"state"` // uploaded + Size int64 `json:"size"` // 123456 + DownloadCount int `json:"download_count"` // 176 + UpdatedAt string `json:"updated_at"` // 2025-03-31T08:54:16Z + BrowserDownloadURL string `json:"browser_download_url"` // https://github.com/flintlib/flint/releases/download/v3.2.2/flint-3.2.2.tar.gz +} + +// Release represents a GitHub release. +type Release struct { + URL string `json:"url"` // https://api.github.com/repos/flintlib/flint/releases/209285187 + ID int `json:"id"` // 209285187 + NodeID string `json:"node_id"` // RE_kwDOAC8YHs4MeXBD + TagName string `json:"tag_name"` // v3.2.2 + TargetCommitish string `json:"target_commitish"` // b8223680e38ad048355a421bf7f617bb6c5d5e12 + Name string `json:"name"` // FLINT v3.2.2 + PublishedAt string `json:"published_at"` // 2025-03-31T08:54:16Z + Body string `json:"body"` // Release Notes + TarballURL string `json:"tarball_url"` // https://api.github.com/repos/flintlib/flint/tarball/v3.2.2 + ZipballURL string `json:"zipball_url"` // https://api.github.com/repos/flintlib/flint/zipball/v3.2.2 + Author Author `json:"author"` + Assets []*ReleaseAsset `json:"assets"` + Prerelease bool `json:"prerelease"` +} + +// releaseURL constructs the URL for a GitHub release. +func releaseURL(pkgPath, ver string) string { + if ver == "" || ver == "latest" { + return "https://api.github.com/repos/" + pkgPath + "/releases/latest" + } + return "https://api.github.com/repos/" + pkgPath + "/releases/tags/" + ver +} + +// GetRelease fetches the release information from GitHub. +func GetRelease(pkgPath, ver string) (ret *Release, err error) { + url := releaseURL(pkgPath, ver) + resp, err := http.Get(url) + if err != nil { + return + } + defer resp.Body.Close() + + ret = new(Release) + err = json.NewDecoder(resp.Body).Decode(ret) + return +} diff --git a/internal/github/tag.go b/internal/github/tag.go new file mode 100644 index 00000000..bf046c8b --- /dev/null +++ b/internal/github/tag.go @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2025 The GoPlus Authors (goplus.org). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package github + +import ( + "encoding/json" + "errors" + "net/http" + "net/url" + "strconv" + "strings" +) + +var ( + ErrBreak = errors.New("break") + ErrNotFound = errors.New("not found") +) + +// Commit represents a commit in a GitHub repository. +type Commit struct { + SHA string `json:"sha"` + URL string `json:"url"` +} + +// Tag represents a GitHub tag. +type Tag struct { + Name string `json:"name"` + ZipballURL string `json:"zipball_url"` + TarballURL string `json:"tarball_url"` + Commit Commit `json:"commit"` + NodeID string `json:"node_id"` +} + +// tagsURL constructs the URL for fetching tags from a GitHub repository. +func tagsURL(pkgPath string) string { + return "https://api.github.com/repos/" + pkgPath + "/tags" +} + +// GetTag retrieves a specific tag from a GitHub repository. +func GetTag(pkgPath, ver string) (tag *Tag, err error) { + err = ErrNotFound + EnumTags(pkgPath, 0, func(tags []*Tag, page, total int) error { + for _, t := range tags { + if t.Name == ver { + tag = t + err = nil + return ErrBreak + } + } + return nil + }) + return +} + +// EnumTags enumerates the tags of a GitHub repository. +func EnumTags(pkgPath string, page int, pager func(tags []*Tag, page, total int) error) (err error) { + total := 0 + ubase := tagsURL(pkgPath) + +loop: + u := ubase + if page > 0 { + vals := url.Values{"page": []string{strconv.Itoa(page + 1)}} + u += "?" + vals.Encode() + } + resp, err := http.Get(u) + if err != nil { + return + } + defer resp.Body.Close() + + var tags []*Tag + err = json.NewDecoder(resp.Body).Decode(&tags) + if err != nil { + return + } + + // Link: ; rel="next", + // ; rel="last" + if total == 0 { + const relLast = `rel="last"` + total = page + 1 + link := resp.Header.Get("Link") + for _, part := range strings.Split(link, ",") { + if strings.HasSuffix(part, relLast) { + left := strings.TrimSpace(part[:len(part)-len(relLast)]) + lastUrl := strings.TrimSuffix(strings.TrimPrefix(left, "<"), ">;") + if pos := strings.LastIndexByte(lastUrl, '?'); pos >= 0 { + if vals, e := url.ParseQuery(lastUrl[pos+1:]); e == nil { + if n, e := strconv.Atoi(vals.Get("page")); e == nil { + total = n + } + } + } + break + } + } + } + err = pager(tags, page, total) + if err != nil { + if err == ErrBreak { + err = nil + } + return + } + page++ + if page < total { + goto loop + } + return +}