M cmd/doltsrht/main.go => cmd/doltsrht/main.go +1 -0
@@ 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)
},
M go.mod => go.mod +4 -0
@@ 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
M go.sum => go.sum +1 -0
@@ 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=
M web/adapters.go => web/adapters.go +39 -0
@@ 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.
M web/deps.go => web/deps.go +12 -0
@@ 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
M web/handlers_internal.go => web/handlers_internal.go +24 -0
@@ 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
}
M web/handlers_internal_test.go => web/handlers_internal_test.go +56 -0
@@ 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{}