Skip to content

Commit 3874205

Browse files
committed
feat: namespace isolation + 29/29 MCP tools Docker e2e verification
- Add namespace field to Community model with composite unique index - Filter RAG index builder queries by namespace (5 queries) - Make community deletion namespace-aware in service layer - Fix syncHandler missing ctxns.WithNamespace call - Fix buildRagIndex indexDir default path resolution - Update docker-compose.yml to postgres:17 - Expand integration-test.sh from 4 to 29 MCP tools coverage (graph query, analysis, structure, workspace, docs, build phases)
1 parent ac7ae0d commit 3874205

8 files changed

Lines changed: 696 additions & 28 deletions

File tree

cmd/ccg/main.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import (
3131
"github.com/imtaebin/code-context-graph/internal/analysis/largefunc"
3232
"github.com/imtaebin/code-context-graph/internal/analysis/query"
3333
"github.com/imtaebin/code-context-graph/internal/cli"
34+
"github.com/imtaebin/code-context-graph/internal/ctxns"
3435
mcpserver "github.com/imtaebin/code-context-graph/internal/mcp"
3536
"github.com/imtaebin/code-context-graph/internal/model"
3637
"github.com/imtaebin/code-context-graph/internal/parse/treesitter"
@@ -274,6 +275,7 @@ func serveStreamableHTTP(deps *cli.Deps, srv *server.MCPServer, cfg cli.ServeCon
274275
}
275276
buildCtx, buildCancel := context.WithTimeout(ctx, 10*time.Minute)
276277
defer buildCancel()
278+
buildCtx = ctxns.WithNamespace(buildCtx, ns)
277279
stats, err := graphSvc.Build(buildCtx, service.BuildOptions{Dir: repoDir})
278280
if err != nil {
279281
deps.Logger.Error("webhook build failed", "repo", repoFullName, "error", err)

docker-compose.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
services:
22
postgres:
3-
image: pgvector/pgvector:pg17
3+
image: postgres:17
44
environment:
55
POSTGRES_USER: postgres
66
POSTGRES_PASSWORD: postgres

internal/analysis/community/service.go

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,10 @@ func New(db *gorm.DB) *Builder {
5151
// @mutates Community CommunityMembership tables
5252
func (b *Builder) Rebuild(ctx context.Context, cfg Config) ([]Stats, error) {
5353
var result []Stats
54+
ns := ctxns.FromContext(ctx)
5455

5556
err := b.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
56-
if err := deleteAllCommunities(tx); err != nil {
57+
if err := deleteCommunities(tx, ns); err != nil {
5758
return err
5859
}
5960

@@ -62,7 +63,7 @@ func (b *Builder) Rebuild(ctx context.Context, cfg Config) ([]Stats, error) {
6263
return err
6364
}
6465

65-
communityMap, nodeComm, err := createCommunitiesAndMemberships(tx, groups)
66+
communityMap, nodeComm, err := createCommunitiesAndMemberships(tx, groups, ns)
6667
if err != nil {
6768
return err
6869
}
@@ -83,16 +84,34 @@ func (b *Builder) Rebuild(ctx context.Context, cfg Config) ([]Stats, error) {
8384
return result, err
8485
}
8586

86-
func deleteAllCommunities(tx *gorm.DB) error {
87-
if err := tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&model.CommunityMembership{}).Error; err != nil {
87+
func deleteCommunities(tx *gorm.DB, ns string) error {
88+
if ns == "" {
89+
if err := tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&model.CommunityMembership{}).Error; err != nil {
90+
return err
91+
}
92+
return tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&model.Community{}).Error
93+
}
94+
var ids []uint
95+
if err := tx.Table("community_memberships").
96+
Select("DISTINCT community_id").
97+
Joins("JOIN nodes ON nodes.id = community_memberships.node_id").
98+
Where("nodes.namespace = ?", ns).
99+
Pluck("community_id", &ids).Error; err != nil {
100+
return err
101+
}
102+
if len(ids) == 0 {
103+
return nil
104+
}
105+
if err := tx.Where("community_id IN ?", ids).Delete(&model.CommunityMembership{}).Error; err != nil {
88106
return err
89107
}
90-
return tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&model.Community{}).Error
108+
return tx.Where("id IN ?", ids).Delete(&model.Community{}).Error
91109
}
92110

93111
func groupNodesByDirectory(tx *gorm.DB, ctx context.Context, depth int) (map[string][]model.Node, error) {
94112
var nodes []model.Node
95-
if err := tx.Where("namespace = ?", ctxns.FromContext(ctx)).Find(&nodes).Error; err != nil {
113+
ns := ctxns.FromContext(ctx)
114+
if err := tx.Where("namespace = ?", ns).Find(&nodes).Error; err != nil {
96115
return nil, err
97116
}
98117

@@ -104,13 +123,14 @@ func groupNodesByDirectory(tx *gorm.DB, ctx context.Context, depth int) (map[str
104123
return groups, nil
105124
}
106125

107-
func createCommunitiesAndMemberships(tx *gorm.DB, groups map[string][]model.Node) (map[string]*model.Community, map[uint]string, error) {
126+
func createCommunitiesAndMemberships(tx *gorm.DB, groups map[string][]model.Node, ns string) (map[string]*model.Community, map[uint]string, error) {
108127
communityMap := map[string]*model.Community{}
109128
for key := range groups {
110129
c := model.Community{
111-
Key: key,
112-
Label: key,
113-
Strategy: "directory",
130+
Namespace: ns,
131+
Key: key,
132+
Label: key,
133+
Strategy: "directory",
114134
}
115135
if err := tx.Create(&c).Error; err != nil {
116136
return nil, nil, err

internal/mcp/handler_docs.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,13 +45,17 @@ func (h *handlers) buildRagIndex(ctx context.Context, request mcp.CallToolReques
4545
return mcp.NewToolResultError(err.Error()), nil
4646
}
4747
outDir = filepath.Join(h.workspaceRoot(), workspace)
48+
ctx = h.applyWorkspace(ctx, request)
4849
}
4950

5051
if indexDir == "" {
5152
indexDir = h.deps.RagIndexDir
53+
if indexDir == "" {
54+
indexDir = ".ccg"
55+
}
5256
}
5357

54-
if workspace != "" && indexDir != "" {
58+
if workspace != "" {
5559
indexDir = filepath.Join(indexDir, workspace)
5660
}
5761

internal/model/community.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ import "time"
66
// @intent 연관된 노드 집합을 전략별 커뮤니티 단위로 표현한다.
77
type Community struct {
88
ID uint `gorm:"primaryKey"`
9-
Key string `gorm:"uniqueIndex;size:512;not null"`
9+
Namespace string `gorm:"size:256;not null;default:'';uniqueIndex:idx_community_ns_key"`
10+
Key string `gorm:"size:512;not null;uniqueIndex:idx_community_ns_key"`
1011
Label string `gorm:"size:256;not null"`
1112
Strategy string `gorm:"size:32;not null;index"`
1213
Description string `gorm:"type:text"`

internal/ragindex/builder.go

Lines changed: 40 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515

1616
"github.com/tae2089/trace"
1717

18+
"github.com/imtaebin/code-context-graph/internal/ctxns"
1819
"github.com/imtaebin/code-context-graph/internal/model"
1920
)
2021

@@ -70,9 +71,20 @@ type nodeInfo struct {
7071
func (b *Builder) Build(ctx context.Context) (int, int, error) {
7172
slog.Debug("ragindex.Builder.Build 시작", "outDir", b.OutDir, "indexDir", b.IndexDir)
7273

74+
ns := ctxns.FromContext(ctx)
75+
7376
// 1. 모든 커뮤니티와 멤버 로드
7477
var communities []model.Community
75-
if err := b.DB.WithContext(ctx).Preload("Members").Find(&communities).Error; err != nil {
78+
q := b.DB.WithContext(ctx).Preload("Members")
79+
if ns != "" {
80+
q = q.Where("id IN (?)",
81+
b.DB.Table("community_memberships").
82+
Select("DISTINCT community_id").
83+
Joins("JOIN nodes ON nodes.id = community_memberships.node_id").
84+
Where("nodes.namespace = ?", ns),
85+
)
86+
}
87+
if err := q.Find(&communities).Error; err != nil {
7688
return 0, 0, trace.Wrap(err, "load communities")
7789
}
7890
slog.Debug("커뮤니티 로드 완료", "count", len(communities))
@@ -89,7 +101,11 @@ func (b *Builder) Build(ctx context.Context) (int, int, error) {
89101
nodeInfoMap := make(map[uint]nodeInfo)
90102
if len(allNodeIDs) > 0 {
91103
var nodes []model.Node
92-
if err := b.DB.WithContext(ctx).Where("id IN ?", allNodeIDs).Find(&nodes).Error; err != nil {
104+
nq := b.DB.WithContext(ctx).Where("id IN ?", allNodeIDs)
105+
if ns != "" {
106+
nq = nq.Where("namespace = ?", ns)
107+
}
108+
if err := nq.Find(&nodes).Error; err != nil {
93109
return 0, 0, trace.Wrap(err, "load all nodes")
94110
}
95111
for _, n := range nodes {
@@ -198,6 +214,8 @@ func (b *Builder) batchFileSummaries(ctx context.Context, filePaths []string) (m
198214
return result, nil
199215
}
200216

217+
ns := ctxns.FromContext(ctx)
218+
201219
// @intent 파일별 첫 번째 index 또는 intent 태그 값을 담는 배치 조회 결과다.
202220
type row struct {
203221
FilePath string
@@ -206,12 +224,15 @@ func (b *Builder) batchFileSummaries(ctx context.Context, filePaths []string) (m
206224

207225
// @index 태그를 파일 경로별로 일괄 조회
208226
var indexRows []row
209-
if err := b.DB.WithContext(ctx).Table("doc_tags").
227+
iq := b.DB.WithContext(ctx).Table("doc_tags").
210228
Select("nodes.file_path, doc_tags.value").
211229
Joins("JOIN annotations ON annotations.id = doc_tags.annotation_id").
212230
Joins("JOIN nodes ON nodes.id = annotations.node_id").
213-
Where("nodes.file_path IN ? AND doc_tags.kind = ?", filePaths, string(model.TagIndex)).
214-
Order("doc_tags.ordinal ASC, doc_tags.id ASC").
231+
Where("nodes.file_path IN ? AND doc_tags.kind = ?", filePaths, string(model.TagIndex))
232+
if ns != "" {
233+
iq = iq.Where("nodes.namespace = ?", ns)
234+
}
235+
if err := iq.Order("doc_tags.ordinal ASC, doc_tags.id ASC").
215236
Scan(&indexRows).Error; err != nil {
216237
return nil, trace.Wrap(err, "batch index tags")
217238
}
@@ -230,12 +251,15 @@ func (b *Builder) batchFileSummaries(ctx context.Context, filePaths []string) (m
230251
}
231252
if len(missing) > 0 {
232253
var intentRows []row
233-
if err := b.DB.WithContext(ctx).Table("doc_tags").
254+
fq := b.DB.WithContext(ctx).Table("doc_tags").
234255
Select("nodes.file_path, doc_tags.value").
235256
Joins("JOIN annotations ON annotations.id = doc_tags.annotation_id").
236257
Joins("JOIN nodes ON nodes.id = annotations.node_id").
237-
Where("nodes.file_path IN ? AND doc_tags.kind = ?", missing, string(model.TagIntent)).
238-
Order("doc_tags.ordinal ASC, doc_tags.id ASC").
258+
Where("nodes.file_path IN ? AND doc_tags.kind = ?", missing, string(model.TagIntent))
259+
if ns != "" {
260+
fq = fq.Where("nodes.namespace = ?", ns)
261+
}
262+
if err := fq.Order("doc_tags.ordinal ASC, doc_tags.id ASC").
239263
Scan(&intentRows).Error; err != nil {
240264
return nil, trace.Wrap(err, "batch intent tags")
241265
}
@@ -259,6 +283,8 @@ func (b *Builder) batchSymbolNodes(ctx context.Context, nodeIDs []uint) (map[str
259283
return result, nil
260284
}
261285

286+
ns := ctxns.FromContext(ctx)
287+
262288
// @intent 심볼 노드와 첫 번째 intent 태그 값을 함께 담는 배치 조회 결과다.
263289
type intentRow struct {
264290
NodeID uint
@@ -269,12 +295,15 @@ func (b *Builder) batchSymbolNodes(ctx context.Context, nodeIDs []uint) (map[str
269295
}
270296

271297
var rows []intentRow
272-
if err := b.DB.WithContext(ctx).Table("nodes").
298+
sq := b.DB.WithContext(ctx).Table("nodes").
273299
Select("nodes.id as node_id, nodes.qualified_name, nodes.name, nodes.file_path, doc_tags.value").
274300
Joins("JOIN annotations ON annotations.node_id = nodes.id").
275301
Joins("JOIN doc_tags ON doc_tags.annotation_id = annotations.id").
276-
Where("nodes.id IN ? AND doc_tags.kind = ?", nodeIDs, string(model.TagIntent)).
277-
Order("nodes.file_path ASC, doc_tags.ordinal ASC, doc_tags.id ASC").
302+
Where("nodes.id IN ? AND doc_tags.kind = ?", nodeIDs, string(model.TagIntent))
303+
if ns != "" {
304+
sq = sq.Where("nodes.namespace = ?", ns)
305+
}
306+
if err := sq.Order("nodes.file_path ASC, doc_tags.ordinal ASC, doc_tags.id ASC").
278307
Scan(&rows).Error; err != nil {
279308
return nil, trace.Wrap(err, "batch symbol nodes")
280309
}

0 commit comments

Comments
 (0)