From 15752949e3c6f086f104b62b8db3d53b97f74737 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Tue, 24 Feb 2026 17:01:42 +0000 Subject: [PATCH] Add RawContentURL and FetchAndParse for remote changelogs RawContentURL builds a raw file URL from a GitHub or GitLab repository URL and filename. FetchAndParse combines URL construction, HTTP fetch, and parsing into one call. --- README.md | 15 ++++++++ fetch.go | 72 ++++++++++++++++++++++++++++++++++++ fetch_test.go | 100 ++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 187 insertions(+) create mode 100644 fetch.go create mode 100644 fetch_test.go diff --git a/README.md b/README.md index 02c4a29..0014aae 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/fetch.go b/fetch.go new file mode 100644 index 0000000..1998e6b --- /dev/null +++ b/fetch.go @@ -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 +} diff --git a/fetch_test.go b/fetch_test.go new file mode 100644 index 0000000..b6cf186 --- /dev/null +++ b/fetch_test.go @@ -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") + } + }) +}