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 }