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
20 changes: 20 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,9 @@ Where:
or command domains (e.g., "submit", "github").
- See CHANGELOG.md for existing patterns.

To skip the changelog check for internal changes, refactors,
or test-only changes, include `[skip changelog]: <cause description>` in the PR description as a trailer.

# Code Quality

- Never introduce new third-party dependencies.
Expand All @@ -343,6 +346,23 @@ Where:

Never exceed 120 characters per line.

- **Package naming**

Do not pluralize package names.
Use singular nouns or compound names instead.

```
// BAD: pluralized package names
urls
utils
helpers
Comment on lines +350 to +358
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❤️

thanks!


// GOOD: singular or compound names
forgeurl
stringutil
testhelper
```

- **Logical grouping with comments**

Use descriptive section headers to group related operations
Expand Down
91 changes: 91 additions & 0 deletions internal/forge/forgeurl/urls.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// Package forgeurl provides shared URL parsing utilities for forge implementations.
package forgeurl

import (
"fmt"
"net"
"net/url"
"strings"
)

// _gitProtocols is a list of known git protocols including the :// suffix.
var _gitProtocols []string

func init() {
protocols := []string{
"ssh",
"git",
"git+ssh",
"git+https",
"git+http",
"https",
"http",
}
_gitProtocols = make([]string, len(protocols))
for i, proto := range protocols {
_gitProtocols[i] = proto + "://"
}
}

// HasGitProtocol reports whether the URL starts with a known git protocol.
func HasGitProtocol(rawURL string) bool {
for _, proto := range _gitProtocols {
if strings.HasPrefix(rawURL, proto) {
return true
}
}
return false
}

// Parse parses a git remote URL, normalizing SSH shorthand syntax.
//
// It converts SCP-style URLs (git@host:path) to standard SSH URLs
// (ssh://git@host/path) before parsing.
func Parse(rawURL string) (*url.URL, error) {
if !HasGitProtocol(rawURL) && strings.Contains(rawURL, ":") {
rawURL = "ssh://" + strings.Replace(rawURL, ":", "/", 1)
}

u, err := url.Parse(rawURL)
if err != nil {
return nil, fmt.Errorf("parse remote URL: %w", err)
}
return u, nil
}

// StripDefaultPort removes default HTTP/HTTPS ports (80, 443) from the
// remote URL's host when the base URL doesn't explicitly specify a port.
func StripDefaultPort(baseURL, remoteURL *url.URL) {
if baseURL.Port() != "" {
return
}
host, port, err := net.SplitHostPort(remoteURL.Host)
if err != nil {
return
}
if port == "443" || port == "80" {
remoteURL.Host = host
}
}

// MatchesHost reports whether the remote URL's host matches the base URL's
// host, either exactly or as a subdomain.
func MatchesHost(baseURL, remoteURL *url.URL) bool {
if remoteURL.Host == baseURL.Host {
return true
}
return strings.HasSuffix(remoteURL.Host, "."+baseURL.Host)
}

// ExtractPath extracts owner and repository name from a URL path.
//
// It strips leading/trailing slashes and the .git suffix, then splits
// on the first slash to get owner/repo components.
func ExtractPath(path string) (owner, repo string, ok bool) {
s := strings.TrimPrefix(path, "/")
s = strings.TrimSuffix(s, "/")
s = strings.TrimSuffix(s, ".git")

owner, repo, ok = strings.Cut(s, "/")
return owner, repo, ok
}
249 changes: 249 additions & 0 deletions internal/forge/forgeurl/urls_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
package forgeurl

import (
"net/url"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestHasGitProtocol(t *testing.T) {
tests := []struct {
name string
give string
want bool
}{
{"HTTPS", "https://github.com/owner/repo", true},
{"HTTP", "http://github.com/owner/repo", true},
{"SSH protocol", "ssh://git@github.com/owner/repo", true},
{"Git protocol", "git://github.com/owner/repo.git", true},
{"Git+SSH", "git+ssh://git@github.com/owner/repo", true},
{"Git+HTTPS", "git+https://github.com/owner/repo", true},
{"Git+HTTP", "git+http://github.com/owner/repo", true},
{"SCP-style SSH", "git@github.com:owner/repo", false},
{"Plain path", "/path/to/repo", false},
{"Empty", "", false},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := HasGitProtocol(tt.give)
assert.Equal(t, tt.want, got)
})
}
}

