Skip to content

Commit da5391d

Browse files
committed
Add GitHub commit resolver
1 parent a5687af commit da5391d

3 files changed

Lines changed: 201 additions & 0 deletions

File tree

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,15 @@ p, _ := purl.Parse("pkg:npm/lodash?repository_url=https://github.com/lodash/loda
193193
repo, err := client.FetchRepositoryFromPURL(ctx, p)
194194
```
195195

196+
GitHub refs can be resolved to full commit SHAs without listing every tag:
197+
198+
```go
199+
import githubforge "github.com/git-pkgs/forge/github"
200+
201+
resolver := githubforge.NewCommitResolver(os.Getenv("GITHUB_TOKEN"), nil)
202+
sha, err := resolver.ResolveCommit(ctx, "actions", "checkout", "v4.2.1")
203+
```
204+
196205
## License
197206

198207
MIT. See [LICENSE](LICENSE).

github/commits.go

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package github
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"net/http"
7+
"net/url"
8+
"strings"
9+
10+
forge "github.com/git-pkgs/forge"
11+
gh "github.com/google/go-github/v82/github"
12+
)
13+
14+
// DefaultAPIBaseURL is the public GitHub REST API base URL.
15+
const DefaultAPIBaseURL = "https://api.github.com/"
16+
17+
const fullCommitSHALength = 40
18+
19+
// CommitResolver resolves GitHub branch, tag, and abbreviated commit refs to
20+
// full commit SHAs. Full 40-character hexadecimal SHAs are returned without a
21+
// network request.
22+
type CommitResolver struct {
23+
client *gh.Client
24+
}
25+
26+
// NewCommitResolver creates a commit resolver for the public GitHub API. The
27+
// token may be empty for unauthenticated requests. A nil HTTP client uses the
28+
// default client selected by go-github.
29+
func NewCommitResolver(token string, client *http.Client) *CommitResolver {
30+
resolver, _ := NewCommitResolverWithBase(DefaultAPIBaseURL, token, client)
31+
return resolver
32+
}
33+
34+
// NewCommitResolverWithBase creates a commit resolver for an explicit GitHub
35+
// API base URL. The URL must include the API path for GitHub Enterprise and is
36+
// normalized to end in a slash.
37+
func NewCommitResolverWithBase(baseURL, token string, client *http.Client) (*CommitResolver, error) {
38+
api := gh.NewClient(client)
39+
if token != "" {
40+
api = api.WithAuthToken(token)
41+
}
42+
43+
base, err := url.Parse(strings.TrimRight(baseURL, "/") + "/")
44+
if err != nil {
45+
return nil, fmt.Errorf("parse GitHub API base URL: %w", err)
46+
}
47+
if base.Scheme != "http" && base.Scheme != "https" {
48+
return nil, fmt.Errorf("parse GitHub API base URL: unsupported scheme %q", base.Scheme)
49+
}
50+
if base.Host == "" {
51+
return nil, fmt.Errorf("parse GitHub API base URL: host is required")
52+
}
53+
api.BaseURL = base
54+
55+
return &CommitResolver{client: api}, nil
56+
}
57+
58+
// ResolveCommit returns the full commit SHA for ref in owner/repo. GitHub's
59+
// commit endpoint dereferences both lightweight and annotated tags.
60+
func (r *CommitResolver) ResolveCommit(ctx context.Context, owner, repo, ref string) (string, error) {
61+
if owner == "" || repo == "" || ref == "" {
62+
return "", fmt.Errorf("resolve GitHub commit: owner, repo, and ref are required")
63+
}
64+
if isFullCommitSHA(ref) {
65+
return strings.ToLower(ref), nil
66+
}
67+
if r == nil || r.client == nil {
68+
return "", fmt.Errorf("resolve GitHub commit: resolver is nil")
69+
}
70+
71+
sha, response, err := r.client.Repositories.GetCommitSHA1(ctx, owner, repo, ref, "")
72+
if err != nil {
73+
if response != nil && response.StatusCode == http.StatusNotFound {
74+
return "", fmt.Errorf("resolve %s/%s ref %q: %w", owner, repo, ref, forge.ErrNotFound)
75+
}
76+
return "", fmt.Errorf("resolve %s/%s ref %q: %w", owner, repo, ref, err)
77+
}
78+
sha = strings.TrimSpace(sha)
79+
if sha == "" {
80+
return "", fmt.Errorf("resolve %s/%s ref %q: empty SHA in response", owner, repo, ref)
81+
}
82+
return sha, nil
83+
}
84+
85+
// isFullCommitSHA reports whether ref is a full-length hexadecimal SHA-1.
86+
func isFullCommitSHA(ref string) bool {
87+
if len(ref) != fullCommitSHALength {
88+
return false
89+
}
90+
for _, char := range ref {
91+
if (char < '0' || char > '9') && (char < 'a' || char > 'f') && (char < 'A' || char > 'F') {
92+
return false
93+
}
94+
}
95+
return true
96+
}

github/commits_test.go

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package github
2+
3+
import (
4+
"context"
5+
"errors"
6+
"net/http"
7+
"net/http/httptest"
8+
"sync/atomic"
9+
"testing"
10+
11+
forge "github.com/git-pkgs/forge"
12+
)
13+
14+
func TestCommitResolverResolveCommit(t *testing.T) {
15+
const wantSHA = "8e8c483db84b4bee98b60c0593521ed34d9990e8"
16+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
17+
if r.URL.Path != "/repos/actions/checkout/commits/v4.2.1" {
18+
t.Errorf("path = %q, want commit endpoint", r.URL.Path)
19+
}
20+
if r.Header.Get("Authorization") != "Bearer token" {
21+
t.Errorf("Authorization = %q, want bearer token", r.Header.Get("Authorization"))
22+
}
23+
if r.Header.Get("Accept") != "application/vnd.github.v3.sha" {
24+
t.Errorf("Accept = %q, want SHA media type", r.Header.Get("Accept"))
25+
}
26+
_, _ = w.Write([]byte(wantSHA))
27+
}))
28+
defer srv.Close()
29+
30+
resolver, err := NewCommitResolverWithBase(srv.URL, "token", srv.Client())
31+
if err != nil {
32+
t.Fatalf("NewCommitResolverWithBase: %v", err)
33+
}
34+
got, err := resolver.ResolveCommit(context.Background(), "actions", "checkout", "v4.2.1")
35+
if err != nil {
36+
t.Fatalf("ResolveCommit: %v", err)
37+
}
38+
if got != wantSHA {
39+
t.Errorf("ResolveCommit() = %q, want %q", got, wantSHA)
40+
}
41+
}
42+
43+
func TestCommitResolverReturnsFullSHADirectly(t *testing.T) {
44+
const sha = "8E8C483DB84B4BEE98B60C0593521ED34D9990E8"
45+
const want = "8e8c483db84b4bee98b60c0593521ed34d9990e8"
46+
var requests atomic.Int32
47+
srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
48+
requests.Add(1)
49+
}))
50+
defer srv.Close()
51+
52+
resolver, err := NewCommitResolverWithBase(srv.URL, "", srv.Client())
53+
if err != nil {
54+
t.Fatalf("NewCommitResolverWithBase: %v", err)
55+
}
56+
got, err := resolver.ResolveCommit(context.Background(), "actions", "checkout", sha)
57+
if err != nil {
58+
t.Fatalf("ResolveCommit: %v", err)
59+
}
60+
if got != want || requests.Load() != 0 {
61+
t.Errorf("ResolveCommit() = %q with %d requests, want normalized direct SHA", got, requests.Load())
62+
}
63+
}
64+
65+
func TestCommitResolverErrors(t *testing.T) {
66+
t.Run("not found", func(t *testing.T) {
67+
srv := httptest.NewServer(http.NotFoundHandler())
68+
defer srv.Close()
69+
resolver, err := NewCommitResolverWithBase(srv.URL, "", srv.Client())
70+
if err != nil {
71+
t.Fatalf("NewCommitResolverWithBase: %v", err)
72+
}
73+
_, err = resolver.ResolveCommit(context.Background(), "actions", "checkout", "missing")
74+
if !errors.Is(err, forge.ErrNotFound) {
75+
t.Errorf("ResolveCommit() error = %v, want ErrNotFound", err)
76+
}
77+
})
78+
79+
t.Run("empty SHA", func(t *testing.T) {
80+
srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
81+
defer srv.Close()
82+
resolver, err := NewCommitResolverWithBase(srv.URL, "", srv.Client())
83+
if err != nil {
84+
t.Fatalf("NewCommitResolverWithBase: %v", err)
85+
}
86+
if _, err := resolver.ResolveCommit(context.Background(), "actions", "checkout", "v4"); err == nil {
87+
t.Fatal("ResolveCommit() error = nil, want empty SHA error")
88+
}
89+
})
90+
91+
t.Run("invalid base URL", func(t *testing.T) {
92+
if _, err := NewCommitResolverWithBase("not a URL", "", nil); err == nil {
93+
t.Fatal("NewCommitResolverWithBase() error = nil, want invalid URL error")
94+
}
95+
})
96+
}

0 commit comments

Comments
 (0)