Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -458,6 +458,7 @@ type SyncOptions struct {
State string
Since time.Time
Numbers []int
MaxItems int
MaxPages int
MaxRequests int
}
Expand Down Expand Up @@ -635,6 +636,9 @@ func normalizeSyncOptions(opts SyncOptions) (SyncOptions, error) {
if opts.MaxPages > 1000 {
return SyncOptions{}, errors.New("max pages cannot exceed 1000")
}
if opts.MaxItems < 0 || opts.MaxItems > 1000 {
return SyncOptions{}, errors.New("max items must be between 0 and 1000")
}
if opts.MaxRequests == 0 {
opts.MaxRequests = defaultSyncMaxRequests
}
Expand Down
53 changes: 53 additions & 0 deletions internal/app/corpus_lifecycle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"

Expand Down Expand Up @@ -65,13 +66,65 @@ func TestApplicationWriteOpenDoesNotMigrateExistingCorpus(t *testing.T) {
if !report.HasFailures() || report.Corpus == nil || report.Corpus.State != "migration_required" {
t.Fatalf("setup corpus preflight = %+v", report)
}
dryRunReport, err := second.Setup(ctx, cli.SetupOptions{
Mode: cli.SetupModeMCP, Clients: []string{"codex"}, TokenSource: "none", DryRun: true,
})
if err != nil {
t.Fatal(err)
}
if !dryRunReport.HasFailures() || dryRunReport.Corpus == nil || dryRunReport.Corpus.State != "migration_required" {
t.Fatalf("dry-run corpus preflight = %+v", dryRunReport)
}
if report.Steps[0].Message != dryRunReport.Steps[0].Message || !strings.Contains(report.Steps[0].Message, "gitcontribute corpus migrate --yes") {
t.Fatalf("setup diagnostics differ: real=%q dry-run=%q", report.Steps[0].Message, dryRunReport.Steps[0].Message)
}
for _, step := range report.Steps {
if step.Name == "mcp-runtime" {
t.Fatalf("setup attempted runtime installation before corpus preflight: %+v", report)
}
}
}

func TestSetupFailsFastForNewerCorpusInDryRunAndRealModes(t *testing.T) {
home := t.TempDir()
dbPath := filepath.Join(home, "newer.db")
db, err := sql.Open("sqlite", dbPath)
if err != nil {
t.Fatal(err)
}
if _, err := db.Exec("CREATE TABLE goose_db_version (id INTEGER PRIMARY KEY, version_id INTEGER)"); err != nil {
t.Fatal(err)
}
if _, err := db.Exec("INSERT INTO goose_db_version (id, version_id) VALUES (1, 9999)"); err != nil {
t.Fatal(err)
}
if err := db.Close(); err != nil {
t.Fatal(err)
}

svc := testService(t, home, "1.2.3", dbPath)
configPath, err := svc.paths.ConfigFile()
if err != nil {
t.Fatal(err)
}
cfg := config.Default()
cfg.Database = dbPath
if err := config.Save(configPath, cfg); err != nil {
t.Fatal(err)
}
for _, dryRun := range []bool{false, true} {
report, err := svc.Setup(context.Background(), cli.SetupOptions{
Mode: cli.SetupModeMCP, Clients: []string{"codex"}, TokenSource: "none", DryRun: dryRun,
})
if err == nil || !strings.Contains(err.Error(), "database schema version 9999 is newer than this binary supports") || !strings.Contains(err.Error(), "gitcontribute corpus inspect") {
t.Fatalf("dry_run=%v report=%+v error = %v", dryRun, report, err)
}
if report == nil || report.Corpus == nil || report.Corpus.State != "newer" || len(report.Steps) != 0 {
t.Fatalf("dry_run=%v report = %+v", dryRun, report)
}
}
}

func TestRestoreCorpusCreatesSafetyBackupAndReplacesState(t *testing.T) {
ctx := context.Background()
home := t.TempDir()
Expand Down
5 changes: 4 additions & 1 deletion internal/app/mcp_authored_sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,10 @@ func (s *Service) syncAuthoredPullRequests(ctx context.Context, in mcpserver.Syn
incomplete := false
requestCapped := false
for discovered < in.Limit {
if requests >= in.MaxRequests {
// Keep enough budget for at least one repository refresh after the next
// discovery page. An accepted request must not spend its full budget on
// discovery and then make no synchronization progress.
if requests+1+syncFixedRequestCost() > in.MaxRequests {
requestCapped, incomplete = true, true
break
}
Expand Down
8 changes: 4 additions & 4 deletions internal/app/mcp_repository_search.go
Original file line number Diff line number Diff line change
Expand Up @@ -161,10 +161,10 @@ func repositorySearchMode(in mcpserver.SearchGitHubRepositoriesInput) (string, s
raw := strings.TrimSpace(in.RawQuery)
structured := hasStructuredRepositorySearch(in)
if raw != "" && structured {
return "", "", nil, false, mcpserver.InvalidArgument("raw_query", "cannot be combined with structured filters; choose one input mode", map[string]any{"text": "inference", "topics": []string{"cuda"}})
return "", "", nil, false, mcpserver.InvalidArgument("raw_query", "cannot be combined with structured filters; choose one input mode", map[string]any{"raw_query": "is:public language:go stars:>=100"})
}
if raw == "" && !structured {
return "", "", nil, false, mcpserver.InvalidArgument("text", "provide raw_query or at least one structured filter such as text, topics, language, or pushed_after", map[string]any{"text": "inference", "match_fields": []string{"name", "description"}})
return "", "", nil, false, mcpserver.InvalidArgument("text", "provide raw_query or at least one structured filter such as text, topics, language, or pushed_after", map[string]any{"text": "GitHub contribution research", "match_fields": []string{"name", "description"}})
}
if raw != "" {
return raw, "advanced raw query", nil, false, nil
Expand All @@ -187,7 +187,7 @@ func compileStructuredRepositorySearch(in mcpserver.SearchGitHubRepositoriesInpu
}
for _, topic := range in.Topics {
if strings.TrimSpace(topic) == "" {
return "", nil, mcpserver.InvalidArgument("topics", "must not contain blank values", map[string]any{"topics": []string{"cuda"}})
return "", nil, mcpserver.InvalidArgument("topics", "must not contain blank values", map[string]any{"topics": []string{"go", "github-actions"}})
}
parts = append(parts, "topic:"+quoteSearchTerm(topic))
}
Expand Down Expand Up @@ -217,7 +217,7 @@ func compileStructuredRepositorySearch(in mcpserver.SearchGitHubRepositoriesInpu

func validateRepositoryMatchFields(text string, fields []string) error {
if len(fields) > 0 && strings.TrimSpace(text) == "" {
return mcpserver.InvalidArgument("match_fields", "requires text", map[string]any{"text": "inference", "match_fields": []string{"name", "description"}})
return mcpserver.InvalidArgument("match_fields", "requires text", map[string]any{"text": "GitHub contribution research", "match_fields": []string{"name", "description"}})
}
for _, field := range fields {
if field != "name" && field != "description" && field != "readme" {
Expand Down
67 changes: 65 additions & 2 deletions internal/app/mcp_scalable_operations.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
// SyncRepositoryMetadata submits a durable metadata-only GitHub read. It does
// not fetch threads, comments, reviews, or code.
func (r *MCPReader) SyncRepositoryMetadata(ctx context.Context, in mcpserver.SyncRepositoryMetadataInput) (mcpserver.JobReference, error) {
in.Repositories = dedupeRepositoryRefs(in.Repositories)
if len(in.Repositories) < 1 || len(in.Repositories) > 100 {
return mcpserver.JobReference{}, errors.New("repositories must contain 1 to 100 items")
}
Expand All @@ -43,12 +44,20 @@ func (r *MCPReader) SyncThreads(ctx context.Context, in mcpserver.SyncThreadsInp
if in.Selection != "repositories" && in.Selection != "threads" {
return mcpserver.JobReference{}, errors.New("selection must be repositories or threads")
}
in.Repositories = dedupeRepositoryRefs(in.Repositories)
in.Threads = dedupeThreadRefs(in.Threads)
if in.Selection == "repositories" && (len(in.Repositories) < 1 || len(in.Repositories) > 50) {
return mcpserver.JobReference{}, errors.New("repositories must contain 1 to 50 items")
}
if in.Selection == "threads" && (len(in.Threads) < 1 || len(in.Threads) > 100) {
return mcpserver.JobReference{}, errors.New("threads must contain 1 to 100 items")
}
if in.LimitPerRepository == 0 {
in.LimitPerRepository = 100
}
if in.LimitPerRepository < 1 || in.LimitPerRepository > 1000 {
return mcpserver.JobReference{}, errors.New("limit_per_repository must be between 1 and 1000")
}
var err error
in.MaxRequests, err = normalizeSyncBatchMaxRequests(in.MaxRequests)
if err != nil {
Expand Down Expand Up @@ -146,7 +155,7 @@ func (s *Service) syncThreadsBatch(ctx context.Context, in mcpserver.SyncThreads
defer wg.Done()
for index := range jobs {
current := tasks[index]
opts := SyncOptions{Kind: kind, State: state, Since: since, Numbers: current.numbers, MaxPages: maxPages, MaxRequests: current.maxRequests}
opts := SyncOptions{Kind: kind, State: state, Since: since, Numbers: current.numbers, MaxItems: in.LimitPerRepository, MaxPages: maxPages, MaxRequests: current.maxRequests}
if len(current.numbers) > 0 {
opts.State = "all"
opts.Since = time.Time{}
Expand Down Expand Up @@ -201,6 +210,7 @@ func (s *Service) syncThreadsBatch(ctx context.Context, in mcpserver.SyncThreads
// HydrateThreads submits a durable GitHub read for explicit child facets on
// selected threads; an empty facet set is rejected.
func (r *MCPReader) HydrateThreads(ctx context.Context, in mcpserver.HydrateThreadsInput) (mcpserver.JobReference, error) {
in.Threads = dedupeThreadRefs(in.Threads)
if len(in.Threads) < 1 || len(in.Threads) > 100 {
return mcpserver.JobReference{}, errors.New("threads must contain 1 to 100 items")
}
Expand All @@ -210,6 +220,9 @@ func (r *MCPReader) HydrateThreads(ctx context.Context, in mcpserver.HydrateThre
if in.MaxPages == 0 {
in.MaxPages = 3
}
if in.MaxPages < 1 || in.MaxPages > 100 {
return mcpserver.JobReference{}, errors.New("max_pages must be between 1 and 100")
}
id, err := r.submitJob(ctx, "hydrate_threads", in, func(ctx context.Context, report func(string, string) error) (any, error) {
return r.hydrateThreadsBatch(ctx, in, report)
})
Expand Down Expand Up @@ -256,6 +269,9 @@ func (r *MCPReader) SyncAuthoredPullRequests(ctx context.Context, in mcpserver.S
if err != nil {
return mcpserver.JobReference{}, err
}
if in.MaxRequests < syncFixedRequestCost()+2 {
return mcpserver.JobReference{}, fmt.Errorf("max requests must be between %d and %d", syncFixedRequestCost()+2, defaultSyncBatchMaxRequests)
}
id, err := r.submitJob(ctx, "sync_authored_pull_requests", in, func(ctx context.Context, report func(string, string) error) (any, error) {
return r.syncAuthoredPullRequests(ctx, in, report)
})
Expand All @@ -269,12 +285,16 @@ func (r *MCPReader) SyncAuthoredPullRequests(ctx context.Context, in mcpserver.S
// reviews, checks, review conversations, merge state, queue state, closing
// issues, and changed paths. Each facet retains independent coverage.
func (r *MCPReader) SyncPullRequestStatus(ctx context.Context, in mcpserver.SyncPullRequestStatusInput) (mcpserver.JobReference, error) {
in.PullRequests = dedupeThreadRefs(in.PullRequests)
if len(in.PullRequests) < 1 || len(in.PullRequests) > 50 {
return mcpserver.JobReference{}, errors.New("pull_requests must contain 1 to 50 items")
}
if in.MaxPages == 0 {
in.MaxPages = 3
}
if in.MaxPages < 1 || in.MaxPages > 20 {
return mcpserver.JobReference{}, errors.New("max_pages must be between 1 and 20")
}
id, err := r.submitJob(ctx, "sync_pull_request_status", in, func(ctx context.Context, report func(string, string) error) (any, error) {
return r.syncPullRequestStatusBatch(ctx, in, report)
})
Expand All @@ -287,6 +307,7 @@ func (r *MCPReader) SyncPullRequestStatus(ctx context.Context, in mcpserver.Sync
// IndexRepositories submits a durable Git acquisition and safe indexing job
// with at most two repositories processed concurrently.
func (r *MCPReader) IndexRepositories(ctx context.Context, in mcpserver.IndexRepositoriesInput) (mcpserver.JobReference, error) {
in.Repositories = dedupeIndexRepositoryInputs(in.Repositories)
if len(in.Repositories) < 1 || len(in.Repositories) > 10 {
return mcpserver.JobReference{}, errors.New("repositories must contain 1 to 10 items")
}
Expand Down Expand Up @@ -618,7 +639,7 @@ func (r *MCPReader) DeepWiki(ctx context.Context, in mcpserver.DeepWikiInput) (m
if len(repositories) > 10 {
return mcpserver.DeepWikiOutput{}, errors.New("DeepWiki supports at most 10 repositories")
}
res, err := r.deepWiki().Read(ctx, deepwiki.Request{Action: in.Action, Repository: in.Repository, Repositories: in.Repositories, Question: in.Question})
res, err := r.deepWiki().Read(ctx, deepwiki.Request{Action: in.Action, Repository: in.Repository, Repositories: repositories, Question: in.Question})
if err != nil {
return mcpserver.DeepWikiOutput{}, err
}
Expand All @@ -641,6 +662,48 @@ func (r *MCPReader) DeepWiki(ctx context.Context, in mcpserver.DeepWikiInput) (m
return out, nil
}

func dedupeRepositoryRefs(inputs []mcpserver.RepositoryRef) []mcpserver.RepositoryRef {
seen := make(map[string]struct{}, len(inputs))
out := make([]mcpserver.RepositoryRef, 0, len(inputs))
for _, input := range inputs {
key := strings.ToLower(input.Owner + "\x00" + input.Repo)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, input)
}
return out
}

func dedupeThreadRefs(inputs []mcpserver.ThreadRef) []mcpserver.ThreadRef {
seen := make(map[string]struct{}, len(inputs))
out := make([]mcpserver.ThreadRef, 0, len(inputs))
for _, input := range inputs {
key := strings.ToLower(fmt.Sprintf("%s\x00%s\x00%d", input.Owner, input.Repo, input.Number))
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, input)
}
return out
}

func dedupeIndexRepositoryInputs(inputs []mcpserver.IndexRepositoryInput) []mcpserver.IndexRepositoryInput {
seen := make(map[string]struct{}, len(inputs))
out := make([]mcpserver.IndexRepositoryInput, 0, len(inputs))
for _, input := range inputs {
key := strings.ToLower(input.Owner + "\x00" + input.Repo)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, input)
}
return out
}

func validUTF8Prefix(value string, maxBytes int) string {
if len(value) <= maxBytes {
return value
Expand Down
73 changes: 73 additions & 0 deletions internal/app/mcp_scalable_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package app
import (
"context"
"encoding/json"
"errors"
"reflect"
"strings"
"testing"
"time"
Expand Down Expand Up @@ -433,6 +435,22 @@ func TestCompileRepositorySearchRejectsAmbiguousAndInvalidInputs(t *testing.T) {
}
}

func TestRepositorySearchValidationExamplesAreUsable(t *testing.T) {
_, _, _, err := compileRepositorySearch(mcpserver.SearchGitHubRepositoriesInput{})
var toolErr *mcpserver.ToolError
if !errors.As(err, &toolErr) {
t.Fatalf("error = %v, want ToolError", err)
}
if toolErr.Example["text"] != "GitHub contribution research" || !reflect.DeepEqual(toolErr.Example["match_fields"], []string{"name", "description"}) {
t.Fatalf("empty-search example = %#v", toolErr.Example)
}

_, _, _, err = compileRepositorySearch(mcpserver.SearchGitHubRepositoriesInput{RawQuery: "language:go", Language: "Go"})
if !errors.As(err, &toolErr) || toolErr.Example["raw_query"] != "is:public language:go stars:>=100" {
t.Fatalf("ambiguous-search example = %#v, error=%v", toolErr.Example, err)
}
}

func TestCompileRepositorySearchWarnsAboutRawReadmeQueries(t *testing.T) {
query, interpretation, warnings, err := compileRepositorySearch(mcpserver.SearchGitHubRepositoriesInput{RawQuery: "attention in:readme"})
if err != nil {
Expand Down Expand Up @@ -517,6 +535,61 @@ func TestDeepWikiReturnsDerivedProvenanceAndBoundsOutput(t *testing.T) {
}
}

func TestDeepWikiUsesNormalizedRepositoriesForRequestAndOutput(t *testing.T) {
svc := newSearchTestService(t)
fake := &fakeDeepWikiReader{response: deepwiki.Response{Available: true, Text: "ok"}}
svc.SetDeepWikiReader(fake)
out, err := (&MCPReader{svc}).DeepWiki(context.Background(), mcpserver.DeepWikiInput{
Action: "question", Repository: "acme/rocket", Repositories: []string{"wrong/one", "wrong/two"}, Question: "architecture?", MaxOutputBytes: 1024,
})
if err != nil {
t.Fatal(err)
}
want := []string{"acme/rocket"}
if !reflect.DeepEqual(fake.request.Repositories, want) || !reflect.DeepEqual(out.Repositories, want) {
t.Fatalf("request repositories = %v, output repositories = %v", fake.request.Repositories, out.Repositories)
}
}

func TestScalableBatchInputsDeduplicateInFirstSeenOrder(t *testing.T) {
repositories := dedupeRepositoryRefs([]mcpserver.RepositoryRef{{Owner: "one", Repo: "repo"}, {Owner: "two", Repo: "repo"}, {Owner: "one", Repo: "repo"}})
if want := []mcpserver.RepositoryRef{{Owner: "one", Repo: "repo"}, {Owner: "two", Repo: "repo"}}; !reflect.DeepEqual(repositories, want) {
t.Fatalf("repositories = %+v, want %+v", repositories, want)
}
threads := dedupeThreadRefs([]mcpserver.ThreadRef{{Owner: "one", Repo: "repo", Number: 1}, {Owner: "one", Repo: "repo", Number: 2}, {Owner: "one", Repo: "repo", Number: 1}})
if want := []mcpserver.ThreadRef{{Owner: "one", Repo: "repo", Number: 1}, {Owner: "one", Repo: "repo", Number: 2}}; !reflect.DeepEqual(threads, want) {
t.Fatalf("threads = %+v, want %+v", threads, want)
}
indexed := dedupeIndexRepositoryInputs([]mcpserver.IndexRepositoryInput{{Owner: "one", Repo: "repo", Remote: "first"}, {Owner: "one", Repo: "repo", Remote: "second"}})
if len(indexed) != 1 || indexed[0].Remote != "first" {
t.Fatalf("indexed repositories = %+v", indexed)
}
}

func TestScalableRuntimeRejectsPageBoundsBeforeSubmittingJob(t *testing.T) {
reader := &MCPReader{newSearchTestService(t)}
ctx := context.Background()
thread := mcpserver.ThreadRef{Owner: "acme", Repo: "rocket", Number: 1}
for _, maxPages := range []int{-1, 101} {
if _, err := reader.HydrateThreads(ctx, mcpserver.HydrateThreadsInput{Threads: []mcpserver.ThreadRef{thread}, Facets: []string{"issue_comments"}, MaxPages: maxPages}); err == nil {
t.Fatalf("HydrateThreads accepted max_pages=%d", maxPages)
}
}
for _, maxPages := range []int{-1, 21} {
if _, err := reader.SyncPullRequestStatus(ctx, mcpserver.SyncPullRequestStatusInput{PullRequests: []mcpserver.ThreadRef{thread}, MaxPages: maxPages}); err == nil {
t.Fatalf("SyncPullRequestStatus accepted max_pages=%d", maxPages)
}
}
for _, limit := range []int{-1, 1001} {
if _, err := reader.SyncThreads(ctx, mcpserver.SyncThreadsInput{Selection: "repositories", Repositories: []mcpserver.RepositoryRef{{Owner: "acme", Repo: "rocket"}}, LimitPerRepository: limit}); err == nil {
t.Fatalf("SyncThreads accepted limit_per_repository=%d", limit)
}
}
if _, err := reader.SyncAuthoredPullRequests(ctx, mcpserver.SyncAuthoredPullRequestsInput{Limit: 1, MaxRequests: syncFixedRequestCost() + 1}); err == nil {
t.Fatal("SyncAuthoredPullRequests accepted budget that cannot fund identity, discovery, and one repository sync")
}
}

func TestDeepWikiTruncationPreservesUTF8(t *testing.T) {
svc := newSearchTestService(t)
fake := &fakeDeepWikiReader{response: deepwiki.Response{Available: true, Text: strings.Repeat("x", 1023) + "€", SourceURL: "https://deepwiki.com/acme/rocket"}}
Expand Down
Loading
Loading