- /api/search traduit les filtres en requête GitHub (langage, étoiles, activité, création, licence, topic, forks, archivés, good first issues) - cache mémoire des recherches identiques (CACHE_TTL) et suivi du quota - /api/status, /healthz, authentification basique optionnelle - Dockerfile et docker-compose.yml Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
193 lines
5.8 KiB
Go
193 lines
5.8 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"net/url"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Criteria are the search filters chosen in the UI. They are turned into a
|
|
// GitHub search query (https://docs.github.com/search-github/searching-on-github/searching-for-repositories).
|
|
type Criteria struct {
|
|
Text string // free text, may already contain GitHub qualifiers (topic:cli user:foo…)
|
|
Language string
|
|
MinStars int
|
|
MaxStars int
|
|
MinForks int
|
|
Topic string
|
|
License string // SPDX key in lower case: mit, apache-2.0…
|
|
PushedIn time.Duration // last push less than this ago (0 = any)
|
|
CreatedIn time.Duration // created less than this ago (0 = any)
|
|
In string // where the text is searched: "", "name", "description", "readme"
|
|
Archived bool // include archived repositories
|
|
Forks bool // include forks
|
|
GoodFirst bool // has issues labeled "good first issue"
|
|
Sort string // "", "stars", "forks", "updated", "help-wanted-issues"
|
|
Order string // "desc" (default) or "asc"
|
|
Page int
|
|
PerPage int
|
|
}
|
|
|
|
var (
|
|
sorts = map[string]bool{"": true, "stars": true, "forks": true, "updated": true, "help-wanted-issues": true}
|
|
ins = map[string]bool{"": true, "name": true, "description": true, "readme": true}
|
|
safeValue = regexp.MustCompile(`^[\w.+#-]+$`) // topic, license
|
|
safeLang = regexp.MustCompile(`^[\w.+# -]+$`) // "Jupyter Notebook", "C#", "C++"
|
|
durations = map[string]time.Duration{
|
|
"1w": 7 * 24 * time.Hour, "1m": 30 * 24 * time.Hour, "3m": 91 * 24 * time.Hour,
|
|
"6m": 182 * 24 * time.Hour, "1y": 365 * 24 * time.Hour, "2y": 730 * 24 * time.Hour,
|
|
"5y": 1826 * 24 * time.Hour,
|
|
}
|
|
)
|
|
|
|
// ParseCriteria reads the criteria from the /api/search query string.
|
|
func ParseCriteria(v url.Values) (Criteria, error) {
|
|
c := Criteria{
|
|
Text: strings.TrimSpace(v.Get("q")),
|
|
Language: strings.TrimSpace(v.Get("language")),
|
|
Topic: strings.ToLower(strings.TrimSpace(v.Get("topic"))),
|
|
License: strings.ToLower(strings.TrimSpace(v.Get("license"))),
|
|
In: v.Get("in"),
|
|
Archived: v.Get("archived") == "1",
|
|
Forks: v.Get("forks") == "1",
|
|
GoodFirst: v.Get("goodfirst") == "1",
|
|
Sort: v.Get("sort"),
|
|
Order: v.Get("order"),
|
|
Page: 1,
|
|
PerPage: 30,
|
|
}
|
|
var err error
|
|
if c.MinStars, err = intParam(v, "stars"); err != nil {
|
|
return c, err
|
|
}
|
|
if c.MaxStars, err = intParam(v, "maxstars"); err != nil {
|
|
return c, err
|
|
}
|
|
if c.MinForks, err = intParam(v, "minforks"); err != nil {
|
|
return c, err
|
|
}
|
|
if p, err := intParam(v, "page"); err != nil {
|
|
return c, err
|
|
} else if p > 0 {
|
|
c.Page = p
|
|
}
|
|
if n, err := intParam(v, "per_page"); err != nil {
|
|
return c, err
|
|
} else if n > 0 {
|
|
c.PerPage = min(n, 100)
|
|
}
|
|
if c.PushedIn, err = durationParam(v, "pushed"); err != nil {
|
|
return c, err
|
|
}
|
|
if c.CreatedIn, err = durationParam(v, "created"); err != nil {
|
|
return c, err
|
|
}
|
|
if c.Language != "" && !safeLang.MatchString(c.Language) {
|
|
return c, &codedError{code: "bad_param", msg: "invalid language", detail: "language"}
|
|
}
|
|
for name, val := range map[string]string{"topic": c.Topic, "license": c.License} {
|
|
if val != "" && !safeValue.MatchString(val) {
|
|
return c, &codedError{code: "bad_param", msg: "invalid " + name, detail: name}
|
|
}
|
|
}
|
|
if !sorts[c.Sort] {
|
|
return c, &codedError{code: "bad_param", msg: "invalid sort", detail: "sort"}
|
|
}
|
|
if !ins[c.In] {
|
|
return c, &codedError{code: "bad_param", msg: "invalid in", detail: "in"}
|
|
}
|
|
if c.Order != "asc" {
|
|
c.Order = "desc"
|
|
}
|
|
// GitHub never returns more than the first 1000 results.
|
|
if c.Page*c.PerPage > 1000 {
|
|
return c, &codedError{code: "too_far", msg: "GitHub only returns the first 1000 results"}
|
|
}
|
|
return c, nil
|
|
}
|
|
|
|
func intParam(v url.Values, key string) (int, error) {
|
|
s := strings.TrimSpace(v.Get(key))
|
|
if s == "" {
|
|
return 0, nil
|
|
}
|
|
n, err := strconv.Atoi(s)
|
|
if err != nil || n < 0 {
|
|
return 0, &codedError{code: "bad_param", msg: "invalid " + key, detail: key}
|
|
}
|
|
return n, nil
|
|
}
|
|
|
|
func durationParam(v url.Values, key string) (time.Duration, error) {
|
|
s := v.Get(key)
|
|
if s == "" {
|
|
return 0, nil
|
|
}
|
|
d, ok := durations[s]
|
|
if !ok {
|
|
return 0, &codedError{code: "bad_param", msg: "invalid " + key, detail: key}
|
|
}
|
|
return d, nil
|
|
}
|
|
|
|
// Query builds the GitHub "q" parameter. now is passed in for tests.
|
|
func (c Criteria) Query(now time.Time) string {
|
|
var parts []string
|
|
if c.Text != "" {
|
|
parts = append(parts, c.Text)
|
|
}
|
|
if c.In != "" && c.Text != "" {
|
|
parts = append(parts, "in:"+c.In)
|
|
}
|
|
if c.Language != "" {
|
|
parts = append(parts, "language:"+quoteIfNeeded(c.Language))
|
|
}
|
|
if c.Topic != "" {
|
|
parts = append(parts, "topic:"+c.Topic)
|
|
}
|
|
if c.License != "" {
|
|
parts = append(parts, "license:"+c.License)
|
|
}
|
|
switch {
|
|
case c.MinStars > 0 && c.MaxStars > 0:
|
|
parts = append(parts, fmt.Sprintf("stars:%d..%d", c.MinStars, c.MaxStars))
|
|
case c.MinStars > 0:
|
|
parts = append(parts, fmt.Sprintf("stars:>=%d", c.MinStars))
|
|
case c.MaxStars > 0:
|
|
parts = append(parts, fmt.Sprintf("stars:<=%d", c.MaxStars))
|
|
}
|
|
if c.MinForks > 0 {
|
|
parts = append(parts, fmt.Sprintf("forks:>=%d", c.MinForks))
|
|
}
|
|
if c.PushedIn > 0 {
|
|
parts = append(parts, "pushed:>="+now.Add(-c.PushedIn).UTC().Format("2006-01-02"))
|
|
}
|
|
if c.CreatedIn > 0 {
|
|
parts = append(parts, "created:>="+now.Add(-c.CreatedIn).UTC().Format("2006-01-02"))
|
|
}
|
|
if !c.Archived {
|
|
parts = append(parts, "archived:false")
|
|
}
|
|
if c.Forks {
|
|
parts = append(parts, "fork:true")
|
|
}
|
|
if c.GoodFirst {
|
|
parts = append(parts, "good-first-issues:>0")
|
|
}
|
|
// GitHub refuses an empty query: search everything with at least one star.
|
|
if c.Text == "" && c.Language == "" && c.Topic == "" && c.License == "" && c.MinStars == 0 && c.MaxStars == 0 {
|
|
parts = append(parts, "stars:>=1")
|
|
}
|
|
return strings.Join(parts, " ")
|
|
}
|
|
|
|
func quoteIfNeeded(s string) string {
|
|
if strings.ContainsAny(s, " ") {
|
|
return `"` + s + `"`
|
|
}
|
|
return s
|
|
}
|