From fd06617ca6e9483709a6d284a15ae7f64c6c9e21 Mon Sep 17 00:00:00 2001 From: qiansc Date: Sun, 8 Nov 2026 16:53:01 +0000 Subject: [PATCH] feat: accept refs filters as HTTP query parameters GET /v1/repo/refs now accepts kind, query, author, title, since, until, sort and first alongside repo and after; other routes keep rejecting them. The INVALID_REVISION message names what resolve accepts. Co-Authored-By: Claude Opus 5.5 --- docs/interfaces.md | 2 +- internal/httpserver/plugins_test.go | 30 +++++++++++++++++++++++++++ internal/httpserver/runtime.go | 2 +- internal/httpserver/website_access.go | 3 +++ internal/service/service.go | 2 +- 5 files changed, 36 insertions(+), 3 deletions(-) diff --git a/docs/interfaces.md b/docs/interfaces.md index 87ddd26..459a616 100644 --- a/docs/interfaces.md +++ b/docs/interfaces.md @@ -41,7 +41,7 @@ Other tools accept a monitored branch or a full SHA. To work with any other bran | Full 40-character SHA | An observed or retained version; otherwise `REVISION_NOT_ELIGIBLE` | `observed` | | Any other branch or tag name, `refs/heads/`, `refs/tags/` | The repository's ref catalog when it is fresh and not truncated; otherwise the remote is asked for exactly these names and the matching ref is downloaded to a scratch directory, without file trees, to confirm its object type and commit details, then deleted | `catalog` or `remote` | -A bare name that is both a branch and a tag returns `REF_AMBIGUOUS` listing both; pass the full form. A missing name returns `REF_NOT_FOUND`, and a tag confirmed to end at a tree or blob returns `REF_NOT_COMMIT`. Resolution uses the ref as actually downloaded, so a branch that moves between listing and download resolves to the downloaded commit; an object that cannot be verified returns a retryable `REMOTE_UNAVAILABLE`, never `REF_NOT_COMMIT`. Annotated tags resolve to their commit; `created_at/author/title` describe the tag and `committer_time` the commit. Short SHAs, `HEAD` and revision expressions are still rejected. Identical concurrent remote lookups share one request, and results, including "not found", are cached for 30 seconds (at most 10,000 entries). Remote lookups run at most 2 per repository and 8 per instance, at most 64 may be running or waiting (beyond that, `REMOTE_UNAVAILABLE` with `Retry-After`), and they back off per repository after failures, from 10 seconds up to 5 minutes. Visibility is checked before every cached answer. +A bare name that is both a branch and a tag returns `REF_AMBIGUOUS` listing both; pass the full form. A missing name returns `REF_NOT_FOUND`, and a tag confirmed to end at a tree or blob returns `REF_NOT_COMMIT`. Resolution uses the ref as actually downloaded, so a branch that moves between listing and download resolves to the downloaded commit; an object that cannot be verified returns a retryable `REMOTE_UNAVAILABLE`, never `REF_NOT_COMMIT`. Annotated tags resolve to their commit; `created_at/author/title` describe the tag and `committer_time` the commit. Short SHAs, `HEAD`, revision expressions and branch or tag names with characters the service does not accept (such as parentheses or spaces) are rejected with `SHORT_SHA_UNSUPPORTED` or `INVALID_REVISION`; such names still appear in `refs`. Identical concurrent remote lookups share one request, and results, including "not found", are cached for 30 seconds (at most 10,000 entries). Remote lookups run at most 2 per repository and 8 per instance, at most 64 may be running or waiting (beyond that, `REMOTE_UNAVAILABLE` with `Retry-After`), and they back off per repository after failures, from 10 seconds up to 5 minutes. Visibility is checked before every cached answer. `refs` lists a registered repository's branches or tags. Optional arguments: `kind` (`branch` default, `tag`, `all`); `query`, `author` and `title`, case-insensitive substrings of the name, the author or tagger, and the commit title or tag message first line; `since/until` (RFC 3339 or `YYYY-MM-DD`, compared with `created_at`); `sort` (`created`, newest first, the default, or `name`); `first` (1–100, default 30); `after` (the previous `next_cursor`, with the other arguments unchanged). It returns `repo/catalog/sort/refs/next_cursor/has_more` and sometimes a `hint`. Each entry has `name/ref/kind/monitored/indexed`; entries verified to end at a commit add `commit/created_at/committer_time/author/title`, annotated tags add `annotated`, and tags of trees, blobs or unverifiable objects carry `object/target_type` instead of `commit`. `created_at` is the commit time for branches and lightweight tags and the tagging time for annotated tags. diff --git a/internal/httpserver/plugins_test.go b/internal/httpserver/plugins_test.go index 8d1b4ed..ee4974d 100644 --- a/internal/httpserver/plugins_test.go +++ b/internal/httpserver/plugins_test.go @@ -41,3 +41,33 @@ func TestPluginDiscoveryRouteAndVisibility(t *testing.T) { } } } + +func TestRefsRouteAcceptsItsFilters(t *testing.T) { + store := &pluginRepoStore{public: true} + s, e := service.New(store, t.TempDir(), "", "") + if e != nil { + t.Fatal(e) + } + defer s.Close() + h := Runtime(config.Defaults(), slog.New(slog.NewTextHandler(io.Discard, nil)), s, "") + for _, tc := range []struct { + query, code string + }{ + // Filters reach the service, which validates their values. + {"&kind=tag&query=v1&author=a&title=b&sort=name&first=5&until=2026-01-01&since=yesterday", "INVALID_TIME"}, + {"&kind=tag&kind=branch", "INVALID_QUERY_PARAMETERS"}, + {"&unknown=1", "INVALID_QUERY_PARAMETERS"}, + } { + w := httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequest("GET", "/sourcegraph/v1/repo/refs?repo=org/repo"+tc.query, nil)) + if w.Code != 400 || !strings.Contains(w.Body.String(), tc.code) { + t.Fatalf("%s: %d %s", tc.query, w.Code, w.Body.String()) + } + } + // A filter name is accepted only on the refs route. + w := httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequest("GET", "/sourcegraph/v1/repo/availability?repo=org/repo&kind=tag", nil)) + if w.Code != 400 || !strings.Contains(w.Body.String(), "INVALID_QUERY_PARAMETERS") { + t.Fatalf("availability: %d %s", w.Code, w.Body.String()) + } +} diff --git a/internal/httpserver/runtime.go b/internal/httpserver/runtime.go index a1891c0..b3ad4b6 100644 --- a/internal/httpserver/runtime.go +++ b/internal/httpserver/runtime.go @@ -250,7 +250,7 @@ func runtime(c config.Config, log *slog.Logger, s *service.Service, token string } if r.URL.RawQuery != "" { for k, v := range r.URL.Query() { - if (k != "repo" && k != "after" && !(k == "revision" && r.URL.Path == Prefix+"/v1/repo/plugins") && !(k == "branch" && r.URL.Path == Prefix+"/api/admin/v1/repo/commit-options")) || len(v) != 1 { + if (k != "repo" && k != "after" && !(k == "revision" && r.URL.Path == Prefix+"/v1/repo/plugins") && !(k == "branch" && r.URL.Path == Prefix+"/api/admin/v1/repo/commit-options") && !(refsParameters[k] && r.URL.Path == Prefix+"/v1/repo/refs")) || len(v) != 1 { status = 400 writeError(w, id, status, "INVALID_QUERY_PARAMETERS", "Unexpected or repeated query parameter.") return diff --git a/internal/httpserver/website_access.go b/internal/httpserver/website_access.go index 15d240d..3094a3b 100644 --- a/internal/httpserver/website_access.go +++ b/internal/httpserver/website_access.go @@ -15,6 +15,9 @@ import ( // Public access is method/path specific. A future management route // cannot become public merely by sharing the same URL prefix. +// refsParameters are the filters GET /v1/repo/refs accepts besides repo and after. +var refsParameters = map[string]bool{"kind": true, "query": true, "author": true, "title": true, "since": true, "until": true, "sort": true, "first": true} + func publicRead(r *http.Request) bool { if r.Method == "GET" { switch r.URL.Path { diff --git a/internal/service/service.go b/internal/service/service.go index 174a0d6..1fc7ce6 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -371,7 +371,7 @@ func eligible(r control.Repository, revision string) (control.Version, error) { return control.Version{}, contract.Fail("SHORT_SHA_UNSUPPORTED", "Abbreviated commit SHAs are not supported; obtain the original full 40-character lowercase SHA from trusted source metadata. Do not guess or switch branches.", 400) } if (revision == branch && isHexRevision(revision)) || gitstore.ValidateRevision(revision) != nil || (strings.HasPrefix(revision, "refs/") && revision == branch) { - return control.Version{}, contract.Fail("INVALID_REVISION", "Use a monitored branch or a full 40-character lowercase commit SHA; HEAD, tags and arbitrary refs are not supported.", 400) + return control.Version{}, contract.Fail("INVALID_REVISION", "Use a monitored branch or a full 40-character lowercase commit SHA; resolve also accepts other branch and tag names. HEAD, revision expressions, other ref namespaces and names with unsupported characters are rejected.", 400) } return control.Version{}, contract.Fail("BRANCH_NOT_MONITORED", "The requested branch is not monitored; inspect availability for configured branches without substituting a different baseline.", 404) }