Scheduled searches with a weekly review of new and rising repositories
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>
This commit is contained in:
1 parent
8f4048d046
commit
02b51fed77
16 files changed
+1745
-45
No files matched your search
+200
@@ -0,0 +1,200 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Saved search routes:
|
||||
//
|
||||
// GET /api/saved list
|
||||
// POST /api/saved create (and run it once in the background)
|
||||
// GET /api/saved/{id}
|
||||
// PUT /api/saved/{id} update
|
||||
// DELETE /api/saved/{id}
|
||||
// POST /api/saved/{id}/run run now
|
||||
// GET /api/saved/{id}/runs history (summaries)
|
||||
// GET /api/saved/{id}/runs/{run} one snapshot ("latest" accepted)
|
||||
func (a *API) savedRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /api/saved", a.savedList)
|
||||
mux.HandleFunc("POST /api/saved", a.savedCreate)
|
||||
mux.HandleFunc("GET /api/saved/{id}", a.savedGet)
|
||||
mux.HandleFunc("PUT /api/saved/{id}", a.savedUpdate)
|
||||
mux.HandleFunc("DELETE /api/saved/{id}", a.savedDelete)
|
||||
mux.HandleFunc("POST /api/saved/{id}/run", a.savedRun)
|
||||
mux.HandleFunc("GET /api/saved/{id}/runs", a.savedRuns)
|
||||
mux.HandleFunc("GET /api/saved/{id}/runs/{run}", a.savedRunGet)
|
||||
}
|
||||
|
||||
// savedInput is what the UI sends on create and update.
|
||||
type savedInput struct {
|
||||
Name string `json:"name"`
|
||||
Params string `json:"params"`
|
||||
Schedule Schedule `json:"schedule"`
|
||||
MaxResults int `json:"maxResults"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func (in *savedInput) validate() error {
|
||||
in.Name = strings.TrimSpace(in.Name)
|
||||
if in.Name == "" || len(in.Name) > 120 {
|
||||
return &codedError{code: "bad_param", msg: "invalid name", detail: "name"}
|
||||
}
|
||||
v, err := url.ParseQuery(in.Params)
|
||||
if err != nil {
|
||||
return &codedError{code: "bad_param", msg: "invalid parameters", detail: "params"}
|
||||
}
|
||||
v.Del("page")
|
||||
v.Del("per_page")
|
||||
in.Params = v.Encode()
|
||||
if _, err := ParseCriteria(v); err != nil {
|
||||
return err
|
||||
}
|
||||
switch in.MaxResults {
|
||||
case 0:
|
||||
in.MaxResults = 50
|
||||
case 30, 50, 100:
|
||||
default:
|
||||
return &codedError{code: "bad_param", msg: "invalid maxResults", detail: "maxResults"}
|
||||
}
|
||||
if !in.Schedule.Valid() {
|
||||
return &codedError{code: "bad_param", msg: "invalid schedule", detail: "schedule"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *API) savedList(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, a.store.List())
|
||||
}
|
||||
|
||||
func (a *API) savedGet(w http.ResponseWriter, r *http.Request) {
|
||||
x, err := a.store.Get(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, x)
|
||||
}
|
||||
|
||||
func readInput(r *http.Request) (savedInput, error) {
|
||||
var in savedInput
|
||||
dec := json.NewDecoder(http.MaxBytesReader(nil, r.Body, 64<<10))
|
||||
if err := dec.Decode(&in); err != nil {
|
||||
return in, &codedError{code: "bad_param", msg: "invalid JSON", detail: err.Error()}
|
||||
}
|
||||
return in, in.validate()
|
||||
}
|
||||
|
||||
func (a *API) savedCreate(w http.ResponseWriter, r *http.Request) {
|
||||
in, err := readInput(r)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
x := SavedSearch{Name: in.Name, Params: in.Params, Schedule: in.Schedule, MaxResults: in.MaxResults, Enabled: true}
|
||||
if in.Enabled != nil {
|
||||
x.Enabled = *in.Enabled
|
||||
}
|
||||
x.NextRunAt = x.Schedule.Next(time.Now(), time.Time{})
|
||||
if !x.Enabled {
|
||||
x.NextRunAt = time.Time{}
|
||||
}
|
||||
x, err = a.store.Create(x)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
// First run right away: it gives a baseline to compare the next ones with.
|
||||
go func(id string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||
defer cancel()
|
||||
if _, err := a.sched.Run(ctx, id, "manual"); err != nil {
|
||||
log.Printf("first run of saved search %s: %v", id, err)
|
||||
}
|
||||
}(x.ID)
|
||||
writeJSON(w, http.StatusCreated, x)
|
||||
}
|
||||
|
||||
func (a *API) savedUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
in, err := readInput(r)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
x, err := a.store.Update(r.PathValue("id"), func(v *SavedSearch) {
|
||||
v.Name, v.Params, v.Schedule, v.MaxResults = in.Name, in.Params, in.Schedule, in.MaxResults
|
||||
if in.Enabled != nil {
|
||||
v.Enabled = *in.Enabled
|
||||
}
|
||||
v.NextRunAt = time.Time{}
|
||||
if v.Enabled {
|
||||
v.NextRunAt = v.Schedule.Next(time.Now(), v.LastRunAt)
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, x)
|
||||
}
|
||||
|
||||
func (a *API) savedDelete(w http.ResponseWriter, r *http.Request) {
|
||||
if err := a.store.Delete(r.PathValue("id")); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (a *API) savedRun(w http.ResponseWriter, r *http.Request) {
|
||||
run, err := a.sched.Run(r.Context(), r.PathValue("id"), "manual")
|
||||
if err != nil && run.ID == "" {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
// A failed GitHub search is still a recorded run: the error is in run.error.
|
||||
writeJSON(w, http.StatusOK, run.RunInfo)
|
||||
}
|
||||
|
||||
func (a *API) savedRuns(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if _, err := a.store.Get(id); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
runs, err := a.store.Runs(id)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, runs)
|
||||
}
|
||||
|
||||
func (a *API) savedRunGet(w http.ResponseWriter, r *http.Request) {
|
||||
run, err := a.store.Run(r.PathValue("id"), r.PathValue("run"))
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, run)
|
||||
}
|
||||
|
||||
func writeStoreErr(w http.ResponseWriter, err error) {
|
||||
status := http.StatusInternalServerError
|
||||
var ce *codedError
|
||||
if errors.As(err, &ce) {
|
||||
switch ce.code {
|
||||
case "not_found":
|
||||
status = http.StatusNotFound
|
||||
case "bad_param":
|
||||
status = http.StatusBadRequest
|
||||
}
|
||||
}
|
||||
writeErr(w, status, err)
|
||||
}
|
||||
Reference in new issue
Block a user