Skip to content

Commit f6e1cf1

Browse files
committed
forge: extract shared URL parsing utilities
Extract duplicate URL parsing code from github and gitlab forges into a new internal/forge/urls package. The new package provides: - HasGitProtocol: check if URL has a known git protocol - Parse: parse git remote URLs, normalizing SCP-style SSH syntax - StripDefaultPort: remove default ports when base URL doesn't specify one - MatchesHost: check if hosts match including subdomains - ExtractPath: extract owner/repo from URL path This reduces code duplication and will allow the bitbucket forge to reuse the same URL parsing logic.
1 parent 83d18b5 commit f6e1cf1

File tree

5 files changed

+386
-132
lines changed

5 files changed

+386
-132
lines changed

CLAUDE.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,9 @@ Where:
329329
or command domains (e.g., "submit", "github").
330330
- See CHANGELOG.md for existing patterns.
331331
332+
To skip the changelog check for internal changes, refactors,
333+
or test-only changes, include `[skip changelog]: <cause description>` in the PR description as a trailer.
334+
332335
# Code Quality
333336
334337
- Never introduce new third-party dependencies.
@@ -343,6 +346,23 @@ Where:
343346
344347
Never exceed 120 characters per line.
345348
349+
- **Package naming**
350+
351+
Do not pluralize package names.
352+
Use singular nouns or compound names instead.
353+
354+
```
355+
// BAD: pluralized package names
356+
urls
357+
utils
358+
helpers
359+
360+
// GOOD: singular or compound names
361+
forgeurl
362+
stringutil
363+
testhelper
364+
```
365+
346366
- **Logical grouping with comments**
347367
348368
Use descriptive section headers to group related operations

