diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-11 18:28:41 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-11 18:28:41 +0200 |
| commit | 9706e53620045c20bf732054a286183c70f77581 (patch) | |
| tree | 166e822e9d41b0cc980d4481c5c175ec7164a107 | |
| parent | 1c12325c64bb734dd0ae95a0c803de1ff45d2b4c (diff) | |
fix(api): add timed shared HTTP client
| -rw-r--r-- | internal/codeberg/codeberg.go | 80 | ||||
| -rw-r--r-- | internal/github/github.go | 77 | ||||
| -rw-r--r-- | internal/httpclient/client.go | 30 | ||||
| -rw-r--r-- | internal/httpclient/client_test.go | 44 | ||||
| -rw-r--r-- | internal/release/release.go | 68 |
5 files changed, 205 insertions, 94 deletions
diff --git a/internal/codeberg/codeberg.go b/internal/codeberg/codeberg.go index 7ef583d..9ecb3d8 100644 --- a/internal/codeberg/codeberg.go +++ b/internal/codeberg/codeberg.go @@ -9,6 +9,8 @@ import ( "os" "path/filepath" "time" + + "codeberg.org/snonux/gitsyncer/internal/httpclient" ) // Repository represents a Codeberg/Gitea repository @@ -77,15 +79,16 @@ func (c *Client) HasToken() bool { func (c *Client) GetRepo(repoName string) (Repository, bool, error) { var repo Repository url := fmt.Sprintf("%s/repos/%s/%s", c.baseURL, c.org, repoName) - req, err := http.NewRequest("GET", url, nil) + req, cancel, err := httpclient.NewRequest(http.MethodGet, url, nil) if err != nil { return repo, false, err } + defer cancel() if c.HasToken() { req.Header.Set("Authorization", "token "+c.token) } - resp, err := http.DefaultClient.Do(req) + resp, err := httpclient.Do(req) if err != nil { return repo, false, err } @@ -120,14 +123,15 @@ func (c *Client) UpdateRepoDescription(repoName, description string) error { return err } - req, err := http.NewRequest("PATCH", url, bytes.NewBuffer(body)) + req, cancel, err := httpclient.NewRequest(http.MethodPatch, url, bytes.NewBuffer(body)) if err != nil { return err } + defer cancel() req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "token "+c.token) - resp, err := http.DefaultClient.Do(req) + resp, err := httpclient.Do(req) if err != nil { return err } @@ -149,19 +153,9 @@ func (c *Client) ListPublicRepos() ([]Repository, error) { for { url := fmt.Sprintf("%s/orgs/%s/repos?page=%d&limit=%d", c.baseURL, c.org, page, perPage) - resp, err := http.Get(url) + repos, err := c.listReposPage(url) if err != nil { - return nil, fmt.Errorf("failed to fetch repositories: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - return nil, fmt.Errorf("API returned status %d", resp.StatusCode) - } - - var repos []Repository - if err := json.NewDecoder(resp.Body).Decode(&repos); err != nil { - return nil, fmt.Errorf("failed to parse response: %w", err) + return nil, err } // Filter only public, non-fork, non-archived, non-empty repos @@ -191,19 +185,9 @@ func (c *Client) ListUserPublicRepos() ([]Repository, error) { for { url := fmt.Sprintf("%s/users/%s/repos?page=%d&limit=%d", c.baseURL, c.org, page, perPage) - resp, err := http.Get(url) + repos, err := c.listReposPage(url) if err != nil { - return nil, fmt.Errorf("failed to fetch repositories: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - return nil, fmt.Errorf("API returned status %d", resp.StatusCode) - } - - var repos []Repository - if err := json.NewDecoder(resp.Body).Decode(&repos); err != nil { - return nil, fmt.Errorf("failed to parse response: %w", err) + return nil, err } // Filter only public, non-fork, non-archived, non-empty repos @@ -233,19 +217,45 @@ func GetRepoNames(repos []Repository) []string { return names } +func (c *Client) listReposPage(url string) ([]Repository, error) { + req, cancel, err := httpclient.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, err + } + defer cancel() + + resp, err := httpclient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to fetch repositories: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("API returned status %d", resp.StatusCode) + } + + var repos []Repository + if err := json.NewDecoder(resp.Body).Decode(&repos); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return repos, nil +} + // RepoExists checks if a repository exists on Codeberg func (c *Client) RepoExists(repoName string) (bool, error) { url := fmt.Sprintf("%s/repos/%s/%s", c.baseURL, c.org, repoName) - req, err := http.NewRequest("GET", url, nil) + req, cancel, err := httpclient.NewRequest(http.MethodGet, url, nil) if err != nil { return false, err } + defer cancel() if c.HasToken() { req.Header.Set("Authorization", "token "+c.token) } - resp, err := http.DefaultClient.Do(req) + resp, err := httpclient.Do(req) if err != nil { return false, err } @@ -277,17 +287,18 @@ func (c *Client) CreateRepo(repoName, description string, private bool) error { return err } - req, err := http.NewRequest("POST", url, bytes.NewBuffer(body)) + req, cancel, err := httpclient.NewRequest(http.MethodPost, url, bytes.NewBuffer(body)) if err != nil { return err } + defer cancel() req.Header.Set("Content-Type", "application/json") if c.HasToken() { req.Header.Set("Authorization", "token "+c.token) } - resp, err := http.DefaultClient.Do(req) + resp, err := httpclient.Do(req) if err != nil { return err } @@ -334,14 +345,15 @@ func (c *Client) DeleteRepo(repoName string) error { url := fmt.Sprintf("%s/repos/%s/%s", c.baseURL, c.org, repoName) - req, err := http.NewRequest("DELETE", url, nil) + req, cancel, err := httpclient.NewRequest(http.MethodDelete, url, nil) if err != nil { return err } + defer cancel() req.Header.Set("Authorization", "token "+c.token) - resp, err := http.DefaultClient.Do(req) + resp, err := httpclient.Do(req) if err != nil { return err } diff --git a/internal/github/github.go b/internal/github/github.go index c886e43..2667658 100644 --- a/internal/github/github.go +++ b/internal/github/github.go @@ -9,6 +9,8 @@ import ( "os" "path/filepath" "strings" + + "codeberg.org/snonux/gitsyncer/internal/httpclient" ) // Client handles GitHub API operations @@ -85,15 +87,16 @@ func (c *Client) RepoExists(repoName string) (bool, error) { url := fmt.Sprintf("https://api.github.com/repos/%s/%s", c.org, repoName) fmt.Printf(" Checking URL: %s\n", url) - req, err := http.NewRequest("GET", url, nil) + req, cancel, err := httpclient.NewRequest(http.MethodGet, url, nil) if err != nil { return false, err } + defer cancel() req.Header.Set("Authorization", "Bearer "+c.token) req.Header.Set("Accept", "application/vnd.github.v3+json") - resp, err := http.DefaultClient.Do(req) + resp, err := httpclient.Do(req) if err != nil { return false, err } @@ -145,16 +148,17 @@ func (c *Client) CreateRepo(repoName, description string, private bool) error { return err } - req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonBody)) + req, cancel, err := httpclient.NewRequest(http.MethodPost, url, bytes.NewBuffer(jsonBody)) if err != nil { return err } + defer cancel() req.Header.Set("Authorization", "Bearer "+c.token) req.Header.Set("Accept", "application/vnd.github.v3+json") req.Header.Set("Content-Type", "application/json") - resp, err := http.DefaultClient.Do(req) + resp, err := httpclient.Do(req) if err != nil { return err } @@ -196,14 +200,15 @@ func (c *Client) GetRepo(repoName string) (Repository, bool, error) { } url := fmt.Sprintf("https://api.github.com/repos/%s/%s", c.org, repoName) - req, err := http.NewRequest("GET", url, nil) + req, cancel, err := httpclient.NewRequest(http.MethodGet, url, nil) if err != nil { return repo, false, err } + defer cancel() req.Header.Set("Authorization", "Bearer "+c.token) req.Header.Set("Accept", "application/vnd.github.v3+json") - resp, err := http.DefaultClient.Do(req) + resp, err := httpclient.Do(req) if err != nil { return repo, false, err } @@ -238,15 +243,16 @@ func (c *Client) UpdateRepoDescription(repoName, description string) error { return err } - req, err := http.NewRequest("PATCH", url, bytes.NewBuffer(body)) + req, cancel, err := httpclient.NewRequest(http.MethodPatch, url, bytes.NewBuffer(body)) if err != nil { return err } + defer cancel() req.Header.Set("Authorization", "Bearer "+c.token) req.Header.Set("Accept", "application/vnd.github.v3+json") req.Header.Set("Content-Type", "application/json") - resp, err := http.DefaultClient.Do(req) + resp, err := httpclient.Do(req) if err != nil { return err } @@ -284,30 +290,11 @@ func (c *Client) ListPublicRepos() ([]Repository, error) { url := fmt.Sprintf("https://api.github.com/users/%s/repos?page=%d&per_page=%d&type=owner", c.org, page, perPage) fmt.Printf(" Fetching page %d...\n", page) - req, err := http.NewRequest("GET", url, nil) + repos, err := c.listPublicReposPage(url) if err != nil { return nil, err } - req.Header.Set("Authorization", "Bearer "+c.token) - req.Header.Set("Accept", "application/vnd.github.v3+json") - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - body, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("failed to list repos: status %d: %s", resp.StatusCode, string(body)) - } - - var repos []Repository - if err := json.NewDecoder(resp.Body).Decode(&repos); err != nil { - return nil, fmt.Errorf("failed to decode response: %w", err) - } - // Filter for public, non-fork, non-archived for _, repo := range repos { if !repo.Private && !repo.Fork && !repo.Archived && !repo.Disabled { @@ -325,6 +312,35 @@ func (c *Client) ListPublicRepos() ([]Repository, error) { return allRepos, nil } +func (c *Client) listPublicReposPage(url string) ([]Repository, error) { + req, cancel, err := httpclient.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, err + } + defer cancel() + + req.Header.Set("Authorization", "Bearer "+c.token) + req.Header.Set("Accept", "application/vnd.github.v3+json") + + resp, err := httpclient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("failed to list repos: status %d: %s", resp.StatusCode, string(body)) + } + + var repos []Repository + if err := json.NewDecoder(resp.Body).Decode(&repos); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return repos, nil +} + // GetRepoNames extracts repository names from a list of repos func GetRepoNames(repos []Repository) []string { names := make([]string, len(repos)) @@ -352,15 +368,16 @@ func (c *Client) DeleteRepo(repoName string) error { url := fmt.Sprintf("https://api.github.com/repos/%s/%s", c.org, repoName) - req, err := http.NewRequest("DELETE", url, nil) + req, cancel, err := httpclient.NewRequest(http.MethodDelete, url, nil) if err != nil { return err } + defer cancel() req.Header.Set("Authorization", "Bearer "+c.token) req.Header.Set("Accept", "application/vnd.github.v3+json") - resp, err := http.DefaultClient.Do(req) + resp, err := httpclient.Do(req) if err != nil { return err } diff --git a/internal/httpclient/client.go b/internal/httpclient/client.go new file mode 100644 index 0000000..5c57cdc --- /dev/null +++ b/internal/httpclient/client.go @@ -0,0 +1,30 @@ +package httpclient + +import ( + "context" + "io" + "net/http" + "time" +) + +const DefaultTimeout = 30 * time.Second + +var defaultClient = &http.Client{ + Timeout: DefaultTimeout, +} + +func Do(req *http.Request) (*http.Response, error) { + return defaultClient.Do(req) +} + +func NewRequest(method, url string, body io.Reader) (*http.Request, context.CancelFunc, error) { + ctx, cancel := context.WithTimeout(context.Background(), DefaultTimeout) + + req, err := http.NewRequestWithContext(ctx, method, url, body) + if err != nil { + cancel() + return nil, nil, err + } + + return req, cancel, nil +} diff --git a/internal/httpclient/client_test.go b/internal/httpclient/client_test.go new file mode 100644 index 0000000..a8b2216 --- /dev/null +++ b/internal/httpclient/client_test.go @@ -0,0 +1,44 @@ +package httpclient + +import ( + "net/http" + "testing" + "time" +) + +func TestNewRequest_SetsDeadline(t *testing.T) { + req, cancel, err := NewRequest(http.MethodGet, "https://example.com", nil) + if err != nil { + t.Fatalf("NewRequest returned error: %v", err) + } + defer cancel() + + deadline, ok := req.Context().Deadline() + if !ok { + t.Fatal("expected request context to include a deadline") + } + + remaining := time.Until(deadline) + if remaining <= 0 { + t.Fatalf("expected future deadline, got %v", remaining) + } + if remaining > DefaultTimeout+time.Second { + t.Fatalf("expected deadline near %v, got %v", DefaultTimeout, remaining) + } +} + +func TestNewRequest_InvalidMethod(t *testing.T) { + req, cancel, err := NewRequest("bad method", "https://example.com", nil) + if cancel != nil { + defer cancel() + } + if err == nil { + t.Fatalf("expected invalid method error, got request %#v", req) + } +} + +func TestDo_UsesSharedTimeout(t *testing.T) { + if defaultClient.Timeout != DefaultTimeout { + t.Fatalf("expected shared timeout %v, got %v", DefaultTimeout, defaultClient.Timeout) + } +} diff --git a/internal/release/release.go b/internal/release/release.go index 2acd621..6d6091f 100644 --- a/internal/release/release.go +++ b/internal/release/release.go @@ -11,6 +11,8 @@ import ( "regexp" "sort" "strings" + + "codeberg.org/snonux/gitsyncer/internal/httpclient" ) // Tag represents a git tag @@ -64,12 +66,13 @@ func (m *Manager) EnsureCodebergReleasesEnabled(owner, repo string) error { // Fetch repository metadata infoURL := fmt.Sprintf("https://codeberg.org/api/v1/repos/%s/%s", owner, repo) - getReq, err := http.NewRequest("GET", infoURL, nil) + getReq, cancel, err := httpclient.NewRequest(http.MethodGet, infoURL, nil) if err != nil { return err } + defer cancel() getReq.Header.Set("Authorization", "token "+m.codebergToken) - resp, err := (&http.Client{}).Do(getReq) + resp, err := httpclient.Do(getReq) if err != nil { return err } @@ -95,13 +98,14 @@ func (m *Manager) EnsureCodebergReleasesEnabled(owner, repo string) error { if err != nil { return err } - patchReq, err := http.NewRequest("PATCH", infoURL, bytes.NewBuffer(body)) + patchReq, patchCancel, err := httpclient.NewRequest(http.MethodPatch, infoURL, bytes.NewBuffer(body)) if err != nil { return err } + defer patchCancel() patchReq.Header.Set("Authorization", "token "+m.codebergToken) patchReq.Header.Set("Content-Type", "application/json") - patchResp, err := (&http.Client{}).Do(patchReq) + patchResp, err := httpclient.Do(patchReq) if err != nil { return err } @@ -522,10 +526,11 @@ func (m *Manager) GenerateAIReleaseNotes(repoPath, repoName, tag string, allTags func (m *Manager) GetGitHubReleases(owner, repo string) ([]string, error) { url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases", owner, repo) - req, err := http.NewRequest("GET", url, nil) + req, cancel, err := httpclient.NewRequest(http.MethodGet, url, nil) if err != nil { return nil, err } + defer cancel() // Add GitHub token if available if m.githubToken != "" { @@ -533,8 +538,7 @@ func (m *Manager) GetGitHubReleases(owner, repo string) ([]string, error) { } req.Header.Set("Accept", "application/vnd.github.v3+json") - client := &http.Client{} - resp, err := client.Do(req) + resp, err := httpclient.Do(req) if err != nil { return nil, err } @@ -567,18 +571,18 @@ func (m *Manager) GetGitHubReleases(owner, repo string) ([]string, error) { func (m *Manager) GetCodebergReleases(owner, repo string) ([]string, error) { url := fmt.Sprintf("https://codeberg.org/api/v1/repos/%s/%s/releases", owner, repo) - req, err := http.NewRequest("GET", url, nil) + req, cancel, err := httpclient.NewRequest(http.MethodGet, url, nil) if err != nil { return nil, err } + defer cancel() // Add Codeberg token if available if m.codebergToken != "" { req.Header.Set("Authorization", "token "+m.codebergToken) } - client := &http.Client{} - resp, err := client.Do(req) + resp, err := httpclient.Do(req) if err != nil { return nil, err } @@ -649,17 +653,17 @@ func (m *Manager) CreateGitHubRelease(owner, repo, tag, releaseNotes string) err return err } - req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) + req, cancel, err := httpclient.NewRequest(http.MethodPost, url, bytes.NewBuffer(jsonData)) if err != nil { return err } + defer cancel() req.Header.Set("Authorization", "Bearer "+m.githubToken) req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/vnd.github.v3+json") - client := &http.Client{} - resp, err := client.Do(req) + resp, err := httpclient.Do(req) if err != nil { return err } @@ -702,17 +706,17 @@ func (m *Manager) CreateCodebergRelease(owner, repo, tag, releaseNotes string) e return err } - req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) + req, cancel, err := httpclient.NewRequest(http.MethodPost, url, bytes.NewBuffer(jsonData)) if err != nil { return err } + defer cancel() req.Header.Set("Authorization", "token "+m.codebergToken) req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") - client := &http.Client{} - resp, err := client.Do(req) + resp, err := httpclient.Do(req) if err != nil { return err } @@ -725,13 +729,14 @@ func (m *Manager) CreateCodebergRelease(owner, repo, tag, releaseNotes string) e if resp.StatusCode == 404 { // Probe repository details to distinguish scenarios probeURL := fmt.Sprintf("https://codeberg.org/api/v1/repos/%s/%s", owner, repo) - probeReq, perr := http.NewRequest("GET", probeURL, nil) + probeReq, probeCancel, perr := httpclient.NewRequest(http.MethodGet, probeURL, nil) if perr == nil { + defer probeCancel() // Prefer probing with the same token if m.codebergToken != "" { probeReq.Header.Set("Authorization", "token "+m.codebergToken) } - if probeResp, perr2 := (&http.Client{}).Do(probeReq); perr2 == nil { + if probeResp, perr2 := httpclient.Do(probeReq); perr2 == nil { defer probeResp.Body.Close() if probeResp.StatusCode == 200 { // Try to detect if releases are disabled @@ -749,14 +754,15 @@ func (m *Manager) CreateCodebergRelease(owner, repo, tag, releaseNotes string) e ) } // Retry POST after enabling - retryReq, rerr := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) + retryReq, retryCancel, rerr := httpclient.NewRequest(http.MethodPost, url, bytes.NewBuffer(jsonData)) if rerr != nil { return rerr } + defer retryCancel() retryReq.Header.Set("Authorization", "token "+m.codebergToken) retryReq.Header.Set("Content-Type", "application/json") retryReq.Header.Set("Accept", "application/json") - retryResp, rerr := (&http.Client{}).Do(retryReq) + retryResp, rerr := httpclient.Do(retryReq) if rerr != nil { return rerr } @@ -835,16 +841,16 @@ func (m *Manager) UpdateGitHubRelease(owner, repo, tag, releaseNotes string) err // First, get the release ID url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/tags/%s", owner, repo, tag) - req, err := http.NewRequest("GET", url, nil) + req, cancel, err := httpclient.NewRequest(http.MethodGet, url, nil) if err != nil { return err } + defer cancel() req.Header.Set("Authorization", "Bearer "+m.githubToken) req.Header.Set("Accept", "application/vnd.github.v3+json") - client := &http.Client{} - resp, err := client.Do(req) + resp, err := httpclient.Do(req) if err != nil { return err } @@ -876,16 +882,17 @@ func (m *Manager) UpdateGitHubRelease(owner, repo, tag, releaseNotes string) err return err } - updateReq, err := http.NewRequest("PATCH", updateURL, bytes.NewBuffer(jsonData)) + updateReq, updateCancel, err := httpclient.NewRequest(http.MethodPatch, updateURL, bytes.NewBuffer(jsonData)) if err != nil { return err } + defer updateCancel() updateReq.Header.Set("Authorization", "Bearer "+m.githubToken) updateReq.Header.Set("Content-Type", "application/json") updateReq.Header.Set("Accept", "application/vnd.github.v3+json") - updateResp, err := client.Do(updateReq) + updateResp, err := httpclient.Do(updateReq) if err != nil { return err } @@ -908,15 +915,15 @@ func (m *Manager) UpdateCodebergRelease(owner, repo, tag, releaseNotes string) e // First, get the release ID url := fmt.Sprintf("https://codeberg.org/api/v1/repos/%s/%s/releases/tags/%s", owner, repo, tag) - req, err := http.NewRequest("GET", url, nil) + req, cancel, err := httpclient.NewRequest(http.MethodGet, url, nil) if err != nil { return err } + defer cancel() req.Header.Set("Authorization", "token "+m.codebergToken) - client := &http.Client{} - resp, err := client.Do(req) + resp, err := httpclient.Do(req) if err != nil { return err } @@ -948,15 +955,16 @@ func (m *Manager) UpdateCodebergRelease(owner, repo, tag, releaseNotes string) e return err } - updateReq, err := http.NewRequest("PATCH", updateURL, bytes.NewBuffer(jsonData)) + updateReq, updateCancel, err := httpclient.NewRequest(http.MethodPatch, updateURL, bytes.NewBuffer(jsonData)) if err != nil { return err } + defer updateCancel() updateReq.Header.Set("Authorization", "token "+m.codebergToken) updateReq.Header.Set("Content-Type", "application/json") - updateResp, err := client.Do(updateReq) + updateResp, err := httpclient.Do(updateReq) if err != nil { return err } |
