Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,15 @@ p, _ := purl.Parse("pkg:npm/lodash?repository_url=https://github.com/lodash/loda
repo, err := client.FetchRepositoryFromPURL(ctx, p)
```

GitHub refs can be resolved to full commit SHAs without listing every tag:

```go
import githubforge "github.com/git-pkgs/forge/github"

resolver := githubforge.NewCommitResolver(os.Getenv("GITHUB_TOKEN"), nil)
sha, err := resolver.ResolveCommit(ctx, "actions", "checkout", "v4.2.1")
```

## License

MIT. See [LICENSE](LICENSE).
99 changes: 99 additions & 0 deletions github/commits.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package github

import (
"context"
"fmt"
"net/http"
"net/url"
"strings"

forge "github.com/git-pkgs/forge"
gh "github.com/google/go-github/v82/github"
)

// DefaultAPIBaseURL is the public GitHub REST API base URL.
const DefaultAPIBaseURL = "https://api.github.com/"

const fullCommitSHALength = 40

// CommitResolver resolves GitHub branch, tag, and abbreviated commit refs to
// full commit SHAs. Full 40-character hexadecimal SHAs are returned without a
// network request.
type CommitResolver struct {
client *gh.Client
}

// NewCommitResolver creates a commit resolver for the public GitHub API. The
// token may be empty for unauthenticated requests. A nil HTTP client uses the
// default client selected by go-github.
func NewCommitResolver(token string, client *http.Client) *CommitResolver {
resolver, _ := NewCommitResolverWithBase(DefaultAPIBaseURL, token, client)
return resolver
}

// NewCommitResolverWithBase creates a commit resolver for an explicit GitHub
// API base URL. The URL must include the API path for GitHub Enterprise and is
// normalized to end in a slash.
func NewCommitResolverWithBase(baseURL, token string, client *http.Client) (*CommitResolver, error) {
api := gh.NewClient(client)
if token != "" {
api = api.WithAuthToken(token)
}

base, err := url.Parse(strings.TrimRight(baseURL, "/") + "/")
if err != nil {
return nil, fmt.Errorf("parse GitHub API base URL: %w", err)
}
if base.Scheme != "http" && base.Scheme != "https" {
return nil, fmt.Errorf("parse GitHub API base URL: unsupported scheme %q", base.Scheme)
}
if base.Host == "" {
return nil, fmt.Errorf("parse GitHub API base URL: host is required")
}
api.BaseURL = base

return &CommitResolver{client: api}, nil
}

// ResolveCommit returns the full commit SHA for ref in owner/repo. GitHub's
// commit endpoint dereferences both lightweight and annotated tags.
func (r *CommitResolver) ResolveCommit(ctx context.Context, owner, repo, ref string) (string, error) {
if owner == "" || repo == "" || ref == "" {
return "", fmt.Errorf("resolve GitHub commit: owner, repo, and ref are required")
}
if isFullCommitSHA(ref) {
return strings.ToLower(ref), nil
}
if r == nil || r.client == nil {
return "", fmt.Errorf("resolve GitHub commit: resolver is nil")
}

sha, response, err := r.client.Repositories.GetCommitSHA1(ctx, owner, repo, ref, "")
if err != nil {
if response != nil && response.StatusCode == http.StatusNotFound {
return "", fmt.Errorf("resolve %s/%s ref %q: %w", owner, repo, ref, forge.ErrNotFound)
}
return "", fmt.Errorf("resolve %s/%s ref %q: %w", owner, repo, ref, err)
}
sha = strings.TrimSpace(sha)
if sha == "" {
return "", fmt.Errorf("resolve %s/%s ref %q: empty SHA in response", owner, repo, ref)
}
if !isFullCommitSHA(sha) {
return "", fmt.Errorf("resolve %s/%s ref %q: invalid full SHA in response", owner, repo, ref)
}
return strings.ToLower(sha), nil
}

// isFullCommitSHA reports whether ref is a full-length hexadecimal SHA-1.
func isFullCommitSHA(ref string) bool {
if len(ref) != fullCommitSHALength {
return false
}
for _, char := range ref {
if (char < '0' || char > '9') && (char < 'a' || char > 'f') && (char < 'A' || char > 'F') {
return false
}
}
return true
}
111 changes: 111 additions & 0 deletions github/commits_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package github

import (
"context"
"errors"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"

forge "github.com/git-pkgs/forge"
)

func TestCommitResolverResolveCommit(t *testing.T) {
const responseSHA = "8E8C483DB84B4BEE98B60C0593521ED34D9990E8"
const wantSHA = "8e8c483db84b4bee98b60c0593521ed34d9990e8"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/repos/actions/checkout/commits/v4.2.1" {
t.Errorf("path = %q, want commit endpoint", r.URL.Path)
}
if r.Header.Get("Authorization") != "Bearer token" {
t.Errorf("Authorization = %q, want bearer token", r.Header.Get("Authorization"))
}
if r.Header.Get("Accept") != "application/vnd.github.v3.sha" {
t.Errorf("Accept = %q, want SHA media type", r.Header.Get("Accept"))
}
_, _ = w.Write([]byte(responseSHA))
}))
defer srv.Close()

resolver, err := NewCommitResolverWithBase(srv.URL, "token", srv.Client())
if err != nil {
t.Fatalf("NewCommitResolverWithBase: %v", err)
}
got, err := resolver.ResolveCommit(context.Background(), "actions", "checkout", "v4.2.1")
if err != nil {
t.Fatalf("ResolveCommit: %v", err)
}
if got != wantSHA {
t.Errorf("ResolveCommit() = %q, want %q", got, wantSHA)
}
}

func TestCommitResolverReturnsFullSHADirectly(t *testing.T) {
const sha = "8E8C483DB84B4BEE98B60C0593521ED34D9990E8"
const want = "8e8c483db84b4bee98b60c0593521ed34d9990e8"
var requests atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
requests.Add(1)
}))
defer srv.Close()

resolver, err := NewCommitResolverWithBase(srv.URL, "", srv.Client())
if err != nil {
t.Fatalf("NewCommitResolverWithBase: %v", err)
}
got, err := resolver.ResolveCommit(context.Background(), "actions", "checkout", sha)
if err != nil {
t.Fatalf("ResolveCommit: %v", err)
}
if got != want || requests.Load() != 0 {
t.Errorf("ResolveCommit() = %q with %d requests, want normalized direct SHA", got, requests.Load())
}
}

func TestCommitResolverErrors(t *testing.T) {
t.Run("not found", func(t *testing.T) {
srv := httptest.NewServer(http.NotFoundHandler())
defer srv.Close()
resolver, err := NewCommitResolverWithBase(srv.URL, "", srv.Client())
if err != nil {
t.Fatalf("NewCommitResolverWithBase: %v", err)
}
_, err = resolver.ResolveCommit(context.Background(), "actions", "checkout", "missing")
if !errors.Is(err, forge.ErrNotFound) {
t.Errorf("ResolveCommit() error = %v, want ErrNotFound", err)
}
})

t.Run("empty SHA", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
defer srv.Close()
resolver, err := NewCommitResolverWithBase(srv.URL, "", srv.Client())
if err != nil {
t.Fatalf("NewCommitResolverWithBase: %v", err)
}
if _, err := resolver.ResolveCommit(context.Background(), "actions", "checkout", "v4"); err == nil {
t.Fatal("ResolveCommit() error = nil, want empty SHA error")
}
})

t.Run("invalid SHA", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("deadbeef"))
}))
defer srv.Close()
resolver, err := NewCommitResolverWithBase(srv.URL, "", srv.Client())
if err != nil {
t.Fatalf("NewCommitResolverWithBase: %v", err)
}
if _, err := resolver.ResolveCommit(context.Background(), "actions", "checkout", "v4"); err == nil {
t.Fatal("ResolveCommit() error = nil, want invalid SHA error")
}
})

t.Run("invalid base URL", func(t *testing.T) {
if _, err := NewCommitResolverWithBase("not a URL", "", nil); err == nil {
t.Fatal("NewCommitResolverWithBase() error = nil, want invalid URL error")
}
})
}