diff --git a/docs/architecture.md b/docs/architecture.md index 3a9bbfa7..4c9ded60 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -57,7 +57,7 @@ product-owned types, errors, or constants. | Private MCP runtime installation | explicit setup `--mode mcp` | no | yes | no | no | | Global CLI installation | explicit setup `--mode cli` or `--mode both` | npm registry dependent | yes | `npm` only | no | | Setup verification | all applied setup modes | no | no | `git --version` | no | -| GitHub read | sync, crawl, hydrate | yes | yes | no | no | +| GitHub read | sync, crawl, hydrate, bounded search/source acquisition | yes | yes | no | no | | Coverage workflow | explicit `corpus.ensure_coverage` | yes, bounded | yes | no | no | | DeepWiki external read | public repository structure, contents, questions | yes | no | no | no | | Git acquisition | acquire, workspace create | remote-dependent | yes | `git` only | no | @@ -326,6 +326,34 @@ Code indexes are separate immutable artifact records addressed as unchanged source commit emits a new artifact identity and does not mutate old resource payloads. See [ADR 0005](adr/0005-immutable-snapshot-and-artifact-identity.md). +### Live acquisition and corpus artifacts + +Explicit live acquisition follows one direction: + +```text +live GitHub request + -> adapter conversion with provider provenance + -> local observations and/or immutable digest-bound artifact + -> compact MCP result plus opaque resource URI + -> local resources/read +``` + +`github.search_threads` persists the returned issue or pull-request +observations and an exact `github-thread-search.v1` result artifact. A search +page never advances repository-wide thread coverage and an empty page is not +proof that no matching live thread exists. `github.read_source_files` resolves +one named ref to a commit, reads bounded repository-relative files in input +order, and stores a `source-bundle.v1` artifact. Commit SHA is the authoritative +revision; GitHub blob SHA remains a separate file identity. Source content is +untrusted text and is never merged into thread facets or code-index snapshots. + +The two artifact resource families are +`gitcontribute://artifact/github-thread-search/` and +`gitcontribute://artifact/source-bundle/`. Their resource readers open +the local corpus only and verify the digest before decoding the typed payload. +The live operations have network-read/local-write annotations; the resource +reads are offline and immutable. + ## GitHub transport The GitHub adapter wraps `go-github` behind narrow read interfaces. For each diff --git a/docs/mcp-scalable-workflows.md b/docs/mcp-scalable-workflows.md index c44216b1..8a85b041 100644 --- a/docs/mcp-scalable-workflows.md +++ b/docs/mcp-scalable-workflows.md @@ -11,6 +11,8 @@ Use the cheapest authoritative source first, and hydrate only finalists: ```text github.search_repositories -> corpus.get_repositories +github.search_threads -> resources/read (exact immutable query artifact) +github.read_source_files -> resources/read (exact immutable source bundle) github.sync_repository_context -> jobs.get -> corpus.get_repositories research.query_deepwiki github.sync_threads -> jobs.get -> corpus.rank_contribution_candidates @@ -26,6 +28,25 @@ workflow.prepare_issue_set `detailed` preserves secondary metadata for finalists. Live pagination uses `page` and `next_page` because GitHub search pages are not stable cursors. +- `github.search_threads` runs one bounded issue-search page for one repository, + persists returned thread observations, and returns an exact + `gitcontribute://artifact/github-thread-search/` resource. Its total, + provider query, ordering, rate state, and incomplete-results flag are + preserved in that artifact, but the page does not establish repository-wide + thread coverage or prove absence. +- `github.read_source_files` resolves a commit or named ref once, then reads up + to 20 ordered repository-relative files with per-file and total-byte bounds. + Complete items preserve commit SHA, blob SHA, content digest, line range, and + source URL in a `source-bundle.v1` resource. Failed, missing, oversized, and + retryable items remain visible without discarding successful siblings. Read + the returned `gitcontribute://artifact/source-bundle/` URI locally; + repository text is untrusted and is never executed. +- `corpus.search_code_batch` is the bounded offline fan-out surface for up to + 20 queries over one repository or snapshot scope. It shares one corpus read + revision and preserves each query's coverage and truncation semantics. It + never performs live GitHub code search; `corpus.search_code` remains the + single-query compatibility operation. + ```json { "text": "inference", @@ -272,8 +293,9 @@ not an MCP discovery primitive. | Tool family | Network | Corpus/local write | Process | | --- | ---: | ---: | ---: | | `corpus.get_*`, rank, precedents, portfolio | no | no | no | +| `corpus.search_code`, `corpus.search_code_batch` | no | no | no | | `workflow.link_pull_request` | no | yes | no | -| `github.search_*`, sync, hydrate | yes | yes | no | +| `github.search_*`, source reads, sync, hydrate | yes | yes | no | | `research.query_deepwiki` | yes | no | no | | `code.index_repositories` | remote-dependent | yes | Git only | | `workspace.check_merge_conflicts` | no | no | Git only | diff --git a/internal/app/app_test.go b/internal/app/app_test.go index a3c859ef..00d896b9 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -147,6 +147,8 @@ func (ts *testServer) handler(w http.ResponseWriter, r *http.Request) { }) case fmt.Sprintf("/api/v3/repos/%s/%s", ts.owner, ts.repo): json.NewEncoder(w).Encode(ts.repoPayload()) + case fmt.Sprintf("/api/v3/repos/%s/%s/commits/main", ts.owner, ts.repo): + json.NewEncoder(w).Encode(map[string]any{"sha": "guided-commit-sha", "html_url": fmt.Sprintf("https://github.com/%s/%s/commit/guided-commit-sha", ts.owner, ts.repo)}) case fmt.Sprintf("/api/v3/repos/%s/%s/issues", ts.owner, ts.repo): page := r.URL.Query().Get("page") if page == "" || page == "1" { diff --git a/internal/app/guidance.go b/internal/app/guidance.go index 9d47f362..dcc368a5 100644 --- a/internal/app/guidance.go +++ b/internal/app/guidance.go @@ -41,9 +41,24 @@ func syncRepositoryGuidance( runID int64, budget *syncRequestBudget, ) error { - fileReader, ok := reader.(github.RepositoryFileReader) + resolver, ok := reader.(github.RepositoryRefResolver) if !ok { - return errors.New("GitHub reader does not support repository contribution-guidance reads") + return errors.New("GitHub reader does not support repository ref resolution for contribution guidance") + } + fileReader, ok := reader.(github.RepositoryFileAtResolvedRefReader) + if !ok { + return errors.New("GitHub reader does not support contribution-guidance reads at a resolved ref") + } + requestedRef := repo.DefaultBranch + if strings.TrimSpace(requestedRef) == "" { + requestedRef = "HEAD" + } + if err := budget.take(); err != nil { + return err + } + resolution, _, err := resolver.ResolveRepositoryRef(ctx, ref.Owner, ref.Repo, requestedRef) + if err != nil { + return fmt.Errorf("resolve contribution guidance ref %q: %w", requestedRef, err) } pages := make([]corpus.FacetObservationInput, 0, len(contributionGuidancePaths)) @@ -54,7 +69,7 @@ func syncRepositoryGuidance( if err := budget.take(); err != nil { return err } - file, _, err := fileReader.GetRepositoryFile(ctx, ref.Owner, ref.Repo, path) + file, _, err := fileReader.GetRepositoryFileAtResolvedRef(ctx, ref.Owner, ref.Repo, path, resolution) if err != nil { var notFound *github.NotFoundError if errors.As(err, ¬Found) { @@ -109,7 +124,7 @@ func renderContributionGuidance(documents []storedGuidanceDocument) (string, []d for _, document := range documents { sections = append(sections, fmt.Sprintf("## %s\n\n%s", document.File.Path, strings.TrimSpace(document.File.Content))) refs = append(refs, domain.SourceRef{ - Source: "github:rest", URL: document.File.HTMLURL, CommitSHA: document.File.SHA, + Source: "github:rest", URL: document.File.HTMLURL, CommitSHA: document.File.CommitSHA, ObservedAt: document.ObservedAt, AsOf: document.AsOf, }) } diff --git a/internal/app/guidance_test.go b/internal/app/guidance_test.go index ec5048cc..cca05d76 100644 --- a/internal/app/guidance_test.go +++ b/internal/app/guidance_test.go @@ -24,9 +24,12 @@ func TestRepositoryContextSyncPersistsSourceBackedContributionGuidance(t *testin base := &testServer{owner: "octocat", repo: "guided"} srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/api/v3/repos/octocat/guided/contents/.github/CONTRIBUTING.md" { + if got := r.URL.Query().Get("ref"); got != "guided-commit-sha" { + t.Errorf("guidance content ref = %q, want guided-commit-sha", got) + } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{ - "type": "file", "path": ".github/CONTRIBUTING.md", "sha": "policy-sha", + "type": "file", "path": ".github/CONTRIBUTING.md", "sha": "policy-blob-sha", "html_url": "https://github.com/octocat/guided/blob/main/.github/CONTRIBUTING.md", "encoding": "base64", "content": base64.StdEncoding.EncodeToString([]byte(guidance)), }) @@ -50,7 +53,7 @@ func TestRepositoryContextSyncPersistsSourceBackedContributionGuidance(t *testin if !strings.Contains(text, "## .github/CONTRIBUTING.md") || !strings.Contains(text, guidance) { t.Fatalf("guidance = %q", text) } - if len(refs) != 1 || refs[0].CommitSHA != "policy-sha" || refs[0].URL == "" { + if len(refs) != 1 || refs[0].CommitSHA != "guided-commit-sha" || refs[0].URL == "" { t.Fatalf("refs = %+v", refs) } @@ -81,7 +84,7 @@ func TestRadarClassifiesStoredPolicyAndNaturalLanguageClaimOffline(t *testing.T) t.Fatal(err) } file := github.RepositoryFile{ - Path: ".github/CONTRIBUTING.md", SHA: "policy-sha", + Path: ".github/CONTRIBUTING.md", BlobSHA: "policy-blob-sha", CommitSHA: "guided-commit-sha", HTMLURL: "https://github.com/owner/repo/blob/main/.github/CONTRIBUTING.md", Content: "We accept pull requests for issues labelled help wanted.", } @@ -132,6 +135,17 @@ func (interruptedGuidanceReader) GetRepositoryFile(_ context.Context, _, _, path return github.RepositoryFile{}, github.RateInfo{}, errors.New("interrupted guidance retrieval") } +func (interruptedGuidanceReader) ResolveRepositoryRef(_ context.Context, _, _, requested string) (github.RefResolution, github.RateInfo, error) { + return github.RefResolution{RequestedRef: requested, ResolvedRef: "new-commit", CommitSHA: "new-commit"}, github.RateInfo{}, nil +} + +func (interruptedGuidanceReader) GetRepositoryFileAtResolvedRef(_ context.Context, _, _, path string, resolution github.RefResolution) (github.RepositoryFile, github.RateInfo, error) { + if path == contributionGuidancePaths[0] { + return github.RepositoryFile{Path: path, BlobSHA: "new-blob", CommitSHA: resolution.CommitSHA, Content: "new guidance"}, github.RateInfo{}, nil + } + return github.RepositoryFile{}, github.RateInfo{}, errors.New("interrupted guidance retrieval") +} + func TestGuidanceRetrievalReplacesSnapshotOnlyAfterAllPathsComplete(t *testing.T) { t.Parallel() fixture := newRadarTestFixture(t) diff --git a/internal/app/mcp_code_search.go b/internal/app/mcp_code_search.go index 69187f19..29932f95 100644 --- a/internal/app/mcp_code_search.go +++ b/internal/app/mcp_code_search.go @@ -14,38 +14,63 @@ import ( // SearchCode searches indexed code snapshots in the local corpus. func (r *MCPReader) SearchCode(ctx context.Context, in mcpcontract.SearchCodeInput) (mcpcontract.SearchCodeOutput, error) { in.Query = strings.TrimSpace(in.Query) + ref, err := validateSearchCodeInput(&in) + if err != nil { + return mcpcontract.SearchCodeOutput{}, err + } + c, err := r.openReadOnlyCorpus(ctx) + if err != nil { + return mcpcontract.SearchCodeOutput{}, err + } + revision, err := beginCorpusRead(ctx, c, in.SnapshotToken) + if err != nil { + return mcpcontract.SearchCodeOutput{}, err + } + out, coverage, page, truncated, unknownCoverage, err := r.searchCodeAtRevision(ctx, c, in, ref) + if err != nil { + return mcpcontract.SearchCodeOutput{}, err + } + if err := finishCorpusRead(ctx, c, revision); err != nil { + return mcpcontract.SearchCodeOutput{}, err + } + provenance, err := offlineReadProvenance("code_search", revision, in, !truncated && !unknownCoverage, truncated, unknownCoverage) + if err != nil { + return mcpcontract.SearchCodeOutput{}, err + } + return mcpcontract.SearchCodeOutput{Query: in.Query, Total: page.Total, Matches: out, Coverage: coverage, NextCursor: page.NextCursor, SnapshotToken: snapshotIdentity(in.SnapshotToken, revision), Provenance: provenance}, nil +} + +func validateSearchCodeInput(in *mcpcontract.SearchCodeInput) (domain.RepoRef, error) { if in.Query == "" { - return mcpcontract.SearchCodeOutput{}, errors.New("query is required") + return domain.RepoRef{}, errors.New("query is required") } if in.Limit == 0 { in.Limit = 20 } if in.Limit < 1 || in.Limit > 100 { - return mcpcontract.SearchCodeOutput{}, errors.New("limit must be between 1 and 100") + return domain.RepoRef{}, errors.New("limit must be between 1 and 100") } var ref domain.RepoRef if in.Owner != "" || in.Repo != "" { if (in.Owner == "") != (in.Repo == "") { - return mcpcontract.SearchCodeOutput{}, errors.New("owner and repo must be provided together") + return domain.RepoRef{}, errors.New("owner and repo must be provided together") } ref = domain.RepoRef{Owner: in.Owner, Repo: in.Repo} if err := ref.Validate(); err != nil { - return mcpcontract.SearchCodeOutput{}, err + return domain.RepoRef{}, err } } - c, err := r.openReadOnlyCorpus(ctx) - if err != nil { - return mcpcontract.SearchCodeOutput{}, err - } - revision, err := beginCorpusRead(ctx, c, in.SnapshotToken) - if err != nil { - return mcpcontract.SearchCodeOutput{}, err - } + return ref, nil +} + +func (r *MCPReader) searchCodeAtRevision(ctx context.Context, c *corpus.Corpus, in mcpcontract.SearchCodeInput, ref domain.RepoRef) ( + []mcpcontract.CodeMatchOutput, []mcpcontract.CodeIndexCoverageOutput, corpus.CodeSearchPage, bool, bool, error, +) { page, err := c.SearchCodeWithOptions(ctx, in.Query, corpus.CodeSearchOptions{Ref: ref, Limit: in.Limit, Cursor: in.Cursor}) if err != nil { - return mcpcontract.SearchCodeOutput{}, fmt.Errorf("search code: %w", err) + return nil, nil, corpus.CodeSearchPage{}, false, false, fmt.Errorf("search code: %w", err) } - out := make([]mcpcontract.CodeMatchOutput, len(page.Matches)) + matches := make([]mcpcontract.CodeMatchOutput, len(page.Matches)) coverage := make([]mcpcontract.CodeIndexCoverageOutput, 0, len(page.Snapshots)+1) for _, snapshot := range page.Snapshots { manifest := snapshot.Manifest @@ -65,24 +90,89 @@ func (r *MCPReader) SearchCode(ctx context.Context, in mcpcontract.SearchCodeInp } for i, match := range page.Matches { repo := match.Repo.String() - out[i] = mcpcontract.CodeMatchOutput{ + matches[i] = mcpcontract.CodeMatchOutput{ ID: fmt.Sprintf("%s@%s:%s", repo, match.Commit, match.Path), Repo: repo, Commit: match.Commit, Path: match.Path, Language: match.Language, Snippet: boundedText(match.Content, 2000), Bytes: match.Bytes, } } - if err := finishCorpusRead(ctx, c, revision); err != nil { - return mcpcontract.SearchCodeOutput{}, err - } truncated := page.NextCursor != "" unknownCoverage := false for _, entry := range coverage { truncated = truncated || entry.Truncated unknownCoverage = unknownCoverage || entry.Status != "indexed" } - provenance, err := offlineReadProvenance("code_search", revision, in, !truncated && !unknownCoverage, truncated, unknownCoverage) + return matches, coverage, page, truncated, unknownCoverage, nil +} + +// SearchCodeBatch performs ordered offline code searches over one shared +// corpus revision. Each query keeps the single-query coverage and truncation +// semantics; the tool only removes the model-side fan-out loop. +func (r *MCPReader) SearchCodeBatch(ctx context.Context, in mcpcontract.SearchCodeBatchInput) (mcpcontract.SearchCodeBatchOutput, error) { + if len(in.Queries) < 1 || len(in.Queries) > 20 { + return mcpcontract.SearchCodeBatchOutput{}, errors.New("queries must contain 1 to 20 items") + } + if in.Limit == 0 { + in.Limit = 20 + } + if in.Limit < 1 || in.Limit > 100 { + return mcpcontract.SearchCodeBatchOutput{}, errors.New("limit must be between 1 and 100") + } + if in.Owner == "" || in.Repo == "" { + return mcpcontract.SearchCodeBatchOutput{}, errors.New("owner and repo are required") + } + ref := domain.RepoRef{Owner: in.Owner, Repo: in.Repo} + if err := ref.Validate(); err != nil { + return mcpcontract.SearchCodeBatchOutput{}, err + } + queries := make([]string, len(in.Queries)) + for i, query := range in.Queries { + queries[i] = strings.TrimSpace(query) + if queries[i] == "" { + return mcpcontract.SearchCodeBatchOutput{}, fmt.Errorf("queries[%d] is required", i) + } + } + in.Queries = queries + c, err := r.openReadOnlyCorpus(ctx) if err != nil { - return mcpcontract.SearchCodeOutput{}, err + return mcpcontract.SearchCodeBatchOutput{}, err } - return mcpcontract.SearchCodeOutput{Query: in.Query, Total: page.Total, Matches: out, Coverage: coverage, NextCursor: page.NextCursor, SnapshotToken: snapshotIdentity(in.SnapshotToken, revision), Provenance: provenance}, nil + revision, err := beginCorpusRead(ctx, c, in.SnapshotToken) + if err != nil { + return mcpcontract.SearchCodeBatchOutput{}, err + } + out := mcpcontract.SearchCodeBatchOutput{Status: "complete", Repository: mcpcontract.RepositoryRef{Owner: in.Owner, Repo: in.Repo}, SnapshotToken: snapshotIdentity(in.SnapshotToken, revision), Items: make([]mcpcontract.BatchItem[mcpcontract.SearchCodeOutput], len(in.Queries))} + allTruncated, allUnknown := false, false + for i, query := range in.Queries { + searchIn := mcpcontract.SearchCodeInput{Owner: in.Owner, Repo: in.Repo, Query: query, Limit: in.Limit, SnapshotToken: in.SnapshotToken} + matches, coverage, page, truncated, unknown, searchErr := r.searchCodeAtRevision(ctx, c, searchIn, ref) + item := mcpcontract.BatchItem[mcpcontract.SearchCodeOutput]{Key: query, Status: "complete"} + if searchErr != nil { + item.Status, item.Reason, item.Message = "failed", "code_search_failed", searchErr.Error() + out.Status = "partial" + allUnknown = true + out.Items[i] = item + continue + } + provenance, provenanceErr := offlineReadProvenance("code_search_batch", revision, searchIn, !truncated && !unknown, truncated, unknown) + if provenanceErr != nil { + return mcpcontract.SearchCodeBatchOutput{}, provenanceErr + } + value := mcpcontract.SearchCodeOutput{Query: query, Total: page.Total, Matches: matches, Coverage: coverage, NextCursor: page.NextCursor, SnapshotToken: out.SnapshotToken, Provenance: provenance} + item.Value = &value + out.Items[i] = item + allTruncated, allUnknown = allTruncated || truncated, allUnknown || unknown + if truncated || unknown { + out.Status = "partial" + } + } + if err := finishCorpusRead(ctx, c, revision); err != nil { + return mcpcontract.SearchCodeBatchOutput{}, err + } + provenance, err := offlineReadProvenance("code_search_batch", revision, in, !allTruncated && !allUnknown, allTruncated, allUnknown) + if err != nil { + return mcpcontract.SearchCodeBatchOutput{}, err + } + out.Provenance = provenance + return out, nil } diff --git a/internal/app/mcp_github_acquisition.go b/internal/app/mcp_github_acquisition.go new file mode 100644 index 00000000..cc556bcb --- /dev/null +++ b/internal/app/mcp_github_acquisition.go @@ -0,0 +1,452 @@ +package app + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "path" + "strings" + "time" + + "github.com/morluto/gitcontribute/internal/corpus" + "github.com/morluto/gitcontribute/internal/domain" + "github.com/morluto/gitcontribute/internal/github" + "github.com/morluto/gitcontribute/internal/mcpcontract" +) + +const ( + githubThreadSearchArtifactKind = "github-thread-search.v1" + sourceBundleArtifactKind = "source-bundle.v1" + maxSourceFileRequests = 20 + defaultSourcePerFileBytes = 256 * 1024 + defaultSourceTotalBytes = 2 * 1024 * 1024 + maxSourcePerFileBytes = 1024 * 1024 + maxSourceTotalBytes = 4 * 1024 * 1024 +) + +// SearchGitHubThreads performs one bounded live issue-search request, records +// returned thread observations, and creates an immutable query-result +// artifact. It never claims repository-wide thread coverage. +func (r *MCPReader) SearchGitHubThreads(ctx context.Context, in mcpcontract.SearchGitHubThreadsInput) (mcpcontract.SearchGitHubThreadsOutput, error) { + if err := validateGitHubThreadSearchInput(&in); err != nil { + return mcpcontract.SearchGitHubThreadsOutput{}, err + } + reader, err := r.githubReader() //nolint:contextcheck // construction does not perform a request + if err != nil { + return mcpcontract.SearchGitHubThreadsOutput{}, err + } + searcher, ok := reader.(github.ThreadSearcher) + if !ok { + return mcpcontract.SearchGitHubThreadsOutput{}, errors.New("configured GitHub reader does not support thread search") + } + result, err := searcher.SearchThreads(ctx, github.ThreadSearchOptions{ + Owner: in.Owner, Repo: in.Repo, Query: in.Query, Kind: github.ThreadKind(in.Kind), State: in.State, + Sort: in.Sort, Order: in.Order, PageOptions: github.PageOptions{Page: in.Page, PerPage: in.Limit}, + }) + if err != nil { + return mcpcontract.SearchGitHubThreadsOutput{}, err + } + return r.persistGitHubThreadSearch(ctx, in, result) +} + +func validateGitHubThreadSearchInput(in *mcpcontract.SearchGitHubThreadsInput) error { + if err := (domain.RepoRef{Owner: in.Owner, Repo: in.Repo}).Validate(); err != nil { + return err + } + in.Query = strings.TrimSpace(in.Query) + if in.Query == "" { + return errors.New("query is required") + } + if in.Kind != "" && in.Kind != "issue" && in.Kind != "pull_request" { + return fmt.Errorf("kind must be issue or pull_request") + } + if in.State == "" { + in.State = "all" + } + if in.State != "open" && in.State != "closed" && in.State != "all" { + return fmt.Errorf("state must be open, closed, or all") + } + if in.Sort != "" && in.Sort != "comments" && in.Sort != "created" && in.Sort != "updated" && in.Sort != "reactions" { + return fmt.Errorf("sort must be comments, created, updated, or reactions") + } + if in.Order == "" { + in.Order = "desc" + } + if in.Order != "asc" && in.Order != "desc" { + return fmt.Errorf("order must be asc or desc") + } + if in.Limit == 0 { + in.Limit = 20 + } + if in.Limit < 1 || in.Limit > 100 { + return fmt.Errorf("limit must be between 1 and 100") + } + if in.Page == 0 { + in.Page = 1 + } + if in.Page < 1 || in.Page > 1000 || (in.Page-1)*in.Limit >= 1000 { + return fmt.Errorf("page must keep the requested result offset below GitHub's 1,000-result cap") + } + return nil +} + +func (r *MCPReader) persistGitHubThreadSearch(ctx context.Context, in mcpcontract.SearchGitHubThreadsInput, result github.ThreadSearchResult) (mcpcontract.SearchGitHubThreadsOutput, error) { + c, err := r.openCorpus(ctx) + if err != nil { + return mcpcontract.SearchGitHubThreadsOutput{}, err + } + now := r.now().UTC() + out := mcpcontract.SearchGitHubThreadsOutput{ + Status: "complete", Repository: mcpcontract.RepositoryRef{Owner: in.Owner, Repo: in.Repo}, Query: in.Query, + ProviderQuery: result.Query, Kind: in.Kind, State: in.State, Sort: in.Sort, Order: in.Order, + Page: in.Page, Limit: in.Limit, Total: result.Total, Incomplete: result.Incomplete, + Rate: githubRateOutput(result.Rate), Coverage: "repository_thread_coverage_incomplete", ObservedAt: formatTime(now), + Items: make([]mcpcontract.BatchItem[mcpcontract.ThreadOutput], len(result.Items)), + } + artifact := mcpcontract.GitHubThreadSearchArtifact{ + SchemaVersion: githubThreadSearchArtifactKind, ArtifactKind: githubThreadSearchArtifactKind, + Repository: out.Repository, Query: in.Query, ProviderQuery: result.Query, Kind: in.Kind, State: in.State, + Sort: in.Sort, Order: in.Order, Page: in.Page, Limit: in.Limit, Total: result.Total, + Incomplete: result.Incomplete, HasNextPage: result.Page.HasNext, Rate: githubRateOutput(result.Rate), + Provenance: mcpcontract.GitHubAcquisitionProvenance{Provider: "github", Endpoint: "search/issues", ObservedAt: formatTime(now)}, + CreatedAt: formatTime(now), Items: make([]mcpcontract.GitHubThreadSearchArtifactItem, len(result.Items)), + } + if result.Page.HasNext { + out.NextPage = result.Page.NextPage + } else if in.Page*in.Limit < result.Total && in.Page*in.Limit < 1000 { + out.NextPage = in.Page + 1 + } + artifact.NextPage = out.NextPage + hasNextPage := out.NextPage != 0 + artifact.HasNextPage = hasNextPage + artifact.Completeness = mcpcontract.GitHubThreadSearchCompleteness{ + Status: "page_complete", IncompleteResults: result.Incomplete, HasNextPage: hasNextPage, + RepositoryThreadCoverageKnown: false, RepositoryThreadCoverageFull: false, + } + if result.Incomplete || hasNextPage { + out.Status = "partial" + artifact.Completeness.Status = "partial" + } + + repo, err := ensureSearchRepository(ctx, c, in.Owner, in.Repo) + if err != nil { + return mcpcontract.SearchGitHubThreadsOutput{}, err + } + for index, issue := range result.Items { + if issue.RepositoryOwner == "" { + issue.RepositoryOwner = in.Owner + } + if issue.RepositoryName == "" { + issue.RepositoryName = in.Repo + } + item := mcpcontract.BatchItem[mcpcontract.ThreadOutput]{Key: threadSearchItemKey(issue, index), Status: "complete"} + if !strings.EqualFold(issue.RepositoryOwner, in.Owner) || !strings.EqualFold(issue.RepositoryName, in.Repo) { + item.Status = "failed" + item.Reason = "repository_scope_mismatch" + item.Message = fmt.Sprintf("provider returned %s/%s for requested %s/%s", issue.RepositoryOwner, issue.RepositoryName, in.Owner, in.Repo) + out.Status = "partial" + out.Items[index] = item + artifact.Items[index] = githubThreadSearchArtifactItem(issue, index, in.Owner, in.Repo) + continue + } + thread, payload, payloadErr := threadFromIssue(issue) + value := liveThreadOutput(issue) + item.Value = &value + if payloadErr == nil { + thread.RepositoryID = repo.ID + if _, upsertErr := c.UpsertThread(ctx, thread, payload); upsertErr != nil { + payloadErr = upsertErr + } + } + if payloadErr != nil { + item.Status = "failed" + item.Value = nil + item.Reason = "observation_not_persisted" + item.Message = payloadErr.Error() + out.Status = "partial" + } + out.Items[index] = item + artifact.Items[index] = githubThreadSearchArtifactItem(issue, index, in.Owner, in.Repo) + } + + snapshot, err := c.MaterializeReadSnapshot(ctx, corpus.SnapshotMaterialization{ + Kind: githubThreadSearchArtifactKind, + Scope: map[string]any{"repository": in.Owner + "/" + in.Repo, "query": in.Query, "page": in.Page}, + SourceManifest: map[string]any{"provider_query": result.Query, "item_ids": artifactItemIDs(artifact.Items)}, + DerivedVersions: map[string]string{"github_thread_search": "v1"}, + Completeness: artifact.Completeness, + Provenance: artifact.Provenance, + Payload: artifact, + }) + if err != nil { + return mcpcontract.SearchGitHubThreadsOutput{}, fmt.Errorf("store github thread search artifact: %w", err) + } + out.ArtifactDigest = snapshot.ArtifactDigest + out.ResourceURI = "gitcontribute://artifact/github-thread-search/" + snapshot.ArtifactDigest + return out, nil +} + +// ReadGitHubThreadSearchArtifact is a local-only typed resource reader. +func (r *MCPReader) ReadGitHubThreadSearchArtifact(ctx context.Context, digest string) (mcpcontract.GitHubThreadSearchArtifact, error) { + c, err := r.openReadOnlyCorpus(ctx) + if err != nil { + return mcpcontract.GitHubThreadSearchArtifact{}, err + } + artifact, err := c.ResolveReadArtifact(ctx, githubThreadSearchArtifactKind, digest) + if err != nil { + if errors.Is(err, corpus.ErrSnapshotUnavailable) { + return mcpcontract.GitHubThreadSearchArtifact{}, mcpcontract.ErrNotFound + } + return mcpcontract.GitHubThreadSearchArtifact{}, err + } + var out mcpcontract.GitHubThreadSearchArtifact + if err := json.Unmarshal(artifact.Payload, &out); err != nil { + return mcpcontract.GitHubThreadSearchArtifact{}, fmt.Errorf("decode github thread search artifact: %w", err) + } + if out.SchemaVersion != githubThreadSearchArtifactKind || out.ArtifactKind != githubThreadSearchArtifactKind { + return mcpcontract.GitHubThreadSearchArtifact{}, errors.New("github thread search artifact schema mismatch") + } + return out, nil +} + +// ReadSourceFiles performs bounded live source acquisition and stores only the +// resulting immutable source bundle. It does not touch thread facets or code +// index projections. +func (r *MCPReader) ReadSourceFiles(ctx context.Context, in mcpcontract.ReadSourceFilesInput) (mcpcontract.ReadSourceFilesOutput, error) { + if err := validateReadSourceFilesInput(&in); err != nil { + return mcpcontract.ReadSourceFilesOutput{}, err + } + reader, err := r.githubReader() //nolint:contextcheck // construction does not perform a request + if err != nil { + return mcpcontract.ReadSourceFilesOutput{}, err + } + fileReader, ok := reader.(github.SourceFileReader) + if !ok { + return mcpcontract.ReadSourceFilesOutput{}, errors.New("configured GitHub reader does not support bounded source reads") + } + requests := make([]github.SourceFileRequest, len(in.Files)) + for i, file := range in.Files { + requests[i] = github.SourceFileRequest{Path: file.Path, StartLine: file.StartLine, EndLine: file.EndLine} + } + result, err := fileReader.ReadSourceFiles(ctx, in.Owner, in.Repo, in.Ref, requests, github.SourceFileReadOptions{PerFileBytes: in.PerFileBytes, TotalBytes: in.TotalBytes}) + if err != nil { + return mcpcontract.ReadSourceFilesOutput{}, err + } + return r.persistSourceBundle(ctx, in, result) +} + +func validateReadSourceFilesInput(in *mcpcontract.ReadSourceFilesInput) error { + if err := (domain.RepoRef{Owner: in.Owner, Repo: in.Repo}).Validate(); err != nil { + return err + } + if strings.TrimSpace(in.Ref) == "" { + return errors.New("ref is required") + } + if len(in.Files) < 1 || len(in.Files) > maxSourceFileRequests { + return fmt.Errorf("files must contain 1 to %d items", maxSourceFileRequests) + } + if in.PerFileBytes == 0 { + in.PerFileBytes = defaultSourcePerFileBytes + } + if in.TotalBytes == 0 { + in.TotalBytes = defaultSourceTotalBytes + } + if in.PerFileBytes < 1 || in.PerFileBytes > maxSourcePerFileBytes { + return fmt.Errorf("per_file_bytes must be between 1 and %d", maxSourcePerFileBytes) + } + if in.TotalBytes < 1 || in.TotalBytes > maxSourceTotalBytes { + return fmt.Errorf("total_bytes must be between 1 and %d", maxSourceTotalBytes) + } + seen := make(map[string]struct{}, len(in.Files)) + for i, file := range in.Files { + clean := strings.TrimSpace(file.Path) + if clean == "" || strings.HasPrefix(clean, "/") || strings.Contains(clean, "\\") || clean != path.Clean(clean) || clean == "." || strings.HasPrefix(clean, "../") || strings.Contains(clean, "/../") { + return fmt.Errorf("files[%d].path must be a repository-relative path without traversal", i) + } + if file.StartLine < 0 || file.EndLine < 0 || (file.StartLine > 0 && file.EndLine > 0 && file.EndLine < file.StartLine) { + return fmt.Errorf("files[%d] line range must be inclusive and ordered", i) + } + if _, ok := seen[clean]; ok { + return fmt.Errorf("files[%d].path is duplicated", i) + } + seen[clean] = struct{}{} + in.Files[i].Path = clean + } + return nil +} + +func (r *MCPReader) persistSourceBundle(ctx context.Context, in mcpcontract.ReadSourceFilesInput, result github.SourceFileReadResult) (mcpcontract.ReadSourceFilesOutput, error) { + c, err := r.openCorpus(ctx) + if err != nil { + return mcpcontract.ReadSourceFilesOutput{}, err + } + now := r.now().UTC() + out := mcpcontract.ReadSourceFilesOutput{ + Status: "complete", Repository: mcpcontract.RepositoryRef{Owner: in.Owner, Repo: in.Repo}, + RequestedRef: result.Resolution.RequestedRef, ResolvedRef: result.Resolution.ResolvedRef, CommitSHA: result.Resolution.CommitSHA, + PerFileBytes: in.PerFileBytes, TotalByteLimit: in.TotalBytes, TotalBytes: result.TotalBytes, + Items: make([]mcpcontract.SourceFileBatchItem, len(result.Items)), ObservedAt: formatTime(now), Rate: githubRateOutput(result.Rate), + } + artifact := mcpcontract.SourceBundleArtifact{ + SchemaVersion: sourceBundleArtifactKind, ArtifactKind: sourceBundleArtifactKind, Repository: out.Repository, + RequestedRef: out.RequestedRef, ResolvedRef: out.ResolvedRef, CommitSHA: out.CommitSHA, + PerFileBytes: in.PerFileBytes, TotalByteLimit: in.TotalBytes, TotalBytes: result.TotalBytes, + Rate: githubRateOutput(result.Rate), + Items: make([]mcpcontract.SourceFileBatchItem, len(result.Items)), + Provenance: mcpcontract.GitHubAcquisitionProvenance{Provider: "github", Endpoint: "repos/contents", ObservedAt: formatTime(now)}, CreatedAt: formatTime(now), + } + for i, item := range result.Items { + value := sourceFileOutput(item, result.Resolution, now) + artifactItem := mcpcontract.SourceFileBatchItem{Key: item.Request.Path, Status: mcpcontract.SourceFileStatus(item.Status), Value: &value, Message: item.Message} + if item.RetryAfter > 0 { + artifactItem.RetryAfterMS = mcpcontract.NonNegativeInt(item.RetryAfter.Milliseconds()) + } + artifact.Items[i] = artifactItem + compact := artifactItem + if compact.Value != nil { + copyValue := *compact.Value + copyValue.Content = "" + compact.Value = ©Value + } + out.Items[i] = compact + if item.Status != "complete" { + out.Status = "partial" + } + if item.Status == "complete" { + artifact.Completeness.CompleteItems++ + } else { + artifact.Completeness.FailedItems++ + } + } + artifact.Completeness.RequestedItems = len(result.Items) + artifact.Completeness.Status = out.Status + artifact.Completeness.ContentsBounded = true + snapshot, err := c.MaterializeReadSnapshot(ctx, corpus.SnapshotMaterialization{ + Kind: sourceBundleArtifactKind, + Scope: map[string]any{"repository": in.Owner + "/" + in.Repo, "requested_ref": in.Ref, "paths": sourceBundlePaths(in.Files)}, + SourceManifest: map[string]any{"commit_sha": result.Resolution.CommitSHA, "item_statuses": sourceBundleStatuses(result.Items)}, + DerivedVersions: map[string]string{"source_bundle": "v1"}, Completeness: artifact.Completeness, + Provenance: artifact.Provenance, Payload: artifact, + }) + if err != nil { + return mcpcontract.ReadSourceFilesOutput{}, fmt.Errorf("store source bundle artifact: %w", err) + } + out.ArtifactDigest = snapshot.ArtifactDigest + out.ResourceURI = "gitcontribute://artifact/source-bundle/" + snapshot.ArtifactDigest + return out, nil +} + +// ReadSourceBundleArtifact is a local-only typed resource reader. +func (r *MCPReader) ReadSourceBundleArtifact(ctx context.Context, digest string) (mcpcontract.SourceBundleArtifact, error) { + c, err := r.openReadOnlyCorpus(ctx) + if err != nil { + return mcpcontract.SourceBundleArtifact{}, err + } + artifact, err := c.ResolveReadArtifact(ctx, sourceBundleArtifactKind, digest) + if err != nil { + if errors.Is(err, corpus.ErrSnapshotUnavailable) { + return mcpcontract.SourceBundleArtifact{}, mcpcontract.ErrNotFound + } + return mcpcontract.SourceBundleArtifact{}, err + } + var out mcpcontract.SourceBundleArtifact + if err := json.Unmarshal(artifact.Payload, &out); err != nil { + return mcpcontract.SourceBundleArtifact{}, fmt.Errorf("decode source bundle artifact: %w", err) + } + if out.SchemaVersion != sourceBundleArtifactKind || out.ArtifactKind != sourceBundleArtifactKind { + return mcpcontract.SourceBundleArtifact{}, errors.New("source bundle artifact schema mismatch") + } + return out, nil +} + +func ensureSearchRepository(ctx context.Context, c *corpus.Corpus, owner, name string) (*corpus.Repository, error) { + repo, err := c.GetRepository(ctx, owner, name) + if err != nil { + return nil, err + } + if repo != nil { + return repo, nil + } + return c.UpsertRepository(ctx, corpus.Repository{Owner: owner, Name: name}, `{"source":"github-thread-search"}`) +} + +func liveThreadOutput(issue github.Issue) mcpcontract.ThreadOutput { + return mcpcontract.ThreadOutput{ + Owner: issue.RepositoryOwner, Repo: issue.RepositoryName, Kind: string(issue.Kind), Number: issue.Number, + State: issue.State, StateReason: issue.StateReason, Title: issue.Title, Author: issue.Author, + AuthorAssociation: issue.AuthorAssociation, Labels: append([]string(nil), issue.Labels...), Assignees: append([]string(nil), issue.Assignees...), + Draft: issue.Draft, ClosedAt: formatTimePtr(issue.ClosedAt), UpdatedAt: formatTime(issue.UpdatedAt), + } +} + +func formatTimePtr(value *time.Time) string { + if value == nil { + return "" + } + return formatTime(*value) +} + +func threadSearchItemKey(issue github.Issue, index int) string { + if issue.ID != 0 { + return fmt.Sprintf("%s#%d:%d", issue.Kind, issue.Number, issue.ID) + } + return fmt.Sprintf("%s#%d:%d", issue.Kind, issue.Number, index) +} + +func githubThreadSearchArtifactItem(issue github.Issue, index int, owner, repo string) mcpcontract.GitHubThreadSearchArtifactItem { + if issue.RepositoryOwner == "" { + issue.RepositoryOwner = owner + } + if issue.RepositoryName == "" { + issue.RepositoryName = repo + } + return mcpcontract.GitHubThreadSearchArtifactItem{ + Position: index, ID: issue.ID, NodeID: issue.NodeID, Owner: issue.RepositoryOwner, Repo: issue.RepositoryName, + Kind: string(issue.Kind), Number: issue.Number, Title: issue.Title, State: issue.State, SourceURL: issue.HTMLURL, + CreatedAt: formatTime(issue.CreatedAt), UpdatedAt: formatTime(issue.UpdatedAt), ClosedAt: formatTimePtr(issue.ClosedAt), + } +} + +func artifactItemIDs(items []mcpcontract.GitHubThreadSearchArtifactItem) []int64 { + ids := make([]int64, len(items)) + for i, item := range items { + ids[i] = item.ID + } + return ids +} + +func githubRateOutput(rate github.RateInfo) mcpcontract.GitHubRateOutput { + return mcpcontract.GitHubRateOutput{Limit: rate.Limit, Remaining: rate.Remaining, Used: rate.Used, Reset: formatTime(rate.Reset), Resource: rate.Resource} +} + +func sourceFileOutput(item github.SourceFileReadItem, resolution github.RefResolution, observedAt time.Time) mcpcontract.SourceFileOutput { + startLine, endLine := item.StartLine, item.EndLine + if startLine == 0 && item.Request.StartLine != 0 { + startLine = item.Request.StartLine + } + if endLine == 0 && item.Request.EndLine != 0 { + endLine = item.Request.EndLine + } + return mcpcontract.SourceFileOutput{ + Path: item.Request.Path, RequestedRef: resolution.RequestedRef, ResolvedRef: resolution.ResolvedRef, CommitSHA: resolution.CommitSHA, + BlobSHA: item.File.BlobSHA, SourceURL: item.File.HTMLURL, ContentSHA256: item.ContentSHA, Bytes: item.Bytes, + StartLine: startLine, EndLine: endLine, Content: item.File.Content, ObservedAt: formatTime(observedAt), + } +} + +func sourceBundlePaths(files []mcpcontract.SourceFileRequest) []string { + paths := make([]string, len(files)) + for i, file := range files { + paths[i] = file.Path + } + return paths +} + +func sourceBundleStatuses(items []github.SourceFileReadItem) []string { + statuses := make([]string, len(items)) + for i, item := range items { + statuses[i] = item.Status + } + return statuses +} diff --git a/internal/app/mcp_github_acquisition_test.go b/internal/app/mcp_github_acquisition_test.go new file mode 100644 index 00000000..0897c438 --- /dev/null +++ b/internal/app/mcp_github_acquisition_test.go @@ -0,0 +1,196 @@ +package app + +import ( + "context" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/morluto/gitcontribute/internal/codeindex" + "github.com/morluto/gitcontribute/internal/domain" + "github.com/morluto/gitcontribute/internal/github" + "github.com/morluto/gitcontribute/internal/mcpcontract" +) + +type panicOfflineGitHubReader struct{} + +func (panicOfflineGitHubReader) GetRepository(context.Context, string, string) (github.Repository, github.RateInfo, error) { + panic("offline code batch contacted GitHub") +} +func (panicOfflineGitHubReader) ListIssues(context.Context, string, string, github.ListIssueOptions) (github.ListResult[github.Issue], error) { + panic("offline code batch contacted GitHub") +} +func (panicOfflineGitHubReader) ListIssueComments(context.Context, string, string, int, github.PageOptions) (github.ListResult[github.IssueComment], error) { + panic("offline code batch contacted GitHub") +} +func (panicOfflineGitHubReader) GetPullRequestDetails(context.Context, string, string, int) (github.PullRequestDetails, github.RateInfo, error) { + panic("offline code batch contacted GitHub") +} +func (panicOfflineGitHubReader) ListPullRequestReviews(context.Context, string, string, int, github.PageOptions) (github.ListResult[github.Review], error) { + panic("offline code batch contacted GitHub") +} +func (panicOfflineGitHubReader) ListPullRequestComments(context.Context, string, string, int, github.PageOptions) (github.ListResult[github.ReviewComment], error) { + panic("offline code batch contacted GitHub") +} + +func TestMCPReaderSearchGitHubThreadsPersistsArtifactWithoutFullCoverage(t *testing.T) { + var searchCalls int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v3/search/issues" { + http.NotFound(w, r) + return + } + searchCalls++ + if !strings.Contains(r.URL.Query().Get("q"), "repo:acme/rocket") || !strings.Contains(r.URL.Query().Get("q"), "is:issue") { + t.Errorf("provider query = %q", r.URL.Query().Get("q")) + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Link", `; rel="next"`) + json.NewEncoder(w).Encode(map[string]any{ + "total_count": 4, "incomplete_results": false, + "items": []any{map[string]any{ + "id": 101, "node_id": "I_101", "number": 9, "title": "persist this", + "state": "open", "body": "body", "repository_url": "https://api.github.com/repos/acme/rocket", + "html_url": "https://github.com/acme/rocket/issues/9", "user": map[string]any{"login": "alice"}, + "created_at": "2026-07-01T00:00:00Z", "updated_at": "2026-07-02T00:00:00Z", + }}, + }) + })) + defer srv.Close() + + svc := newTestService(t, srv) + defer func() { _ = svc.Close() }() + now := time.Date(2026, 8, 1, 1, 2, 3, 0, time.UTC) + svc.SetClock(func() time.Time { return now }) + reader := &MCPReader{svc} + out, err := reader.SearchGitHubThreads(context.Background(), mcpcontract.SearchGitHubThreadsInput{Owner: "acme", Repo: "rocket", Query: "persist", Kind: "issue", Limit: 2}) + if err != nil { + t.Fatal(err) + } + if searchCalls != 1 || out.Status != "partial" || out.NextPage != 2 || out.Total != 4 || out.Coverage != "repository_thread_coverage_incomplete" || out.ArtifactDigest == "" { + t.Fatalf("search output = %+v", out) + } + if len(out.Items) != 1 || out.Items[0].Value == nil || out.Items[0].Value.Owner != "acme" || out.Items[0].Value.Number != 9 { + t.Fatalf("search items = %+v", out.Items) + } + + c, err := svc.openCorpus(context.Background()) + if err != nil { + t.Fatal(err) + } + repo, err := c.GetRepository(context.Background(), "acme", "rocket") + if err != nil || repo == nil { + t.Fatalf("stored repository = %+v, err=%v", repo, err) + } + thread, err := c.GetThread(context.Background(), repo.ID, "issue", 9) + if err != nil || thread == nil { + t.Fatalf("stored thread = %+v, err=%v", thread, err) + } + coverage, err := c.GetCoverage(context.Background(), repo.ID, nil, "metadata") + if err != nil { + t.Fatal(err) + } + if coverage != nil { + t.Fatalf("thread search unexpectedly completed repository metadata coverage: %+v", coverage) + } + + artifact, err := reader.ReadGitHubThreadSearchArtifact(context.Background(), out.ArtifactDigest) + if err != nil { + t.Fatal(err) + } + if artifact.SchemaVersion != githubThreadSearchArtifactKind || artifact.ProviderQuery == "" || !artifact.HasNextPage || artifact.Completeness.RepositoryThreadCoverageFull || len(artifact.Items) != 1 || artifact.Items[0].ID != 101 || artifact.Items[0].Position != 0 { + t.Fatalf("thread search artifact = %+v", artifact) + } +} + +func TestMCPReaderReadSourceFilesStoresCommitAndBlobProvenanceAndReadsLocally(t *testing.T) { + var contentCalls int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v3/repos/acme/rocket/commits/main": + json.NewEncoder(w).Encode(map[string]any{"sha": "commit-9"}) + case "/api/v3/repos/acme/rocket/contents/README.md": + contentCalls++ + if r.URL.Query().Get("ref") != "commit-9" { + t.Errorf("content ref = %q", r.URL.Query().Get("ref")) + } + json.NewEncoder(w).Encode(map[string]any{ + "type": "file", "path": "README.md", "sha": "blob-9", "html_url": "https://github.com/acme/rocket/blob/commit-9/README.md", + "encoding": "base64", "content": base64.StdEncoding.EncodeToString([]byte("first\nsecond\nthird\n")), + }) + case "/api/v3/repos/acme/rocket/contents/missing.md": + contentCalls++ + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(map[string]any{"message": "Not Found"}) + default: + http.NotFound(w, r) + } + })) + + svc := newTestService(t, srv) + defer func() { _ = svc.Close() }() + reader := &MCPReader{svc} + out, err := reader.ReadSourceFiles(context.Background(), mcpcontract.ReadSourceFilesInput{ + Owner: "acme", Repo: "rocket", Ref: "main", Files: []mcpcontract.SourceFileRequest{{Path: "README.md", StartLine: 2, EndLine: 2}, {Path: "missing.md"}}, + PerFileBytes: 100, TotalBytes: 100, + }) + if err != nil { + t.Fatal(err) + } + if contentCalls != 2 || out.CommitSHA != "commit-9" || out.ResolvedRef != "commit-9" || out.RequestedRef != "main" || out.ArtifactDigest == "" || out.Status != "partial" { + t.Fatalf("source output = %+v calls=%d", out, contentCalls) + } + if len(out.Items) != 2 || out.Items[0].Value == nil || out.Items[0].Value.Content != "" || out.Items[0].Value.CommitSHA != "commit-9" || out.Items[0].Value.BlobSHA != "blob-9" || out.Items[0].Value.StartLine != 2 || out.Items[0].Value.EndLine != 2 { + t.Fatalf("compact source items = %+v", out.Items) + } + if out.Items[1].Status != "not_found" { + t.Fatalf("missing source status = %+v", out.Items[1]) + } + + srv.Close() + artifact, err := reader.ReadSourceBundleArtifact(context.Background(), out.ArtifactDigest) + if err != nil { + t.Fatal(err) + } + if artifact.SchemaVersion != sourceBundleArtifactKind || artifact.CommitSHA != "commit-9" || artifact.Items[0].Value == nil || artifact.Items[0].Value.Content != "second\n" || artifact.Items[0].Value.ContentSHA256 == "" || artifact.Items[1].Status != "not_found" { + t.Fatalf("source artifact = %+v", artifact) + } +} + +func TestMCPReaderSearchCodeBatchUsesOneOfflineRevisionAndPreservesQueryOrder(t *testing.T) { + ctx := context.Background() + svc := newSearchTestService(t) + if _, _, err := svc.corpus.StoreCodeSnapshot(ctx, domain.RepoRef{Owner: "acme", Repo: "rocket"}, codeindex.Snapshot{ + RepoPath: "/rocket", Commit: "commit-1", CreatedAt: time.Now().UTC(), TotalBytes: 40, + Documents: []codeindex.Document{ + {Path: "parser.go", Content: "func parser() {}", Bytes: 16, LanguageHint: "go"}, + {Path: "README.md", Content: "parser notes", Bytes: 12, LanguageHint: "markdown"}, + }, Manifest: codeindex.Manifest{CoverageKnown: true, IndexedFiles: 2, TrackedEntries: 2}, + }); err != nil { + t.Fatal(err) + } + reader := &MCPReader{svc} + svc.SetGitHubReader(panicOfflineGitHubReader{}) + out, err := reader.SearchCodeBatch(ctx, mcpcontract.SearchCodeBatchInput{Owner: "acme", Repo: "rocket", Queries: []string{"parser", "func"}, Limit: 1}) + if err != nil { + t.Fatal(err) + } + if out.Status != "partial" || len(out.Items) != 2 || out.Items[0].Key != "parser" || out.Items[1].Key != "func" || out.SnapshotToken == "" { + t.Fatalf("batch output = %+v", out) + } + for i, item := range out.Items { + if item.Status != "complete" || item.Value == nil || item.Value.SnapshotToken != out.SnapshotToken || item.Value.Provenance.SnapshotToken == "" { + t.Fatalf("batch item %d = %+v", i, item) + } + } + if len(out.Items[0].Value.Matches) != 1 || len(out.Items[1].Value.Matches) != 1 || out.Items[0].Value.Matches[0].Path != "parser.go" || out.Items[1].Value.Matches[0].Path != "parser.go" { + t.Fatalf("batch matches = %+v", out.Items) + } + if out.Provenance.SnapshotToken == "" || out.Provenance.QueryDigestSHA256 == "" { + t.Fatalf("batch provenance = %+v", out.Provenance) + } +} diff --git a/internal/app/mcp_stdio_e2e_test.go b/internal/app/mcp_stdio_e2e_test.go index a50e217c..02fdb3bf 100644 --- a/internal/app/mcp_stdio_e2e_test.go +++ b/internal/app/mcp_stdio_e2e_test.go @@ -100,7 +100,7 @@ func TestMCPStdioScalableResearchFlow(t *testing.T) { } tools[tool.Name] = tool } - for _, name := range []string{mcpcontract.ToolGetRepositories, mcpcontract.ToolGetThreads, mcpcontract.ToolRankThreads, mcpcontract.ToolFindPrecedents, mcpcontract.ToolSyncPortfolio, mcpcontract.ToolListPullRequestPortfolio, mcpcontract.ToolSearchGitHubRepositories, mcpcontract.ToolSyncRepositoryContext, mcpcontract.ToolSyncThreads, mcpcontract.ToolHydrateThreads, mcpcontract.ToolEnsureCoverage, mcpcontract.ToolGetSourceAuditWorkflow, mcpcontract.ToolQueryDeepWiki, mcpcontract.ToolIndexRepositories, mcpcontract.ToolCheckMergeConflicts} { + for _, name := range []string{mcpcontract.ToolGetRepositories, mcpcontract.ToolGetThreads, mcpcontract.ToolRankThreads, mcpcontract.ToolFindPrecedents, mcpcontract.ToolSyncPortfolio, mcpcontract.ToolListPullRequestPortfolio, mcpcontract.ToolSearchGitHubRepositories, mcpcontract.ToolSearchGitHubThreads, mcpcontract.ToolReadSourceFiles, mcpcontract.ToolSearchCodeBatch, mcpcontract.ToolSyncRepositoryContext, mcpcontract.ToolSyncThreads, mcpcontract.ToolHydrateThreads, mcpcontract.ToolEnsureCoverage, mcpcontract.ToolGetSourceAuditWorkflow, mcpcontract.ToolQueryDeepWiki, mcpcontract.ToolIndexRepositories, mcpcontract.ToolCheckMergeConflicts} { if tools[name] == nil { t.Errorf("tools/list missing %s", name) } diff --git a/internal/app/sync_budget_test.go b/internal/app/sync_budget_test.go index 4e8032b8..c1738fa9 100644 --- a/internal/app/sync_budget_test.go +++ b/internal/app/sync_budget_test.go @@ -26,6 +26,14 @@ func (*authoredHeaderReader) GetRepositoryFile(_ context.Context, _, _, path str return github.RepositoryFile{}, github.RateInfo{}, &github.NotFoundError{Resource: path} } +func (*authoredHeaderReader) ResolveRepositoryRef(_ context.Context, _, _, requested string) (github.RefResolution, github.RateInfo, error) { + return github.RefResolution{RequestedRef: requested, ResolvedRef: "repo-commit", CommitSHA: "repo-commit"}, github.RateInfo{}, nil +} + +func (*authoredHeaderReader) GetRepositoryFileAtResolvedRef(_ context.Context, _, _, path string, _ github.RefResolution) (github.RepositoryFile, github.RateInfo, error) { + return github.RepositoryFile{}, github.RateInfo{}, &github.NotFoundError{Resource: path} +} + func (r *authoredHeaderReader) ListIssues(context.Context, string, string, github.ListIssueOptions) (github.ListResult[github.Issue], error) { r.listRequests++ return github.ListResult[github.Issue]{}, errors.New("unexpected issue list") diff --git a/internal/app/sync_metadata_test.go b/internal/app/sync_metadata_test.go index 0649b043..2ee2df50 100644 --- a/internal/app/sync_metadata_test.go +++ b/internal/app/sync_metadata_test.go @@ -27,6 +27,14 @@ func (*threadMetadataReader) GetRepositoryFile(_ context.Context, _, _, path str return github.RepositoryFile{}, github.RateInfo{}, &github.NotFoundError{Resource: path} } +func (*threadMetadataReader) ResolveRepositoryRef(_ context.Context, _, _, requested string) (github.RefResolution, github.RateInfo, error) { + return github.RefResolution{RequestedRef: requested, ResolvedRef: "repo-commit", CommitSHA: "repo-commit"}, github.RateInfo{}, nil +} + +func (*threadMetadataReader) GetRepositoryFileAtResolvedRef(_ context.Context, _, _, path string, _ github.RefResolution) (github.RepositoryFile, github.RateInfo, error) { + return github.RepositoryFile{}, github.RateInfo{}, &github.NotFoundError{Resource: path} +} + func (f *threadMetadataReader) ListIssueComments(ctx context.Context, owner, name string, issueNumber int, opts github.PageOptions) (github.ListResult[github.IssueComment], error) { return github.ListResult[github.IssueComment]{}, nil } diff --git a/internal/corpus/read_snapshot.go b/internal/corpus/read_snapshot.go index b6884d55..1d9f34ed 100644 --- a/internal/corpus/read_snapshot.go +++ b/internal/corpus/read_snapshot.go @@ -152,3 +152,57 @@ func (c *Corpus) ResolveReadSnapshot(ctx context.Context, token string) (ReadSna } return out, nil } + +// ResolveReadArtifact reads one immutable digest-bound artifact without +// consulting a mutable projection. It is the resource-plane counterpart to +// MaterializeReadSnapshot for artifacts whose producer returns a digest URI. +func (c *Corpus) ResolveReadArtifact(ctx context.Context, kind, digest string) (ReadSnapshotArtifact, error) { + if kind == "" { + return ReadSnapshotArtifact{}, fmt.Errorf("%w: artifact kind is required", ErrSnapshotUnavailable) + } + if len(digest) != sha256.Size*2 { + return ReadSnapshotArtifact{}, fmt.Errorf("%w: artifact digest is not a SHA-256 hex digest", ErrSnapshotUnavailable) + } + if _, err := hex.DecodeString(digest); err != nil { + return ReadSnapshotArtifact{}, fmt.Errorf("%w: artifact digest is not hexadecimal", ErrSnapshotUnavailable) + } + var payload string + var created int64 + if err := c.db.QueryRowContext(ctx, ` + SELECT payload_json, created_at + FROM corpus_read_artifacts + WHERE kind = ? AND digest = ? + `, kind, digest).Scan(&payload, &created); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return ReadSnapshotArtifact{}, fmt.Errorf("%w: %s/%s", ErrSnapshotUnavailable, kind, digest) + } + return ReadSnapshotArtifact{}, fmt.Errorf("resolve read artifact: %w", err) + } + artifactHash := sha256.Sum256(append([]byte(kind+"\x00"), []byte(payload)...)) + if hex.EncodeToString(artifactHash[:]) != digest { + return ReadSnapshotArtifact{}, fmt.Errorf("%w: artifact digest mismatch", ErrSnapshotUnavailable) + } + + out := ReadSnapshotArtifact{ArtifactKind: kind, ArtifactDigest: digest, Payload: json.RawMessage(payload), CreatedAt: scanTime(created)} + var token, contract, scope, sourceDigest, derived, complete, provenance string + var watermark, tokenCreated int64 + if err := c.db.QueryRowContext(ctx, ` + SELECT token, contract_version, observation_watermark, scope_json, + source_manifest_sha256, derived_versions_json, completeness_json, + provenance_json, created_at + FROM corpus_snapshot_tokens + WHERE artifact_kind = ? AND artifact_digest = ? + ORDER BY created_at DESC, token DESC + LIMIT 1 + `, kind, digest).Scan(&token, &contract, &watermark, &scope, &sourceDigest, &derived, &complete, &provenance, &tokenCreated); err == nil { + out.Token, out.ContractVersion, out.ObservationWatermark = token, contract, watermark + out.Scope, out.SourceManifestSHA256 = json.RawMessage(scope), sourceDigest + out.DerivedVersions, out.Completeness, out.Provenance = json.RawMessage(derived), json.RawMessage(complete), json.RawMessage(provenance) + if tokenCreated > 0 { + out.CreatedAt = scanTime(tokenCreated) + } + } else if !errors.Is(err, sql.ErrNoRows) { + return ReadSnapshotArtifact{}, fmt.Errorf("resolve read artifact metadata: %w", err) + } + return out, nil +} diff --git a/internal/corpus/read_snapshot_test.go b/internal/corpus/read_snapshot_test.go index ad44c06e..6a2fe280 100644 --- a/internal/corpus/read_snapshot_test.go +++ b/internal/corpus/read_snapshot_test.go @@ -48,3 +48,30 @@ func TestReadSnapshotRejectsInconsistentArtifact(t *testing.T) { t.Fatalf("tampered snapshot error = %v", err) } } + +func TestResolveReadArtifactUsesExactKindAndDigestWithoutProjectionFallback(t *testing.T) { + t.Parallel() + c, _ := openTestCorpus(t) + ctx := context.Background() + want, err := c.MaterializeReadSnapshot(ctx, SnapshotMaterialization{ + Kind: "source-bundle.v1", Scope: "acme/rocket", SourceManifest: "manifest", + DerivedVersions: map[string]string{"source_bundle": "v1"}, Completeness: map[string]bool{"complete": true}, + Provenance: map[string]string{"provider": "github"}, Payload: map[string]any{"commit_sha": "abc", "items": []string{"README.md"}}, + }) + if err != nil { + t.Fatal(err) + } + got, err := c.ResolveReadArtifact(ctx, "source-bundle.v1", want.ArtifactDigest) + if err != nil { + t.Fatal(err) + } + if got.ArtifactKind != want.ArtifactKind || got.ArtifactDigest != want.ArtifactDigest || string(got.Payload) != string(want.Payload) || got.Token != want.Token { + t.Fatalf("artifact = %+v, want %+v", got, want) + } + if _, err := c.ResolveReadArtifact(ctx, "source-bundle.v1", "missing"); !errors.Is(err, ErrSnapshotUnavailable) { + t.Fatalf("malformed artifact error = %v", err) + } + if _, err := c.ResolveReadArtifact(ctx, "other-kind", want.ArtifactDigest); !errors.Is(err, ErrSnapshotUnavailable) { + t.Fatalf("wrong-kind artifact error = %v", err) + } +} diff --git a/internal/github/acquisition_models.go b/internal/github/acquisition_models.go new file mode 100644 index 00000000..560cfe6a --- /dev/null +++ b/internal/github/acquisition_models.go @@ -0,0 +1,84 @@ +package github + +import "time" + +// RefResolution records the provider ref supplied by a caller and the commit +// that GitHub resolved it to. CommitSHA is the authoritative source revision; +// ResolvedRef is the ref used for the content request (normally that commit). +type RefResolution struct { + RequestedRef string + ResolvedRef string + CommitSHA string +} + +// RepositoryFile is a bounded text file read from a repository at an explicit +// resolved revision. Content API types terminate in the GitHub adapter. +type RepositoryFile struct { + Path string + BlobSHA string + CommitSHA string + RequestedRef string + ResolvedRef string + HTMLURL string + Content string +} + +// SourceFileRequest identifies one repository-relative file and optional +// inclusive 1-based line range. A zero range requests the complete file. +type SourceFileRequest struct { + Path string + StartLine int + EndLine int +} + +// SourceFileReadOptions bounds one batch of repository content reads. +type SourceFileReadOptions struct { + PerFileBytes int + TotalBytes int +} + +// SourceFileReadItem is one ordered adapter-level content outcome. Content is +// populated only for complete reads; callers may persist the item unchanged. +type SourceFileReadItem struct { + Request SourceFileRequest + Status string + File RepositoryFile + StartLine int + EndLine int + Bytes int + ContentSHA string + Message string + RetryAfter time.Duration +} + +// SourceFileReadResult preserves the single resolved revision and rate state +// shared by a bounded source-file batch. +type SourceFileReadResult struct { + Resolution RefResolution + Items []SourceFileReadItem + TotalBytes int + Rate RateInfo +} + +// ThreadSearchOptions selects one bounded GitHub issue-search page for one +// repository. Query is the user text before provider qualifiers are added. +type ThreadSearchOptions struct { + Owner string + Repo string + Query string + Kind ThreadKind + State string + Sort string + Order string + PageOptions +} + +// ThreadSearchResult preserves the exact provider query and search metadata. +type ThreadSearchResult struct { + Query string + Total int + Incomplete bool + Items []Issue + Page PageInfo + Rate RateInfo +} diff --git a/internal/github/client.go b/internal/github/client.go index 8a1bb911..4d170c72 100644 --- a/internal/github/client.go +++ b/internal/github/client.go @@ -3,7 +3,6 @@ package github import ( "context" "errors" - "fmt" "net/http" "strconv" "strings" @@ -42,12 +41,6 @@ type IssueTimelineReader interface { ListIssueTimeline(context.Context, string, string, int, PageOptions) (ListResult[IssueTimelineEvent], error) } -// RepositoryFileReader is the optional exact-file capability used to ingest a -// small, fixed set of contribution-policy documents during explicit syncs. -type RepositoryFileReader interface { - GetRepositoryFile(ctx context.Context, owner, name, path string) (RepositoryFile, RateInfo, error) -} - // RepositorySearcher is the optional GitHub Search capability used by broad // discovery. Keeping it separate lets archive-only readers stay small. type RepositorySearcher interface { @@ -159,24 +152,6 @@ func (c *Client) GetRepository(ctx context.Context, owner, name string) (Reposit return convertRepository(repo), rateInfo(resp.Rate), nil } -// GetRepositoryFile reads one text file from the repository's default branch. -func (c *Client) GetRepositoryFile(ctx context.Context, owner, name, path string) (RepositoryFile, RateInfo, error) { - file, _, resp, err := c.gh.Repositories.GetContents(ctx, owner, name, path, nil) - if err != nil { - return RepositoryFile{}, responseRateInfo(resp), classifyError(err) - } - if file == nil { - return RepositoryFile{}, responseRateInfo(resp), &NotFoundError{Resource: path} - } - content, err := file.GetContent() - if err != nil { - return RepositoryFile{}, responseRateInfo(resp), fmt.Errorf("decode repository file %q: %w", path, err) - } - return RepositoryFile{ - Path: file.GetPath(), SHA: file.GetSHA(), HTMLURL: file.GetHTMLURL(), Content: content, - }, responseRateInfo(resp), nil -} - // ListIssueTimeline reads one REST timeline page without exposing go-github // models beyond the adapter boundary. func (c *Client) ListIssueTimeline(ctx context.Context, owner, name string, number int, opts PageOptions) (ListResult[IssueTimelineEvent], error) { diff --git a/internal/github/client_acquisition.go b/internal/github/client_acquisition.go new file mode 100644 index 00000000..84e041fb --- /dev/null +++ b/internal/github/client_acquisition.go @@ -0,0 +1,296 @@ +package github + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "path" + "strings" + "time" + + gh "github.com/google/go-github/v89/github" +) + +// RepositoryFileReader is the optional exact-file capability used to ingest a +// small, fixed set of contribution-policy documents during explicit syncs. +type RepositoryFileReader interface { + GetRepositoryFile(ctx context.Context, owner, name, path string) (RepositoryFile, RateInfo, error) +} + +// RepositoryRefResolver resolves a named branch, tag, or commit to one +// authoritative commit. The result is a read-only provenance record. +type RepositoryRefResolver interface { + ResolveRepositoryRef(ctx context.Context, owner, name, requestedRef string) (RefResolution, RateInfo, error) +} + +// RepositoryFileAtRefReader reads one file at an explicit named ref. The +// adapter resolves the ref before requesting contents and preserves both the +// requested ref and resolved commit in RepositoryFile. +type RepositoryFileAtRefReader interface { + GetRepositoryFileAtRef(ctx context.Context, owner, name, path, requestedRef string) (RepositoryFile, RateInfo, error) +} + +// RepositoryFileAtResolvedRefReader reads one file using a resolution already +// shared by a caller, avoiding one ref-resolution request per file. +type RepositoryFileAtResolvedRefReader interface { + GetRepositoryFileAtResolvedRef(ctx context.Context, owner, name, path string, resolution RefResolution) (RepositoryFile, RateInfo, error) +} + +// ThreadSearcher is the optional live GitHub issue-search capability. +type ThreadSearcher interface { + SearchThreads(ctx context.Context, opts ThreadSearchOptions) (ThreadSearchResult, error) +} + +// SourceFileReader is the optional bounded batch source acquisition capability. +type SourceFileReader interface { + ReadSourceFiles(ctx context.Context, owner, name, requestedRef string, requests []SourceFileRequest, opts SourceFileReadOptions) (SourceFileReadResult, error) +} + +func sourceLineRange(content string, start, end int) (string, int, int, bool) { + lines := strings.SplitAfter(content, "\n") + if len(lines) > 0 && lines[len(lines)-1] == "" { + lines = lines[:len(lines)-1] + } + lineCount := len(lines) + if lineCount == 0 { + lineCount = 1 + lines = []string{""} + } + if start == 0 && end == 0 { + return content, 1, lineCount, true + } + if start == 0 { + start = 1 + } + if end == 0 { + end = lineCount + } + if start < 1 || end < start || start > lineCount || end > lineCount { + return "", 0, 0, false + } + return strings.Join(lines[start-1:end], ""), start, end, true +} + +func sourceReadErrorStatus(err error) (string, time.Duration) { + var primary *PrimaryRateLimitError + var secondary *SecondaryRateLimitError + var transient *TransientError + var notFound *NotFoundError + var denied *AccessDeniedError + switch { + case errors.As(err, &primary): + return "retryable", primary.RetryAfter + case errors.As(err, &secondary): + return "retryable", secondary.RetryAfter + case errors.As(err, &transient): + return "retryable", time.Second + case errors.As(err, ¬Found): + return "not_found", 0 + case errors.As(err, &denied): + return "unavailable", 0 + default: + return "failed", 0 + } +} + +// ResolveRepositoryRef resolves one branch, tag, or commit to a commit SHA. +func (c *Client) ResolveRepositoryRef(ctx context.Context, owner, name, requestedRef string) (RefResolution, RateInfo, error) { + requestedRef = strings.TrimSpace(requestedRef) + if requestedRef == "" { + requestedRef = "HEAD" + } + commit, resp, err := c.gh.Repositories.GetCommit(ctx, owner, name, requestedRef, nil) + if err != nil { + return RefResolution{}, responseRateInfo(resp), classifyError(err) + } + commitSHA := commit.GetSHA() + if commitSHA == "" { + return RefResolution{}, responseRateInfo(resp), fmt.Errorf("resolve repository ref %q: response did not include a commit SHA", requestedRef) + } + return RefResolution{RequestedRef: requestedRef, ResolvedRef: commitSHA, CommitSHA: commitSHA}, responseRateInfo(resp), nil +} + +// GetRepositoryFile reads one text file at GitHub's HEAD after resolving that +// ref to a commit. Callers that need a named branch or tag should use +// GetRepositoryFileAtRef directly. +func (c *Client) GetRepositoryFile(ctx context.Context, owner, name, path string) (RepositoryFile, RateInfo, error) { + return c.GetRepositoryFileAtRef(ctx, owner, name, path, "HEAD") +} + +// GetRepositoryFileAtRef resolves requestedRef once and reads the file at the +// resulting commit. A named branch or tag is never used as authoritative +// provenance after this method returns. +func (c *Client) GetRepositoryFileAtRef(ctx context.Context, owner, name, path, requestedRef string) (RepositoryFile, RateInfo, error) { + resolution, resolutionRate, err := c.ResolveRepositoryRef(ctx, owner, name, requestedRef) + if err != nil { + return RepositoryFile{}, resolutionRate, err + } + file, contentRate, err := c.GetRepositoryFileAtResolvedRef(ctx, owner, name, path, resolution) + if err != nil { + return RepositoryFile{}, contentRate, err + } + if contentRate == (RateInfo{}) { + return file, resolutionRate, nil + } + return file, contentRate, nil +} + +// GetRepositoryFileAtResolvedRef reads contents with the resolved commit in +// the ref query parameter. It performs no ref resolution or other network +// access beyond the content request itself. +func (c *Client) GetRepositoryFileAtResolvedRef(ctx context.Context, owner, name, path string, resolution RefResolution) (RepositoryFile, RateInfo, error) { + if strings.TrimSpace(resolution.CommitSHA) == "" { + return RepositoryFile{}, RateInfo{}, errors.New("resolved repository ref requires a commit SHA") + } + file, _, resp, err := c.gh.Repositories.GetContents(ctx, owner, name, path, &gh.RepositoryContentGetOptions{Ref: resolution.CommitSHA}) + if err != nil { + return RepositoryFile{}, responseRateInfo(resp), classifyError(err) + } + if file == nil { + return RepositoryFile{}, responseRateInfo(resp), &NotFoundError{Resource: path} + } + content, err := file.GetContent() + if err != nil { + return RepositoryFile{}, responseRateInfo(resp), fmt.Errorf("decode repository file %q: %w", path, err) + } + return RepositoryFile{ + Path: file.GetPath(), BlobSHA: file.GetSHA(), CommitSHA: resolution.CommitSHA, + RequestedRef: resolution.RequestedRef, ResolvedRef: resolution.ResolvedRef, + HTMLURL: file.GetHTMLURL(), Content: content, + }, responseRateInfo(resp), nil +} + +// ReadSourceFiles resolves one ref and reads a bounded, ordered batch. Limits +// apply to decoded file bytes before line-range slicing so a large remote file +// cannot bypass the per-file acquisition bound. +func (c *Client) ReadSourceFiles(ctx context.Context, owner, name, requestedRef string, requests []SourceFileRequest, opts SourceFileReadOptions) (SourceFileReadResult, error) { + if len(requests) == 0 { + return SourceFileReadResult{}, errors.New("source file request batch is empty") + } + if opts.PerFileBytes <= 0 || opts.TotalBytes <= 0 { + return SourceFileReadResult{}, errors.New("source file byte limits must be positive") + } + resolution, rate, err := c.ResolveRepositoryRef(ctx, owner, name, requestedRef) + if err != nil { + return SourceFileReadResult{}, err + } + result := SourceFileReadResult{Resolution: resolution, Items: make([]SourceFileReadItem, len(requests)), Rate: rate} + for index, request := range requests { + item := SourceFileReadItem{Request: request, Status: "failed", StartLine: request.StartLine, EndLine: request.EndLine} + if err := ctx.Err(); err != nil { + return SourceFileReadResult{}, err + } + cleanPath := strings.TrimSpace(request.Path) + if cleanPath == "" { + item.Message = "path is required" + result.Items[index] = item + continue + } + if cleanPath != request.Path || cleanPath != path.Clean(cleanPath) || strings.HasPrefix(cleanPath, "/") || strings.Contains(cleanPath, "\\") || cleanPath == "." || strings.HasPrefix(cleanPath, "../") || strings.Contains(cleanPath, "/../") { + item.Message = "path must be repository-relative without traversal" + result.Items[index] = item + continue + } + if request.StartLine < 0 || request.EndLine < 0 || (request.StartLine > 0 && request.EndLine > 0 && request.EndLine < request.StartLine) { + item.Message = "line range must be inclusive, positive, and ordered" + result.Items[index] = item + continue + } + file, contentRate, readErr := c.GetRepositoryFileAtResolvedRef(ctx, owner, name, request.Path, resolution) + if contentRate != (RateInfo{}) { + result.Rate = contentRate + } + if readErr != nil { + if errors.Is(readErr, context.Canceled) || errors.Is(readErr, context.DeadlineExceeded) { + return SourceFileReadResult{}, readErr + } + status, retryAfter := sourceReadErrorStatus(readErr) + item.Status, item.Message, item.RetryAfter = status, readErr.Error(), retryAfter + result.Items[index] = item + continue + } + metadata := file + metadata.Content = "" + item.File = metadata + if len(file.Content) > opts.PerFileBytes { + item.Status, item.Bytes, item.Message = "too_large", len(file.Content), fmt.Sprintf("file exceeds %d-byte per-file limit", opts.PerFileBytes) + contentDigest := sha256.Sum256([]byte(file.Content)) + item.ContentSHA = hex.EncodeToString(contentDigest[:]) + result.Items[index] = item + continue + } + content, startLine, endLine, ok := sourceLineRange(file.Content, request.StartLine, request.EndLine) + if !ok { + item.Status, item.Message = "failed", "requested line range is outside the file" + result.Items[index] = item + continue + } + if result.TotalBytes+len(content) > opts.TotalBytes { + item.Status, item.Bytes, item.Message = "too_large", len(content), fmt.Sprintf("batch exceeds %d-byte total limit", opts.TotalBytes) + contentDigest := sha256.Sum256([]byte(content)) + item.ContentSHA = hex.EncodeToString(contentDigest[:]) + result.Items[index] = item + continue + } + contentDigest := sha256.Sum256([]byte(content)) + item.Status, item.File, item.StartLine, item.EndLine, item.Bytes, item.ContentSHA = "complete", file, startLine, endLine, len(content), hex.EncodeToString(contentDigest[:]) + item.File.Content = content + result.TotalBytes += len(content) + result.Items[index] = item + } + return result, nil +} + +// SearchThreads searches one repository through GitHub's issue-search +// endpoint. The repository, kind, and state qualifiers are part of the +// preserved provider query; sort and order remain explicit request options. +func (c *Client) SearchThreads(ctx context.Context, opts ThreadSearchOptions) (ThreadSearchResult, error) { + owner, repo := strings.TrimSpace(opts.Owner), strings.TrimSpace(opts.Repo) + if owner == "" || repo == "" { + return ThreadSearchResult{}, errors.New("thread search repository owner and name are required") + } + queryParts := []string{"repo:" + owner + "/" + repo} + if text := strings.TrimSpace(opts.Query); text != "" { + queryParts = append(queryParts, text) + } + switch opts.Kind { + case ThreadKindIssue: + queryParts = append(queryParts, "is:issue") + case ThreadKindPullRequest: + queryParts = append(queryParts, "is:pr") + case "": + default: + return ThreadSearchResult{}, fmt.Errorf("unsupported thread kind %q", opts.Kind) + } + if opts.State != "" && opts.State != "all" { + if opts.State != "open" && opts.State != "closed" { + return ThreadSearchResult{}, fmt.Errorf("unsupported thread state %q", opts.State) + } + queryParts = append(queryParts, "is:"+opts.State) + } + query := strings.Join(queryParts, " ") + result, resp, err := c.gh.Search.Issues(ctx, query, &gh.SearchOptions{ + Sort: opts.Sort, Order: opts.Order, + ListOptions: gh.ListOptions{Page: opts.Page, PerPage: opts.PerPage}, + }) + if err != nil { + return ThreadSearchResult{}, classifyError(err) + } + items := make([]Issue, 0, len(result.Issues)) + for _, issue := range result.Issues { + converted := convertIssue(issue) + if converted.RepositoryOwner == "" { + converted.RepositoryOwner = owner + } + if converted.RepositoryName == "" { + converted.RepositoryName = repo + } + items = append(items, converted) + } + return ThreadSearchResult{ + Query: query, Total: result.GetTotal(), Incomplete: result.GetIncompleteResults(), + Items: items, Page: pageInfo(resp), Rate: rateInfo(resp.Rate), + }, nil +} diff --git a/internal/github/client_acquisition_test.go b/internal/github/client_acquisition_test.go new file mode 100644 index 00000000..9b16c4e9 --- /dev/null +++ b/internal/github/client_acquisition_test.go @@ -0,0 +1,177 @@ +package github + +import ( + "context" + "encoding/base64" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestSearchThreadsPreservesProviderQueryFiltersPaginationAndRate(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v3/search/issues" { + http.NotFound(w, r) + return + } + q := r.URL.Query().Get("q") + for _, part := range []string{"repo:" + testOwner + "/" + testRepo, "needle", "is:pr", "is:open"} { + if !strings.Contains(q, part) { + t.Errorf("provider query %q does not contain %q", q, part) + } + } + if r.URL.Query().Get("sort") != "updated" || r.URL.Query().Get("order") != "asc" || r.URL.Query().Get("page") != "2" || r.URL.Query().Get("per_page") != "3" { + t.Errorf("request query = %v", r.URL.Query()) + } + setRateHeaders(w.Header()) + w.Header().Set("Link", `; rel="next"`) + writeJSON(w, map[string]any{ + "total_count": 12, "incomplete_results": true, + "items": []any{map[string]any{ + "id": 41, "node_id": "PR_41", "number": 7, "title": "needle", "state": "open", + "repository_url": "https://api.github.com/repos/" + testOwner + "/" + testRepo, + "html_url": "https://github.com/" + testOwner + "/" + testRepo + "/pull/7", + "pull_request": map[string]any{"html_url": "https://github.com/" + testOwner + "/" + testRepo + "/pull/7"}, + "created_at": "2026-07-01T00:00:00Z", "updated_at": "2026-07-02T00:00:00Z", + }}, + }) + })) + defer srv.Close() + + client := newTestClient(t, srv, StaticTokenSource("")) + result, err := client.SearchThreads(context.Background(), ThreadSearchOptions{ + Owner: testOwner, Repo: testRepo, Query: "needle", Kind: ThreadKindPullRequest, State: "open", Sort: "updated", Order: "asc", + PageOptions: PageOptions{Page: 2, PerPage: 3}, + }) + if err != nil { + t.Fatal(err) + } + if result.Total != 12 || !result.Incomplete || !result.Page.HasNext || result.Page.NextPage != 3 || len(result.Items) != 1 { + t.Fatalf("result = %+v", result) + } + if result.Items[0].Kind != ThreadKindPullRequest || result.Items[0].RepositoryOwner != testOwner || result.Items[0].RepositoryName != testRepo { + t.Fatalf("item = %+v", result.Items[0]) + } + if result.Rate.Limit != 5000 || result.Rate.Remaining != 4999 { + t.Fatalf("rate = %+v", result.Rate) + } +} + +func TestRepositoryFileAtRefSeparatesCommitAndBlobSHA(t *testing.T) { + const content = "package example\n" + var gotRef string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v3/repos/" + testOwner + "/" + testRepo + "/commits/main": + writeJSON(w, map[string]any{"sha": "commit-sha"}) + case "/api/v3/repos/" + testOwner + "/" + testRepo + "/contents/internal/example.go": + gotRef = r.URL.Query().Get("ref") + writeJSON(w, map[string]any{ + "type": "file", "path": "internal/example.go", "sha": "blob-sha", "html_url": "https://github.com/example", + "encoding": "base64", "content": base64.StdEncoding.EncodeToString([]byte(content)), + }) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + client := newTestClient(t, srv, StaticTokenSource("")) + file, _, err := client.GetRepositoryFileAtRef(context.Background(), testOwner, testRepo, "internal/example.go", "main") + if err != nil { + t.Fatal(err) + } + if gotRef != "commit-sha" { + t.Fatalf("content ref = %q, want commit-sha", gotRef) + } + if file.CommitSHA != "commit-sha" || file.BlobSHA != "blob-sha" || file.RequestedRef != "main" || file.ResolvedRef != "commit-sha" || file.Content != content { + t.Fatalf("file = %+v", file) + } +} + +func TestReadSourceFilesPreservesOrderRangesAndBoundedStatuses(t *testing.T) { + const readme = "one\ntwo\nthree\n" + const big = "0123456789" + var contentRefs []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v3/repos/" + testOwner + "/" + testRepo + "/commits/main": + writeJSON(w, map[string]any{"sha": "commit-sha"}) + case "/api/v3/repos/" + testOwner + "/" + testRepo + "/contents/README.md": + contentRefs = append(contentRefs, r.URL.Query().Get("ref")) + writeJSON(w, map[string]any{"type": "file", "path": "README.md", "sha": "readme-blob", "encoding": "base64", "content": base64.StdEncoding.EncodeToString([]byte(readme))}) + case "/api/v3/repos/" + testOwner + "/" + testRepo + "/contents/missing.md": + http.NotFound(w, r) + case "/api/v3/repos/" + testOwner + "/" + testRepo + "/contents/big.txt": + contentRefs = append(contentRefs, r.URL.Query().Get("ref")) + writeJSON(w, map[string]any{"type": "file", "path": "big.txt", "sha": "big-blob", "encoding": "base64", "content": base64.StdEncoding.EncodeToString([]byte(big))}) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + client := newTestClient(t, srv, StaticTokenSource("")) + result, err := client.ReadSourceFiles(context.Background(), testOwner, testRepo, "main", []SourceFileRequest{ + {Path: "README.md", StartLine: 2, EndLine: 2}, {Path: "missing.md"}, {Path: "big.txt"}, + }, SourceFileReadOptions{PerFileBytes: 32, TotalBytes: 8}) + if err != nil { + t.Fatal(err) + } + if result.Resolution.CommitSHA != "commit-sha" || len(result.Items) != 3 || result.TotalBytes != 4 { + t.Fatalf("result = %+v", result) + } + if item := result.Items[0]; item.Status != "complete" || item.File.BlobSHA != "readme-blob" || item.File.Content != "two\n" || item.StartLine != 2 || item.EndLine != 2 || item.Bytes != 4 { + t.Fatalf("readme item = %+v", item) + } + if result.Items[1].Status != "not_found" || result.Items[2].Status != "too_large" { + t.Fatalf("bounded statuses = %+v", result.Items) + } + for _, ref := range contentRefs { + if ref != "commit-sha" { + t.Fatalf("content request ref = %q", ref) + } + } +} + +func TestReadSourceFilesClassifiesMalformedContentAsFailed(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v3/repos/" + testOwner + "/" + testRepo + "/commits/main": + writeJSON(w, map[string]any{"sha": "commit-sha"}) + case "/api/v3/repos/" + testOwner + "/" + testRepo + "/contents/broken.txt": + writeJSON(w, map[string]any{"type": "file", "path": "broken.txt", "sha": "blob-sha", "encoding": "base64", "content": "not base64!"}) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + client := newTestClient(t, srv, StaticTokenSource("")) + result, err := client.ReadSourceFiles(context.Background(), testOwner, testRepo, "main", []SourceFileRequest{{Path: "broken.txt"}}, SourceFileReadOptions{PerFileBytes: 100, TotalBytes: 100}) + if err != nil { + t.Fatal(err) + } + if len(result.Items) != 1 || result.Items[0].Status != "failed" { + t.Fatalf("result = %+v", result) + } +} + +func TestReadSourceFilesRejectsInvalidLimitsBeforeResolvingRef(t *testing.T) { + var requests int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + http.NotFound(w, r) + })) + defer srv.Close() + + client := newTestClient(t, srv, StaticTokenSource("")) + _, err := client.ReadSourceFiles(context.Background(), testOwner, testRepo, "main", []SourceFileRequest{{Path: "README.md"}}, SourceFileReadOptions{PerFileBytes: 0, TotalBytes: 100}) + if err == nil || !strings.Contains(err.Error(), "byte limits must be positive") { + t.Fatalf("error = %v", err) + } + if requests != 0 { + t.Fatalf("invalid limits triggered %d GitHub requests", requests) + } +} diff --git a/internal/github/client_guidance_test.go b/internal/github/client_guidance_test.go index e7b4b955..3a1f3c9c 100644 --- a/internal/github/client_guidance_test.go +++ b/internal/github/client_guidance_test.go @@ -11,16 +11,27 @@ import ( func TestGetRepositoryFileDecodesContentAndContainsVendorTypes(t *testing.T) { const content = "We accept pull requests for help-wanted issues." srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet || r.URL.Path != "/api/v3/repos/"+testOwner+"/"+testRepo+"/contents/.github/CONTRIBUTING.md" { + if r.Method != http.MethodGet { http.NotFound(w, r) return } - setRateHeaders(w.Header()) - writeJSON(w, map[string]any{ - "type": "file", "path": ".github/CONTRIBUTING.md", "sha": "abc123", - "html_url": "https://github.com/octocat/hello-world/blob/main/.github/CONTRIBUTING.md", - "encoding": "base64", "content": base64.StdEncoding.EncodeToString([]byte(content)), - }) + switch r.URL.Path { + case "/api/v3/repos/" + testOwner + "/" + testRepo + "/commits/HEAD": + setRateHeaders(w.Header()) + writeJSON(w, map[string]any{"sha": "commit-sha"}) + case "/api/v3/repos/" + testOwner + "/" + testRepo + "/contents/.github/CONTRIBUTING.md": + if got := r.URL.Query().Get("ref"); got != "commit-sha" { + t.Errorf("content ref = %q, want commit-sha", got) + } + setRateHeaders(w.Header()) + writeJSON(w, map[string]any{ + "type": "file", "path": ".github/CONTRIBUTING.md", "sha": "abc123", + "html_url": "https://github.com/octocat/hello-world/blob/main/.github/CONTRIBUTING.md", + "encoding": "base64", "content": base64.StdEncoding.EncodeToString([]byte(content)), + }) + default: + http.NotFound(w, r) + } })) defer srv.Close() @@ -29,7 +40,7 @@ func TestGetRepositoryFileDecodesContentAndContainsVendorTypes(t *testing.T) { if err != nil { t.Fatal(err) } - if file.Path != ".github/CONTRIBUTING.md" || file.SHA != "abc123" || file.Content != content { + if file.Path != ".github/CONTRIBUTING.md" || file.BlobSHA != "abc123" || file.CommitSHA != "commit-sha" || file.RequestedRef != "HEAD" || file.ResolvedRef != "commit-sha" || file.Content != content { t.Fatalf("file = %+v", file) } if rate.Limit != 5000 || rate.Remaining != 4999 { diff --git a/internal/github/models.go b/internal/github/models.go index 5636029e..ee601333 100644 --- a/internal/github/models.go +++ b/internal/github/models.go @@ -36,15 +36,6 @@ type Repository struct { PushedAt *time.Time } -// RepositoryFile is a bounded text file read from a repository at its default -// branch. Content API types terminate in the GitHub adapter. -type RepositoryFile struct { - Path string - SHA string - HTMLURL string - Content string -} - // Issue is a domain-neutral view of an issue or pull-request marker from // the issues list endpoint. type Issue struct { diff --git a/internal/mcpcontract/github_acquisition_contracts.go b/internal/mcpcontract/github_acquisition_contracts.go new file mode 100644 index 00000000..2a17f236 --- /dev/null +++ b/internal/mcpcontract/github_acquisition_contracts.go @@ -0,0 +1,219 @@ +package mcpcontract + +// GitHubRateOutput is the provider rate-limit state observed with one live +// request. A zero value means the provider did not return rate metadata. +type GitHubRateOutput struct { + Limit int `json:"limit,omitempty"` + Remaining int `json:"remaining,omitempty"` + Used int `json:"used,omitempty"` + Reset string `json:"reset,omitempty"` + Resource string `json:"resource,omitempty"` +} + +// SearchGitHubThreadsInput selects one bounded live GitHub issue-search page. +// The repository is required; live code search is intentionally not part of +// this operation. +type SearchGitHubThreadsInput struct { + Owner string `json:"owner" jsonschema:"GitHub repository owner"` + Repo string `json:"repo" jsonschema:"GitHub repository name"` + Query string `json:"query" jsonschema:"User search text or GitHub issue-search qualifiers"` + Kind string `json:"kind,omitempty" jsonschema:"Optional issue or pull_request filter"` + State string `json:"state,omitempty" jsonschema:"Optional open, closed, or all state filter"` + Sort string `json:"sort,omitempty" jsonschema:"Optional GitHub issue-search sort: comments, created, updated, or reactions"` + Order string `json:"order,omitempty" jsonschema:"Optional asc or desc order"` + Page int `json:"page,omitempty" jsonschema:"GitHub result page from 1 to 1000"` + Limit int `json:"limit,omitempty" jsonschema:"Results per page from 1 to 100"` +} + +// SearchGitHubThreadsOutput is the compact live result. The complete ordered +// result, including provider provenance and item IDs, is available through +// ResourceURI. +type SearchGitHubThreadsOutput struct { + Status string `json:"status"` + Repository RepositoryRef `json:"repository"` + Query string `json:"query"` + ProviderQuery string `json:"provider_query"` + Kind string `json:"kind,omitempty"` + State string `json:"state,omitempty"` + Sort string `json:"sort,omitempty"` + Order string `json:"order,omitempty"` + Page int `json:"page"` + Limit int `json:"limit"` + NextPage int `json:"next_page,omitempty"` + Total int `json:"total"` + Incomplete bool `json:"incomplete"` + Rate GitHubRateOutput `json:"rate"` + Items []BatchItem[ThreadOutput] `json:"items"` + Coverage string `json:"coverage" jsonschema:"Repository-wide thread coverage remains incomplete; this search page is not proof of absence"` + ObservedAt string `json:"observed_at"` + ArtifactDigest string `json:"artifact_digest"` + ResourceURI string `json:"resource_uri"` +} + +// GitHubThreadSearchArtifact is the immutable github-thread-search.v1 +// payload. Items preserve provider ordering and identity even when a compact +// tool result omits fields needed only for later inspection. +type GitHubThreadSearchArtifact struct { + SchemaVersion string `json:"schema_version"` + ArtifactKind string `json:"artifact_kind"` + Repository RepositoryRef `json:"repository"` + Query string `json:"query"` + ProviderQuery string `json:"provider_query"` + Kind string `json:"kind,omitempty"` + State string `json:"state,omitempty"` + Sort string `json:"sort,omitempty"` + Order string `json:"order,omitempty"` + Page int `json:"page"` + Limit int `json:"limit"` + NextPage int `json:"next_page,omitempty"` + Total int `json:"total"` + Incomplete bool `json:"incomplete"` + HasNextPage bool `json:"has_next_page"` + Rate GitHubRateOutput `json:"rate"` + Items []GitHubThreadSearchArtifactItem `json:"items"` + Completeness GitHubThreadSearchCompleteness `json:"completeness"` + Provenance GitHubAcquisitionProvenance `json:"provenance"` + CreatedAt string `json:"created_at"` +} + +type GitHubThreadSearchArtifactItem struct { + Position int `json:"position"` + ID int64 `json:"id"` + NodeID string `json:"node_id,omitempty"` + Owner string `json:"owner"` + Repo string `json:"repo"` + Kind string `json:"kind"` + Number int `json:"number"` + Title string `json:"title"` + State string `json:"state"` + SourceURL string `json:"source_url,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` + ClosedAt string `json:"closed_at,omitempty"` +} + +type GitHubThreadSearchCompleteness struct { + Status string `json:"status"` + IncompleteResults bool `json:"incomplete_results"` + HasNextPage bool `json:"has_next_page"` + RepositoryThreadCoverageKnown bool `json:"repository_thread_coverage_known"` + RepositoryThreadCoverageFull bool `json:"repository_thread_coverage_complete"` +} + +// GitHubAcquisitionProvenance identifies the live provider request and its +// local observation time. +type GitHubAcquisitionProvenance struct { + Provider string `json:"provider"` + Endpoint string `json:"endpoint"` + ObservedAt string `json:"observed_at"` +} + +// ReadSourceFilesInput selects a bounded source bundle at a commit or named +// ref. Named refs are resolved before content is read and are not authoritative +// provenance. +type ReadSourceFilesInput struct { + Owner string `json:"owner" jsonschema:"GitHub repository owner"` + Repo string `json:"repo" jsonschema:"GitHub repository name"` + Ref string `json:"ref" jsonschema:"Commit SHA, branch, or tag; resolved commit is authoritative"` + Files []SourceFileRequest `json:"files" jsonschema:"Ordered repository-relative files with optional inclusive line ranges"` + PerFileBytes int `json:"per_file_bytes,omitempty" jsonschema:"Maximum decoded bytes per file from 1 to 1048576"` + TotalBytes int `json:"total_bytes,omitempty" jsonschema:"Maximum selected bytes for the complete bundle from 1 to 4194304"` +} + +type SourceFileRequest struct { + Path string `json:"path" jsonschema:"Repository-relative file path"` + StartLine int `json:"start_line,omitempty" jsonschema:"Inclusive 1-based start line; zero means beginning"` + EndLine int `json:"end_line,omitempty" jsonschema:"Inclusive 1-based end line; zero means end"` +} + +type SourceFileOutput struct { + Path string `json:"path"` + RequestedRef string `json:"requested_ref"` + ResolvedRef string `json:"resolved_ref"` + CommitSHA string `json:"commit_sha"` + BlobSHA string `json:"blob_sha"` + SourceURL string `json:"source_url,omitempty"` + ContentSHA256 string `json:"content_sha256,omitempty"` + Bytes int `json:"bytes"` + StartLine int `json:"start_line"` + EndLine int `json:"end_line"` + Content string `json:"content,omitempty"` + ObservedAt string `json:"observed_at"` +} + +// SourceFileStatus preserves the distinct bounded-read outcomes that are +// useful to callers: a missing path or an oversized file is not the same as a +// provider retry or an unexpected decoding failure. +type SourceFileStatus string + +type SourceFileBatchItem struct { + Key string `json:"key"` + Status SourceFileStatus `json:"item_status"` + Value *SourceFileOutput `json:"value,omitempty"` + Reason string `json:"reason,omitempty"` + Message string `json:"message,omitempty"` + RetryAfterMS NonNegativeInt `json:"retry_after_ms,omitempty"` +} + +type ReadSourceFilesOutput struct { + Status string `json:"status"` + Repository RepositoryRef `json:"repository"` + RequestedRef string `json:"requested_ref"` + ResolvedRef string `json:"resolved_ref"` + CommitSHA string `json:"commit_sha"` + PerFileBytes int `json:"per_file_bytes"` + TotalByteLimit int `json:"total_byte_limit"` + TotalBytes int `json:"total_bytes"` + Items []SourceFileBatchItem `json:"items"` + Completeness SourceBundleCompleteness `json:"completeness"` + ObservedAt string `json:"observed_at"` + Rate GitHubRateOutput `json:"rate"` + ArtifactDigest string `json:"artifact_digest"` + ResourceURI string `json:"resource_uri"` +} + +// SourceBundleArtifact is the canonical source-bundle.v1 resource payload. +// It contains bounded content only for complete items; failed items preserve +// their status and recovery message. +type SourceBundleArtifact struct { + SchemaVersion string `json:"schema_version"` + ArtifactKind string `json:"artifact_kind"` + Repository RepositoryRef `json:"repository"` + RequestedRef string `json:"requested_ref"` + ResolvedRef string `json:"resolved_ref"` + CommitSHA string `json:"commit_sha"` + PerFileBytes int `json:"per_file_bytes"` + TotalByteLimit int `json:"total_byte_limit"` + TotalBytes int `json:"total_bytes"` + Rate GitHubRateOutput `json:"rate"` + Items []SourceFileBatchItem `json:"items"` + Completeness SourceBundleCompleteness `json:"completeness"` + Provenance GitHubAcquisitionProvenance `json:"provenance"` + CreatedAt string `json:"created_at"` +} + +type SourceBundleCompleteness struct { + Status string `json:"status"` + CompleteItems int `json:"complete_items"` + RequestedItems int `json:"requested_items"` + FailedItems int `json:"failed_items"` + ContentsBounded bool `json:"contents_bounded"` +} + +// SearchCodeBatchInput composes up to 20 offline code searches over one +// repository scope and one optional snapshot precondition. +type SearchCodeBatchInput struct { + Owner string `json:"owner" jsonschema:"Repository owner"` + Repo string `json:"repo" jsonschema:"Repository name"` + Queries []string `json:"queries" jsonschema:"One to 20 code queries, returned in input order"` + Limit int `json:"limit,omitempty" jsonschema:"Shared per-query result limit from 1 to 100"` + SnapshotToken string `json:"snapshot_token,omitempty" jsonschema:"Optional immutable corpus snapshot token"` +} + +type SearchCodeBatchOutput struct { + Status string `json:"status"` + Repository RepositoryRef `json:"repository,omitempty"` + Items []BatchItem[SearchCodeOutput] `json:"items"` + SnapshotToken string `json:"snapshot_token"` + Provenance CorpusReadProvenance `json:"provenance"` +} diff --git a/internal/mcpcontract/tool_contracts.go b/internal/mcpcontract/tool_contracts.go index 1ce1f315..41580478 100644 --- a/internal/mcpcontract/tool_contracts.go +++ b/internal/mcpcontract/tool_contracts.go @@ -44,6 +44,7 @@ const ( ToolSearchRepositories = "corpus.search_repositories" ToolSearchThreads = "corpus.search_threads" ToolSearchCode = "corpus.search_code" + ToolSearchCodeBatch = "corpus.search_code_batch" ToolGetRepositories = "corpus.get_repositories" ToolGetThreads = "corpus.get_threads" ToolGetThreadFacets = "corpus.get_thread_facets" @@ -62,6 +63,8 @@ const ( ToolGetJob = "jobs.get" ToolCancelJob = "jobs.cancel" ToolSearchGitHubRepositories = "github.search_repositories" + ToolSearchGitHubThreads = "github.search_threads" + ToolReadSourceFiles = "github.read_source_files" ToolSyncRepositoryContext = "github.sync_repository_context" ToolSyncThreads = "github.sync_threads" ToolHydrateThreads = "github.sync_thread_facets" diff --git a/internal/mcpserver/acquisition_resources_test.go b/internal/mcpserver/acquisition_resources_test.go new file mode 100644 index 00000000..d189285e --- /dev/null +++ b/internal/mcpserver/acquisition_resources_test.go @@ -0,0 +1,128 @@ +package mcpserver + +import ( + "context" + "strings" + "testing" + + "github.com/morluto/gitcontribute/internal/mcpcontract" +) + +const testArtifactDigest = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + +type acquisitionArtifactReader struct { + *fakeReader +} + +type acquisitionCapabilityReader struct { + *acquisitionArtifactReader +} + +func (*acquisitionCapabilityReader) SearchGitHubThreads(context.Context, mcpcontract.SearchGitHubThreadsInput) (mcpcontract.SearchGitHubThreadsOutput, error) { + return mcpcontract.SearchGitHubThreadsOutput{ResourceURI: "gitcontribute://artifact/github-thread-search/" + testArtifactDigest}, nil +} + +func (*acquisitionCapabilityReader) ReadSourceFiles(context.Context, mcpcontract.ReadSourceFilesInput) (mcpcontract.ReadSourceFilesOutput, error) { + return mcpcontract.ReadSourceFilesOutput{ResourceURI: "gitcontribute://artifact/source-bundle/" + testArtifactDigest}, nil +} + +func (*acquisitionCapabilityReader) SearchCodeBatch(context.Context, mcpcontract.SearchCodeBatchInput) (mcpcontract.SearchCodeBatchOutput, error) { + return mcpcontract.SearchCodeBatchOutput{Status: "complete"}, nil +} + +func (r *acquisitionArtifactReader) ReadGitHubThreadSearchArtifact(context.Context, string) (mcpcontract.GitHubThreadSearchArtifact, error) { + return mcpcontract.GitHubThreadSearchArtifact{SchemaVersion: "github-thread-search.v1", ArtifactKind: "github-thread-search.v1", Query: "needle"}, nil +} + +func (r *acquisitionArtifactReader) ReadSourceBundleArtifact(context.Context, string) (mcpcontract.SourceBundleArtifact, error) { + return mcpcontract.SourceBundleArtifact{SchemaVersion: "source-bundle.v1", ArtifactKind: "source-bundle.v1", CommitSHA: "commit-sha"}, nil +} + +func TestAcquisitionArtifactResourcesRouteExactOpaqueURIs(t *testing.T) { + reader := &acquisitionArtifactReader{fakeReader: &fakeReader{searchStarted: make(chan struct{})}} + server := &Server{reader: reader} + for _, test := range []struct { + uri string + assert func(t *testing.T, value any) + }{ + { + uri: "gitcontribute://artifact/github-thread-search/" + testArtifactDigest, + assert: func(t *testing.T, value any) { + artifact, ok := value.(mcpcontract.GitHubThreadSearchArtifact) + if !ok || artifact.ArtifactKind != "github-thread-search.v1" || artifact.Query != "needle" { + t.Fatalf("thread artifact = %#v", value) + } + }, + }, + { + uri: "gitcontribute://artifact/source-bundle/" + testArtifactDigest, + assert: func(t *testing.T, value any) { + artifact, ok := value.(mcpcontract.SourceBundleArtifact) + if !ok || artifact.ArtifactKind != "source-bundle.v1" || artifact.CommitSHA != "commit-sha" { + t.Fatalf("source artifact = %#v", value) + } + }, + }, + } { + u := strings.TrimPrefix(test.uri, "gitcontribute://") + host, path, _ := strings.Cut(u, "/") + value, err := server.readResourceValue(context.Background(), resourceRequest{uri: test.uri, scheme: "gitcontribute", host: host, parts: strings.Split(path, "/")}) + if err != nil { + t.Fatalf("read %s: %v", test.uri, err) + } + test.assert(t, value) + } + if _, err := server.readResourceValue(context.Background(), resourceRequest{ + uri: "gitcontribute://artifact/unknown/" + testArtifactDigest, scheme: "gitcontribute", host: "artifact", parts: []string{"unknown", testArtifactDigest}, + }); err == nil { + t.Fatal("unknown artifact namespace was routed") + } +} + +func TestAcquisitionArtifactResourceTemplatesTrackReaderCapabilities(t *testing.T) { + reader := &acquisitionArtifactReader{fakeReader: &fakeReader{searchStarted: make(chan struct{})}} + client, closeSessions := connect(t, reader) + defer closeSessions() + + want := map[string]bool{ + "gitcontribute://artifact/github-thread-search/{artifact_digest}": false, + "gitcontribute://artifact/source-bundle/{artifact_digest}": false, + } + for template, err := range client.ResourceTemplates(context.Background(), nil) { + if err != nil { + t.Fatal(err) + } + if _, ok := want[template.URITemplate]; ok { + want[template.URITemplate] = true + } + } + for template, found := range want { + if !found { + t.Errorf("missing resource template %q", template) + } + } +} + +func TestAcquisitionToolsExposeTheirSideEffectBoundaries(t *testing.T) { + reader := &acquisitionCapabilityReader{acquisitionArtifactReader: &acquisitionArtifactReader{fakeReader: &fakeReader{searchStarted: make(chan struct{})}}} + client, closeSessions := connect(t, reader) + defer closeSessions() + + tools := map[string]bool{} + for tool, err := range client.Tools(context.Background(), nil) { + if err != nil { + t.Fatal(err) + } + tools[tool.Name] = true + if tool.Name == mcpcontract.ToolSearchGitHubThreads || tool.Name == mcpcontract.ToolReadSourceFiles { + if tool.Annotations == nil || tool.Annotations.ReadOnlyHint || tool.Annotations.OpenWorldHint == nil || !*tool.Annotations.OpenWorldHint { + t.Errorf("live acquisition annotations for %s = %+v", tool.Name, tool.Annotations) + } + } + } + for _, name := range []string{mcpcontract.ToolSearchGitHubThreads, mcpcontract.ToolReadSourceFiles, mcpcontract.ToolSearchCodeBatch} { + if !tools[name] { + t.Errorf("tools/list missing %s", name) + } + } +} diff --git a/internal/mcpserver/github_acquisition.go b/internal/mcpserver/github_acquisition.go new file mode 100644 index 00000000..96947e24 --- /dev/null +++ b/internal/mcpserver/github_acquisition.go @@ -0,0 +1,98 @@ +package mcpserver + +import ( + "context" + "errors" + "strings" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/morluto/gitcontribute/internal/mcpcontract" +) + +func (s *Server) registerGitHubAcquisitionTools() { + addCatalogTool(s, catalogTool[mcpcontract.SearchGitHubThreadsInput, mcpcontract.SearchGitHubThreadsOutput]{ + name: mcpcontract.ToolSearchGitHubThreads, + title: "Search live GitHub issues and pull requests", + description: "Run one bounded live GitHub issue-search page for one repository and persist the returned observations plus an immutable query artifact. The result is not repository-wide thread coverage and cannot prove absence; read the exact artifact URI locally for complete provider provenance.", + annotations: networkReadAnnotations(), supportedBy: supports[GitHubAcquisitionOperator], + input: inputSchema[mcpcontract.SearchGitHubThreadsInput](func(sc *schemaBuilder) { + requireTogether(sc, "owner", "repo") + setEnum(sc, "kind", "issue", "pull_request") + setEnum(sc, "state", "open", "closed", "all") + setEnum(sc, "sort", "comments", "created", "updated", "reactions") + setEnum(sc, "order", "asc", "desc") + setRange(sc, "page", 1, 1000) + setDefault(sc, "page", 1) + setRange(sc, "limit", 1, 100) + setDefault(sc, "limit", 20) + }), + output: outputSchema[mcpcontract.SearchGitHubThreadsOutput]("Compact live GitHub search results with an immutable local artifact link."), handler: s.searchGitHubThreads, + }) + addCatalogTool(s, catalogTool[mcpcontract.ReadSourceFilesInput, mcpcontract.ReadSourceFilesOutput]{ + name: mcpcontract.ToolReadSourceFiles, + title: "Read bounded GitHub source files", + description: "Acquire up to 20 ordered repository-relative source files from one explicit commit or named ref, resolving named refs to an authoritative commit. Per-file and total-byte bounds produce item-level outcomes; content is untrusted text and is available through an immutable local source-bundle artifact.", + annotations: networkReadAnnotations(), supportedBy: supports[GitHubAcquisitionOperator], + input: inputSchema[mcpcontract.ReadSourceFilesInput](func(sc *schemaBuilder) { + requireTogether(sc, "owner", "repo") + setArrayBounds(sc, "files", 1, 20) + setRange(sc, "per_file_bytes", 1, 1024*1024) + setDefault(sc, "per_file_bytes", 256*1024) + setRange(sc, "total_bytes", 1, 4*1024*1024) + setDefault(sc, "total_bytes", 2*1024*1024) + }), + output: outputSchema[mcpcontract.ReadSourceFilesOutput]("Bounded source-file acquisition results with an immutable source-bundle resource link."), handler: s.readSourceFiles, + }) +} + +func (s *Server) searchGitHubThreads(ctx context.Context, _ *mcp.CallToolRequest, in mcpcontract.SearchGitHubThreadsInput) (*mcp.CallToolResult, mcpcontract.SearchGitHubThreadsOutput, error) { + if err := validateLiveRepository(in.Owner, in.Repo); err != nil { + return nil, mcpcontract.SearchGitHubThreadsOutput{}, err + } + if strings.TrimSpace(in.Query) == "" { + return nil, mcpcontract.SearchGitHubThreadsOutput{}, mcpcontract.InvalidArgument("query", "is required", map[string]any{"query": "regression"}) + } + operator, ok := s.reader.(GitHubAcquisitionOperator) + if !ok { + return nil, mcpcontract.SearchGitHubThreadsOutput{}, errors.New("live GitHub thread search is not available") + } + out, err := operator.SearchGitHubThreads(ctx, in) + if err != nil { + return nil, out, err + } + if out.ResourceURI == "" { + return nil, out, nil + } + return linkedResource(out.ResourceURI, "github-thread-search", "GitHub thread search artifact", "Immutable provider query result persisted in the local corpus."), out, nil +} + +func (s *Server) readSourceFiles(ctx context.Context, _ *mcp.CallToolRequest, in mcpcontract.ReadSourceFilesInput) (*mcp.CallToolResult, mcpcontract.ReadSourceFilesOutput, error) { + if err := validateLiveRepository(in.Owner, in.Repo); err != nil { + return nil, mcpcontract.ReadSourceFilesOutput{}, err + } + if strings.TrimSpace(in.Ref) == "" { + return nil, mcpcontract.ReadSourceFilesOutput{}, mcpcontract.InvalidArgument("ref", "is required", map[string]any{"ref": "main"}) + } + if len(in.Files) < 1 || len(in.Files) > 20 { + return nil, mcpcontract.ReadSourceFilesOutput{}, mcpcontract.InvalidArgument("files", "must contain 1 to 20 items", map[string]any{"files": []map[string]any{{"path": "README.md"}}}) + } + operator, ok := s.reader.(GitHubAcquisitionOperator) + if !ok { + return nil, mcpcontract.ReadSourceFilesOutput{}, errors.New("bounded GitHub source reads are not available") + } + out, err := operator.ReadSourceFiles(ctx, in) + if err != nil { + return nil, out, err + } + if out.ResourceURI == "" { + return nil, out, nil + } + return linkedResource(out.ResourceURI, "source-bundle", "GitHub source bundle", "Immutable bounded source text persisted in the local corpus."), out, nil +} + +func validateLiveRepository(owner, repo string) error { + if strings.TrimSpace(owner) == "" || strings.TrimSpace(repo) == "" { + return mcpcontract.InvalidArgument("owner", "owner and repo are required", map[string]any{"owner": "acme", "repo": "rocket"}) + } + return nil +} diff --git a/internal/mcpserver/resource_templates.go b/internal/mcpserver/resource_templates.go index b6c0e787..25a28291 100644 --- a/internal/mcpserver/resource_templates.go +++ b/internal/mcpserver/resource_templates.go @@ -37,6 +37,18 @@ func (s *Server) registerResourceTemplates() { name: "Code-index artifact", description: "Immutable digest-bound indexed repository commit manifest", }) } + if _, ok := s.reader.(GitHubThreadSearchArtifactReader); ok { + templates = append(templates, resourceTemplateDefinition{ + template: "gitcontribute://artifact/github-thread-search/{artifact_digest}", + name: "GitHub thread-search artifact", description: "Immutable provider query result with ordered thread identities and completeness", + }) + } + if _, ok := s.reader.(SourceBundleArtifactReader); ok { + templates = append(templates, resourceTemplateDefinition{ + template: "gitcontribute://artifact/source-bundle/{artifact_digest}", + name: "Source bundle", description: "Immutable bounded source text with commit and blob provenance", + }) + } if _, ok := s.reader.(SnapshotReader); ok { templates = append(templates, resourceTemplateDefinition{ template: "gitcontribute://snapshot/{snapshot_token}", diff --git a/internal/mcpserver/resources.go b/internal/mcpserver/resources.go index 26f6d517..99d7bf65 100644 --- a/internal/mcpserver/resources.go +++ b/internal/mcpserver/resources.go @@ -111,11 +111,20 @@ func (s *Server) readResourceValue(ctx context.Context, req resourceRequest) (an case "ci-job-log": return s.readCIJobLogResource(ctx, req) case "artifact": - if len(req.parts) != 2 || req.parts[0] != "code-index" { + if len(req.parts) != 2 { + return nil, mcp.ResourceNotFoundError(req.uri) + } + switch req.parts[0] { + case "code-index": + req.parts = req.parts[1:] + return s.readCodeIndexResource(ctx, req) + case "github-thread-search": + return s.readGitHubThreadSearchResource(ctx, req) + case "source-bundle": + return s.readSourceBundleResource(ctx, req) + default: return nil, mcp.ResourceNotFoundError(req.uri) } - req.parts = req.parts[1:] - return s.readCodeIndexResource(ctx, req) case "snapshot": if len(req.parts) != 1 || strings.TrimSpace(req.parts[0]) == "" { return nil, mcp.ResourceNotFoundError(req.uri) @@ -141,6 +150,28 @@ func (s *Server) readCodeIndexResource(ctx context.Context, req resourceRequest) return reader.CodeIndexArtifact(ctx, req.parts[0]) } +func (s *Server) readGitHubThreadSearchResource(ctx context.Context, req resourceRequest) (mcpcontract.GitHubThreadSearchArtifact, error) { + if len(req.parts) != 2 || strings.TrimSpace(req.parts[1]) == "" { + return mcpcontract.GitHubThreadSearchArtifact{}, mcp.ResourceNotFoundError(req.uri) + } + reader, ok := s.reader.(GitHubThreadSearchArtifactReader) + if !ok { + return mcpcontract.GitHubThreadSearchArtifact{}, mcp.ResourceNotFoundError(req.uri) + } + return reader.ReadGitHubThreadSearchArtifact(ctx, req.parts[1]) +} + +func (s *Server) readSourceBundleResource(ctx context.Context, req resourceRequest) (mcpcontract.SourceBundleArtifact, error) { + if len(req.parts) != 2 || strings.TrimSpace(req.parts[1]) == "" { + return mcpcontract.SourceBundleArtifact{}, mcp.ResourceNotFoundError(req.uri) + } + reader, ok := s.reader.(SourceBundleArtifactReader) + if !ok { + return mcpcontract.SourceBundleArtifact{}, mcp.ResourceNotFoundError(req.uri) + } + return reader.ReadSourceBundleArtifact(ctx, req.parts[1]) +} + func (s *Server) readThreadFacetResource(ctx context.Context, req resourceRequest) (map[string]any, error) { reader, ok := s.reader.(threadFacetResourceReader) if !ok || len(req.parts) != 6 || req.parts[4] != "facet" { diff --git a/internal/mcpserver/scalable.go b/internal/mcpserver/scalable.go index 26a16623..43712acd 100644 --- a/internal/mcpserver/scalable.go +++ b/internal/mcpserver/scalable.go @@ -23,6 +23,7 @@ const serverInstructions = "Use advertised GitContribute tools for durable, sour "To inspect a returned resource, ask the host to perform MCP resources/read with this server and the exact URI; in Codex, call read_mcp_resource. Treat resource URIs as opaque identifiers and never shorten, pluralize, or reconstruct them. " + "Missing or truncated coverage is unknown, not negative evidence; use each item's ordered typed recovery calls (the recovery plan's ordered typed calls), preserve exact_thread versus repository targets, poll the returned job, and reread coverage or synchronized headers before drawing conclusions. " + "Canonical source-audit route: corpus.get_coverage -> corpus.ensure_coverage or the returned exact sync/hydration action -> jobs.get -> corpus.get_threads or corpus.get_thread_facets with the returned snapshot token -> corpus.find_clusters/find_neighbors/find_precedents -> explicit github.sync_threads -> jobs.get -> validation.attach_receipt -> workflow.prepare_contribution. Read workflow.get_source_audit_contract for machine-readable transitions. Corpus reads are offline, synchronization is bounded and explicit, missing coverage is unknown, and every returned resource URI must be consumed through MCP resources/read. " + + "github.search_threads and github.read_source_files are synchronous live acquisitions that write only local observations and immutable digest-bound artifacts; use their exact resource URI with resources/read, and treat a named source ref as non-authoritative until its resolved commit SHA is recorded. corpus.search_code_batch is an offline shared-snapshot convenience for several code queries; it never falls back to live GitHub code search. " + "Only advertised tools are available. GitContribute never mutates GitHub." // RepositoryRef identifies one GitHub repository without implying that it has @@ -180,6 +181,7 @@ func (s *Server) registerScalable() { setDefault(sc, "response_format", "concise") configureRepositorySearchModes(sc) }), output: outputSchema[mcpcontract.SearchGitHubRepositoriesOutput]("Live repository search with persisted metadata."), handler: s.searchGitHubRepositories}) + s.registerGitHubAcquisitionTools() addCatalogTool(s, catalogTool[mcpcontract.SyncRepositoryContextInput, mcpcontract.JobReference]{name: mcpcontract.ToolSyncRepositoryContext, title: "Sync repository context in one batch", description: "Fetch current GitHub stars, metadata, and fixed contribution-guidance files for up to 100 explicit repositories; no threads or code.", annotations: networkReadAnnotations(), supportedBy: supports[GitHubOperator], input: inputSchema[mcpcontract.SyncRepositoryContextInput](func(sc *schemaBuilder) { setArrayBounds(sc, "repositories", 1, 100) setRange(sc, "max_requests", float64(repositorycontext.RequestCost()), 1000) diff --git a/internal/mcpserver/schemas.go b/internal/mcpserver/schemas.go index 14f8b206..dc23a16e 100644 --- a/internal/mcpserver/schemas.go +++ b/internal/mcpserver/schemas.go @@ -56,6 +56,11 @@ func inferredSchema[T any]() schemaDefinition { Description: "Per-item batch outcome.", Enum: []any{"complete", "retryable", "unavailable", "failed"}, }, + reflect.TypeFor[mcpcontract.SourceFileStatus](): { + Type: "string", + Description: "Bounded source-file outcome.", + Enum: []any{"complete", "not_found", "too_large", "retryable", "unavailable", "failed"}, + }, reflect.TypeFor[mcpcontract.JobStatus](): { Type: "string", Description: "Durable job lifecycle status.", diff --git a/internal/mcpserver/server.go b/internal/mcpserver/server.go index adc18b74..38a524f1 100644 --- a/internal/mcpserver/server.go +++ b/internal/mcpserver/server.go @@ -62,6 +62,22 @@ type GitHubOperator interface { SyncPortfolio(context.Context, mcpcontract.SyncPortfolioInput) (mcpcontract.JobReference, error) } +// GitHubAcquisitionOperator exposes synchronous, explicitly bounded live +// GitHub acquisition that writes only local corpus observations and artifacts. +// It is separate from GitHubOperator so older adapters can retain their +// existing capability set while the new acquisition tools are adopted. +type GitHubAcquisitionOperator interface { + SearchGitHubThreads(context.Context, mcpcontract.SearchGitHubThreadsInput) (mcpcontract.SearchGitHubThreadsOutput, error) + ReadSourceFiles(context.Context, mcpcontract.ReadSourceFilesInput) (mcpcontract.ReadSourceFilesOutput, error) +} + +// CodeSearchBatchReader exposes one bounded offline batch over a shared code +// snapshot scope. It remains separate from Reader so existing local readers +// can retain the single-query compatibility tool. +type CodeSearchBatchReader interface { + SearchCodeBatch(context.Context, mcpcontract.SearchCodeBatchInput) (mcpcontract.SearchCodeBatchOutput, error) +} + type CoverageOperator interface { EnsureCoverage(context.Context, mcpcontract.EnsureCoverageInput) (mcpcontract.JobReference, error) } @@ -110,6 +126,14 @@ type CodeIndexReader interface { CodeIndexArtifact(context.Context, string) (mcpcontract.CodeIndexArtifact, error) } +type GitHubThreadSearchArtifactReader interface { + ReadGitHubThreadSearchArtifact(context.Context, string) (mcpcontract.GitHubThreadSearchArtifact, error) +} + +type SourceBundleArtifactReader interface { + ReadSourceBundleArtifact(context.Context, string) (mcpcontract.SourceBundleArtifact, error) +} + type SnapshotReader interface { ReadSnapshot(context.Context, string) (mcpcontract.CorpusSnapshotArtifact, error) } @@ -299,6 +323,19 @@ func (s *Server) register() { requireTogether(schema, "owner", "repo") }), output: outputSchema[mcpcontract.SearchCodeOutput]("One page of stored code matches."), handler: s.searchCode, }) + addCatalogTool(s, catalogTool[mcpcontract.SearchCodeBatchInput, mcpcontract.SearchCodeBatchOutput]{ + name: mcpcontract.ToolSearchCodeBatch, + title: "Search stored code in one batch", + description: "Run up to 20 ordered code searches against one shared immutable local corpus revision. This is offline, performs no GitHub fallback or mutation, and preserves each query's coverage and truncation semantics; corpus.search_code remains available for one query.", + annotations: readOnly, supportedBy: supports[CodeSearchBatchReader], + input: inputSchema[mcpcontract.SearchCodeBatchInput](func(sc *schemaBuilder) { + requireTogether(sc, "owner", "repo") + setArrayBounds(sc, "queries", 1, 20) + setRange(sc, "limit", 1, 100) + setDefault(sc, "limit", 20) + }), + output: outputSchema[mcpcontract.SearchCodeBatchOutput]("Ordered offline code-search results over one shared corpus revision."), handler: s.searchCodeBatch, + }) addCatalogTool(s, catalogTool[mcpcontract.FindClustersInput, mcpcontract.FindClustersOutput]{ name: mcpcontract.ToolFindClusters, title: "Find duplicate clusters in one batch", description: "Read stored duplicate clusters for up to 20 repository or exact-member targets in input order. This does not recompute similarity; use " + mcpcontract.ToolFindNeighbors + " for transient nearest-thread scoring. Offline.", @@ -363,6 +400,27 @@ func (s *Server) searchCode(ctx context.Context, _ *mcp.CallToolRequest, in mcpc return nil, out, err } +func (s *Server) searchCodeBatch(ctx context.Context, _ *mcp.CallToolRequest, in mcpcontract.SearchCodeBatchInput) (*mcp.CallToolResult, mcpcontract.SearchCodeBatchOutput, error) { + if len(in.Queries) < 1 || len(in.Queries) > 20 { + return nil, mcpcontract.SearchCodeBatchOutput{}, mcpcontract.InvalidArgument("queries", "must contain 1 to 20 items", map[string]any{"queries": []string{"MIDI", "latency"}}) + } + if in.Limit == 0 { + in.Limit = 20 + } + if in.Limit < 1 || in.Limit > 100 { + return nil, mcpcontract.SearchCodeBatchOutput{}, mcpcontract.InvalidArgument("limit", "must be between 1 and 100", map[string]any{"limit": 20}) + } + if in.Owner == "" || in.Repo == "" { + return nil, mcpcontract.SearchCodeBatchOutput{}, mcpcontract.InvalidArgument("owner", "owner and repo are required", map[string]any{"owner": "acme", "repo": "synth"}) + } + reader, ok := s.reader.(CodeSearchBatchReader) + if !ok { + return nil, mcpcontract.SearchCodeBatchOutput{}, errors.New("batched offline code search is not available") + } + out, err := reader.SearchCodeBatch(ctx, in) + return nil, out, err +} + func (s *Server) findClusters(ctx context.Context, _ *mcp.CallToolRequest, in mcpcontract.FindClustersInput) (*mcp.CallToolResult, mcpcontract.FindClustersOutput, error) { if len(in.Targets) < 1 || len(in.Targets) > 20 { return nil, mcpcontract.FindClustersOutput{}, mcpcontract.InvalidArgument("targets", "must contain 1 to 20 items", map[string]any{ diff --git a/internal/mcpserver/server_contract_test.go b/internal/mcpserver/server_contract_test.go index 716b35e7..24d7005f 100644 --- a/internal/mcpserver/server_contract_test.go +++ b/internal/mcpserver/server_contract_test.go @@ -31,6 +31,9 @@ func TestServerInstructionsContainRoutingPhrases(t *testing.T) { "never shorten, pluralize, or reconstruct them", "Only advertised tools are available", "never mutates GitHub", + "github.search_threads", + "github.read_source_files", + "corpus.search_code_batch", } { if !strings.Contains(init.Instructions, phrase) { t.Errorf("instructions missing routing phrase %q:\n%s", phrase, init.Instructions) diff --git a/internal/repositorycontext/policy.go b/internal/repositorycontext/policy.go index 09b7b71b..bae244ea 100644 --- a/internal/repositorycontext/policy.go +++ b/internal/repositorycontext/policy.go @@ -22,7 +22,8 @@ func GuidancePaths() []string { return slices.Clone(guidancePaths) } -// RequestCost is one metadata request plus every fixed guidance-path probe. +// RequestCost is one metadata request, one ref-resolution request, plus every +// fixed guidance-path probe. func RequestCost() int { - return 1 + len(guidancePaths) + return 2 + len(guidancePaths) }