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 @@ Stores: stores, 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 @@ github.com/fernet/fernet-go v0.0.0-20211208181803-9f70042a33ee 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 @@ github.com/cespare/xxhash/v2 v2.3.0 // indirect 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 @@ github.com/oracle/oci-go-sdk/v65 v65.55.0 // indirect 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 @@ google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect 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+wExME= 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" @@ -89,6 +91,43 @@ if err != nil { return nil, err } 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 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 @@ // Users resolves a SourceHut username to its account (for ACL add-by-username), // 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 @@ -116,6 +120,14 @@ // BrowseOpener opens a BrowseSession over the bare store at diskPath. Open must // be paired with Session.Close by the caller (handlers defer it). 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 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 @@ http.Error(w, fmt.Sprintf("resolve owner %q: %v", req.Owner, err), http.StatusUnprocessableEntity) 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 @@ // not an error — return 200 without touching disk. 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,10 +1,14 @@ 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" @@ -109,6 +113,58 @@ rec := h.postInternalCreate(`{"owner":"alice","name":".."}`) if rec.Code != http.StatusBadRequest { t.Fatalf("bad name: got %d, want 400", rec.Code) } +} + +// 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{}