Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
815739758e | ||
|
|
6f1cb53295 |
@@ -8,6 +8,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
@@ -120,21 +121,26 @@ func (s *PGStore) Search(ctx context.Context, query []float32, limit int) ([]Hit
|
||||
return hits, nil
|
||||
}
|
||||
|
||||
// KnownPaths returns the path set already present in the store. Used by
|
||||
// the watcher to diff against the wiki/ tree and decide what to upsert.
|
||||
func (s *PGStore) KnownPaths(ctx context.Context) (map[string]struct{}, error) {
|
||||
rows, err := s.pool.Query(ctx, `SELECT path FROM brain_embeddings`)
|
||||
// KnownPathsWithTime returns every embedded chunk path paired with the
|
||||
// row's updated_at. Sync uses the timestamps to decide whether a file
|
||||
// has been edited since its chunks were last embedded — when the file's
|
||||
// mtime exceeds the oldest chunk's updated_at, the file is re-embedded.
|
||||
func (s *PGStore) KnownPathsWithTime(ctx context.Context) (map[string]time.Time, error) {
|
||||
rows, err := s.pool.Query(ctx, `SELECT path, updated_at FROM brain_embeddings`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query paths: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make(map[string]struct{})
|
||||
out := make(map[string]time.Time)
|
||||
for rows.Next() {
|
||||
var p string
|
||||
if err := rows.Scan(&p); err != nil {
|
||||
var (
|
||||
p string
|
||||
t time.Time
|
||||
)
|
||||
if err := rows.Scan(&p, &t); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[p] = struct{}{}
|
||||
out[p] = t
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ func freshStore(t *testing.T) (*vectorstore.PGStore, context.Context) {
|
||||
t.Cleanup(s.Close)
|
||||
require.NoError(t, s.Init(ctx))
|
||||
// Clean slate per test.
|
||||
_, _ = s.KnownPaths(ctx)
|
||||
_, _ = s.KnownPathsWithTime(ctx)
|
||||
require.NoError(t, s.Delete(ctx, "%test-fixture%"))
|
||||
return s, ctx
|
||||
}
|
||||
@@ -67,15 +67,18 @@ func TestIntegration_UpsertAndSearch(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegration_KnownPaths(t *testing.T) {
|
||||
func TestIntegration_KnownPathsWithTime(t *testing.T) {
|
||||
s, ctx := freshStore(t)
|
||||
before := time.Now()
|
||||
require.NoError(t, s.Upsert(ctx, "wiki/k.md", vec(768, 0.5)))
|
||||
t.Cleanup(func() { _ = s.Delete(ctx, "wiki/k.md") })
|
||||
|
||||
paths, err := s.KnownPaths(ctx)
|
||||
paths, err := s.KnownPathsWithTime(ctx)
|
||||
require.NoError(t, err)
|
||||
_, ok := paths["wiki/k.md"]
|
||||
assert.True(t, ok)
|
||||
at, ok := paths["wiki/k.md"]
|
||||
require.True(t, ok)
|
||||
assert.False(t, at.IsZero(), "updated_at must not be zero")
|
||||
assert.WithinDuration(t, before, at, 5*time.Second, "updated_at must be recent")
|
||||
}
|
||||
|
||||
func TestUpsert_RejectsWrongDimension(t *testing.T) {
|
||||
|
||||
@@ -18,7 +18,11 @@ type Embedder interface {
|
||||
|
||||
// Store is the subset of PGStore that Sync needs. Lets tests stub it.
|
||||
type Store interface {
|
||||
KnownPaths(ctx context.Context) (map[string]struct{}, error)
|
||||
// KnownPathsWithTime returns every embedded chunk path paired with the
|
||||
// row's updated_at. Sync uses the timestamp to detect edits — a file
|
||||
// whose mtime is newer than ANY of its chunks' updated_at is re-embedded
|
||||
// from scratch (old chunks deleted, fresh chunks upserted).
|
||||
KnownPathsWithTime(ctx context.Context) (map[string]time.Time, error)
|
||||
Upsert(ctx context.Context, path string, embedding []float32) error
|
||||
Delete(ctx context.Context, path string) error
|
||||
}
|
||||
@@ -58,15 +62,31 @@ func Sync(ctx context.Context, brainDir string, store Store, embedder Embedder)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
known, err := store.KnownPaths(ctx)
|
||||
known, err := store.KnownPathsWithTime(ctx)
|
||||
if err != nil {
|
||||
return res, fmt.Errorf("known paths: %w", err)
|
||||
}
|
||||
// Build a parent → "any chunk known?" set so we can skip files that
|
||||
// already have at least one chunk row in the store.
|
||||
knownParents := make(map[string]struct{}, len(known))
|
||||
for p := range known {
|
||||
knownParents[ParentPath(p)] = struct{}{}
|
||||
// Group known chunks by parent path and remember the EARLIEST
|
||||
// updated_at per parent. A file is considered stale if its mtime is
|
||||
// after the oldest of its chunk rows — i.e. at least one chunk hasn't
|
||||
// been refreshed since the last edit. Also keep the full chunk-path
|
||||
// list per parent so we can delete every old chunk before re-embedding
|
||||
// (handles "file shrunk → fewer chunks → orphan rows" cleanly).
|
||||
type parentState struct {
|
||||
minUpdatedAt time.Time
|
||||
chunkPaths []string
|
||||
}
|
||||
parents := make(map[string]*parentState, len(known))
|
||||
for p, t := range known {
|
||||
parent := ParentPath(p)
|
||||
ps, ok := parents[parent]
|
||||
if !ok {
|
||||
ps = &parentState{minUpdatedAt: t}
|
||||
parents[parent] = ps
|
||||
} else if t.Before(ps.minUpdatedAt) {
|
||||
ps.minUpdatedAt = t
|
||||
}
|
||||
ps.chunkPaths = append(ps.chunkPaths, p)
|
||||
}
|
||||
seenParents := make(map[string]struct{})
|
||||
|
||||
@@ -90,11 +110,26 @@ func Sync(ctx context.Context, brainDir string, store Store, embedder Embedder)
|
||||
relSlash := filepath.ToSlash(rel)
|
||||
seenParents[relSlash] = struct{}{}
|
||||
|
||||
if _, ok := knownParents[relSlash]; ok {
|
||||
// File has at least one chunk in the store already.
|
||||
// TODO: compare mtime once Store exposes updated_at so we
|
||||
// re-embed on edit. For now, skip.
|
||||
return nil
|
||||
if ps, ok := parents[relSlash]; ok {
|
||||
// File already has chunks in the store. Re-embed only when
|
||||
// the file has been edited since the oldest chunk was
|
||||
// written. Tolerate clock skew with a sub-second grace.
|
||||
info, statErr := d.Info()
|
||||
if statErr != nil {
|
||||
res.Errors = append(res.Errors, fmt.Errorf("stat %s: %w", relSlash, statErr))
|
||||
return nil
|
||||
}
|
||||
if !info.ModTime().After(ps.minUpdatedAt) {
|
||||
return nil
|
||||
}
|
||||
// Stale: delete old chunks before re-embedding so a shrunk
|
||||
// file doesn't leave orphan rows at higher #NNNN indexes.
|
||||
for _, oldPath := range ps.chunkPaths {
|
||||
if delErr := store.Delete(ctx, oldPath); delErr != nil {
|
||||
res.Errors = append(res.Errors, fmt.Errorf("delete %s for re-embed: %w", oldPath, delErr))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
content, readErr := os.ReadFile(path)
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/vectorstore"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -14,16 +15,27 @@ import (
|
||||
)
|
||||
|
||||
type stubStore struct {
|
||||
known map[string]struct{}
|
||||
// known maps chunk-path → updated_at. Tests that don't care about
|
||||
// re-embed-on-mtime use a far-future time so the Sync skip path
|
||||
// always wins. Tests that do exercise the mtime path set the
|
||||
// updated_at explicitly.
|
||||
known map[string]time.Time
|
||||
upserts map[string][]float32
|
||||
deletes []string
|
||||
failNext error
|
||||
}
|
||||
|
||||
func (s *stubStore) KnownPaths(_ context.Context) (map[string]struct{}, error) {
|
||||
out := make(map[string]struct{}, len(s.known))
|
||||
for k := range s.known {
|
||||
out[k] = struct{}{}
|
||||
// farFuture is "newer than any file mtime", used as the default
|
||||
// updated_at in stubs that don't care about re-embed behavior.
|
||||
var farFuture = time.Now().Add(24 * time.Hour)
|
||||
|
||||
func (s *stubStore) KnownPathsWithTime(_ context.Context) (map[string]time.Time, error) {
|
||||
out := make(map[string]time.Time, len(s.known))
|
||||
for k, t := range s.known {
|
||||
if t.IsZero() {
|
||||
t = farFuture
|
||||
}
|
||||
out[k] = t
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -67,7 +79,7 @@ func TestSync_AddsNewFiles(t *testing.T) {
|
||||
writeNote(t, dir, "wiki/jepa-fx/facts/x.md", "body of x")
|
||||
writeNote(t, dir, "wiki/jepa-fx/facts/y.md", "body of y")
|
||||
|
||||
store := &stubStore{known: map[string]struct{}{}}
|
||||
store := &stubStore{known: map[string]time.Time{}}
|
||||
emb := stubEmbedder{vec: make([]float32, 768)}
|
||||
res, err := vectorstore.Sync(context.Background(), dir, store, emb)
|
||||
require.NoError(t, err)
|
||||
@@ -81,7 +93,7 @@ func TestSync_SkipsAlreadyKnown(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeNote(t, dir, "wiki/a/facts/x.md", "x")
|
||||
|
||||
store := &stubStore{known: map[string]struct{}{"wiki/a/facts/x.md#0001": {}}}
|
||||
store := &stubStore{known: map[string]time.Time{"wiki/a/facts/x.md#0001": {}}}
|
||||
emb := stubEmbedder{vec: make([]float32, 768)}
|
||||
res, err := vectorstore.Sync(context.Background(), dir, store, emb)
|
||||
require.NoError(t, err)
|
||||
@@ -93,7 +105,7 @@ func TestSync_DeletesDisappearedFiles(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki"), 0o755))
|
||||
// store has a path that doesn't exist on disk anymore
|
||||
store := &stubStore{known: map[string]struct{}{"wiki/old/facts/ghost.md#0001": {}}}
|
||||
store := &stubStore{known: map[string]time.Time{"wiki/old/facts/ghost.md#0001": {}}}
|
||||
res, err := vectorstore.Sync(context.Background(), dir, &stubStoreWithDelete{stubStore: store}, stubEmbedder{vec: make([]float32, 768)})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, res.Deleted)
|
||||
@@ -111,7 +123,7 @@ func TestSync_SkipsIndexFiles(t *testing.T) {
|
||||
writeNote(t, dir, "wiki/a/_index.md", "moc")
|
||||
writeNote(t, dir, "wiki/a/facts/real.md", "body")
|
||||
|
||||
store := &stubStore{known: map[string]struct{}{}}
|
||||
store := &stubStore{known: map[string]time.Time{}}
|
||||
res, err := vectorstore.Sync(context.Background(), dir, store, stubEmbedder{vec: make([]float32, 768)})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, res.Added)
|
||||
@@ -123,7 +135,7 @@ func TestSync_ScansKnowledgeDir(t *testing.T) {
|
||||
writeNote(t, dir, "wiki/a/facts/x.md", "x")
|
||||
writeNote(t, dir, "knowledge/2026-05-19-koala-gpu-setup.md", "knowledge body")
|
||||
|
||||
store := &stubStore{known: map[string]struct{}{}}
|
||||
store := &stubStore{known: map[string]time.Time{}}
|
||||
emb := stubEmbedder{vec: make([]float32, 768)}
|
||||
res, err := vectorstore.Sync(context.Background(), dir, store, emb)
|
||||
require.NoError(t, err)
|
||||
@@ -143,7 +155,7 @@ func TestSync_ChunksLongFiles(t *testing.T) {
|
||||
}
|
||||
writeNote(t, dir, "knowledge/long.md", body)
|
||||
|
||||
store := &stubStore{known: map[string]struct{}{}}
|
||||
store := &stubStore{known: map[string]time.Time{}}
|
||||
emb := stubEmbedder{vec: make([]float32, 768)}
|
||||
res, err := vectorstore.Sync(context.Background(), dir, store, emb)
|
||||
require.NoError(t, err)
|
||||
@@ -164,7 +176,7 @@ func TestSync_ShortFileGetsSingleChunkRow(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeNote(t, dir, "wiki/short.md", "tiny body\n")
|
||||
|
||||
store := &stubStore{known: map[string]struct{}{}}
|
||||
store := &stubStore{known: map[string]time.Time{}}
|
||||
emb := stubEmbedder{vec: make([]float32, 768)}
|
||||
res, err := vectorstore.Sync(context.Background(), dir, store, emb)
|
||||
require.NoError(t, err)
|
||||
@@ -176,7 +188,7 @@ func TestSync_SkipsFileIfAnyChunkAlreadyKnown(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeNote(t, dir, "wiki/foo.md", "body\n")
|
||||
|
||||
store := &stubStore{known: map[string]struct{}{
|
||||
store := &stubStore{known: map[string]time.Time{
|
||||
"wiki/foo.md#0001": {},
|
||||
}}
|
||||
emb := stubEmbedder{vec: make([]float32, 768)}
|
||||
@@ -189,7 +201,7 @@ func TestSync_SkipsFileIfAnyChunkAlreadyKnown(t *testing.T) {
|
||||
func TestSync_DeletesAllChunksOfDisappearedFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki"), 0o755))
|
||||
store := &stubStore{known: map[string]struct{}{
|
||||
store := &stubStore{known: map[string]time.Time{
|
||||
"wiki/ghost.md#0001": {},
|
||||
"wiki/ghost.md#0002": {},
|
||||
"wiki/ghost.md#0003": {},
|
||||
@@ -199,6 +211,49 @@ func TestSync_DeletesAllChunksOfDisappearedFile(t *testing.T) {
|
||||
assert.Equal(t, 3, res.Deleted)
|
||||
}
|
||||
|
||||
func TestSync_ReembedsFileWhenMtimeNewer(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeNote(t, dir, "wiki/edited.md", "original body\n")
|
||||
// Force the file's mtime ahead of any plausible store updated_at.
|
||||
future := time.Now().Add(1 * time.Hour)
|
||||
require.NoError(t, os.Chtimes(filepath.Join(dir, "wiki/edited.md"), future, future))
|
||||
|
||||
store := &stubStore{
|
||||
known: map[string]time.Time{
|
||||
// Existing chunk row pre-dates the file's mtime.
|
||||
"wiki/edited.md#0001": time.Now().Add(-1 * time.Hour),
|
||||
},
|
||||
}
|
||||
emb := stubEmbedder{vec: make([]float32, 768)}
|
||||
res, err := vectorstore.Sync(context.Background(), dir, store, emb)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, res.Added, "file with newer mtime should be re-embedded")
|
||||
assert.Contains(t, store.upserts, "wiki/edited.md#0001")
|
||||
// Old chunks of the same parent must be deleted before re-embed so
|
||||
// shrunk files don't leave orphan rows at higher #NNNN indexes.
|
||||
assert.Contains(t, store.deletes, "wiki/edited.md#0001")
|
||||
}
|
||||
|
||||
func TestSync_SkipsFileWhenMtimeOlder(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeNote(t, dir, "wiki/stable.md", "body\n")
|
||||
// Backdate mtime to before the store's recorded updated_at.
|
||||
past := time.Now().Add(-2 * time.Hour)
|
||||
require.NoError(t, os.Chtimes(filepath.Join(dir, "wiki/stable.md"), past, past))
|
||||
|
||||
store := &stubStore{
|
||||
known: map[string]time.Time{
|
||||
"wiki/stable.md#0001": time.Now(),
|
||||
},
|
||||
}
|
||||
emb := stubEmbedder{vec: make([]float32, 768)}
|
||||
res, err := vectorstore.Sync(context.Background(), dir, store, emb)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, res.Added)
|
||||
assert.Empty(t, store.upserts)
|
||||
assert.Empty(t, store.deletes)
|
||||
}
|
||||
|
||||
func TestSync_NoOpWhenComponentsNil(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeNote(t, dir, "wiki/a/facts/x.md", "x")
|
||||
@@ -210,7 +265,7 @@ func TestSync_NoOpWhenComponentsNil(t *testing.T) {
|
||||
func TestSync_CollectsEmbedderErrors(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeNote(t, dir, "wiki/a/facts/x.md", "x")
|
||||
store := &stubStore{known: map[string]struct{}{}}
|
||||
store := &stubStore{known: map[string]time.Time{}}
|
||||
emb := stubEmbedder{err: errors.New("upstream down")}
|
||||
res, err := vectorstore.Sync(context.Background(), dir, store, emb)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -13,12 +13,13 @@ import (
|
||||
)
|
||||
|
||||
type createArgs struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Hypothesis string `json:"hypothesis"`
|
||||
Folder string `json:"folder"`
|
||||
Stack string `json:"stack"`
|
||||
Private bool `json:"private"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Hypothesis string `json:"hypothesis"`
|
||||
Folder string `json:"folder"`
|
||||
Stack string `json:"stack"`
|
||||
Private bool `json:"private"`
|
||||
MirrorToGitHub bool `json:"mirror_to_github,omitempty"`
|
||||
}
|
||||
|
||||
type createResult struct {
|
||||
@@ -59,11 +60,12 @@ func (s *Skill) handleCreate(ctx context.Context, raw json.RawMessage) (json.Raw
|
||||
|
||||
tmpl := templateFor(args.Stack)
|
||||
giteaURL := fmt.Sprintf("http://gitea.d-ma.be/%s/%s", s.cfg.GiteaOwner, args.Name)
|
||||
githubURL := fmt.Sprintf("https://github.com/%s/%s", s.cfg.GitHubOwner, args.Name)
|
||||
|
||||
res := createResult{
|
||||
GiteaURL: giteaURL,
|
||||
GitHubURL: githubURL,
|
||||
GiteaURL: giteaURL,
|
||||
}
|
||||
if args.MirrorToGitHub {
|
||||
res.GitHubURL = fmt.Sprintf("https://github.com/%s/%s", s.cfg.GitHubOwner, args.Name)
|
||||
}
|
||||
|
||||
// Step 1: create_project_from_template. If the repo already exists,
|
||||
@@ -75,25 +77,32 @@ func (s *Skill) handleCreate(ctx context.Context, raw json.RawMessage) (json.Raw
|
||||
}
|
||||
res.Reached = append(res.Reached, stepCreateRepo)
|
||||
|
||||
// Step 2: create empty GitHub repo. Gitea's push-mirror cannot push
|
||||
// to a non-existent remote, so the destination must exist before
|
||||
// step 3 configures the mirror. Skipped when GitHub client is unset
|
||||
// (degraded mode — see Config.GitHub doc).
|
||||
if s.cfg.GitHub != nil {
|
||||
if err := s.callCreateGitHubRepo(ctx, args); err != nil && !errors.Is(err, githubclient.ErrAlreadyExists) {
|
||||
return marshalPartial(res, stepCreateGitHub, err)
|
||||
// Steps 2+3 are skipped when MirrorToGitHub is false. Default per
|
||||
// infra ADR (Gitea as true master, GitHub as optional opt-in): keep
|
||||
// client / business-logic / personal repos Gitea-only. Set
|
||||
// `mirror_to_github: true` for open-source projects that want a
|
||||
// public GitHub mirror (hyperguild, gitea-mcp, template-*).
|
||||
if args.MirrorToGitHub {
|
||||
// Step 2: create empty GitHub repo. Gitea's push-mirror cannot push
|
||||
// to a non-existent remote, so the destination must exist before
|
||||
// step 3 configures the mirror. Skipped when GitHub client is unset
|
||||
// (degraded mode — see Config.GitHub doc).
|
||||
if s.cfg.GitHub != nil {
|
||||
if err := s.callCreateGitHubRepo(ctx, args); err != nil && !errors.Is(err, githubclient.ErrAlreadyExists) {
|
||||
return marshalPartial(res, stepCreateGitHub, err)
|
||||
}
|
||||
res.Reached = append(res.Reached, stepCreateGitHub)
|
||||
}
|
||||
res.Reached = append(res.Reached, stepCreateGitHub)
|
||||
}
|
||||
|
||||
// Step 3: configure push mirror to GitHub. Idempotent: if a mirror with
|
||||
// the same remote already exists, gitea-mcp returns Conflict; we swallow it.
|
||||
if err := s.callMirror(ctx, args.Name); err != nil {
|
||||
if !isConflict(err) {
|
||||
return marshalPartial(res, stepMirror, err)
|
||||
// Step 3: configure push mirror to GitHub. Idempotent: if a mirror with
|
||||
// the same remote already exists, gitea-mcp returns Conflict; we swallow it.
|
||||
if err := s.callMirror(ctx, args.Name); err != nil {
|
||||
if !isConflict(err) {
|
||||
return marshalPartial(res, stepMirror, err)
|
||||
}
|
||||
}
|
||||
res.Reached = append(res.Reached, stepMirror)
|
||||
}
|
||||
res.Reached = append(res.Reached, stepMirror)
|
||||
|
||||
// Step 3: commit staging namespace manifest to infra repo. Done before
|
||||
// the issue so the staging env is reconciling by the time the issue lands.
|
||||
@@ -228,7 +237,11 @@ func experimentBrief(args createArgs, existed bool) string {
|
||||
b.WriteString("- Repo created from `template-")
|
||||
b.WriteString(args.Stack)
|
||||
b.WriteString("` on Gitea.\n")
|
||||
b.WriteString("- Push-mirror configured to GitHub.\n")
|
||||
if args.MirrorToGitHub {
|
||||
b.WriteString("- Push-mirror configured to GitHub.\n")
|
||||
} else {
|
||||
b.WriteString("- Gitea-only (no GitHub mirror — set `mirror_to_github: true` to opt in).\n")
|
||||
}
|
||||
b.WriteString("- Staging namespace manifest committed to infra repo.\n\n")
|
||||
if existed {
|
||||
b.WriteString("> Note: this repo already existed when `project_create` ran — provisioning steps were re-applied idempotently.\n")
|
||||
|
||||
@@ -158,6 +158,9 @@ func mustClient(t *testing.T, url string) *mcpclient.Client {
|
||||
return c
|
||||
}
|
||||
|
||||
// happyArgs returns the minimal valid request. With the Gitea-as-true-master
|
||||
// ADR shipped, this defaults to Gitea-only (mirror_to_github omitted = false).
|
||||
// Tests that need the full Gitea + GitHub mirror flow use mirroredArgs().
|
||||
func happyArgs() json.RawMessage {
|
||||
return json.RawMessage(`{
|
||||
"name":"my-experiment",
|
||||
@@ -169,6 +172,20 @@ func happyArgs() json.RawMessage {
|
||||
}`)
|
||||
}
|
||||
|
||||
// mirroredArgs is happyArgs + mirror_to_github=true — the explicit opt-in
|
||||
// path. Equivalent to the pre-ADR default.
|
||||
func mirroredArgs() json.RawMessage {
|
||||
return json.RawMessage(`{
|
||||
"name":"my-experiment",
|
||||
"description":"One-line desc",
|
||||
"hypothesis":"We believe X produces Y",
|
||||
"folder":"AGENTS",
|
||||
"stack":"go-agent",
|
||||
"private":true,
|
||||
"mirror_to_github":true
|
||||
}`)
|
||||
}
|
||||
|
||||
func TestProjectCreate_HappyPath(t *testing.T) {
|
||||
f := &fakeGiteaMCP{
|
||||
Responses: map[string]any{
|
||||
@@ -177,7 +194,7 @@ func TestProjectCreate_HappyPath(t *testing.T) {
|
||||
}
|
||||
skill, gh := newSkill(t, f)
|
||||
|
||||
out, err := skill.Handle(context.Background(), "project_create", happyArgs())
|
||||
out, err := skill.Handle(context.Background(), "project_create", mirroredArgs())
|
||||
require.NoError(t, err)
|
||||
|
||||
var res map[string]any
|
||||
@@ -228,7 +245,7 @@ func TestProjectCreate_GitHubExists_Idempotent(t *testing.T) {
|
||||
skill, gh := newSkill(t, f)
|
||||
gh.ReturnError = 422 // already exists
|
||||
|
||||
_, err := skill.Handle(context.Background(), "project_create", happyArgs())
|
||||
_, err := skill.Handle(context.Background(), "project_create", mirroredArgs())
|
||||
require.NoError(t, err, "422 already-exists should be idempotent")
|
||||
require.Len(t, f.Calls, 4, "all gitea steps still run despite github 422")
|
||||
}
|
||||
@@ -238,7 +255,7 @@ func TestProjectCreate_GitHubFails(t *testing.T) {
|
||||
skill, gh := newSkill(t, f)
|
||||
gh.ReturnError = 401 // bad PAT
|
||||
|
||||
out, err := skill.Handle(context.Background(), "project_create", happyArgs())
|
||||
out, err := skill.Handle(context.Background(), "project_create", mirroredArgs())
|
||||
require.Error(t, err)
|
||||
var res map[string]any
|
||||
require.NoError(t, json.Unmarshal(out, &res))
|
||||
@@ -255,7 +272,11 @@ func TestProjectCreate_NoGitHubClient_DegradedMode(t *testing.T) {
|
||||
}
|
||||
skill := newSkillNoGitHub(t, f)
|
||||
|
||||
out, err := skill.Handle(context.Background(), "project_create", happyArgs())
|
||||
// Use mirroredArgs so we exercise the GitHub-mirror path. With the
|
||||
// GitHub client nil, the create_github_repo step is skipped but the
|
||||
// mirror step still attempts to configure the push-mirror remote
|
||||
// (degraded mode preserves the prior contract for opted-in projects).
|
||||
out, err := skill.Handle(context.Background(), "project_create", mirroredArgs())
|
||||
require.NoError(t, err)
|
||||
var res map[string]any
|
||||
require.NoError(t, json.Unmarshal(out, &res))
|
||||
@@ -275,7 +296,7 @@ func TestProjectCreate_Idempotent_RepoExists(t *testing.T) {
|
||||
}
|
||||
skill, _ := newSkill(t, f)
|
||||
|
||||
out, err := skill.Handle(context.Background(), "project_create", happyArgs())
|
||||
out, err := skill.Handle(context.Background(), "project_create", mirroredArgs())
|
||||
require.NoError(t, err)
|
||||
|
||||
var res map[string]any
|
||||
@@ -295,7 +316,7 @@ func TestProjectCreate_MirrorFails(t *testing.T) {
|
||||
}
|
||||
skill, _ := newSkill(t, f)
|
||||
|
||||
out, err := skill.Handle(context.Background(), "project_create", happyArgs())
|
||||
out, err := skill.Handle(context.Background(), "project_create", mirroredArgs())
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), `"mirror" failed`)
|
||||
|
||||
@@ -317,7 +338,7 @@ func TestProjectCreate_InfraCommitFails(t *testing.T) {
|
||||
}
|
||||
skill, _ := newSkill(t, f)
|
||||
|
||||
out, err := skill.Handle(context.Background(), "project_create", happyArgs())
|
||||
out, err := skill.Handle(context.Background(), "project_create", mirroredArgs())
|
||||
require.Error(t, err)
|
||||
|
||||
var res map[string]any
|
||||
@@ -351,6 +372,45 @@ func TestProjectCreate_ValidationErrors(t *testing.T) {
|
||||
assert.Empty(t, f.Calls, "no upstream calls should occur on validation failure")
|
||||
}
|
||||
|
||||
func TestProjectCreate_DefaultSkipsGitHubMirror(t *testing.T) {
|
||||
// Default (mirror_to_github omitted) skips create_github_repo + mirror
|
||||
// per the Gitea-as-true-master ADR. Gitea repo + staging namespace
|
||||
// + issue still run; github_url is empty in the response.
|
||||
f := &fakeGiteaMCP{
|
||||
Responses: map[string]any{
|
||||
"issue_create": map[string]any{"html_url": "http://gitea.d-ma.be/mathias/my-experiment/issues/1"},
|
||||
},
|
||||
}
|
||||
skill, gh := newSkill(t, f)
|
||||
|
||||
out, err := skill.Handle(context.Background(), "project_create", happyArgs())
|
||||
require.NoError(t, err)
|
||||
|
||||
var res map[string]any
|
||||
require.NoError(t, json.Unmarshal(out, &res))
|
||||
|
||||
assert.Equal(t, "http://gitea.d-ma.be/mathias/my-experiment", res["gitea_url"])
|
||||
assert.Equal(t, "", res["github_url"], "github_url must be empty when mirror not opted in")
|
||||
assert.Equal(t, "http://gitea.d-ma.be/mathias/my-experiment/issues/1", res["issue_url"])
|
||||
|
||||
// 3 gitea-mcp calls: template create, staging file write, issue. NO mirror call.
|
||||
require.Len(t, f.Calls, 3)
|
||||
assert.Equal(t, "create_project_from_template", f.Calls[0].Tool)
|
||||
assert.Equal(t, "file_write_branch", f.Calls[1].Tool)
|
||||
assert.Equal(t, "issue_create", f.Calls[2].Tool)
|
||||
|
||||
// Zero GitHub API calls.
|
||||
assert.Empty(t, gh.Calls, "no GitHub repo created when mirror_to_github is false")
|
||||
|
||||
// reached lists the Gitea-only path.
|
||||
reached := res["reached"].([]any)
|
||||
assert.Equal(t, []any{"create_repo", "infra_commit", "issue"}, reached)
|
||||
|
||||
// experiment-brief body reflects Gitea-only provisioning.
|
||||
require.Contains(t, f.Calls[2].Args["body"], "Gitea-only")
|
||||
require.NotContains(t, f.Calls[2].Args["body"], "Push-mirror configured")
|
||||
}
|
||||
|
||||
func TestProjectCreate_UnknownTool(t *testing.T) {
|
||||
f := &fakeGiteaMCP{}
|
||||
skill, _ := newSkill(t, f)
|
||||
|
||||
@@ -79,13 +79,22 @@ func (s *Skill) Tools() []registry.ToolDef {
|
||||
"description": "Selects template-go-agent or template-go-web.",
|
||||
},
|
||||
"private": map[string]any{"type": "boolean"},
|
||||
"mirror_to_github": map[string]any{
|
||||
"type": "boolean",
|
||||
"description": "Default false. When true, also create an empty GitHub repo " +
|
||||
"and configure a push-mirror from Gitea. Opt-in per the Gitea-as-true-master " +
|
||||
"ADR — only set true for open-source projects (hyperguild, gitea-mcp, template-*). " +
|
||||
"Never set true for client projects, business logic, or personal experiments.",
|
||||
},
|
||||
},
|
||||
"required": []string{"name", "description", "hypothesis", "stack"},
|
||||
})
|
||||
return []registry.ToolDef{
|
||||
{
|
||||
Name: "project_create",
|
||||
Description: "Bootstrap a new project: Gitea repo from template, GitHub push-mirror, staging namespace manifest, experiment-brief issue. Idempotent — re-running with an existing repo returns the existing URLs.",
|
||||
Name: "project_create",
|
||||
Description: "Bootstrap a new project: Gitea repo from template, staging namespace manifest, " +
|
||||
"experiment-brief issue. Optionally mirrors to GitHub when `mirror_to_github: true` " +
|
||||
"(default false). Idempotent — re-running with an existing repo returns the existing URLs.",
|
||||
InputSchema: schema,
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user