Skip to content
Open
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
70 changes: 70 additions & 0 deletions cmd/gitlab/main.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
10 changes: 6 additions & 4 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
)

Expand All @@ -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
Expand Down
14 changes: 14 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand All @@ -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=
Expand Down Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand All @@ -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=
Expand All @@ -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=
Expand Down
120 changes: 120 additions & 0 deletions internal/gitlab/gitlab.go
Original file line number Diff line number Diff line change
@@ -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
}
80 changes: 80 additions & 0 deletions internal/gitlab/gitlab_test.go
Original file line number Diff line number Diff line change
@@ -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) {

Check warning on line 54 in internal/gitlab/gitlab_test.go

View workflow job for this annotation

GitHub Actions / lint

parameter 'r' seems to be unused, consider removing or renaming it as _
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) {

Check warning on line 70 in internal/gitlab/gitlab_test.go

View workflow job for this annotation

GitHub Actions / lint

parameter 'r' seems to be unused, consider removing or renaming it as _
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)
}
Loading
Loading