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
|
||||
|
||||
# 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