Squelette : recherche de dépôts GitHub avec l'interface de Logstream #1

Merged
claude Bot merged 4 commits from squelette into main 2026-10-01 14:45:20 +02:00
9 changed files with 472 additions and 0 deletions
Showing only changes of commit 1885488d05 - Show all commits

No files matched your search

+7
View File
@@ -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=
+3
View File
@@ -25,3 +25,6 @@ go.work.sum
# env file
.env
# Binary
/searchgit
+19
View File
@@ -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"]
+89
View File
@@ -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)
}
+12
View File
@@ -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:-}
+3
View File
@@ -0,0 +1,3 @@
module searchgit
go 1.23
+95
View File
@@ -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)
})
}
+192
View File
@@ -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
}
+52
View File
@@ -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)
}
}