diff --git a/cmd/gitlab/main.go b/cmd/gitlab/main.go new file mode 100644 index 0000000..6f0c049 --- /dev/null +++ b/cmd/gitlab/main.go @@ -0,0 +1,70 @@ +// Command gitlab prints public gitlab.com projects matching a language and query. +// +// go run ./cmd/gitlab -lang Go -q bot -min-stars 5 -n 20 +// +// Set GITLAB_TOKEN for higher rate limits. +package main + +import ( + "context" + "flag" + "fmt" + "os" + + gl "github.com/till/golangoss-bluesky/internal/gitlab" +) + +func main() { + lang := flag.String("lang", "Go", "programming language filter (empty disables)") + query := flag.String("q", "", "free-text search") + minStars := flag.Int64("min-stars", 0, "drop projects with fewer stars") + perPage := flag.Int("n", 20, "page size (1-100)") + page := flag.Int("page", 1, "page number (1-indexed)") + random := flag.Bool("random", false, "return one randomly-picked project (ignores -q, -n, -page, -min-stars)") + flag.Parse() + + c, err := gl.New(os.Getenv("GITLAB_TOKEN"), nil) + if err != nil { + fmt.Fprintln(os.Stderr, "client:", err) + os.Exit(1) + } + + ctx := context.Background() + + if *random { + p, err := c.RandomProject(ctx, *lang) + if err != nil { + fmt.Fprintln(os.Stderr, "random:", err) + os.Exit(1) + } + if p == nil { + fmt.Println("no results") + return + } + fmt.Printf("%-40s %5d %s\n", p.PathWithNS, p.Stars, p.WebURL) + if p.Description != "" { + fmt.Println(p.Description) + } + return + } + + results, err := c.Search(ctx, gl.SearchOptions{ + Language: *lang, + Query: *query, + MinStars: *minStars, + PerPage: *perPage, + Page: *page, + }) + if err != nil { + fmt.Fprintln(os.Stderr, "search:", err) + os.Exit(1) + } + + if len(results) == 0 { + fmt.Println("no results") + return + } + for _, p := range results { + fmt.Printf("%-40s %5d %s\n", p.PathWithNS, p.Stars, p.WebURL) + } +} diff --git a/go.mod b/go.mod index 6c99961..a42cb60 100644 --- a/go.mod +++ b/go.mod @@ -21,7 +21,7 @@ require ( github.com/go-logr/logr v1.4.1 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/golang/protobuf v1.5.3 // indirect - github.com/google/go-querystring v1.1.0 // indirect + github.com/google/go-querystring v1.2.0 // indirect github.com/klauspost/compress v1.18.2 // indirect github.com/klauspost/crc32 v1.3.0 // indirect github.com/minio/crc64nvme v1.1.1 // indirect @@ -31,16 +31,18 @@ require ( github.com/rs/xid v1.6.0 // indirect github.com/tinylib/msgp v1.6.1 // indirect github.com/zeebo/xxh3 v1.1.0 // indirect + gitlab.com/gitlab-org/api/client-go v1.46.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 // indirect go.opentelemetry.io/otel v1.21.0 // indirect go.opentelemetry.io/otel/metric v1.21.0 // indirect go.opentelemetry.io/otel/trace v1.21.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/net v0.48.0 // indirect - golang.org/x/oauth2 v0.8.0 // indirect + golang.org/x/oauth2 v0.34.0 // indirect golang.org/x/text v0.32.0 // indirect + golang.org/x/time v0.14.0 // indirect google.golang.org/appengine v1.6.7 // indirect - google.golang.org/protobuf v1.33.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) @@ -54,7 +56,7 @@ require ( github.com/golang-jwt/jwt/v5 v5.0.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect - github.com/hashicorp/go-retryablehttp v0.7.5 // indirect + github.com/hashicorp/go-retryablehttp v0.7.8 // indirect github.com/hashicorp/golang-lru v1.0.2 // indirect github.com/ipfs/bbloom v0.0.4 // indirect github.com/ipfs/go-block-format v0.2.0 // indirect diff --git a/go.sum b/go.sum index 93aab01..2967713 100644 --- a/go.sum +++ b/go.sum @@ -65,10 +65,13 @@ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-github/v39 v39.2.0 h1:rNNM311XtPOz5rDdsJXAp2o8F67X9FnROXTvto3aSnQ= github.com/google/go-github/v39 v39.2.0/go.mod h1:C1s8C5aCC9L+JXIYpJM5GYytdX52vC1bLvHEF1IhBrE= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= +github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -78,8 +81,11 @@ github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9n github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= github.com/hashicorp/go-hclog v0.9.2 h1:CG6TE5H9/JXsFWJCfoIVpKFIkFe6ysEuHirp4DxCsHI= github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= github.com/hashicorp/go-retryablehttp v0.7.5 h1:bJj+Pj19UZMIweq/iie+1u5YCdGrnxCT9yvm0e+Nd5M= github.com/hashicorp/go-retryablehttp v0.7.5/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8= +github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= +github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= @@ -217,6 +223,8 @@ github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= +gitlab.com/gitlab-org/api/client-go v1.46.0 h1:YxBWFZIFYKcGESCb9fpkwzouo+apyB9pr/XTWzNoL24= +gitlab.com/gitlab-org/api/client-go v1.46.0/go.mod h1:FtgyU6g2HS5+fMhw6nLK96GBEEBx5MzntOiJWfIaiN8= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 h1:aFJWCqJMNjENlcleuuOkGAPH82y0yULBScfXcIEdS24= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1/go.mod h1:sEGXWArGqc3tVa+ekntsN65DmVbVeW+7lTKTjZF3/Fo= go.opentelemetry.io/otel v1.21.0 h1:hzLeKBZEL7Okw2mGzZ0cc4k/A7Fta0uoPgaJCr8fsFc= @@ -271,6 +279,8 @@ golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.8.0 h1:6dkIjl3j3LtZ/O3sTgZTMsLKSftL/B8Zgq4huOIIUu8= golang.org/x/oauth2 v0.8.0/go.mod h1:yr7u4HXZRm1R1kBWqr/xKNqewf0plRYoB7sla+BCIXE= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -294,6 +304,8 @@ golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= @@ -316,6 +328,8 @@ google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp0 google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= diff --git a/internal/gitlab/gitlab.go b/internal/gitlab/gitlab.go new file mode 100644 index 0000000..b742b61 --- /dev/null +++ b/internal/gitlab/gitlab.go @@ -0,0 +1,120 @@ +// Package gitlab is a thin wrapper around the official GitLab Go SDK +// that searches gitlab.com for public projects in a given language. +package gitlab + +import ( + "context" + "fmt" + "net/http" + + gl "gitlab.com/gitlab-org/api/client-go" +) + +// Project is the subset of fields we care about for a search result. +type Project struct { + ID int64 + Name string + PathWithNS string + WebURL string + Description string + Stars int64 + Topics []string +} + +// SearchOptions controls a single page of results. +type SearchOptions struct { + // Language is matched against GitLab's per-project language detection + // (case-insensitive). Empty disables the filter. + Language string + // Query is a free-text search; empty means no text filter. + Query string + // MinStars filters out projects with fewer than this many stars. + // Applied client-side after fetching the page. + MinStars int64 + // PerPage caps the page size; <=0 means SDK default. + PerPage int + // Page is 1-indexed. + Page int +} + +// Client wraps a gitlab.Client. Token may be empty for unauthenticated +// access to public projects (rate-limited). +type Client struct { + gl *gl.Client +} + +// New builds a client against gitlab.com. +// httpClient is optional; pass nil for the default. +func New(token string, httpClient *http.Client) (*Client, error) { + opts := []gl.ClientOptionFunc{} + if httpClient != nil { + opts = append(opts, gl.WithHTTPClient(httpClient)) + } + c, err := gl.NewClient(token, opts...) + if err != nil { + return nil, fmt.Errorf("new gitlab client: %w", err) + } + return &Client{gl: c}, nil +} + +// NewWithBaseURL is like New but targets a self-hosted GitLab. +// Useful for tests against an httptest server. +func NewWithBaseURL(token, baseURL string, httpClient *http.Client) (*Client, error) { + opts := []gl.ClientOptionFunc{gl.WithBaseURL(baseURL)} + if httpClient != nil { + opts = append(opts, gl.WithHTTPClient(httpClient)) + } + c, err := gl.NewClient(token, opts...) + if err != nil { + return nil, fmt.Errorf("new gitlab client: %w", err) + } + return &Client{gl: c}, nil +} + +// Search returns one page of public projects matching opts, +// ordered by star_count descending. +func (c *Client) Search(ctx context.Context, opts SearchOptions) ([]Project, error) { + return c.searchWithOrder(ctx, opts, "star_count") +} + +func (c *Client) searchWithOrder(ctx context.Context, opts SearchOptions, orderBy string) ([]Project, error) { + list := &gl.ListProjectsOptions{ + Visibility: new(gl.PublicVisibility), + OrderBy: new(orderBy), + Sort: new("desc"), + } + if opts.Language != "" { + list.WithProgrammingLanguage = new(opts.Language) + } + if opts.Query != "" { + list.Search = new(opts.Query) + } + if opts.PerPage > 0 { + list.PerPage = int64(opts.PerPage) + } + if opts.Page > 0 { + list.Page = int64(opts.Page) + } + + raw, _, err := c.gl.Projects.ListProjects(list, gl.WithContext(ctx)) + if err != nil { + return nil, fmt.Errorf("list projects: %w", err) + } + + out := make([]Project, 0, len(raw)) + for _, p := range raw { + if p.StarCount < opts.MinStars { + continue + } + out = append(out, Project{ + ID: p.ID, + Name: p.Name, + PathWithNS: p.PathWithNamespace, + WebURL: p.WebURL, + Description: p.Description, + Stars: p.StarCount, + Topics: p.Topics, + }) + } + return out, nil +} diff --git a/internal/gitlab/gitlab_test.go b/internal/gitlab/gitlab_test.go new file mode 100644 index 0000000..434c07a --- /dev/null +++ b/internal/gitlab/gitlab_test.go @@ -0,0 +1,80 @@ +package gitlab_test + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/require" + "github.com/till/golangoss-bluesky/internal/gitlab" +) + +const samplePage = `[ + {"id":1,"name":"alpha","path_with_namespace":"a/alpha","web_url":"https://gitlab.com/a/alpha","description":"first","star_count":42,"topics":["go","cli"]}, + {"id":2,"name":"beta","path_with_namespace":"b/beta","web_url":"https://gitlab.com/b/beta","description":"second","star_count":3,"topics":[]} +]` + +func TestSearch_PassesFiltersAndMapsFields(t *testing.T) { + var gotQuery url.Values + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/api/v4/projects", r.URL.Path) + gotQuery = r.URL.Query() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(samplePage)) + })) + t.Cleanup(srv.Close) + + c, err := gitlab.NewWithBaseURL("", srv.URL+"/api/v4", srv.Client()) + require.NoError(t, err) + + got, err := c.Search(t.Context(), gitlab.SearchOptions{ + Language: "Go", + Query: "bot", + PerPage: 25, + Page: 1, + }) + require.NoError(t, err) + + require.Equal(t, "Go", gotQuery.Get("with_programming_language")) + require.Equal(t, "bot", gotQuery.Get("search")) + require.Equal(t, "public", gotQuery.Get("visibility")) + require.Equal(t, "star_count", gotQuery.Get("order_by")) + require.Equal(t, "desc", gotQuery.Get("sort")) + require.Equal(t, "25", gotQuery.Get("per_page")) + + require.Len(t, got, 2) + require.Equal(t, "a/alpha", got[0].PathWithNS) + require.Equal(t, int64(42), got[0].Stars) + require.Equal(t, []string{"go", "cli"}, got[0].Topics) + require.Equal(t, "https://gitlab.com/b/beta", got[1].WebURL) +} + +func TestSearch_MinStarsFiltersClientSide(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(samplePage)) + })) + t.Cleanup(srv.Close) + + c, err := gitlab.NewWithBaseURL("", srv.URL+"/api/v4", srv.Client()) + require.NoError(t, err) + + got, err := c.Search(t.Context(), gitlab.SearchOptions{MinStars: 10}) + require.NoError(t, err) + require.Len(t, got, 1) + require.Equal(t, "a/alpha", got[0].PathWithNS) +} + +func TestSearch_PropagatesError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, `{"message":"boom"}`, http.StatusInternalServerError) + })) + t.Cleanup(srv.Close) + + c, err := gitlab.NewWithBaseURL("", srv.URL+"/api/v4", srv.Client()) + require.NoError(t, err) + + _, err = c.Search(t.Context(), gitlab.SearchOptions{Language: "Go"}) + require.Error(t, err) +} diff --git a/internal/gitlab/random.go b/internal/gitlab/random.go new file mode 100644 index 0000000..628d7ca --- /dev/null +++ b/internal/gitlab/random.go @@ -0,0 +1,57 @@ +package gitlab + +import ( + "context" + "fmt" + "math/rand/v2" +) + +// randAlphabet is the pool of single-char queries we send as `search`. +// GitLab matches `search` against project name and path, so any char here +// will surface a different slice of public projects. +const RandAlphabet = "abcdefghijklmnopqrstuvwxyz0123456789" + +var randOrderBy = []string{"star_count", "last_activity_at", "created_at"} + +// randIntN is package-level so tests can swap in a deterministic source. +var RandIntN = rand.IntN + +// RandomProject returns one randomly-picked public project in the given +// language. It does this by sending a random single-char `search` query +// and a random `order_by`, then picking one project from the response. +// +// Retries up to maxRetries-1 additional times with different chars if the +// page comes back empty. Returns (nil, nil) if no project is found after +// all retries. +func (c *Client) RandomProject(ctx context.Context, language string) (*Project, error) { + const maxRetries = 3 + const perPage = 100 + + tried := make(map[byte]bool, maxRetries) + for range maxRetries { + var ch byte + for { + ch = RandAlphabet[RandIntN(len(RandAlphabet))] + if !tried[ch] { + tried[ch] = true + break + } + } + orderBy := randOrderBy[RandIntN(len(randOrderBy))] + + results, err := c.searchWithOrder(ctx, SearchOptions{ + Language: language, + Query: string(ch), + PerPage: perPage, + }, orderBy) + if err != nil { + return nil, fmt.Errorf("random project: %w", err) + } + if len(results) == 0 { + continue + } + pick := results[RandIntN(len(results))] + return &pick, nil + } + return nil, nil +} diff --git a/internal/gitlab/random_test.go b/internal/gitlab/random_test.go new file mode 100644 index 0000000..bf2dbfb --- /dev/null +++ b/internal/gitlab/random_test.go @@ -0,0 +1,96 @@ +package gitlab_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" + "github.com/till/golangoss-bluesky/internal/gitlab" +) + +// fakeRand returns a sequence of ints, looping if exhausted. +func fakeRand(seq ...int) func(int) int { + i := 0 + return func(n int) int { + v := seq[i%len(seq)] + i++ + return v % n + } +} + +func TestRandomProject_PassesRandomCharAndPicksOne(t *testing.T) { + // Sequence: 0 -> first char ('a'), 0 -> first order_by ('star_count'), + // 1 -> second project in the response. + prev := gitlab.RandIntN + gitlab.RandIntN = fakeRand(0, 0, 1) + t.Cleanup(func() { gitlab.RandIntN = prev }) + + var seenQueries []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seenQueries = append(seenQueries, r.URL.Query().Get("search")) + require.Equal(t, "Go", r.URL.Query().Get("with_programming_language")) + require.Equal(t, "star_count", r.URL.Query().Get("order_by")) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(samplePage)) + })) + t.Cleanup(srv.Close) + + c, err := gitlab.NewWithBaseURL("", srv.URL+"/api/v4", srv.Client()) + require.NoError(t, err) + + got, err := c.RandomProject(t.Context(), "Go") + require.NoError(t, err) + require.NotNil(t, got) + require.Equal(t, []string{"a"}, seenQueries) + require.Equal(t, "b/beta", got.PathWithNS) +} + +func TestRandomProject_RetriesOnEmptyPage(t *testing.T) { + // First two chars miss ('a','b'), third hits ('c'). + // Order indices: 0,0,0 (always star_count). Final pick: 0. + prev := gitlab.RandIntN + gitlab.RandIntN = fakeRand(0, 0, 1, 0, 2, 0, 0) + t.Cleanup(func() { gitlab.RandIntN = prev }) + + var seenQueries []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query().Get("search") + seenQueries = append(seenQueries, q) + w.Header().Set("Content-Type", "application/json") + if q == "c" { + _, _ = w.Write([]byte(samplePage)) + return + } + _, _ = w.Write([]byte(`[]`)) + })) + t.Cleanup(srv.Close) + + c, err := gitlab.NewWithBaseURL("", srv.URL+"/api/v4", srv.Client()) + require.NoError(t, err) + + got, err := c.RandomProject(t.Context(), "Go") + require.NoError(t, err) + require.NotNil(t, got) + require.Equal(t, []string{"a", "b", "c"}, seenQueries) + require.Equal(t, "a/alpha", got.PathWithNS) +} + +func TestRandomProject_NilAfterMaxRetries(t *testing.T) { + prev := gitlab.RandIntN + gitlab.RandIntN = fakeRand(0, 0, 1, 0, 2, 0) + t.Cleanup(func() { gitlab.RandIntN = prev }) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[]`)) + })) + t.Cleanup(srv.Close) + + c, err := gitlab.NewWithBaseURL("", srv.URL+"/api/v4", srv.Client()) + require.NoError(t, err) + + got, err := c.RandomProject(t.Context(), "Go") + require.NoError(t, err) + require.Nil(t, got) +}