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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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/<digest>` and
`gitcontribute://artifact/source-bundle/<digest>`. 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
Expand Down
24 changes: 23 additions & 1 deletion docs/mcp-scalable-workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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/<digest>` 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/<digest>` 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",
Expand Down Expand Up @@ -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 |
Expand Down
2 changes: 2 additions & 0 deletions internal/app/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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" {
Expand Down
23 changes: 19 additions & 4 deletions internal/app/guidance.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment on lines +59 to +61

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Treat empty repositories as having no guidance files

When repository-context synchronization targets a newly created or otherwise empty GitHub repository, resolving its default branch or HEAD through the commits endpoint returns GitHub's empty-repository error rather than a commit. This now aborts the entire context-sync task after metadata has already been written, whereas probing the fixed guidance paths previously treated their 404 responses as an empty, complete guidance set. Handle the empty-repository response by atomically storing an empty guidance snapshot instead of failing the repository.

Useful? React with 👍 / 👎.

}

pages := make([]corpus.FacetObservationInput, 0, len(contributionGuidancePaths))
Expand All @@ -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, &notFound) {
Expand Down Expand Up @@ -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,
})
}
Expand Down
20 changes: 17 additions & 3 deletions internal/app/guidance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
})
Expand All @@ -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)
}

Expand Down Expand Up @@ -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.",
}
Expand Down Expand Up @@ -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)
Expand Down
132 changes: 111 additions & 21 deletions internal/app/mcp_code_search.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
Loading
Loading