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:
claude BotandClaude Opus 5.5 committed 2026-10-01 16:36:50 +02:00
1 parent 8f4048d046
commit 02b51fed77
16 files changed
+1745 -45

No files matched your search

+124
View File
@@ -0,0 +1,124 @@
package main
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func newTestAPI(t *testing.T) (*http.ServeMux, *API) {
t.Helper()
gh, _ := newFake(http.StatusOK, searchBody)
store, err := OpenStore(t.TempDir(), 0)
if err != nil {
t.Fatal(err)
}
a := &API{gh: gh, store: store, sched: NewScheduler(store, gh)}
mux := http.NewServeMux()
a.Routes(mux)
return mux, a
}
func call(t *testing.T, mux *http.ServeMux, method, path, body string, out any) int {
t.Helper()
req := httptest.NewRequest(method, path, strings.NewReader(body))
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if out != nil && rec.Code < 300 {
if err := json.Unmarshal(rec.Body.Bytes(), out); err != nil {
t.Fatalf("%s %s: %v (%s)", method, path, err, rec.Body)
}
}
return rec.Code
}
func TestSavedSearchAPI(t *testing.T) {
mux, a := newTestAPI(t)
for _, bad := range []string{
`{"name": "", "params": "q=x", "schedule": {"every": "week", "weekday": 1, "hour": 8}}`,
`{"name": "x", "params": "sort=nope", "schedule": {"every": "week", "weekday": 1, "hour": 8}}`,
`{"name": "x", "params": "q=x", "schedule": {"every": "month"}}`,
`{"name": "x", "params": "q=x", "maxResults": 7, "schedule": {"every": "day", "hour": 8}}`,
} {
if code := call(t, mux, "POST", "/api/saved", bad, nil); code != http.StatusBadRequest {
t.Errorf("%s: got %d, want 400", bad, code)
}
}
var x SavedSearch
code := call(t, mux, "POST", "/api/saved",
`{"name": "Go syslog", "params": "q=syslog&language=Go&page=3", "schedule": {"every": "week", "weekday": 1, "hour": 8}}`, &x)
if code != http.StatusCreated || x.ID == "" || x.MaxResults != 50 || !x.Enabled || x.NextRunAt.IsZero() {
t.Fatalf("create: %d %+v", code, x)
}
if strings.Contains(x.Params, "page") {
t.Errorf("page should be dropped from params: %s", x.Params)
}
// The first run starts in the background; run once more by hand.
var info RunInfo
if code := call(t, mux, "POST", "/api/saved/"+x.ID+"/run", "", &info); code != http.StatusOK || info.Error != "" || info.Count != 1 {
t.Fatalf("run: %d %+v", code, info)
}
deadline := time.Now().Add(2 * time.Second)
var runs []RunInfo
for time.Now().Before(deadline) {
call(t, mux, "GET", "/api/saved/"+x.ID+"/runs", "", &runs)
if len(runs) == 2 {
break
}
time.Sleep(20 * time.Millisecond)
}
if len(runs) != 2 {
t.Fatalf("want 2 runs (background + manual), got %d", len(runs))
}
var run Run
if code := call(t, mux, "GET", "/api/saved/"+x.ID+"/runs/latest", "", &run); code != http.StatusOK || len(run.Items) != 1 || run.Items[0].FullName != "mcuadros/go-syslog" {
t.Fatalf("latest run: %d %+v", code, run)
}
if d := run.Items[0].StarsDelta; d == nil || *d != 0 || run.Items[0].New {
t.Errorf("second run: repository should be known with a 0 delta: %+v", run.Items[0])
}
var upd SavedSearch
code = call(t, mux, "PUT", "/api/saved/"+x.ID,
`{"name": "Renamed", "params": "q=syslog", "maxResults": 100, "enabled": false, "schedule": {"every": "hours", "hours": 6}}`, &upd)
if code != http.StatusOK || upd.Name != "Renamed" || upd.Enabled || !upd.NextRunAt.IsZero() || upd.LastRun == nil {
t.Fatalf("update: %d %+v", code, upd)
}
if code := call(t, mux, "GET", "/api/saved/"+x.ID+"/runs/../../x", "", nil); code == http.StatusOK {
t.Error("path traversal should not succeed")
}
if code := call(t, mux, "DELETE", "/api/saved/"+x.ID, "", nil); code != http.StatusNoContent {
t.Errorf("delete: %d", code)
}
if code := call(t, mux, "GET", "/api/saved/"+x.ID, "", nil); code != http.StatusNotFound {
t.Errorf("get after delete: %d", code)
}
_ = a
}
func TestSchedulerRunsDueSearches(t *testing.T) {
_, a := newTestAPI(t)
x, _ := a.store.Create(SavedSearch{Name: "due", Params: "q=syslog", MaxResults: 30, Enabled: true,
Schedule: Schedule{Every: "day", Hour: 8}, NextRunAt: time.Now().Add(-time.Minute)})
a.store.Create(SavedSearch{Name: "paused", Params: "q=syslog", MaxResults: 30,
Schedule: Schedule{Every: "day", Hour: 8}, NextRunAt: time.Now().Add(-time.Minute)})
a.sched.runDue(context.Background())
got, _ := a.store.Get(x.ID)
if got.LastRun == nil || !got.NextRunAt.After(time.Now()) {
t.Errorf("due search should have run and be planned again: %+v", got)
}
for _, s := range a.store.List() {
if s.Name == "paused" && s.LastRun != nil {
t.Error("a paused search must not run")
}
}
}