internal/forge/forgeurl/urls.go

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
// Package forgeurl provides shared URL parsing utilities for forge implementations.
2+
package forgeurl
3+
4+
import (
5+
"fmt"
6+
"net"
7+
"net/url"
8+
"strings"
9+
)
10+
11+
// _gitProtocols is a list of known git protocols including the :// suffix.
12+
var _gitProtocols []string
13+
14+
func init() {
15+
protocols := []string{
16+
"ssh",
17+
"git",
18+
"git+ssh",
19+
"git+https",
20+
"git+http",
21+
"https",
22+
"http",
23+
}
24+
_gitProtocols = make([]string, len(protocols))
25+
for i, proto := range protocols {
26+
_gitProtocols[i] = proto + "://"
27+
}
28+
}
29+
30+
// HasGitProtocol reports whether the URL starts with a known git protocol.
31+
func HasGitProtocol(rawURL string) bool {
32+
for _, proto := range _gitProtocols {
33+
if strings.HasPrefix(rawURL, proto) {
34+
return true
35+
}
36+
}
37+
return false
38+
}
39+
40+
// Parse parses a git remote URL, normalizing SSH shorthand syntax.
41+
//
42+
// It converts SCP-style URLs (git@host:path) to standard SSH URLs
43+
// (ssh://git@host/path) before parsing.
44+
func Parse(rawURL string) (*url.URL, error) {
45+
if !HasGitProtocol(rawURL) && strings.Contains(rawURL, ":") {
46+
rawURL = "ssh://" + strings.Replace(rawURL, ":", "/", 1)
47+
}
48+
49+
u, err := url.Parse(rawURL)
50+
if err != nil {
51+
return nil, fmt.Errorf("parse remote URL: %w", err)
52+
}
53+
return u, nil
54+
}
55+
56+
// StripDefaultPort removes default HTTP/HTTPS ports (80, 443) from the
57+
// remote URL's host when the base URL doesn't explicitly specify a port.
58+
func StripDefaultPort(baseURL, remoteURL *url.URL) {
59+
if baseURL.Port() != "" {
60+
return
61+
}
62+
host, port, err := net.SplitHostPort(remoteURL.Host)
63+
if err != nil {
64+
return
65+
}
66+
if port == "443" || port == "80" {
67+
remoteURL.Host = host
68+
}
69+
}
70+
71+
// MatchesHost reports whether the remote URL's host matches the base URL's
72+
// host, either exactly or as a subdomain.
73+
func MatchesHost(baseURL, remoteURL *url.URL) bool {
74+
if remoteURL.Host == baseURL.Host {
75+
return true
76+
}
77+
return strings.HasSuffix(remoteURL.Host, "."+baseURL.Host)
78+
}
79+
80+
// ExtractPath extracts owner and repository name from a URL path.
81+
//
82+
// It strips leading/trailing slashes and the .git suffix, then splits
83+
// on the first slash to get owner/repo components.
84+
func ExtractPath(path string) (owner, repo string, ok bool) {
85+
s := strings.TrimPrefix(path, "/")
86+
s = strings.TrimSuffix(s, "/")
87+
s = strings.TrimSuffix(s, ".git")
88+
89+
owner, repo, ok = strings.Cut(s, "/")
90+
return owner, repo, ok
91+
}
Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
package forgeurl
2+
3+
import (
4+
"net/url"
5+
"testing"
6+
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
)
10+
11+
func TestHasGitProtocol(t *testing.T) {
12+
tests := []struct {
13+
name string
14+
give string
15+
want bool
16+
}{
17+
{"HTTPS", "https://github.com/owner/repo", true},
18+
{"HTTP", "http://github.com/owner/repo", true},
19+
{"SSH protocol", "ssh://git@github.com/owner/repo", true},
20+
{"Git protocol", "git://github.com/owner/repo.git", true},
21+
{"Git+SSH", "git+ssh://git@github.com/owner/repo", true},
22+
{"Git+HTTPS", "git+https://github.com/owner/repo", true},
23+
{"Git+HTTP", "git+http://github.com/owner/repo", true},
24+
{"SCP-style SSH", "git@github.com:owner/repo", false},
25+
{"Plain path", "/path/to/repo", false},
26+
{"Empty", "", false},
27+
}
28+
29+
for _, tt := range tests {
30+
t.Run(tt.name, func(t *testing.T) {
31+
got := HasGitProtocol(tt.give)
32+
assert.Equal(t, tt.want, got)
33+
})
34+
}
35+
}
36+
37+
func TestParse(t *testing.T) {
38+
tests := []struct {
39+
name string
40+
give string
41+
wantHost string
42+
wantPath string
43+
}{
44+
{
45+
name: "HTTPS",
46+
give: "https://github.com/owner/repo",
47+
wantHost: "github.com",
48+
wantPath: "/owner/repo",
49+
},
50+
{
51+
name: "SSH protocol",
52+
give: "ssh://git@github.com/owner/repo",
53+
wantHost: "github.com",
54+
wantPath: "/owner/repo",
55+
},
56+
{
57+
name: "SCP-style SSH normalized",
58+
give: "git@github.com:owner/repo",
59+
wantHost: "github.com",
60+
wantPath: "/owner/repo",
61+
},
62+
{
63+
name: "SSH with port",
64+
give: "ssh://git@ssh.github.com:443/owner/repo",
65+
wantHost: "ssh.github.com:443",
66+
wantPath: "/owner/repo",
67+
},
68+
}
69+
70+
for _, tt := range tests {
71+
t.Run(tt.name, func(t *testing.T) {
72+
got, err := Parse(tt.give)
73+
require.NoError(t, err)
74+
75+
assert.Equal(t, tt.wantHost, got.Host)
76+
assert.Equal(t, tt.wantPath, got.Path)
77+
})
78+
}
79+
}
80+
81+
func TestParse_error(t *testing.T) {
82+
_, err := Parse("NOT\tA\nVALID URL")
83+
require.Error(t, err)
84+
assert.Contains(t, err.Error(), "parse remote URL")
85+
}
86+
87+
func TestStripDefaultPort(t *testing.T) {
88+
tests := []struct {
89+
name string
90+
baseURL string
91+
remoteHost string
92+
wantHost string
93+
}{
94+
{
95+
name: "strip 443",
96+
baseURL: "https://github.com",
97+
remoteHost: "github.com:443",
98+
wantHost: "github.com",
99+
},
100+
{
101+
name: "strip 80",
102+
baseURL: "http://github.com",
103+
remoteHost: "github.com:80",
104+
wantHost: "github.com",
105+
},
106+
{
107+
name: "keep custom port",
108+
baseURL: "https://github.com",
109+
remoteHost: "github.com:8443",
110+
wantHost: "github.com:8443",
111+
},
112+
{
113+
name: "base has port",
114+
baseURL: "https://github.com:443",
115+
remoteHost: "github.com:443",
116+
wantHost: "github.com:443",
117+
},
118+
{
119+
name: "no port to strip",
120+
baseURL: "https://github.com",
121+
remoteHost: "github.com",
122+
wantHost: "github.com",
123+
},
124+
}
125+
126+
for _, tt := range tests {
127+
t.Run(tt.name, func(t *testing.T) {
128+
baseURL, err := url.Parse(tt.baseURL)
129+
require.NoError(t, err)
130+
131+
remoteURL := &url.URL{Host: tt.remoteHost}
132+
StripDefaultPort(baseURL, remoteURL)
133+
134+
assert.Equal(t, tt.wantHost, remoteURL.Host)
135+
})
136+
}
137+
}
138+
139+
func TestMatchesHost(t *testing.T) {
140+
tests := []struct {
141+
name string
142+
baseHost string
143+
remoteHost string
144+
want bool
145+
}{
146+
{
147+
name: "exact match",
148+
baseHost: "github.com",
149+
remoteHost: "github.com",
150+
want: true,
151+
},
152+
{
153+
name: "subdomain match",
154+
baseHost: "github.com",
155+
remoteHost: "ssh.github.com",
156+
want: true,
157+
},
158+
{
159+
name: "no match",
160+
baseHost: "github.com",
161+
remoteHost: "gitlab.com",
162+
want: false,
163+
},
164+
{
165+
name: "partial suffix not a match",
166+
baseHost: "github.com",
167+
remoteHost: "notgithub.com",
168+
want: false,
169+
},
170+
}
171+
172+
for _, tt := range tests {
173+
t.Run(tt.name, func(t *testing.T) {
174+
baseURL := &url.URL{Host: tt.baseHost}
175+
remoteURL := &url.URL{Host: tt.remoteHost}
176+
177+
got := MatchesHost(baseURL, remoteURL)
178+
assert.Equal(t, tt.want, got)
179+
})
180+
}
181+
}
182+
183+
func TestExtractPath(t *testing.T) {
184+
tests := []struct {
185+
name string
186+
give string
187+
wantOwner string
188+
wantRepo string
189+
wantOK bool
190+
}{
191+
{
192+
name: "simple",
193+
give: "/owner/repo",
194+
wantOwner: "owner",
195+
wantRepo: "repo",
196+
wantOK: true,
197+
},
198+
{
199+
name: "with .git suffix",
200+
give: "/owner/repo.git",
201+
wantOwner: "owner",
202+
wantRepo: "repo",
203+
wantOK: true,
204+
},
205+
{
206+
name: "trailing slash",
207+
give: "/owner/repo/",
208+
wantOwner: "owner",
209+
wantRepo: "repo",
210+
wantOK: true,
211+
},
212+
{
213+
name: "both suffix and slash",
214+
give: "/owner/repo.git/",
215+
wantOwner: "owner",
216+
wantRepo: "repo",
217+
wantOK: true,
218+
},
219+
{
220+
name: "no leading slash",
221+
give: "owner/repo",
222+
wantOwner: "owner",
223+
wantRepo: "repo",
224+
wantOK: true,
225+
},
226+
{
227+
name: "no repo component",
228+
give: "/owner",
229+
wantOK: false,
230+
},
231+
{
232+
name: "empty",
233+
give: "",
234+
wantOK: false,
235+
},
236+
}
237+
238+
for _, tt := range tests {
239+
t.Run(tt.name, func(t *testing.T) {
240+
owner, repo, ok := ExtractPath(tt.give)
241+
242+
assert.Equal(t, tt.wantOK, ok)
243+
if tt.wantOK {
244+
assert.Equal(t, tt.wantOwner, owner)
245+
assert.Equal(t, tt.wantRepo, repo)
246+
}
247+
})
248+
}
249+
}

0 commit comments

Comments
 (0)