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
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,21 @@ The first capture group is the version string. An optional second capture group
content, ok := p.Between("1.0.0", "2.0.0")
```

### Fetch and parse from a repository URL

```go
p, err := changelog.FetchAndParse(ctx, "https://github.com/owner/repo", "CHANGELOG.md")
```

Constructs a raw content URL (GitHub and GitLab are supported), fetches the file, and parses it.

You can also build the raw URL yourself:

```go
url, err := changelog.RawContentURL("https://github.com/owner/repo", "CHANGELOG.md")
// "https://raw.githubusercontent.com/owner/repo/HEAD/CHANGELOG.md"
```

### Find line number for a version

```go
Expand Down
72 changes: 72 additions & 0 deletions fetch.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package changelog

import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"strings"
)

// RawContentURL constructs a URL that serves the raw content of a file in a
// repository. Supports GitHub and GitLab. The repoURL should be the repository's
// web URL (e.g. "https://github.com/owner/repo"). Trailing ".git" suffixes and
// slashes are stripped automatically.
func RawContentURL(repoURL, filename string) (string, error) {
repoURL = strings.TrimSuffix(repoURL, ".git")
repoURL = strings.TrimSuffix(repoURL, "/")

parsed, err := url.Parse(repoURL)
if err != nil {
return "", fmt.Errorf("parsing repository URL: %w", err)
}

parts := strings.SplitN(strings.TrimPrefix(parsed.Path, "/"), "/", 3)
if len(parts) < 2 {
return "", fmt.Errorf("cannot parse owner/repo from %s", repoURL)
}
owner := parts[0]
repo := parts[1]

switch parsed.Host {
case "github.com":
return fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/HEAD/%s", owner, repo, filename), nil
case "gitlab.com":
return fmt.Sprintf("https://gitlab.com/%s/%s/-/raw/HEAD/%s", owner, repo, filename), nil
default:
return "", fmt.Errorf("unsupported host %s (only github.com and gitlab.com are supported)", parsed.Host)
}
}

// FetchAndParse fetches a changelog from a repository and parses it.
// It constructs the raw content URL from the repository URL and changelog
// filename, fetches the content over HTTP, and returns a Parser.
func FetchAndParse(ctx context.Context, repoURL, filename string) (*Parser, error) {
rawURL, err := RawContentURL(repoURL, filename)
if err != nil {
return nil, err
}

req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return nil, err
}

resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()

if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP %d fetching %s", resp.StatusCode, rawURL)
}

body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}

return Parse(string(body)), nil
}
100 changes: 100 additions & 0 deletions fetch_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package changelog

import (
"context"
"net/http"
"net/http/httptest"
"testing"
)

func TestRawContentURL(t *testing.T) {
tests := []struct {
name string
repoURL string
filename string
want string
wantErr bool
}{
{
name: "github https",
repoURL: "https://github.com/olivierlacan/keep-a-changelog",
filename: "CHANGELOG.md",
want: "https://raw.githubusercontent.com/olivierlacan/keep-a-changelog/HEAD/CHANGELOG.md",
},
{
name: "github with trailing .git",
repoURL: "https://github.com/lodash/lodash.git",
filename: "CHANGELOG.md",
want: "https://raw.githubusercontent.com/lodash/lodash/HEAD/CHANGELOG.md",
},
{
name: "github with trailing slash",
repoURL: "https://github.com/lodash/lodash/",
filename: "CHANGELOG.md",
want: "https://raw.githubusercontent.com/lodash/lodash/HEAD/CHANGELOG.md",
},
{
name: "gitlab https",
repoURL: "https://gitlab.com/inkscape/inkscape",
filename: "NEWS.md",
want: "https://gitlab.com/inkscape/inkscape/-/raw/HEAD/NEWS.md",
},
{
name: "gitlab with trailing .git",
repoURL: "https://gitlab.com/inkscape/inkscape.git",
filename: "NEWS.md",
want: "https://gitlab.com/inkscape/inkscape/-/raw/HEAD/NEWS.md",
},
{
name: "unsupported host",
repoURL: "https://bitbucket.org/owner/repo",
filename: "CHANGELOG.md",
wantErr: true,
},
{
name: "no path segments",
repoURL: "https://github.com/",
filename: "CHANGELOG.md",
wantErr: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := RawContentURL(tt.repoURL, tt.filename)
if tt.wantErr {
if err == nil {
t.Fatalf("expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tt.want {
t.Errorf("got %q, want %q", got, tt.want)
}
})
}
}

func TestFetchAndParse(t *testing.T) {
changelogContent := "## [2.0.0] - 2024-03-01\n\nNew features\n\n## [1.0.0] - 2024-01-01\n\nInitial release\n"

srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(changelogContent))
}))
defer srv.Close()

// We can't easily test with real GitHub/GitLab URLs, but we can test
// the parsing side by testing FetchAndParse's error handling and
// RawContentURL separately. For a real integration-like test, we'd
// need to mock the URL construction. Instead, test that unsupported
// hosts produce errors.
t.Run("unsupported host returns error", func(t *testing.T) {
_, err := FetchAndParse(context.Background(), "https://bitbucket.org/owner/repo", "CHANGELOG.md")
if err == nil {
t.Error("expected error for unsupported host")
}
})
}
Loading