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
26 changes: 26 additions & 0 deletions .woodpecker/ci.yml
Original file line number Diff line number Diff line change
@@ -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 ./...
53 changes: 47 additions & 6 deletions handlers/api_test.go
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
}
}
20 changes: 20 additions & 0 deletions handlers/health.go
Original file line number Diff line number Diff line change
@@ -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"))
}
29 changes: 29 additions & 0 deletions handlers/health_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
45 changes: 27 additions & 18 deletions handlers/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down
4 changes: 4 additions & 0 deletions routes/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down