From e8a202e5138d5b3cd879eb276ee5cf370a79c3d2 Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Thu, 6 Aug 2026 23:10:23 +0300 Subject: [PATCH] web: mirror the git twin's description onto companion databases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The internal create endpoint accepts a description, but its only caller — dolt-git-hook — never sends one: git.sr.ht's push context does not carry it. Companion databases therefore all sat descriptionless on the dashboard while their git twins had perfectly good descriptions. Resolve the description server-side instead: a GitDescriber dependency (internal GraphQL query to git.sr.ht in the owner's name, the same network-key trust the hook uses to reach us, pointed the other way) is consulted on every /internal/repos call. A fresh companion is created with the twin's description; for an existing one the push doubles as the sync point — a changed, non-empty git description overwrites the stored one. An empty git description never clobbers one set in dolt's own settings, and every failure mode (no twin, git.sr.ht down, no resolver wired) degrades to no mirroring. The lookup is capped at 3s so the hook's own 5s POST timeout is never exceeded. Adds testify as a direct dependency for the new tests. --- cmd/doltsrht/main.go | 1 + go.mod | 4 +++ go.sum | 1 + web/adapters.go | 39 ++++++++++++++++++++++++ web/deps.go | 12 ++++++++ web/handlers_internal.go | 24 +++++++++++++++ web/handlers_internal_test.go | 56 +++++++++++++++++++++++++++++++++++ 7 files changed, 137 insertions(+) diff --git a/cmd/doltsrht/main.go b/cmd/doltsrht/main.go index 536d4fb86ed1b37192a83a302d10b7c6fcd7eb6c..1c3107968905516ecdd7517832f4ba2bec319eef 100644 --- a/cmd/doltsrht/main.go +++ b/cmd/doltsrht/main.go @@ -190,6 +190,7 @@ func main() { Repos: web.DBAdapter{}, Browse: web.BrowseAdapter{}, Users: web.MetaUserResolver{}, + Git: web.GitDescriptionResolver{}, RepoDiskPath: func(owner, name string) string { return storage.RepoDiskPath(cfg.reposRoot, owner, name) }, diff --git a/go.mod b/go.mod index de33b0ebec606d0264b829a0b2ea5e742dff6426..727036969b9eb7a6d972086a2afb17b84e778157 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/go-chi/chi/v5 v5.3.1 github.com/lib/pq v1.10.9 github.com/sirupsen/logrus v1.8.3 + github.com/stretchr/testify v1.11.1 github.com/vaughan0/go-ini v0.0.0-20130923145212-a98ad7ee00ec google.golang.org/grpc v1.79.3 gopkg.in/go-jose/go-jose.v2 v2.6.3 @@ -70,6 +71,7 @@ require ( github.com/cloudflare/circl v1.6.0 // indirect github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 // indirect github.com/cockroachdb/apd/v3 v3.2.3 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/denisbrodbeck/machineid v1.0.1 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dolthub/aws-sdk-go-ini-parser v0.0.0-20250305001723-2821c37f6c12 // indirect @@ -128,6 +130,7 @@ require ( github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pkg/errors v0.9.1 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.66.1 // indirect @@ -163,6 +166,7 @@ require ( google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/src-d/go-errors.v1 v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) replace github.com/dolthub/gozstd => ./third_party/gozstd-purego diff --git a/go.sum b/go.sum index aaa5c108bac85115b19ca18e61ba999dbce0226c..ce08c5fbc0b028daf6e878a3384f6664280ca10f 100644 --- a/go.sum +++ b/go.sum @@ -429,6 +429,7 @@ github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= diff --git a/web/adapters.go b/web/adapters.go index a55a3df6b6c6cf0b5a5289a1f0cfe1635514b4c9..0fd8da904da344c9c00938c260591317a5a459c0 100644 --- a/web/adapters.go +++ b/web/adapters.go @@ -2,8 +2,10 @@ package web import ( "context" + "time" "sourcecraft.dev/bigbes/sr-ht-core/auth" + "sourcecraft.dev/bigbes/sr-ht-core/client" "sourcecraft.dev/bigbes/sr-ht-dolt/authn" "sourcecraft.dev/bigbes/sr-ht-dolt/browse" @@ -91,6 +93,43 @@ func (BrowseAdapter) Open(ctx context.Context, diskPath string) (BrowseSession, return dbh, nil } +// gitDescribeTimeout bounds the git.sr.ht GraphQL lookup. It must stay well +// under dolt-git-hook's 5s client timeout: the lookup runs inside the hook's +// POST to /internal/repos, so a slow git.sr.ht API must not push the whole +// request past what the hook will wait for. +const gitDescribeTimeout = 3 * time.Second + +// GitDescriptionResolver satisfies GitDescriber over git.sr.ht's GraphQL API, +// queried with internal auth in the owner's name — the same service-to-service +// network-key trust dolt-git-hook uses to reach us, pointed the other way. +type GitDescriptionResolver struct{} + +var _ GitDescriber = GitDescriptionResolver{} + +func (GitDescriptionResolver) Description(ctx context.Context, owner, name string) (string, bool) { + ctx, cancel := context.WithTimeout(ctx, gitDescribeTimeout) + defer cancel() + + var resp struct { + Me struct { + Repository *struct { + Description *string `json:"description"` + } `json:"repository"` + } `json:"me"` + } + err := client.Do(ctx, owner, "git.sr.ht", client.GraphQLQuery{ + Query: `query($name: String!) { me { repository(name: $name) { description } } }`, + Variables: map[string]any{"name": name}, + }, &resp) + if err != nil || resp.Me.Repository == nil { + return "", false + } + if resp.Me.Repository.Description == nil { + return "", true + } + return *resp.Me.Repository.Description, true +} + // MetaUserResolver satisfies UserResolver via core-go's auth.LookupUser, which // mirrors the meta.sr.ht profile into the local user table and yields the // account's UserID. Used to resolve an ACL grantee by username. diff --git a/web/deps.go b/web/deps.go index df4c8becd3acd0b49d3ea6d2528104bbe7bbab39..6f1c15c4e76de6433dbe7c595b0bca447e0e3351 100644 --- a/web/deps.go +++ b/web/deps.go @@ -56,6 +56,10 @@ type Config struct { // mirroring the meta profile on first sight. Satisfied in production by a // core-go auth.LookupUser adapter. Users UserResolver + // Git resolves the description of the owner's same-named git.sr.ht + // repository, so companion databases mirror it (see handleInternalCreate). + // nil disables mirroring entirely (tests, instances without git.sr.ht). + Git GitDescriber // RepoDiskPath returns the absolute on-disk store dir for owner/name. In // production this is storage.RepoDiskPath bound to ReposRoot. RepoDiskPath func(owner, name string) string @@ -118,6 +122,14 @@ type BrowseOpener interface { Open(ctx context.Context, diskPath string) (BrowseSession, error) } +// GitDescriber looks up the description of owner's same-named repository on +// git.sr.ht. ok=false means it could not be resolved — no git twin, git.sr.ht +// unreachable — and the caller must leave the stored description alone; +// ("", true) means the twin exists and has no description. +type GitDescriber interface { + Description(ctx context.Context, owner, name string) (desc string, ok bool) +} + // UserResolver resolves a username to a core account, mirroring the meta // profile into the local user table on first sight (so the resolved UserID can // be used as an ACL grantee). Returns an error the caller treats as "no such diff --git a/web/handlers_internal.go b/web/handlers_internal.go index 686beee955f90f0bf8d9838e43025ada108e9942..bccaf5c7a211073504543a6dffaa8d5077260188 100644 --- a/web/handlers_internal.go +++ b/web/handlers_internal.go @@ -130,6 +130,21 @@ func (a *app) handleInternalCreate(w http.ResponseWriter, r *http.Request) { return } + // Mirror the git twin's description. The hook that calls us fires on every + // git push but its push context carries no description, so resolve it from + // git.sr.ht ourselves. Strictly best-effort: a miss (no twin, git.sr.ht + // unreachable, no resolver wired) only means no mirroring this time. + var ( + gitDesc string + gitOK bool + ) + if a.cfg.Git != nil { + gitDesc, gitOK = a.cfg.Git.Description(ctx, caller.Username, req.Name) + } + if req.Description == "" && gitOK { + req.Description = gitDesc + } + url := a.repoURL(caller.Username, req.Name) diskPath := a.cfg.RepoDiskPath(caller.Username, req.Name) repo := &core.Repo{ @@ -147,6 +162,15 @@ func (a *app) handleInternalCreate(w http.ResponseWriter, r *http.Request) { created, err := a.cfg.Repos.CreateRepo(ctx, repo) if err != nil { if errors.Is(err, db.ErrNameTaken) { + // The push is also the description sync point for an existing + // companion. Only a non-empty git description overwrites, so a + // twin with no description never clobbers one set in dolt's own + // settings; failures are swallowed like the rest of this path. + if gitOK && gitDesc != "" { + if existing, gerr := a.cfg.Repos.GetRepoByOwnerAndName(ctx, caller.Username, req.Name); gerr == nil && existing.Description != gitDesc { + _ = a.cfg.Repos.UpdateRepo(ctx, existing.ID, gitDesc, existing.Visibility) + } + } writeJSON(w, http.StatusOK, internalCreateResponse{URL: url, Created: false}) return } diff --git a/web/handlers_internal_test.go b/web/handlers_internal_test.go index f5eb32b5bd11363abd67f997ec5c97d1c3f606bb..7a980ec7a9e6af06089af39f3e11045f84a0ab21 100644 --- a/web/handlers_internal_test.go +++ b/web/handlers_internal_test.go @@ -1,11 +1,15 @@ package web import ( + "context" "net/http" "net/http/httptest" "strings" "testing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "sourcecraft.dev/bigbes/sr-ht-dolt/core" "sourcecraft.dev/bigbes/sr-ht-dolt/db" ) @@ -111,6 +115,58 @@ func TestInternalCreateRejectsBadName(t *testing.T) { } } +// fakeGit is a canned GitDescriber: one description per owner/name key, with +// ok=false for anything unlisted (no git twin / git.sr.ht down). +type fakeGit struct { + byRepo map[string]string +} + +func (g *fakeGit) Description(_ context.Context, owner, name string) (string, bool) { + d, ok := g.byRepo[owner+"/"+name] + return d, ok +} + +func TestInternalCreateMirrorsGitDescription(t *testing.T) { + h := newHarness(t) + h.users.byName["alice"] = &core.Caller{UserID: 7, Username: "alice"} + h.app.cfg.Git = &fakeGit{byRepo: map[string]string{"alice/widgets": "widget factory"}} + + rec := h.postInternalCreate(`{"owner":"alice","name":"widgets"}`) + require.Equal(t, http.StatusCreated, rec.Code, rec.Body.String()) + require.Len(t, h.store.createdCalls, 1) + assert.Equal(t, "widget factory", h.store.createdCalls[0].Description) +} + +func TestInternalCreateSyncsChangedGitDescription(t *testing.T) { + h := newHarness(t) + h.users.byName["alice"] = &core.Caller{UserID: 7, Username: "alice"} + h.store.add(&core.Repo{Name: "widgets", Description: "stale", OwnerID: 7, OwnerName: "alice", Path: "/p", Visibility: core.VisibilityUnlisted}) + h.app.cfg.Git = &fakeGit{byRepo: map[string]string{"alice/widgets": "fresh"}} + + rec := h.postInternalCreate(`{"owner":"alice","name":"widgets"}`) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + + got, err := h.store.GetRepoByOwnerAndName(nil, "alice", "widgets") + require.NoError(t, err) + assert.Equal(t, "fresh", got.Description) + assert.Equal(t, core.VisibilityUnlisted, got.Visibility, "sync must not touch visibility") +} + +func TestInternalCreateEmptyGitDescriptionKeepsLocal(t *testing.T) { + h := newHarness(t) + h.users.byName["alice"] = &core.Caller{UserID: 7, Username: "alice"} + h.store.add(&core.Repo{Name: "widgets", Description: "set in dolt", OwnerID: 7, OwnerName: "alice", Path: "/p", Visibility: core.VisibilityPrivate}) + // Twin exists but carries no description: ("", true) must not clobber. + h.app.cfg.Git = &fakeGit{byRepo: map[string]string{"alice/widgets": ""}} + + rec := h.postInternalCreate(`{"owner":"alice","name":"widgets"}`) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + + got, err := h.store.GetRepoByOwnerAndName(nil, "alice", "widgets") + require.NoError(t, err) + assert.Equal(t, "set in dolt", got.Description) +} + var errInitBoom = &boomError{} type boomError struct{}