Skip to content

Commit 189bfd3

Browse files
authored
Support URLs in forge pr checkout (#104)
* Support URLs in forge pr checkout Fix #102 * Address review feedback * Introduce ResourceRef * Add URL checkout test
1 parent 3385a9a commit 189bfd3

20 files changed

Lines changed: 606 additions & 19 deletions

bitbucket/url.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package bitbucket
2+
3+
import (
4+
"fmt"
5+
"strconv"
6+
7+
"github.com/git-pkgs/forge"
8+
)
9+
10+
// ParsePath implements Forge.ParsePath for Bitbucket URLs.
11+
func (f *bitbucketForge) ParsePath(parts []string) (*forges.ResourceRef, error) {
12+
if len(parts) < 2 {
13+
return nil, fmt.Errorf("URL path must contain owner/repo")
14+
}
15+
16+
ref := &forges.ResourceRef{
17+
Owner: parts[0],
18+
Repo: parts[1],
19+
}
20+
21+
if len(parts) >= 4 {
22+
num, err := strconv.Atoi(parts[3])
23+
if err != nil {
24+
return nil, fmt.Errorf("invalid number %q", parts[3])
25+
}
26+
ref.Number = num
27+
28+
switch parts[2] {
29+
case "pull-requests":
30+
ref.Type = forges.ResourceTypePR
31+
case "issues":
32+
ref.Type = forges.ResourceTypeIssue
33+
}
34+
}
35+
36+
return ref, nil
37+
}

bitbucket/url_test.go

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package bitbucket
2+
3+
import (
4+
"testing"
5+
6+
forges "github.com/git-pkgs/forge"
7+
)
8+
9+
func TestParsePath(t *testing.T) {
10+
tests := []struct {
11+
name string
12+
parts []string
13+
wantOwner string
14+
wantRepo string
15+
wantResource forges.ResourceType
16+
wantNumber int
17+
wantErr bool
18+
}{
19+
{
20+
name: "repo only",
21+
parts: []string{"owner", "repo"},
22+
wantOwner: "owner", wantRepo: "repo",
23+
},
24+
{
25+
name: "pull request",
26+
parts: []string{"owner", "repo", "pull-requests", "123"},
27+
wantOwner: "owner", wantRepo: "repo",
28+
wantResource: forges.ResourceTypePR, wantNumber: 123,
29+
},
30+
{
31+
name: "issue",
32+
parts: []string{"owner", "repo", "issues", "456"},
33+
wantOwner: "owner", wantRepo: "repo",
34+
wantResource: forges.ResourceTypeIssue, wantNumber: 456,
35+
},
36+
{
37+
name: "missing repo",
38+
parts: []string{"owner"},
39+
wantErr: true,
40+
},
41+
{
42+
name: "invalid PR number",
43+
parts: []string{"owner", "repo", "pull-requests", "abc"},
44+
wantErr: true,
45+
},
46+
}
47+
48+
f := &bitbucketForge{}
49+
for _, tt := range tests {
50+
t.Run(tt.name, func(t *testing.T) {
51+
ref, err := f.ParsePath(tt.parts)
52+
if tt.wantErr {
53+
if err == nil {
54+
t.Fatal("expected error")
55+
}
56+
return
57+
}
58+
if err != nil {
59+
t.Fatalf("unexpected error: %v", err)
60+
}
61+
if ref.Owner != tt.wantOwner {
62+
t.Errorf("owner: got %q, want %q", ref.Owner, tt.wantOwner)
63+
}
64+
if ref.Repo != tt.wantRepo {
65+
t.Errorf("repo: got %q, want %q", ref.Repo, tt.wantRepo)
66+
}
67+
if ref.Type != tt.wantResource {
68+
t.Errorf("resource: got %q, want %q", ref.Type, tt.wantResource)
69+
}
70+
if ref.Number != tt.wantNumber {
71+
t.Errorf("number: got %d, want %d", ref.Number, tt.wantNumber)
72+
}
73+
})
74+
}
75+
}

forge.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,22 @@ var ErrNotSupported = errors.New("not supported by this forge")
2323
// ErrLabelExists is returned when creating a label that already exists.
2424
var ErrLabelExists = errors.New("label already exists")
2525

26+
// ResourceType identifies the kind of resource a URL points to.
27+
type ResourceType string
28+
29+
const (
30+
ResourceTypePR ResourceType = "pr"
31+
ResourceTypeIssue ResourceType = "issue"
32+
)
33+
34+
// ResourceRef identifies a resource (PR, issue, etc.) within a repository.
35+
type ResourceRef struct {
36+
Owner string
37+
Repo string
38+
Type ResourceType
39+
Number int
40+
}
41+
2642
// HTTPError represents a non-OK HTTP response from a forge API.
2743
type HTTPError struct {
2844
StatusCode int
@@ -52,6 +68,8 @@ type Forge interface {
5268
Collaborators() CollaboratorService
5369
CommitStatuses() CommitStatusService
5470
GetRateLimit(ctx context.Context) (*RateLimit, error)
71+
// ParsePath parses URL path segments into a resource reference.
72+
ParsePath(pathParts []string) (*ResourceRef, error)
5573
}
5674

5775
// Client routes requests to the appropriate Forge based on the URL domain.

forges_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -505,6 +505,10 @@ func (m *mockForge) GetRateLimit(_ context.Context) (*RateLimit, error) {
505505
return nil, ErrNotSupported
506506
}
507507

508+
func (m *mockForge) ParsePath(_ []string) (*ResourceRef, error) {
509+
return &ResourceRef{}, nil
510+
}
511+
508512
type mockFileService struct{}
509513

510514
func (m *mockFileService) Get(_ context.Context, _, _, _, _ string) (*FileContent, error) {

gitea/url.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package gitea
2+
3+
import (
4+
"fmt"
5+
"strconv"
6+
7+
"github.com/git-pkgs/forge"
8+
)
9+
10+
// ParsePath implements Forge.ParsePath for Gitea/Forgejo URLs.
11+
func (f *giteaForge) ParsePath(parts []string) (*forges.ResourceRef, error) {
12+
if len(parts) < 2 {
13+
return nil, fmt.Errorf("URL path must contain owner/repo")
14+
}
15+
16+
ref := &forges.ResourceRef{
17+
Owner: parts[0],
18+
Repo: parts[1],
19+
}
20+
21+
if len(parts) >= 4 {
22+
num, err := strconv.Atoi(parts[3])
23+
if err != nil {
24+
return nil, fmt.Errorf("invalid number %q", parts[3])
25+
}
26+
ref.Number = num
27+
28+
switch parts[2] {
29+
case "pulls":
30+
ref.Type = forges.ResourceTypePR
31+
case "issues":
32+
ref.Type = forges.ResourceTypeIssue
33+
}
34+
}
35+
36+
return ref, nil
37+
}

gitea/url_test.go

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package gitea
2+
3+
import (
4+
"testing"
5+
6+
forges "github.com/git-pkgs/forge"
7+
)
8+
9+
func TestParsePath(t *testing.T) {
10+
tests := []struct {
11+
name string
12+
parts []string
13+
wantOwner string
14+
wantRepo string
15+
wantResource forges.ResourceType
16+
wantNumber int
17+
wantErr bool
18+
}{
19+
{
20+
name: "repo only",
21+
parts: []string{"owner", "repo"},
22+
wantOwner: "owner", wantRepo: "repo",
23+
},
24+
{
25+
name: "pull request",
26+
parts: []string{"owner", "repo", "pulls", "123"},
27+
wantOwner: "owner", wantRepo: "repo",
28+
wantResource: forges.ResourceTypePR, wantNumber: 123,
29+
},
30+
{
31+
name: "issue",
32+
parts: []string{"owner", "repo", "issues", "456"},
33+
wantOwner: "owner", wantRepo: "repo",
34+
wantResource: forges.ResourceTypeIssue, wantNumber: 456,
35+
},
36+
{
37+
name: "missing repo",
38+
parts: []string{"owner"},
39+
wantErr: true,
40+
},
41+
{
42+
name: "invalid PR number",
43+
parts: []string{"owner", "repo", "pulls", "abc"},
44+
wantErr: true,
45+
},
46+
}
47+
48+
f := &giteaForge{}
49+
for _, tt := range tests {
50+
t.Run(tt.name, func(t *testing.T) {
51+
ref, err := f.ParsePath(tt.parts)
52+
if tt.wantErr {
53+
if err == nil {
54+
t.Fatal("expected error")
55+
}
56+
return
57+
}
58+
if err != nil {
59+
t.Fatalf("unexpected error: %v", err)
60+
}
61+
if ref.Owner != tt.wantOwner {
62+
t.Errorf("owner: got %q, want %q", ref.Owner, tt.wantOwner)
63+
}
64+
if ref.Repo != tt.wantRepo {
65+
t.Errorf("repo: got %q, want %q", ref.Repo, tt.wantRepo)
66+
}
67+
if ref.Type != tt.wantResource {
68+
t.Errorf("resource: got %q, want %q", ref.Type, tt.wantResource)
69+
}
70+
if ref.Number != tt.wantNumber {
71+
t.Errorf("number: got %d, want %d", ref.Number, tt.wantNumber)
72+
}
73+
})
74+
}
75+
}

github/url.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package github
2+
3+
import (
4+
"fmt"
5+
"strconv"
6+
7+
forges "github.com/git-pkgs/forge"
8+
)
9+
10+
// ParsePath implements Forge.ParsePath for GitHub URLs.
11+
func (f *gitHubForge) ParsePath(parts []string) (*forges.ResourceRef, error) {
12+
if len(parts) < 2 {
13+
return nil, fmt.Errorf("URL path must contain owner/repo")
14+
}
15+
16+
ref := &forges.ResourceRef{
17+
Owner: parts[0],
18+
Repo: parts[1],
19+
}
20+
21+
if len(parts) >= 4 {
22+
num, err := strconv.Atoi(parts[3])
23+
if err != nil {
24+
return nil, fmt.Errorf("invalid number %q", parts[3])
25+
}
26+
ref.Number = num
27+
28+
switch parts[2] {
29+
case "pull":
30+
ref.Type = forges.ResourceTypePR
31+
case "issues":
32+
ref.Type = forges.ResourceTypeIssue
33+
}
34+
}
35+
36+
return ref, nil
37+
}

github/url_test.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package github
2+
3+
import (
4+
"testing"
5+
6+
forges "github.com/git-pkgs/forge"
7+
)
8+
9+
func TestParsePath(t *testing.T) {
10+
tests := []struct {
11+
name string
12+
parts []string
13+
wantOwner string
14+
wantRepo string
15+
wantResource forges.ResourceType
16+
wantNumber int
17+
wantErr bool
18+
}{
19+
{
20+
name: "repo only",
21+
parts: []string{"owner", "repo"},
22+
wantOwner: "owner", wantRepo: "repo",
23+
},
24+
{
25+
name: "pull request",
26+
parts: []string{"owner", "repo", "pull", "123"},
27+
wantOwner: "owner", wantRepo: "repo",
28+
wantResource: forges.ResourceTypePR, wantNumber: 123,
29+
},
30+
{
31+
name: "pull request with extra path",
32+
parts: []string{"owner", "repo", "pull", "123", "files"},
33+
wantOwner: "owner", wantRepo: "repo",
34+
wantResource: forges.ResourceTypePR, wantNumber: 123,
35+
},
36+
{
37+
name: "issue",
38+
parts: []string{"owner", "repo", "issues", "456"},
39+
wantOwner: "owner", wantRepo: "repo",
40+
wantResource: forges.ResourceTypeIssue, wantNumber: 456,
41+
},
42+
{
43+
name: "missing repo",
44+
parts: []string{"owner"},
45+
wantErr: true,
46+
},
47+
{
48+
name: "invalid PR number",
49+
parts: []string{"owner", "repo", "pull", "abc"},
50+
wantErr: true,
51+
},
52+
}
53+
54+
f := &gitHubForge{}
55+
for _, tt := range tests {
56+
t.Run(tt.name, func(t *testing.T) {
57+
ref, err := f.ParsePath(tt.parts)
58+
if tt.wantErr {
59+
if err == nil {
60+
t.Fatal("expected error")
61+
}
62+
return
63+
}
64+
if err != nil {
65+
t.Fatalf("unexpected error: %v", err)
66+
}
67+
if ref.Owner != tt.wantOwner {
68+
t.Errorf("owner: got %q, want %q", ref.Owner, tt.wantOwner)
69+
}
70+
if ref.Repo != tt.wantRepo {
71+
t.Errorf("repo: got %q, want %q", ref.Repo, tt.wantRepo)
72+
}
73+
if ref.Type != tt.wantResource {
74+
t.Errorf("resource: got %q, want %q", ref.Type, tt.wantResource)
75+
}
76+
if ref.Number != tt.wantNumber {
77+
t.Errorf("number: got %d, want %d", ref.Number, tt.wantNumber)
78+
}
79+
})
80+
}
81+
}

0 commit comments

Comments
 (0)