~bigbes/sr-ht-dolt

e8a202e5138d5b3cd879eb276ee5cf370a79c3d2 — Eugene Blikh 11 days ago 8e7786c
web: mirror the git twin's description onto companion databases

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.
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{}