Files
searchgit/query.go
T
claude BotandClaude Opus 5.5 1885488d05 Serveur Go : recherche GitHub par critères
- /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>
2026-10-01 12:46:42 +02:00

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
}