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
16 changes: 14 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ client := forges.NewClient(
repo, err := client.FetchRepository(ctx, "https://github.com/octocat/hello-world")
```

The `Forge` interface exposes services for repos, issues, pull requests, reviews, releases, CI, branches, labels, milestones, deploy keys, secrets, notifications, files, collaborators, and commit statuses. Each backend implements these using its native SDK.
The `Forge` interface exposes services for repos, issues, pull requests, reviews, releases, CI, branches, labels, milestones, deploy keys, secrets, notifications, files, collaborators, commit statuses and commits. Each backend implements these using its native SDK.

```go
f, _ := client.ForgeFor("github.com")
Expand Down Expand Up @@ -193,7 +193,19 @@ 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:
Branches, tags and abbreviated commit refs can be resolved to full commit SHAs without listing every tag. GitHub, GitLab and Gitea/Forgejo implement this; the remaining backends return `forges.ErrNotSupported`.

```go
f, _ := client.ForgeFor("gitlab.com")
sha, err := f.Commits().ResolveCommit(ctx, "gitlab-org", "gitlab", "v17.0.0")

// or route by repository URL
sha, err = client.ResolveCommit(ctx, "https://github.com/actions/checkout", "v4.2.1")
```

A ref that does not exist returns `forges.ErrNotFound`. A ref that is already a full 40-character SHA is normalized and returned without a network request.

The GitHub backend also exposes a standalone resolver for callers that do not need the full `Forge` interface:

```go
import githubforge "github.com/git-pkgs/forge/github"
Expand Down
17 changes: 17 additions & 0 deletions bitbucket/commits.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package bitbucket

import (
"context"

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

type bitbucketCommitService struct{}

func (f *bitbucketForge) Commits() forge.CommitService {
return &bitbucketCommitService{}
}

func (s *bitbucketCommitService) ResolveCommit(_ context.Context, _, _, _ string) (string, error) {
return "", forge.ErrNotSupported
}
87 changes: 87 additions & 0 deletions commits.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package forges

import (
"context"
"errors"
"fmt"
"strings"
)

// FullCommitSHALength is the number of hexadecimal characters in a full SHA-1
// commit ID.
const FullCommitSHALength = 40

// ErrCommitRefRequired is returned when owner, repo or ref is empty.
var ErrCommitRefRequired = errors.New("resolve commit: owner, repo and ref are required")

// CommitService resolves repository refs to immutable commit SHAs. Callers use
// it to pin a mutable ref, such as an action reference or a package source
// revision, to the exact commit it currently points at without listing every
// branch or tag on the repository.
type CommitService interface {
// ResolveCommit returns the full commit SHA that ref points at in
// owner/repo. ref may be a branch name, a tag name, an abbreviated
// commit SHA or a full commit SHA. Implementations return ErrNotFound
// when the ref does not exist on the repository. They return
// ErrNotSupported when the forge exposes no ref resolution endpoint.
ResolveCommit(ctx context.Context, owner, repo, ref string) (string, error)
}

// IsFullCommitSHA reports whether ref is already a full-length hexadecimal
// SHA-1. Backends call it to skip a network round trip for refs that are
// immutable to begin with.
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
}

// ValidateCommitRef rejects the empty arguments every CommitService
// implementation has to guard against before issuing a request.
func ValidateCommitRef(owner, repo, ref string) error {
if owner == "" || repo == "" || ref == "" {
return ErrCommitRefRequired
}
return nil
}

// CommitRefError wraps err with the repository and ref being resolved so every
// backend reports resolution failures the same way.
func CommitRefError(owner, repo, ref string, err error) error {
return fmt.Errorf("resolve %s/%s ref %q: %w", owner, repo, ref, err)
}

// ResolvedCommitSHA normalizes a commit SHA a forge API returned for
// owner/repo ref. It trims surrounding whitespace, lowercases the value and
// rejects anything that is not a full hexadecimal SHA-1, so callers never
// receive a value they cannot pin against.
func ResolvedCommitSHA(owner, repo, ref, sha string) (string, error) {
sha = strings.TrimSpace(sha)
if sha == "" {
return "", CommitRefError(owner, repo, ref, errors.New("empty SHA in response"))
}
if !IsFullCommitSHA(sha) {
return "", CommitRefError(owner, repo, ref, fmt.Errorf("invalid full SHA %q in response", sha))
}
return strings.ToLower(sha), nil
}

// ResolveCommit resolves ref to a full commit SHA for the repository at
// repoURL, routing to the forge registered for that URL's domain.
func (c *Client) ResolveCommit(ctx context.Context, repoURL, ref string) (string, error) {
domain, owner, repo, err := ParseRepoURL(repoURL)
if err != nil {
return "", err
}
f, err := c.forgeFor(domain)
if err != nil {
return "", err
}
return f.Commits().ResolveCommit(ctx, owner, repo, ref)
}
142 changes: 142 additions & 0 deletions commits_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
package forges

import (
"context"
"errors"
"strings"
"testing"
)

const (
testCommitSHA = "8e8c483db84b4bee98b60c0593521ed34d9990e8"
testCommitSHAUpper = "8E8C483DB84B4BEE98B60C0593521ED34D9990E8"
)

func TestIsFullCommitSHA(t *testing.T) {
tests := []struct {
name string
ref string
want bool
}{
{name: "lowercase full SHA", ref: testCommitSHA, want: true},
{name: "uppercase full SHA", ref: testCommitSHAUpper, want: true},
{name: "mixed case full SHA", ref: "8e8C483db84B4bee98b60C0593521ed34d9990E8", want: true},
{name: "abbreviated SHA", ref: "8e8c483", want: false},
{name: "branch name", ref: "main", want: false},
{name: "tag name", ref: "v4.2.1", want: false},
{name: "empty", ref: "", want: false},
{name: "too short by one", ref: testCommitSHA[1:], want: false},
{name: "too long by one", ref: testCommitSHA + "0", want: false},
{name: "right length with non-hex character", ref: strings.Repeat("g", FullCommitSHALength), want: false},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := IsFullCommitSHA(tt.ref); got != tt.want {
t.Errorf("IsFullCommitSHA(%q) = %v, want %v", tt.ref, got, tt.want)
}
})
}
}

func TestValidateCommitRef(t *testing.T) {
tests := []struct {
name string
owner, repo, ref string
wantErr bool
}{
{name: "all set", owner: "actions", repo: "checkout", ref: "v4", wantErr: false},
{name: "empty owner", owner: "", repo: "checkout", ref: "v4", wantErr: true},
{name: "empty repo", owner: "actions", repo: "", ref: "v4", wantErr: true},
{name: "empty ref", owner: "actions", repo: "checkout", ref: "", wantErr: true},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateCommitRef(tt.owner, tt.repo, tt.ref)
if tt.wantErr && !errors.Is(err, ErrCommitRefRequired) {
t.Errorf("ValidateCommitRef() error = %v, want ErrCommitRefRequired", err)
}
if !tt.wantErr && err != nil {
t.Errorf("ValidateCommitRef() error = %v, want nil", err)
}
})
}
}

func TestResolvedCommitSHA(t *testing.T) {
t.Run("normalizes case and whitespace", func(t *testing.T) {
got, err := ResolvedCommitSHA("actions", "checkout", "v4", " "+testCommitSHAUpper+"\n")
if err != nil {
t.Fatalf("ResolvedCommitSHA() error = %v", err)
}
if got != testCommitSHA {
t.Errorf("ResolvedCommitSHA() = %q, want %q", got, testCommitSHA)
}
})

t.Run("rejects empty SHA", func(t *testing.T) {
if _, err := ResolvedCommitSHA("actions", "checkout", "v4", " \n"); err == nil {
t.Fatal("ResolvedCommitSHA() error = nil, want empty SHA error")
}
})

t.Run("rejects abbreviated SHA", func(t *testing.T) {
if _, err := ResolvedCommitSHA("actions", "checkout", "v4", "deadbeef"); err == nil {
t.Fatal("ResolvedCommitSHA() error = nil, want invalid SHA error")
}
})
}

func TestCommitRefErrorUnwraps(t *testing.T) {
err := CommitRefError("actions", "checkout", "missing", ErrNotFound)
if !errors.Is(err, ErrNotFound) {
t.Fatalf("CommitRefError() = %v, want an error wrapping ErrNotFound", err)
}
for _, want := range []string{"actions/checkout", `"missing"`} {
if !strings.Contains(err.Error(), want) {
t.Errorf("CommitRefError() = %q, want it to mention %s", err.Error(), want)
}
}
}

func TestClientResolveCommitRoutes(t *testing.T) {
mock := &mockForge{commitService: &mockCommitService{sha: testCommitSHA}}
c := &Client{
forges: map[string]Forge{"example.com": mock},
tokens: make(map[string]string),
}

sha, err := c.ResolveCommit(context.Background(), "https://example.com/test/repo", "v1.0.0")
if err != nil {
t.Fatalf("ResolveCommit() error = %v", err)
}
if sha != testCommitSHA {
t.Errorf("ResolveCommit() = %q, want %q", sha, testCommitSHA)
}

cs := mock.commitService
if cs.lastOwner != "test" || cs.lastRepo != "repo" || cs.lastRef != "v1.0.0" {
t.Errorf("backend received owner=%q repo=%q ref=%q, want test/repo at v1.0.0",
cs.lastOwner, cs.lastRepo, cs.lastRef)
}
}

func TestClientResolveCommitPropagatesBackendError(t *testing.T) {
mock := &mockForge{commitService: &mockCommitService{err: ErrNotFound}}
c := &Client{
forges: map[string]Forge{"example.com": mock},
tokens: make(map[string]string),
}

if _, err := c.ResolveCommit(context.Background(), "https://example.com/test/repo", "nope"); !errors.Is(err, ErrNotFound) {
t.Errorf("ResolveCommit() error = %v, want ErrNotFound", err)
}
}

func TestClientResolveCommitUnregisteredDomain(t *testing.T) {
c := NewClient()
if _, err := c.ResolveCommit(context.Background(), "https://example.com/test/repo", "v1.0.0"); err == nil {
t.Error("ResolveCommit() error = nil, want error for unregistered domain")
}
}
1 change: 1 addition & 0 deletions forge.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ type Forge interface {
Files() FileService
Collaborators() CollaboratorService
CommitStatuses() CommitStatusService
Commits() CommitService
GetRateLimit(ctx context.Context) (*RateLimit, error)
// ParsePath parses URL path segments into a resource reference.
ParsePath(pathParts []string) (*ResourceRef, error)
Expand Down
23 changes: 23 additions & 0 deletions forges_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -620,6 +620,7 @@ type mockForge struct {
deployKeyService *mockDeployKeyService
secretService *mockSecretService
reviewService *mockReviewService
commitService *mockCommitService
}

func (m *mockForge) Repos() RepoService {
Expand Down Expand Up @@ -724,6 +725,13 @@ func (m *mockForge) CommitStatuses() CommitStatusService {
return &mockCommitStatusService{}
}

func (m *mockForge) Commits() CommitService {
if m.commitService != nil {
return m.commitService
}
return &mockCommitService{}
}

func (m *mockForge) GetRateLimit(_ context.Context) (*RateLimit, error) {
return nil, ErrNotSupported
}
Expand Down Expand Up @@ -766,6 +774,21 @@ func (m *mockCommitStatusService) Set(_ context.Context, _, _, _ string, _ SetCo
return nil, nil
}

type mockCommitService struct {
sha string
err error
lastOwner string
lastRepo string
lastRef string
}

func (m *mockCommitService) ResolveCommit(_ context.Context, owner, repo, ref string) (string, error) {
m.lastOwner = owner
m.lastRepo = repo
m.lastRef = ref
return m.sha, m.err
}

type mockRepoService struct {
repo *Repository
repos []Repository
Expand Down
6 changes: 6 additions & 0 deletions gerrit/stubs.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ type unsupportedSecretService struct{}
type unsupportedNotificationService struct{}
type unsupportedCollaboratorService struct{}
type unsupportedCommitStatusService struct{}
type unsupportedCommitService struct{}

func (f *gerritForge) Issues() forge.IssueService { return &unsupportedIssueService{} }
func (f *gerritForge) Labels() forge.LabelService { return &unsupportedLabelService{} }
Expand All @@ -35,6 +36,7 @@ func (f *gerritForge) Collaborators() forge.CollaboratorService {
func (f *gerritForge) CommitStatuses() forge.CommitStatusService {
return &unsupportedCommitStatusService{}
}
func (f *gerritForge) Commits() forge.CommitService { return &unsupportedCommitService{} }
func (f *gerritForge) GetRateLimit(context.Context) (*forge.RateLimit, error) {
return nil, forge.ErrNotSupported
}
Expand Down Expand Up @@ -206,3 +208,7 @@ func (s *unsupportedCommitStatusService) List(context.Context, string, string, s
func (s *unsupportedCommitStatusService) Set(context.Context, string, string, string, forge.SetCommitStatusOpts) (*forge.CommitStatus, error) {
return nil, forge.ErrNotSupported
}

func (s *unsupportedCommitService) ResolveCommit(context.Context, string, string, string) (string, error) {
return "", forge.ErrNotSupported
}
Loading