From 03dd84d409cd3832be7b59916e78f056f38f2724 Mon Sep 17 00:00:00 2001 From: Matt Turner Date: Fri, 23 Oct 2026 19:29:51 -0400 Subject: [PATCH 2/2] serve: find a report by date with ?before= A client wanting the report as of a date, such as the last of a month, had to clone gentoo-ci.git to find the scan id. ?before= on a report URL without a commit serves the last trunk scan before that time, and says which in X-Scan-Id as usual. PR scans are skipped, since they are not the state of the repository. A scan's time is its commit's for scans loaded from history, and when it was uploaded for the rest. On a copy of the production store, it finds the same scans as git log -1 --before on gentoo-ci.git's master. Signed-off-by: Matt Turner --- README.md | 8 +++++++ internal/server/parent_test.go | 39 ++++++++++++++++++++++++++++++++++ internal/server/routes.go | 3 +++ internal/server/server.go | 33 +++++++++++++++++++++++----- internal/store/info.go | 15 +++++++++++++ internal/store/store_test.go | 23 ++++++++++++++++++++ 6 files changed, 116 insertions(+), 5 deletions(-) diff --git ./README.md ./README.md index e660a57..e9712ba 100644 --- ./README.md +++ ./README.md @@ -72,6 +72,14 @@ appended to the filename with semicolons, as the CGI parsed them: belongs to, following subprojects that inherit members. `pkg` takes `cat:pkg` pairs, comma separated. +To get a report by date without knowing its commit, `before` asks for the +last trunk scan before an RFC 3339 time. The scan served is in the +`X-Scan-Id` header: + +``` +/output/gentoo-ci/output.json?before=2026-10-01T00:00:00Z +``` + ## Uploading a scan A master-branch scan, diffed against the trunk commit before it: diff --git ./internal/server/parent_test.go ./internal/server/parent_test.go index 4145da0..2260d8a 100644 --- ./internal/server/parent_test.go +++ ./internal/server/parent_test.go @@ -4,6 +4,8 @@ package server import ( "fmt" + "net/http" + "net/http/httptest" "path/filepath" "testing" "time" @@ -143,3 +145,40 @@ func TestPickParentUnknownCandidate(t *testing.T) { t.Errorf("parent = %q, want the fallback %s", choice.Sha, only) } } + +func TestServeBefore(t *testing.T) { + s, st := parentTestServer(t) + for i, sha := range []string{"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"} { + rows, err := st.Intern(results(0, 10)) + if err != nil { + t.Fatal(err) + } + ts := time.Date(2026, 9, 30+i, 12, 0, 0, 0, time.UTC) + if _, _, err := st.Put(sha, "", ts, rows, true); err != nil { + t.Fatal(err) + } + } + h := s.Handler() + + for _, c := range []struct { + path string + status int + scan string + }{ + {"/output/gentoo-ci/output.json?before=2026-10-01T00:00:00Z", http.StatusOK, + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, + {"/output/gentoo-ci/output.json?before=2026-10-01T15:00:00%2B02:00", http.StatusOK, + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}, + {"/output/gentoo-ci/output.json?before=2026-09-01T00:00:00Z", http.StatusNotFound, ""}, + {"/output/gentoo-ci/output.json?before=2026-10-01", http.StatusBadRequest, ""}, + {"/output/gentoo-ci/aaaaaaaa/output.json?before=2026-10-01T00:00:00Z", + http.StatusBadRequest, ""}, + } { + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, c.path, nil)) + if rec.Code != c.status || rec.Header().Get("X-Scan-Id") != c.scan { + t.Errorf("%s: %d, scan %q; want %d, scan %q", c.path, + rec.Code, rec.Header().Get("X-Scan-Id"), c.status, c.scan) + } + } +} diff --git ./internal/server/routes.go ./internal/server/routes.go index 76fb125..5c85e81 100644 --- ./internal/server/routes.go +++ ./internal/server/routes.go @@ -24,6 +24,9 @@ // output.html;maintainer=larry@gentoo.org;include-projects // output.html;pkg=dev-libs:foo,dev-libs:bar // +// Without a commit, ?before= serves the last trunk scan before +// that time instead of the newest scan. +// // A semicolon-delimited path segment is unusual, but it is what the existing // links use. Semicolons are legal inside a path segment, so the router hands // the whole thing over intact. diff --git ./internal/server/server.go ./internal/server/server.go index c404db5..9dd0514 100644 --- ./internal/server/server.go +++ ./internal/server/server.go @@ -134,14 +134,37 @@ func (s *Server) serveReport(w http.ResponseWriter, r *http.Request) { if !ok { return } + q := r.URL.Query() + hasBefore := q.Has("before") + var before time.Time + if hasBefore { + if commit != "" { + http.Error(w, "before cannot be given with a scan id", http.StatusBadRequest) + return + } + var err error + if before, err = time.Parse(time.RFC3339, q.Get("before")); err != nil { + http.Error(w, "before: "+err.Error(), http.StatusBadRequest) + return + } + } start := time.Now() s.mu.RLock() defer s.mu.RUnlock() - sha, err := s.resolveScan(commit) - if err != nil { - s.reportLookupError(w, err) - return + var sha string + if hasBefore { + if sha = s.st.TrunkScanBefore(before); sha == "" { + http.Error(w, "no trunk scan before "+before.Format(time.RFC3339), + http.StatusNotFound) + return + } + } else { + var err error + if sha, err = s.resolveScan(commit); err != nil { + s.reportLookupError(w, err) + return + } } rows, err := s.st.Get(sha) if err != nil { @@ -169,7 +192,7 @@ func (s *Server) serveReport(w http.ResponseWriter, r *http.Request) { } default: page := qahtml.Page{Timestamp: s.st.ScanTime(sha), Filter: file.Filter} - if commit != "" { + if commit != "" || hasBefore { page.Revision = sha } if err := s.cfg.Render(w, seq, len(rows), s.meta, page); err != nil { diff --git ./internal/store/info.go ./internal/store/info.go index faf7484..f351680 100644 --- ./internal/store/info.go +++ ./internal/store/info.go @@ -55,6 +55,21 @@ func (s *Store) LatestScan() string { return s.scans[len(s.scans)-1].sha } +// TrunkScanBefore is the trunk scan with the latest time before t, or "" when +// there is none. Of scans with the same time, the last stored wins. +func (s *Store) TrunkScanBefore(t time.Time) string { + best := -1 + for i, r := range s.scans { + if r.trunk && time.Unix(r.ts, 0).Before(t) && (best < 0 || r.ts >= s.scans[best].ts) { + best = i + } + } + if best < 0 { + return "" + } + return s.scans[best].sha +} + // RecentScans returns up to n scans, newest first. func (s *Store) RecentScans(n int) []ScanInfo { out := make([]ScanInfo, 0, n) diff --git ./internal/store/store_test.go ./internal/store/store_test.go index 87514b8..de12e6c 100644 --- ./internal/store/store_test.go +++ ./internal/store/store_test.go @@ -310,3 +310,26 @@ func TestTrunkBackfilled(t *testing.T) { t.Errorf("trunk = %v, want %v", got, want) } } + +func TestTrunkScanBefore(t *testing.T) { + s, err := Open(t.TempDir()+"/scans.db", 16) + if err != nil { + t.Fatal(err) + } + defer s.Close() + putChain(t, s, 3, false) // t0, t1, t2 at 0, 1, 2s; pr at 3s + + for _, c := range []struct { + before time.Time + want string + }{ + {time.Unix(0, 0), ""}, + {time.Unix(1, 0), "t0"}, + {time.Unix(1, 500), "t1"}, + {time.Unix(10, 0), "t2"}, // not the later PR scan + } { + if got := s.TrunkScanBefore(c.before); got != c.want { + t.Errorf("TrunkScanBefore(%v) = %q, want %q", c.before.Unix(), got, c.want) + } + } +} -- 2.55.0