A search can now be saved with a schedule (every N hours, daily, or weekly at a given day and time). An internal scheduler runs it and keeps a snapshot of the results on a /data volume (JSON files, no new dependency). The new Watch tab lists the scheduled searches; the review page of each one shows, for any run of its history, the repositories never seen before and the ones that gained the most stars since the previous run. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
104 lines
2.4 KiB
Go
104 lines
2.4 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"time"
|
|
)
|
|
|
|
type API struct {
|
|
gh *GitHub
|
|
store *Store
|
|
sched *Scheduler
|
|
}
|
|
|
|
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)
|
|
a.savedRoutes(mux)
|
|
}
|
|
|
|
// 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(),
|
|
"tz": zoneName(),
|
|
})
|
|
}
|
|
|
|
// zoneName is the server time zone used by the schedules (TZ).
|
|
func zoneName() string {
|
|
if name := time.Local.String(); name != "Local" {
|
|
return name
|
|
}
|
|
name, _ := time.Now().Zone()
|
|
return name
|
|
}
|
|
|
|
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)
|
|
}
|