diff --git a/.woodpecker/ci.yml b/.woodpecker/ci.yml new file mode 100644 index 0000000..6c22fe0 --- /dev/null +++ b/.woodpecker/ci.yml @@ -0,0 +1,26 @@ +# Go build, vet and test on PRs and pushes to main +when: + - event: pull_request + - event: push + branch: main + +labels: + platform: linux/amd64 + +clone: + git: + image: woodpeckerci/plugin-git + settings: + lfs: false + depth: 1 + +variables: + - &ci_image 'ghcr.io/keploy/keploy-ci:1.2.25' + +steps: + build: + image: *ci_image + commands: + - go build -o /dev/null . + - go vet ./... + - go test ./... diff --git a/handlers/api_test.go b/handlers/api_test.go index 600cd66..ca46e79 100644 --- a/handlers/api_test.go +++ b/handlers/api_test.go @@ -1,10 +1,13 @@ package handlers import ( + "encoding/json" "net/http" "net/http/httptest" "strings" "testing" + + cu "github.com/keploy/gitstats/common" ) // Test generated using Keploy @@ -165,14 +168,52 @@ func TestHandleStarHistory_MethodNotAllowed(t *testing.T) { // Test generated using Keploy func TestHandleActiveContributors_ValidOrgAndRepo(t *testing.T) { - req, err := http.NewRequest(http.MethodGet, "/active-contributors?repo=https://github.com/keploy/keploy&org=keploy", nil) - if err != nil { - t.Fatalf("Failed to create request: %v", err) - } + // Hermetic: stub the GitHub REST API so this never touches the network. + // (It previously hit live api.github.com with a weak Body.Len()>0 assertion + // that passed even on rate-limit/error responses.) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/orgs/keploy/members": + // one org member: must be excluded from active contributors + _, _ = w.Write([]byte(`[{"login":"orgdev"}]`)) + case "/repos/keploy/keploy/commits": + // an external contributor (2 commits) + an org member (1 commit) + _, _ = w.Write([]byte(`[ + {"author":{"login":"extuser"},"commit":{"author":{"date":"2026-09-10T10:00:00Z"}}}, + {"author":{"login":"extuser"},"commit":{"author":{"date":"2026-09-12T10:00:00Z"}}}, + {"author":{"login":"orgdev"},"commit":{"author":{"date":"2026-09-11T10:00:00Z"}}} + ]`)) + default: + _, _ = w.Write([]byte(`[]`)) + } + })) + defer srv.Close() + + orig := githubAPIBaseURL + githubAPIBaseURL = srv.URL + defer func() { githubAPIBaseURL = orig }() + + req := httptest.NewRequest(http.MethodGet, "/active-contributors?repo=https://github.com/keploy/keploy&org=keploy", nil) rr := httptest.NewRecorder() HandleActiveContributors(rr, req) - if rr.Body.Len() == 0 { - t.Errorf("Expected non-empty response body") + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body=%s", rr.Code, http.StatusOK, rr.Body.String()) + } + + var resp cu.ActiveContributorsResponse + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("invalid JSON response: %v; body=%s", err, rr.Body.String()) + } + if resp.RepoName != "keploy/keploy" { + t.Errorf("repo_name = %q, want %q", resp.RepoName, "keploy/keploy") + } + // orgdev is an org member -> excluded; extuser is external -> the sole active + // contributor, with both of its commits counted. + if len(resp.ActiveContributors) != 1 { + t.Fatalf("active_contributors = %d (%+v), want 1", len(resp.ActiveContributors), resp.ActiveContributors) + } + if got := resp.ActiveContributors[0]; got.Login != "extuser" || got.Contributions != 2 { + t.Errorf("contributor = %+v, want {Login:extuser Contributions:2}", got) } } diff --git a/handlers/health.go b/handlers/health.go new file mode 100644 index 0000000..6a75f93 --- /dev/null +++ b/handlers/health.go @@ -0,0 +1,20 @@ +package handlers + +import "net/http" + +// Healthz is a liveness probe: it returns 200 as long as the HTTP server is +// running and able to serve requests. It performs no I/O and has no external +// dependencies, so it stays cheap and never restarts the pod for a slow +// upstream (gitstats talks to the GitHub API only per user request). +func Healthz(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) +} + +// Readyz is a readiness probe. gitstats has no hard startup dependency — it +// serves static pages and fetches GitHub data lazily per request — so being +// ready is equivalent to the HTTP server accepting connections. +func Readyz(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ready")) +} diff --git a/handlers/health_test.go b/handlers/health_test.go new file mode 100644 index 0000000..fe6534f --- /dev/null +++ b/handlers/health_test.go @@ -0,0 +1,29 @@ +package handlers + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestHealthz(t *testing.T) { + rec := httptest.NewRecorder() + Healthz(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("healthz status = %d, want %d", rec.Code, http.StatusOK) + } + if got := rec.Body.String(); got != "ok" { + t.Fatalf("healthz body = %q, want %q", got, "ok") + } +} + +func TestReadyz(t *testing.T) { + rec := httptest.NewRecorder() + Readyz(rec, httptest.NewRequest(http.MethodGet, "/readyz", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("readyz status = %d, want %d", rec.Code, http.StatusOK) + } + if got := rec.Body.String(); got != "ready" { + t.Fatalf("readyz body = %q, want %q", got, "ready") + } +} diff --git a/handlers/utils.go b/handlers/utils.go index 8d4105b..70bd52f 100644 --- a/handlers/utils.go +++ b/handlers/utils.go @@ -14,6 +14,15 @@ import ( cu "github.com/keploy/gitstats/common" ) +// githubHTTPClientTimeout bounds every outbound GitHub API call. Without it the +// default http.Client waits forever, which can hang request handlers (and CI +// tests) indefinitely when GitHub is slow or unreachable. +const githubHTTPClientTimeout = 15 * time.Second + +// githubAPIBaseURL is the GitHub REST API root. It is a var (not a const) so +// tests can point it at an httptest stub; production always uses the real host. +var githubAPIBaseURL = "https://api.github.com" + func calculateDownloadStats(releases []cu.Release) *cu.DownloadStats { stats := &cu.DownloadStats{ Releases: make([]cu.ReleaseDownloadStats, 0), @@ -53,10 +62,10 @@ func getAllReleases(owner, repo string, config *cu.Config) ([]cu.Release, error) perPage := 100 for { - url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases?page=%d&per_page=%d", + url := fmt.Sprintf("%s/repos/%s/%s/releases?page=%d&per_page=%d", githubAPIBaseURL, owner, repo, page, perPage) - client := &http.Client{} + client := &http.Client{Timeout: githubHTTPClientTimeout} req, err := http.NewRequest("GET", url, nil) if err != nil { return nil, fmt.Errorf("error creating request: %v", err) @@ -135,10 +144,10 @@ func getStarHistory(owner, repo string, config *cu.Config) (*cu.StarHistory, err history := make([]cu.StarPoint, 0) for { - url := fmt.Sprintf("https://api.github.com/repos/%s/%s/stargazers?page=%d&per_page=%d", + url := fmt.Sprintf("%s/repos/%s/%s/stargazers?page=%d&per_page=%d", githubAPIBaseURL, owner, repo, page, perPage) - client := &http.Client{} + client := &http.Client{Timeout: githubHTTPClientTimeout} req, err := http.NewRequest("GET", url, nil) if err != nil { return nil, fmt.Errorf("error creating request: %v", err) @@ -215,9 +224,9 @@ func getOrgContributors(org string, config *cu.Config) (*cu.OrganizationStats, e totalRepos := 0 for { - url := fmt.Sprintf("https://api.github.com/orgs/%s/repos?page=%d&per_page=%d", org, page, perPage) + url := fmt.Sprintf("%s/orgs/%s/repos?page=%d&per_page=%d", githubAPIBaseURL, org, page, perPage) - client := &http.Client{} + client := &http.Client{Timeout: githubHTTPClientTimeout} req, err := http.NewRequest("GET", url, nil) if err != nil { return nil, fmt.Errorf("error creating request: %v", err) @@ -254,7 +263,7 @@ func getOrgContributors(org string, config *cu.Config) (*cu.OrganizationStats, e for _, repo := range repos { repoName := repo["name"].(string) - repoContributorsURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/contributors", org, repoName) + repoContributorsURL := fmt.Sprintf("%s/repos/%s/%s/contributors", githubAPIBaseURL, org, repoName) contribReq, err := http.NewRequest("GET", repoContributorsURL, nil) if err != nil { @@ -311,10 +320,10 @@ func getOrgMembers(org string) (map[string]struct{}, error) { perPage := 100 for { - url := fmt.Sprintf("https://api.github.com/orgs/%s/members?page=%d&per_page=%d", + url := fmt.Sprintf("%s/orgs/%s/members?page=%d&per_page=%d", githubAPIBaseURL, org, page, perPage) - client := &http.Client{} + client := &http.Client{Timeout: githubHTTPClientTimeout} req, err := http.NewRequest("GET", url, nil) if err != nil { return nil, fmt.Errorf("error creating request: %v", err) @@ -383,10 +392,10 @@ func getRecentCommits(owner, repo string, since time.Time, config *cu.Config) ([ } for { - url := fmt.Sprintf("https://api.github.com/repos/%s/%s/commits?since=%s&page=%d&per_page=%d", + url := fmt.Sprintf("%s/repos/%s/%s/commits?since=%s&page=%d&per_page=%d", githubAPIBaseURL, owner, repo, since.Format(time.RFC3339), page, perPage) - client := &http.Client{} + client := &http.Client{Timeout: githubHTTPClientTimeout} req, err := http.NewRequest("GET", url, nil) if err != nil { return nil, fmt.Errorf("error creating request: %v", err) @@ -506,10 +515,10 @@ func getOrgRepositories(org string, config *cu.Config) ([]struct { } for { - url := fmt.Sprintf("https://api.github.com/orgs/%s/repos?page=%d&per_page=%d&type=public", + url := fmt.Sprintf("%s/orgs/%s/repos?page=%d&per_page=%d&type=public", githubAPIBaseURL, org, page, perPage) - client := &http.Client{} + client := &http.Client{Timeout: githubHTTPClientTimeout} req, err := http.NewRequest("GET", url, nil) if err != nil { return nil, fmt.Errorf("error creating request: %v", err) @@ -624,10 +633,10 @@ func sendJSONResponse(w http.ResponseWriter, response cu.ActiveContributorsRespo func fetchStargazers(owner, repo, token string, page int) ([]cu.Stargazer, bool, int, error) { perPage := 100 - client := &http.Client{} + client := &http.Client{Timeout: githubHTTPClientTimeout} // First, get total stargazer count - repoURL := fmt.Sprintf("https://api.github.com/repos/%s/%s", owner, repo) + repoURL := fmt.Sprintf("%s/repos/%s/%s", githubAPIBaseURL, owner, repo) repoReq, err := http.NewRequest("GET", repoURL, nil) if err != nil { return nil, false, 0, err @@ -654,7 +663,7 @@ func fetchStargazers(owner, repo, token string, page int) ([]cu.Stargazer, bool, } // Fetch stargazers for the requested reverse page - url := fmt.Sprintf("https://api.github.com/repos/%s/%s/stargazers?page=%d&per_page=%d", + url := fmt.Sprintf("%s/repos/%s/%s/stargazers?page=%d&per_page=%d", githubAPIBaseURL, owner, repo, reversePage, perPage) req, err := http.NewRequest("GET", url, nil) @@ -710,8 +719,8 @@ func fetchStargazers(owner, repo, token string, page int) ([]cu.Stargazer, bool, } func fetchUserDetails(username, token string) (*cu.User, error) { - client := &http.Client{} - url := fmt.Sprintf("https://api.github.com/users/%s", username) + client := &http.Client{Timeout: githubHTTPClientTimeout} + url := fmt.Sprintf("%s/users/%s", githubAPIBaseURL, username) req, err := http.NewRequest("GET", url, nil) if err != nil { diff --git a/routes/routes.go b/routes/routes.go index f45a909..78fd0c6 100644 --- a/routes/routes.go +++ b/routes/routes.go @@ -11,6 +11,10 @@ func SetupRoutes() { // Serve static files http.HandleFunc("/", handler.ServerIndex) + + // Health probes (liveness + readiness) for Kubernetes. + http.HandleFunc("/healthz", handler.Healthz) + http.HandleFunc("/readyz", handler.Readyz) http.HandleFunc("/orgs", handler.ServerOrgPage) http.HandleFunc("/starhistory", handler.ServerStartPage) http.HandleFunc("/participants", handler.ServerParticipantPage)