func TestParse(t *testing.T) {
tests := []struct {
name string
give string
wantHost string
wantPath string
}{
{
name: "HTTPS",
give: "https://github.com/owner/repo",
wantHost: "github.com",
wantPath: "/owner/repo",
},
{
name: "SSH protocol",
give: "ssh://git@github.com/owner/repo",
wantHost: "github.com",
wantPath: "/owner/repo",
},
{
name: "SCP-style SSH normalized",
give: "git@github.com:owner/repo",
wantHost: "github.com",
wantPath: "/owner/repo",
},
{
name: "SSH with port",
give: "ssh://git@ssh.github.com:443/owner/repo",
wantHost: "ssh.github.com:443",
wantPath: "/owner/repo",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := Parse(tt.give)
require.NoError(t, err)

assert.Equal(t, tt.wantHost, got.Host)
assert.Equal(t, tt.wantPath, got.Path)
})
}
}

func TestParse_error(t *testing.T) {
_, err := Parse("NOT\tA\nVALID URL")
require.Error(t, err)
assert.Contains(t, err.Error(), "parse remote URL")
}

func TestStripDefaultPort(t *testing.T) {
tests := []struct {
name string
baseURL string
remoteHost string
wantHost string
}{
{
name: "strip 443",
baseURL: "https://github.com",
remoteHost: "github.com:443",
wantHost: "github.com",
},
{
name: "strip 80",
baseURL: "http://github.com",
remoteHost: "github.com:80",
wantHost: "github.com",
},
{
name: "keep custom port",
baseURL: "https://github.com",
remoteHost: "github.com:8443",
wantHost: "github.com:8443",
},
{
name: "base has port",
baseURL: "https://github.com:443",
remoteHost: "github.com:443",
wantHost: "github.com:443",
},
{
name: "no port to strip",
baseURL: "https://github.com",
remoteHost: "github.com",
wantHost: "github.com",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
baseURL, err := url.Parse(tt.baseURL)
require.NoError(t, err)

remoteURL := &url.URL{Host: tt.remoteHost}
StripDefaultPort(baseURL, remoteURL)

assert.Equal(t, tt.wantHost, remoteURL.Host)
})
}
}

func TestMatchesHost(t *testing.T) {
tests := []struct {
name string
baseHost string
remoteHost string
want bool
}{
{
name: "exact match",
baseHost: "github.com",
remoteHost: "github.com",
want: true,
},
{
name: "subdomain match",
baseHost: "github.com",
remoteHost: "ssh.github.com",
want: true,
},
{
name: "no match",
baseHost: "github.com",
remoteHost: "gitlab.com",
want: false,
},
{
name: "partial suffix not a match",
baseHost: "github.com",
remoteHost: "notgithub.com",
want: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
baseURL := &url.URL{Host: tt.baseHost}
remoteURL := &url.URL{Host: tt.remoteHost}

got := MatchesHost(baseURL, remoteURL)
assert.Equal(t, tt.want, got)
})
}
}

func TestExtractPath(t *testing.T) {
tests := []struct {
name string
give string
wantOwner string
wantRepo string
wantOK bool
}{
{
name: "simple",
give: "/owner/repo",
wantOwner: "owner",
wantRepo: "repo",
wantOK: true,
},
{
name: "with .git suffix",
give: "/owner/repo.git",
wantOwner: "owner",
wantRepo: "repo",
wantOK: true,
},
{
name: "trailing slash",
give: "/owner/repo/",
wantOwner: "owner",
wantRepo: "repo",
wantOK: true,
},
{
name: "both suffix and slash",
give: "/owner/repo.git/",
wantOwner: "owner",
wantRepo: "repo",
wantOK: true,
},
{
name: "no leading slash",
give: "owner/repo",
wantOwner: "owner",
wantRepo: "repo",
wantOK: true,
},
{
name: "no repo component",
give: "/owner",
wantOK: false,
},
{
name: "empty",
give: "",
wantOK: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
owner, repo, ok := ExtractPath(tt.give)

assert.Equal(t, tt.wantOK, ok)
if tt.wantOK {
assert.Equal(t, tt.wantOwner, owner)
assert.Equal(t, tt.wantRepo, repo)
}
})
}
}
Loading