Squelette : recherche de dépôts GitHub avec l'interface de Logstream #1
No files matched your search
@@ -0,0 +1,7 @@
|
|||||||
|
# Copy to .env and adjust.
|
||||||
|
HTTP_PORT=8080
|
||||||
|
# GitHub token (fine-grained, no permission needed for public repositories).
|
||||||
|
GITHUB_TOKEN=
|
||||||
|
CACHE_TTL=5m
|
||||||
|
AUTH_USER=
|
||||||
|
AUTH_PASS=
|
||||||
@@ -25,3 +25,6 @@ go.work.sum
|
|||||||
# env file
|
# env file
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
# Binary
|
||||||
|
/searchgit
|
||||||
|
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
# Image versions are pinned: update them on purpose.
|
||||||
|
|
||||||
|
# ---- Build ----
|
||||||
|
FROM golang:1.27.1-alpine3.24 AS build
|
||||||
|
WORKDIR /src
|
||||||
|
COPY go.mod ./
|
||||||
|
COPY *.go ./
|
||||||
|
COPY web ./web
|
||||||
|
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/searchgit .
|
||||||
|
|
||||||
|
# ---- Final image ----
|
||||||
|
FROM alpine:3.24.2
|
||||||
|
RUN apk add --no-cache ca-certificates \
|
||||||
|
&& adduser -D -H -u 10001 searchgit
|
||||||
|
COPY --from=build /out/searchgit /usr/local/bin/searchgit
|
||||||
|
USER searchgit
|
||||||
|
EXPOSE 8080/tcp
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s CMD wget -qO- http://127.0.0.1:8080/healthz || exit 1
|
||||||
|
ENTRYPOINT ["/usr/local/bin/searchgit"]
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
type API struct {
|
||||||
|
gh *GitHub
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) Routes(mux *http.ServeMux) {
|
||||||
|
mux.HandleFunc("GET /healthz", a.health)
|
||||||
|
mux.HandleFunc("GET /api/search", a.search)
|
||||||
|
mux.HandleFunc("GET /api/status", a.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// codedError carries a stable code that the UI translates.
|
||||||
|
type codedError struct {
|
||||||
|
code string
|
||||||
|
msg string
|
||||||
|
detail string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *codedError) Error() string {
|
||||||
|
if e.detail != "" {
|
||||||
|
return e.msg + ": " + e.detail
|
||||||
|
}
|
||||||
|
return e.msg
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/search?q=&language=&stars=&maxstars=&minforks=&topic=&license=
|
||||||
|
// &pushed=&created=&in=&archived=1&forks=1&goodfirst=1&sort=&order=&page=&per_page=
|
||||||
|
func (a *API) search(w http.ResponseWriter, r *http.Request) {
|
||||||
|
c, err := ParseCriteria(r.URL.Query())
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res, err := a.gh.Search(r.Context(), c)
|
||||||
|
if err != nil {
|
||||||
|
status := http.StatusBadGateway
|
||||||
|
var ce *codedError
|
||||||
|
if errors.As(err, &ce) {
|
||||||
|
switch ce.code {
|
||||||
|
case "rate_limited":
|
||||||
|
status = http.StatusTooManyRequests
|
||||||
|
case "bad_query":
|
||||||
|
status = http.StatusBadRequest
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeErr(w, status, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, res)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/status: token configured and last known search quota (footer).
|
||||||
|
func (a *API) status(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"token": a.gh.HasToken(),
|
||||||
|
"rate": a.gh.Rate(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) health(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write([]byte("ok"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeErr returns {"error": "...", "code": "...", "detail": "..."}; the UI
|
||||||
|
// translates known codes and falls back to the English message.
|
||||||
|
func writeErr(w http.ResponseWriter, status int, err error) {
|
||||||
|
body := map[string]string{"error": err.Error()}
|
||||||
|
var ce *codedError
|
||||||
|
if errors.As(err, &ce) {
|
||||||
|
body["code"] = ce.code
|
||||||
|
if ce.detail != "" {
|
||||||
|
body["detail"] = ce.detail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJSON(w, status, body)
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
services:
|
||||||
|
searchgit:
|
||||||
|
build: .
|
||||||
|
container_name: searchgit
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${HTTP_PORT:-8080}:8080"
|
||||||
|
environment:
|
||||||
|
GITHUB_TOKEN: ${GITHUB_TOKEN:-} # optional: 30 searches/min instead of 10
|
||||||
|
CACHE_TTL: ${CACHE_TTL:-5m} # identical searches are served from memory
|
||||||
|
AUTH_USER: ${AUTH_USER:-} # leave empty to disable authentication
|
||||||
|
AUTH_PASS: ${AUTH_PASS:-}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
// Searchgit: search GitHub repositories with criteria, from a web interface.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/subtle"
|
||||||
|
"embed"
|
||||||
|
"errors"
|
||||||
|
"io/fs"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed web
|
||||||
|
var webFS embed.FS
|
||||||
|
|
||||||
|
func getenv(key, def string) string {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
func getenvDuration(key string, def time.Duration) time.Duration {
|
||||||
|
if d, err := time.ParseDuration(os.Getenv(key)); err == nil && d >= 0 {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
httpAddr := getenv("HTTP_ADDR", ":8080")
|
||||||
|
token := os.Getenv("GITHUB_TOKEN")
|
||||||
|
gh := NewGitHub(getenv("GITHUB_API", "https://api.github.com"), token, getenvDuration("CACHE_TTL", 5*time.Minute))
|
||||||
|
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
static, err := fs.Sub(webFS, "web")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
(&API{gh: gh}).Routes(mux)
|
||||||
|
mux.Handle("GET /", http.FileServer(http.FS(static)))
|
||||||
|
|
||||||
|
srv := &http.Server{
|
||||||
|
Addr: httpAddr,
|
||||||
|
Handler: basicAuth(os.Getenv("AUTH_USER"), os.Getenv("AUTH_PASS"), mux),
|
||||||
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
|
BaseContext: func(net.Listener) context.Context { return ctx },
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
<-ctx.Done()
|
||||||
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
_ = srv.Shutdown(shutdownCtx)
|
||||||
|
}()
|
||||||
|
|
||||||
|
if token == "" {
|
||||||
|
log.Printf("GITHUB_TOKEN not set: GitHub allows only 10 searches per minute")
|
||||||
|
}
|
||||||
|
log.Printf("web UI on %s", httpAddr)
|
||||||
|
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
log.Fatalf("http: %v", err)
|
||||||
|
}
|
||||||
|
log.Println("shutdown complete")
|
||||||
|
}
|
||||||
|
|
||||||
|
// basicAuth protects the UI when AUTH_USER is set (except /healthz).
|
||||||
|
func basicAuth(user, pass string, next http.Handler) http.Handler {
|
||||||
|
if user == "" {
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/healthz" {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u, p, ok := r.BasicAuth()
|
||||||
|
if !ok ||
|
||||||
|
subtle.ConstantTimeCompare([]byte(u), []byte(user)) != 1 ||
|
||||||
|
subtle.ConstantTimeCompare([]byte(p), []byte(pass)) != 1 {
|
||||||
|
w.Header().Set("WWW-Authenticate", `Basic realm="searchgit"`)
|
||||||
|
http.Error(w, "authentication required", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/url"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestQuery(t *testing.T) {
|
||||||
|
now := time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC)
|
||||||
|
tests := []struct {
|
||||||
|
params string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"", "archived:false stars:>=1"},
|
||||||
|
{"q=log+viewer&in=name", "log viewer in:name archived:false"},
|
||||||
|
{"language=Go&stars=100", "language:Go stars:>=100 archived:false"},
|
||||||
|
{"stars=10&maxstars=500&minforks=5", "stars:10..500 forks:>=5 archived:false"},
|
||||||
|
{"topic=CLI&license=MIT&archived=1&forks=1", "topic:cli license:mit fork:true"},
|
||||||
|
{"q=syslog&pushed=1y&created=1m&goodfirst=1", "syslog pushed:>=2025-10-01 created:>=2026-09-01 archived:false good-first-issues:>0"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
v, _ := url.ParseQuery(tt.params)
|
||||||
|
c, err := ParseCriteria(v)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%q: %v", tt.params, err)
|
||||||
|
}
|
||||||
|
if got := c.Query(now); got != tt.want {
|
||||||
|
t.Errorf("%q:\n got %q\n want %q", tt.params, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseCriteriaErrors(t *testing.T) {
|
||||||
|
for _, p := range []string{
|
||||||
|
"stars=-1", "stars=abc", "pushed=3d", "sort=name", "in=code",
|
||||||
|
"language=go%20lang%3Arm", "topic=a:b", "page=40&per_page=30",
|
||||||
|
} {
|
||||||
|
v, _ := url.ParseQuery(p)
|
||||||
|
if _, err := ParseCriteria(v); err == nil {
|
||||||
|
t.Errorf("%q: expected an error", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPerPageCap(t *testing.T) {
|
||||||
|
v, _ := url.ParseQuery("per_page=500")
|
||||||
|
c, err := ParseCriteria(v)
|
||||||
|
if err != nil || c.PerPage != 100 {
|
||||||
|
t.Fatalf("per_page=500: got %d, %v", c.PerPage, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user