diff --git a/README.md b/README.md index 56e075c..82497b3 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/github/commits.go b/github/commits.go new file mode 100644 index 0000000..3741b4d --- /dev/null +++ b/github/commits.go @@ -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 +} diff --git a/github/commits_test.go b/github/commits_test.go new file mode 100644 index 0000000..e03531b --- /dev/null +++ b/github/commits_test.go @@ -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") + } + }) +}